Editor Rendering and Views
Views are the primary unit of content presentation. The architecture combines runtime view classes from @lapis-notes/api with Svelte components mounted by the workspace shell or plugins.
View System
View Hierarchy
Component (lifecycle base)
└── View (abstract: getViewType, getDisplayText)
└── ItemView (toolbar actions, contentEl)
└── FileView (file association, onLoadFile/onUnloadFile/onRename)
├── TextFileView (CodeMirror editor, save, data)
│ └── MarkdownView (preview mode, live preview)
└── EditableFileView (marker for editable views)
View Registration
Plugins register views during onload():
plugin.registerView(type, (leaf) => new MyView(leaf));
plugin.registerEditorView({
id: "my.editor",
viewType: type,
label: "My editor",
filenamePatterns: ["*.myext"],
});
plugin.registerExtensions([".ext"], type);
When a file is opened:
workspace.determineViewTypeForPath(path)checksworkspace.editorAssociations, an ordered record map from glob pattern to editor-view ID.- Glob matching uses the maintained
minimatchimplementation with VS Code-style path separators and platform-aware case sensitivity. - If no configured association resolves to a currently registered editor view, the workspace checks registered editor-view filename patterns.
- If no editor-view metadata matches, the legacy extension map remains the fallback; when multiple registered extensions match, the longest suffix wins, so
.notebook.mdbeats.md. - If no registered handler exists, the workspace may consult verified official registry contribution summaries and open an on-demand install prompt for a matching file handler.
- The view constructor is called with the target leaf.
- The view mounts its DOM into
leaf.containerEl. - For file-backed views,
onLoadFile(file)is called.
EditorViewRegistry is the metadata registry for selectable file-backed editor views. It stores stable IDs, display labels, optional plugin ownership, view type, filename patterns, and priority. The constructor registry remains separate: an association can remain persisted even when a plugin is disabled or missing, but it only affects file opening when the associated editor metadata and view constructor are both currently registered. WorkspaceLeaf.openFile() activates a matched associated view type before creating the file view, so lazy code-backed views can be registered before construction.
The workspace registers compatibility metadata for registerExtensions() callers, and first-party file views register explicit editor-view metadata for Markdown, notebook, canvas, bases, PDF, media, and workspace-local code/text views. Users can override defaults through workspace.editorAssociations without breaking layout serialization because existing view type strings remain stable.
On-demand install prompts are a fallback only. Existing registered editor-view
resolution wins when a plugin is already installed, bundled, or enabled. When
the fallback runs, it uses the signed registry index contribution summaries
already available to the distribution layer; it does not fetch plugin detail or
release metadata merely to decide whether a prompt should be shown. The prompt
installs only after user action and calls PluginDistributionManager.install()
with requireOfficial: true and enable: true, then reopens the original file
after the plugin registers its handler.
The workspace view header renders toolbar actions from the standard
view.actions array shape instead of relying only on instanceof ItemView.
First-party packages can be source-resolved independently during development,
so structural action rendering avoids hiding valid actions when class identity
differs across package boundaries.
For new Lapis-native extension surfaces, the preferred direction is declarative or provider-backed view ownership rather than arbitrary plugin DOM mounted into the shared renderer. Obsidian-compatible plugins still keep their compatibility path, but new view-oriented features should favor constrained contracts such as registered tree data providers, output channels, view badges, and isolated webviews.
Hybrid Rendering Pattern
The repo consistently uses a hybrid approach:
- Runtime classes own lifecycle, state transitions, and file associations.
- Svelte components own visible rendering and interaction affordances.
The bridge is leaf.svelte, which injects leaf.containerEl (the view’s native DOM) into the Svelte component tree via a Svelte action.
Shell-Level View Chrome
leaf.svelte
Attaches the runtime-generated containerEl to Svelte. Uses the createView action to inject the leaf’s DOM element. Re-renders on leaf.id change.
view-header.svelte
Tab header providing:
- Back/forward navigation buttons (from leaf’s
HistoryManager). - Breadcrumb trail (file path segments with hover reveal).
- Inline file rename (editable title field).
- Context menu: split down, split right, close.
- Ellipsis menu button.
tabs-split.svelte
Recursive rendering of the workspace tree:
WorkspaceSplit→Resizable.PaneGroup(paneforge).WorkspaceTabs→ Tab container component (top, sidebar, or stacked).- Empty splits show an alert with drag hint.
- Pane resize events trigger
requestSaveLayout().
Empty View
View type "empty". Displayed when a leaf has no file. Centered UI with buttons:
- “Create new note (⌘N)” — Invokes
app:new-note, which creates and opens a uniqueUntitled*.mdnote at the vault root. - “Go to file (⌘O)”
- “See recent files (⌘O)”
- “Close”
- If a leaf is asked to render an unknown view type, layout restoration and runtime
fallback now switch to
emptyand preserve the missing view type instate.__missingViewTypeso the empty tab title shows that unresolved type. The centered empty-view surface also switches from the normal create/open actions to a dedicated unavailable-plugin message and close action.
Workspace Side Views
The workspace package also owns non-file side views that are registered through bundled core plugins rather than the markdown plugin.
Tags View
View type "tags". Mounted by TagsPlugin from packages/workspace/src/lib/views/tags.
- Aggregates slash-delimited tag hierarchy counts from
app.metadataCache.getAllItems(). - Counts each tag path at most once per file so the displayed totals match tag-facet semantics instead of raw tag occurrences.
- Renders as a sidebar browser with search, sort, nested-tree, and hierarchy-scoped expand/collapse-all toggles.
- Refreshes from metadata cache
changed,deleted, andloadedevents, but scopeschangedanddeletedhandling to files that currently contribute tags or still carry tags in the incoming cache payload.
Planned View Contributions
The backlog now tracks several VS Code-style view surfaces that extend the existing view system without replacing it:
- tree views backed by provider APIs instead of plugin-owned custom DOM
- view badges and view-scoped context keys for focused or active view state
- output-channel views for user-visible plugin and service logs
- isolated webview panels for rich UI that should not run directly in the shared renderer DOM
These planned surfaces should integrate with the existing workspace leaf, view, and sidebar registration model so layout restore, cleanup, and plugin ownership stay centralized.
Text Editing Pipeline
TextView
packages/workspace/src/lib/views/text/index.ts defines TextView as a TextFileView subclass. It:
- Hosts a file-backed editor.
- Mounts the shared
NoteEditorcomponent from@lapis-notes/api. - Reports display text and search support for the leaf.
Used by the LangCode plugin for JavaScript, JSON, YAML, CSS, and plain text files.
Editor Integration
The Editor class wraps CodeMirror 6:
view: EditorView— The CM6 editor instance.getValue() / setValue(content)— Full document access.replaceContent(content)— Efficient replace via CM6dispatch.save()— Debounced 500 ms write to vault.updateExtensions(extensions?, context?)— Reconfigures CM6 extensions.trackChanges(callback?)— Syncs external file changes → editor.
The editor pipeline now emits telemetry spans around setValue() and updateExtensions() so full CodeMirror state rebuilds are visible in file-open traces. These spans are intentionally aimed at infrequent lifecycle work rather than high-frequency text entry.
The shared NoteEditor renders its inline file-title element inside the editor scroll area immediately before the .cm-editor-content editor host. The normal appearence.interface.showInlineTitle setting controls that title on desktop, while mobile workspace display mode forces the same in-scroll title on so file-backed mobile pages can scroll the title away with the document instead of mounting separate shell chrome.
When the shared editor rebuilds extensions for an already-open document, it now seeds the new CodeMirror state from the live document text and current selection instead of the last debounced mirror, so opportunistic extension refreshes do not bounce the caret back to the top of the file.
When an editor surface is mounted into a secondary document, such as a browser or Electron popup host, the shared editor action rebases the EditorView onto that document root with EditorView.setRoot(node.getRootNode()) before appending the DOM. Popup-hosted CodeMirror instances therefore keep caret painting, focus, and IME behavior attached to the correct document instead of inheriting ambient root assumptions from the main window.
The shared editor configuration also owns Tab-based indentation. editor.behaviour.indentUsingTabs determines whether Tab and Shift-Tab indent with literal tab characters or spaces, while editor.behaviour.indentVisualWidth drives both the rendered tab width and the number of spaces inserted when tab characters are disabled.
The shared classHighlighter used by API-owned editors also emits compatible highlight.js token classes alongside the existing cm-* classes where there is a direct vocabulary match, so editor surfaces that already ship highlight.js themes can style CodeMirror tokens through either class family.
CM6 State Fields
State fields make app context accessible inside extensions:
| Field | Type | Purpose |
|---|---|---|
editorViewField | StateField<MarkdownFileInfo> | { app, file, editor } |
editorEditorField | StateField<EditorView> | Editor instance |
editorLivePreviewField | StateField<boolean> | Live preview mode flag |
Language Service Adapters
Language-service UI is shared in @lapis-notes/api/editor/language-service. File-backed editors resolve App, TFile, and document text from editorViewField, then call app.languageServices for diagnostics, completions, hover, definition, and code actions. Diagnostics are rendered through the shared lapisCodeMirrorLint() / mapToLapisLintDiagnostic() layer: CodeMirror keeps diagnostic ranges and gutter markers, but its stock lint hover tooltip is disabled so a Lapis-owned view plugin mounts the lapis-lint-tooltip.svelte surface directly under .cm-lapis-tooltip without CodeMirror’s .cm-tooltip wrapper. The same plugin opens that tooltip from lint range hover plus lint gutter marker hover and click, with a short hide delay so users can move into the details. Markdownlint diagnostics that only report a first-column single-character range are displayed across the full source line to match line-level diagnostic behavior. Workspace-loaded codemirror-lint.css supplies lint range, marker, custom tooltip, panel, and inline-problem chrome, including lint-gutter alignment with the styled line-number gutter. The adapters do not import concrete markdownlint, TypeScript, Electron, or LSP providers.
That shared lint-tooltip path is document-aware. Tooltip mount, teardown, event listeners, and viewport measurements use view.dom.ownerDocument plus its defaultView rather than ambient document and window, so popup-hosted editors keep lint hover and tooltip positioning inside the correct renderer document. The same popup-document rule now also applies to search-panel focus, inline-problem widget DOM, and language-service hover DOM, so secondary-window editor chrome stays attached to the popup document instead of the parent renderer.
On Electron desktop, preload advertises language-service when the main process exposes the desktop_ls_* IPC family. Renderer bootstrap wires native LanguageServiceProvider registrations (runtime metadata "native", higher numeric priority) that forward serializable payloads through NativeDesktopBridge.invoke into a forked Node sidecar. Browser/PWA continues to rely on @lapis-notes/language-service worker providers; identical manager APIs keep adapters unchanged. LanguageServiceManager prefers the highest numeric priority when merging diagnostics/code actions across providers sharing a language-capability pairing, treats definitions as first-success descending priority after failures, and already short-circuits completions/hover on the earliest successful provider in that ordering.
Notebook cell editors and other direct EditorView surfaces use the languageServiceDocumentContext Facet to provide a serializable virtual document plus position/range mapping functions. This lets the TypeScript provider see a full virtual notebook document while CodeMirror diagnostics and hover map back to the focused cell.
Extension Registration
Extensions can be registered globally or per-view-type:
// Global — applies to all editors
app.registerEditorExtension(myExtension);
// Scoped — applies only to editors of a specific view type
plugin.registerEditorExtension(myExtension, "markdown");
workspace.updateOptions() re-applies extensions to all open editors when registrations change.
The shared editor stack still uses CodeMirror’s native line wrapping, but markdown-owned wrapping uses a fallback-plus-measurement contract for every indented .cm-line. List, plain-indent, and rich-editor decorations emit deterministic first-paint ch fallbacks through --hmd-indent-padding-fallback and --hmd-indent-prefix-fallback; list and quote fallbacks are derived from the full authored markdown prefix through the first content column, while plain continuation paragraphs inside list items use the parent item content column for padding and their own raw prefix for text-indent. When a visual unordered-list marker slot differs from the authored dash-space marker width, markdown line decorations add the marker-slot delta from --hmd-unordered-list-marker-slot-width to list fallbacks and anchored continuation padding instead of baking the value into per-mode CSS. A dedicated measuredIndentPlugin view plugin is registered after plainIndentPlugin() and refines visible lines by writing --hmd-indent-padding-measured and --hmd-indent-prefix-measured directly onto the visible line DOM; shared CSS prefers measured variables when present and keeps the fallback variables otherwise. For each visible .cm-line carrying data-indent-prefix, the plugin reads the raw prefix width via view.coordsAtPos or DOM range measurement, falls back to getBoundingClientRect() on rendered indent widgets when the prefix is hidden, combines quote/list marker widths by variant, and caches measurements by prefix plus variant/list context rather than line position. Top-level list items still emit data-indent-prefix="" so the same measured pass can hang wrapped rows under the rendered marker width even when there is no authored leading whitespace. Plain-indent continuation paragraphs inside list items also carry data-indent-anchor-line-from; measured padding is anchored to the parent list item’s first content glyph while the negative text-indent remains tied to the continuation line’s own prefix width. Measurements run inside view.requestMeasure({ read, write }), skip while the view is out of view, run a bounded startup retry loop so delayed line decorations receive measured variables, and remove stale measured variables from visible lines before applying the current measurements so recycled line DOM cannot carry old pixel widths. Geometry changes clear the measurement cache; document, viewport, and selection changes schedule a fresh measurement pass without dispatching a CodeMirror transaction. Shared CSS supplies only the base .cm-indent width from --list-indent plus guide styling. A companion shared view plugin still mirrors the computed line-height and padding-top from visible content rows onto visible gutter rows so line numbers and fold controls stay aligned even when headings or font-size changes give the content a different pixel height than the gutter font would imply on its own.
Indentation guides are painted at the line level via a repeating-linear-gradient background image on .indented-wrapped-line::before, sized by the per-line --indent-guide-count custom property the markdown decorations emit alongside data-indent-prefix. Lines whose guides derive from widened unordered-list marker slots may also emit --hmd-indent-guide-offset, normally resolved from --hmd-unordered-list-marker-guide-offset, so visible guide stripes remain centered on the bullet. This keeps the guides spanning the full height of wrapped continuation rows and surviving caret-on-prefix widget removal — the line-level pseudo-element is independent of the inline indent widgets, which now suppress their own legacy ::after guide pseudo-elements to avoid double-painting.
Markdown Specialization
The markdown plugin (packages/plugins/plugin-markdown) is the most substantial view module.
In addition to the leaf-backed MarkdownView, the markdown package now also exposes an EditableMarkdownPreview component for plugin UIs that need the same preview renderer plus the shared markdown CodeMirror editor outside a workspace leaf. The component supports both file-backed and value-backed modes: when given a file it reads and edits that file through the normal Editor lifecycle, and when given only a value it creates a local markdown editor configured through the same app.editorExtensions("markdown", { mode: "live-preview" }) path used by the main markdown editor. The package also exposes a read-only FileEmbed surface for plugin UIs that need the same file-embed rendering path used by markdown embeds without depending on markdown-renderer context.
EditableMarkdownPreview reuses MarkdownPreview for reading mode and the shared NoteEditor surface from @lapis-notes/api/editor for editing mode. A plain primary click on rendered markdown enters editing and, when possible, places the caret near the clicked source offset using preview data-offset metadata. Modifier clicks continue to preserve preview behavior, while explicitly interactive embedded controls opt out of the mode switch. Leaving the editor returns the component to preview mode on blur, but only after confirming focus actually left the editor surface so internal focus movement inside CodeMirror, inline-title chrome, or markdown widgets does not collapse the editor prematurely.
Dual-Mode Editing
MarkdownView extends TextFileView and supports three modes:
- Source mode — Plain CodeMirror text editing.
- Live preview — Rich editor with inline rendering (decorations for headings, links, etc.).
- Reading/preview mode — HTML rendering via
MarkdownPreviewView.
Notebook-owned .notebook.md notes now opt out of the shared readable-line-width centering in all three markdown-derived modes. The notebook view inherits the shared markdown editor and preview stack, while notebook-aware layout switches to the full-width notebook treatment based on file identity instead of post-load markdown patching.
When a markdown leaf switches between source mode, live preview, and reading mode, it now preserves the current per-leaf reading position by capturing a source-line anchor before the old surface unmounts and restoring against the newly mounted surface. Editor-mode restores use the leaf’s scroll-area viewport plus the current selection or caret neighborhood, while reading mode restores use the rendered DOM nodes annotated with data-line source metadata. This first pass is intentionally leaf-local: the anchor lives in leaf ephemeral state and is not yet serialized into saved workspace layout or leaf history.
Internal markdown link navigation now inherits the source leaf’s markdown mode when opening another note. Same-leaf opens reuse the current markdown view instead of constructing a fresh view with the default live-preview mode, and link/file-embed open paths pass the active markdown mode through App.openFile() / WorkspaceLeaf.openFile() so reading mode stays in reading mode, source stays in source, and live preview stays in live preview. Modifier-click opens and reveals of already-open target leaves apply the same inherited mode. Leaf history snapshots the live view state, including markdown mode, before each file navigation so the view-header back control restores the prior note in the same mode rather than a stale default.
Same-mode markdown file opens reuse the editor extension configuration established during onLoadFile() and only reconfigure the editor when the leaf’s markdown mode actually changes. This avoids a redundant second CodeMirror state rebuild during normal file switching.
The markdown view now emits spans for markdown.set_state and markdown.load, while TextFileView.onLoadFile() contributes nested view.text_file.on_load_file and vault.read spans. This keeps the hot path for file-open regressions attributable down to the view-mode transition, file read, and editor state rebuild layers.
In live preview edit mode, ATX headings (#) use heading decorations and may hide their hash markers when the cursor is elsewhere. Setext headings (=== / ---) remain semantically valid markdown, but their underline marker lines stay visible so they behave like plain text while editing.
Markdown editors now decorate the whole markdown link range with .cm-external-link for external destinations and .cm-internal-link for every non-external destination in both source mode and live preview. The authored display label inside markdown link syntax also gets .cm-link-text plus .cm-link-string, while the destination token gets .cm-link-target in addition to .cm-url or .cm-path, so label and target styling can be controlled independently without relying on generic .cm-link token selectors. Link-string styling remains non-underlined while raw path or URL destinations keep the link-decoration styling. On the active line in live preview the wrapper spans both the rendered label text and the hidden destination tokens, raw-space local path destinations still carry .cm-path on the destination token itself, and the external-link icon styling is attached only to the external URL token rather than the whole wrapped link range.
Non-external standard markdown links now share the same internal note-link rendering path as path links and wiki links outside the active live-preview line. Reading mode resolves them through the markdown-owned note-link component instead of leaving them as plain <a href="..."> anchors, and inactive live-preview lines replace the authored markdown link syntax with the same inline rendered note-link widget. External markdown links remain plain anchors in reading mode and stay on the authored-syntax path in live preview.
Bare external autolink URLs such as http://www.g.com also stay on the authored-syntax path in live preview. Inactive lines keep the autolink text visible with the same external-link decoration classes, while URL destination tokens inside [label](url) syntax continue to be hidden when a separate label is present.
When the shared note-link component cannot resolve an internal markdown or wiki-style destination to a vault file, it no longer opens the embed hover preview. Instead the rendered trigger stays visibly muted and exposes the unresolved path in a tooltip so reading mode and live-preview rendered widgets communicate the broken destination without implying a working inline preview.
Wiki links follow that same editor-decoration split while their authored syntax is visible. In source mode, and on the active line in live preview, the whole [[...]] range gets .cm-internal-link, the target portion before | is decorated with .cm-link-target, and the optional display label after | is decorated with .cm-link-text plus .cm-link-string. When a wiki-link line is not active in live preview, the authored [[...]] syntax is replaced with the same rendered internal note-link component used by reading preview instead of remaining visible as decorated source.
Standard markdown links whose destinations are local note or file paths containing raw spaces, such as [Example](My Note.md), are treated as a markdown-plugin extension rather than falling back to CommonMark’s URL parser. Reading mode resolves them through the same markdown-owned internal note-link component used for wikilinks, while CodeMirror source/live-preview keep the label on the normal .cm-link styling path and decorate the destination text with .cm-path instead of the shared .cm-url URL token class.
Bracket-only markdown links such as [My Note] are also treated as internal note links across reading mode, source mode, and live preview. Their label text doubles as the resolved internal link target in reading mode, and the editor continues to decorate that authored label with the markdown-owned internal-link classes used for other non-external markdown links.
Standard Markdown image syntax now follows that same inline-widget path in live preview: when the caret is elsewhere,  renders as an actual image, the rendered widget exposes the same code/edit affordance used by other rendered markdown widgets so a user can jump back to the underlying source range, and failed image loads fall back to an inline SVG placeholder that keeps the alt text and source path visible instead of leaving a browser-default broken-image badge.
Block-level markdown widgets in live preview, including YAML frontmatter, now mount through a compact block-widget shell rather than the inline widget wrapper or the full-page preview chrome. This keeps full-width rendered blocks such as the frontmatter property editor aligned to the markdown column without pulling in the page-level bottom padding used by standalone markdown previews.
Markdown editors render fold controls inline at the first non-whitespace column of foldable lines rather than relying on the shared fold gutter, so nested list folds sit beside the list item or indented block they control in both source mode and live preview.
Nested list prefixes in source mode and live preview use Obsidian-compatible indent wrappers and depth classes so wrapped lines and indentation guides align with the authored markdown prefix instead of a regex-derived fallback. Guide segments are positioned from ancestor list-marker columns so nested unordered items stay aligned with their parent markers, the active guide segment propagates to descendant lines when the cursor is inside the parent item, and plain indented non-list lines render the same markdown-owned indent guide segments and wrapped-indent styling instead of falling back to the generic editor-wide wrapper.
Source-mode list lines now also wrap the visible item content after the authored prefix in a .cm-list-n span, where n is the computed list depth. Ordered and unordered list markers now both keep the authored marker plus trailing markdown space inside their own formatting span instead of leaking into the content wrapper, and the list-formatting classes move off the whole line onto that marker span so DOM consumers can target marker and content separately with the same DOM shape for ol and ul items. In live preview, unordered-marker decorations also zero out the inherited marker padding on that span so the styled bullet glyph stays visually separate from the rendered label text instead of overlapping it.
The list decoration pass also tolerates transient collapsed marker spans that can occur while tab-indenting nested source lines, so marker styling does not crash the CodeMirror plugin or drop indentation-guide rendering mid-edit.
When indentation guides are enabled, plain indented source lines still reuse the live-preview list-line class shape so the shared fold widget and guide rendering stay aligned with the rendered prefix treatment, but they no longer emit a synthetic bullet marker. That plain-indent handling still starts from the first leading whitespace column rather than waiting for a two-space threshold, so any wrapped indented paragraph shifts the whole block immediately, and their guide units still paint on the line box so wrapped continuation rows keep the guide running for the full rendered paragraph height. When those plain-indent lines are continuation paragraphs inside a list item, they also carry the parent list line’s data-line-from as an anchor so the measured-indent pass can hang wrapped rows under the parent item’s text column instead of under the literal rendered width of the authored spaces.
Active-line editing across plain indents, nested lists, and blockquote-style prefixes inherits the same fallback-plus-measurement style as the non-editing state. The padding-inline-start and matching negative text-indent are refined by measuredIndentPlugin whenever the visible prefix changes (caret entering or leaving the prefix, document edits, viewport scrolling), but the cache is keyed by prefix plus rendering context so unchanged lines reuse their cached measurement. Revealing a raw prefix on the active line does not jitter — the line’s hanging indent stays put while the authored leading whitespace, list marker, or quote prefix becomes editable in place.
Markdown-owned indented editor lines now carry first-paint ch fallback variables for hanging-indent padding and prefix width. The measured-indent pass refines those same lines with measured pixel variables once layout is available, and editor CSS prefers measured values while retaining the fallback whenever a measurement is not yet present. This keeps opening, scrolling, and caret movement into raw list, continuation, or quote prefixes from briefly dropping wrapped rows back to the unindented column.
That plain-indent path now also stays active for structured-prefix lines whose leading whitespace is present but whose current parse pass has not yet produced a ListMark, which keeps nested guides stable while a user is in the middle of turning an over-indented line into a list item.
Reading mode and live-preview rendered markdown share the same list-item indentation rules under both .markdown and .markdown-rendered, so nested ordered lists stay visibly indented in preview instead of collapsing flush-left even when the parsed DOM already contains nested <ol> elements.
When editor.display.showIndentationGuides or editor.display.foldIndent is enabled, reading mode list items now resolve those flags from the shared editor.display configuration section, including schema defaults when the vault has no explicit persisted value, and emit the same markdown-owned guide wrappers, bullet hooks, and inline collapse controls as live preview so nested preview lists expose matching indentation markers and fold affordances instead of rendering as plain HTML list items. The reading-view CSS currently pins those affordances with explicit ch-based offsets so the collapse icon sits farther into the marker gutter while unordered bullets are nudged back toward the authored marker lane, the collapse control only becomes visible while the list item is hovered or focused, and the bullet hook keeps its own hoverable gutter hit area.
Live-preview blockquote widgets normalize quote-prefix indentation before reparsing isolated blockquote source and replace from the first rendered line start, so indented multi-line quotes do not leak literal quote markers or leave behind an empty indentation-only editor row before the widget. Reading mode, live-preview widgets, and editable blockquote source lines continue to use the shared markdown renderer blockquote padding, while rendered paragraphs keep the shared line-break behavior.
Quoted list lines in live preview emit both .cm-formatting-quote on the blockquote markers and list marker formatting spans on the checklist or bullet prefix. The list decoration pass only replaces structural whitespace before the quote markers with indent widgets; it must not replace or hide the > characters themselves. Those lines use the quote-list indent variant so measured-indent combines quote-prefix width with list-marker width for hanging-indent layout.
CodeMirror Extensions
In source mode, markdown table and grid-table lines now carry a shared cm-table line class in addition to the existing formatting classes, and the workspace CodeMirror styles pin those lines to the app monospace font stack so column alignment remains legible while editing raw markdown.
Source-mode fenced code blocks now also decorate each code-block line with HyperMD-codeblock HyperMD-codeblock-bg, matching the class shape expected by HyperMD-style selectors without relying on live-preview-only rich-editor decorations.
Markdown editors also decorate every YAML frontmatter source line with cm-hmd-frontmatter, including the opening and closing --- delimiter lines, so source-mode and editable live-preview frontmatter can share consistent block-level styling such as monospace typography.
When fenced code blocks appear inside markdown container directives such as notebook :::cell blocks, source mode now supplements CodeMirror’s normal syntax highlighting with a markdown-owned decoration pass for the fence markers, info string, and mounted inner-language tokens. This keeps directive-contained code blocks emitting the same cm-* token classes as ordinary fenced code in source mode.
When a notebook-style :::cell directive omits an inner fence but still carries a recognized code lang attribute such as ts, js, or sql, source mode applies that same markdown-owned code-block decoration pass to the directive body lines. This gives unfenced code cell bodies the same line treatment and cm-* token classes as fenced code while leaving non-code cells such as lang="md" on the normal markdown path.
The markdown plugin registers 14+ CodeMirror extensions:
| Extension | Function |
|---|---|
| Table | Markdown table editing, Tab/Shift-Tab cell navigation; live-preview table chrome is hover-revealed on (hover: hover) and dimmed-but-visible on (hover: none) |
| Heading | Heading decorations with fold support and heading-aware gutter classes |
| List | List styling, task checkboxes |
| Grid Table | Complex tables with cell spanning |
| WikiLink parser | [[link]] syntax in Lezer |
| Tag parser | #tag support |
| EmbedLink parser | ![[embed]] links |
| Directives | Markdown directive support |
| LaTeX | Math rendering |
| Paste | Paste behavior |
| Input handlers | Custom input |
| Completion | File, header, suggestion autocomplete |
| Hash tags | #tag chip decorations (cm-hashtag-begin / cm-hashtag-end) |
| Rich editor | Live preview decorations and widgets |
Source-mode #tag chips use split cm-hashtag-begin / cm-hashtag-end marks. When language-service linters add cm-lintRange marks on the same line, editor-shared.css keeps the pill visually connected across sibling and nested lint DOM shapes. Workspace Playwright coverage injects synthetic lint range geometries (not specific rule IDs) through __LAPIS_SYNTHETIC_LINT__ while __LAPIS_SUPPRESS_LANGUAGE_SERVICE_LINT__ is set in the browser test context.
Custom Lezer Language
Extends CodeMirror’s Markdown Lezer parser with custom node types: GridTable, Table, WikiLink, Tag, and container directives. YAML frontmatter support. Fold support for GridTable and ContainerDirective nodes.
Container directive parsing treats the standalone closing delimiter line as part of the enclosing ContainerDirective node range rather than a sibling trailing line. Live-preview block widgets depend on that full-range contract so replacing a directive-backed block also consumes the final ::: marker instead of leaving it visible in the editor surface.
Metadata Processing
Worker-backed (PromiseWorker) YAML processor extracts frontmatter, links, tags, headings, sections, list items, footnotes, and block IDs into CachedMetadata. Runs asynchronously on a Web Worker to avoid blocking the main thread.
Side Views
| View Type | Purpose |
|---|---|
OutlineView | Real-time heading extraction, hierarchical collapsible tree that clears to an empty state when the active tab has no heading metadata and repopulates when metadata-cache updates arrive for the active file |
FilePropertiesView | Frontmatter editor bound to active file |
AllPropertiesView | Property browser across all indexed files |
Post-Processing Pipeline
Markdown rendered to HTML passes through sorted MarkdownPostProcessor functions:
- Each processor receives
(el: HTMLElement, ctx: MarkdownPostProcessorContext). - Context provides:
docId,sourcePath,frontmatter,addChild(child),getSectionInfo(el). MarkdownRenderChildextendsComponentfor lifecycle management of rendered elements.- Processors sorted by
sortOrder(default 0, higher = later).
Plugin-registered markdown post processors and code-block processors are wrapped in plugin-scoped telemetry spans, so expensive renderer hooks can be attributed back to the owning plugin instead of appearing as anonymous markdown work.
Markdown directives can also be upgraded before that post-processing pass through an app-level directive renderer registry. The markdown renderer still lowers generic directives to ordinary div or span elements with data-directive, but when the active app has registered a component for a directive name, that directive renders through the normal Svelte markdown component tree instead of waiting for a later DOM mutation pass. The same directive registry is used by both standalone reading-mode previews and live-preview markdown widgets, so directive-backed features do not need a separate post-processing implementation for live preview.
Bases Specialization
The bases plugin extends the view system for structured data:
- Opens
.bases/.basefiles. QueryControllerexecutes PEaQL queries againstBasesTabledatabase context.- TableView — TanStack Table grid with column sizing, sorting, filtering, cell editing.
- CardView — Grid layout with configurable card size, image handling, aspect ratio.
This is a layered application of the architecture:
- Runtime
FileViewclass from@lapis-notes/api. - Plugin-owned query engine and mode switching.
- Svelte UI mounted into the view container.
Media View
Registered by the markdown plugin for image extensions (.jpg, .jpeg, .png, .svg, .bmp, .gif). Scales content to fit. Separate from the text editing pipeline.
Canvas View
Registered by the canvas plugin for .canvas files.
CanvasViewis aFileViewsubclass rather than aTextFileView, because the canvas surface is a dedicated graphical editor rather than a CodeMirror-backed document view.- The view reads the JSON Canvas file from the vault on file open and remounts a Svelte component into the leaf container.
- Initial
SvelteFlowmount waits for JSON hydration, then the mounted flow waits for Xyflow node and viewport readiness and schedules a few delayedfitView()retries aftertick()so restored page loads recenter once the pane geometry settles. That open-time auto-fit is clamped tomaxZoom: 1, so small canvases do not start over-zoomed just because their content would otherwise fit at a larger scale. - The mounted Svelte surface converts JSON Canvas documents into Svelte Flow nodes and edges, renders custom editable node components with transparent resize hit targets, inline markdown preview for text nodes through the shared
EditableMarkdownPreviewcomponent, whole-file markdown file nodes through that same editable surface, and markdown subpath plus non-markdown file nodes through the markdown package’s read-onlyFileEmbedsurface. Text nodes and editable whole-file markdown nodes use a two-step interaction model where the first click selects and a second click or toolbar action enters editing, blur-driven return from the embedded markdown editor back to preview mode, scrollable markdown previews that keep overflow contained to the node body on both axes and opt out of Svelte Flow wheel capture while hovered, with interactive markdown descendants also opting out of node drag capture and native browser dragging so slight pointer movement still triggers their own link or control action, and with the text-node body itself opting out of drag capture while the embedded markdown editor is active so text selection inside CodeMirror works normally, bezier-style connectors for a more curved path profile between nodes, solid arrowheads on directional connector ends, handle geometry that keeps connector endpoints flush with node borders even while the connection affordances stay visually hidden until hover, drag-time alignment guides that surface nearby left/center/right and top/center/bottom matches against neighboring nodes within a 4px threshold for more precise placement and add dot markers where those guides cross the dragged and matched node bounds, edge-specific hover connection affordances on hovered nodes, a selected-node action bar for remove, color, zoom, and future edit actions that clamps itself inside the visible canvas surface, plus group-node label focus and clear actions when relevant, a selected-edge action bar that exposes remove, color, zoom, and line-direction controls at the edge midpoint, a selected-edge interaction where clicking an already selected connector opens an inline label input with a connector-colored 3px border at the edge midpoint, edge labels that render only when they have non-empty text, node and edge action bars that counter-scale against the viewport zoom so they keep a stable on-screen size while the canvas itself zooms, top-right canvas controls that expose zoom in, reset zoom back to1x, zoom out, fit view, and interactivity toggle actions, selected connectors that thicken to 4px and render a lighter glow behind the stroke, uniform node borders that thicken on selection while colored nodes keep their existing tint, JSON Canvas color controls, Obsidian-style tinted node surfaces, visible arrow markers on supported connector ends, basic file/link affordances whose open actions opt out of node drag and selection capture so slight pointer movement still triggers the intended target, lower-layer group styling, and resizer minimums that are smaller than the default node creation sizes so nodes can be shrunk below their initial260x160or320x220footprints, and serializes flow changes back into JSON Canvas. CanvasViewdebouncesvault.modify(...)writes so drag, connect, and inline-edit operations persist without saving on every micro-change.- The canvas surface clips node overflow to the pane body, so dragging nodes beyond the visible viewport does not let them paint over the leaf header or neighboring workspace chrome.
- Styling is injected by the plugin at load time so the bundled graph surface can ship its own Svelte Flow CSS, including the theme-aware dotted canvas overlay, without requiring separate workspace stylesheet imports.
Slides View
The slides plugin adds a dedicated file-backed presentation surface for Markdown notes.
- The active Markdown file can be reopened into
SlidesViewTypefrom the start of the Markdown pane menu, alongside the other view-mode actions, or via theslides:start-presentationcommand. - The workspace opens the same file path in a different registered view type, so standard leaf history and layout persistence still apply.
- The slides view parses the Markdown document into horizontal and vertical slide sections using
reveal.jsseparators and renders each section through the markdown package’s preview component rather than a separate renderer stack. - Presentation rendering keeps the markdown preview pipeline lightweight by skipping code-block syntax highlighting inside slide sections, so opening and closing presentation mode does not leave normal markdown navigation competing with highlight work from the discarded deck.
- Presentation mode renders as a fixed overlay across the workspace shell, with a top-right close affordance and escape-key exit that return the current leaf to the Markdown editor’s live-preview surface through the normal file-open path.
- Speaker notes stay attached to the owning slide via inline
notes:blocks or notes callout syntax.