LoomMark

LoomMark is a source-preserving Markdown editor for Visual Studio Code. It combines a continuous
CodeMirror editing surface with progressive rich rendering while keeping the exact Markdown text
as the only source of truth.
LoomMark does not parse a document into a rich-text model and serialize the whole document back to
Markdown. Visual features are CodeMirror decorations layered over the original text. Opening a
file must not format, normalize, escape, or otherwise rewrite it.
Highlights
- Continuous editing with normal cursor movement, selection, undo, IME composition, and fast input.
- Progressive rendering for headings, emphasis, strong text, strikethrough, links, and inline code.
- Card mode: each heading's section is visually set apart — a soft tint, a colored accent bar, or
a nested bordered card, so it is obvious which heading you're under. Cycle styles from the
editor title bar; colors are customizable.
- Optional background imagery: a fixed, rotating, or per-document image behind the editor, and a
separate stable image per heading section in tint/card mode.
- Fenced code blocks with language-aware highlighting, line numbers, copy controls, and language
selection.
- GFM tables that render as real tables, editable in place or as expand-to-source widgets, in a
bordered grid or a booktabs-style three-line layout.
- Inline and block image preview, including images in a sibling folder or wrapped in
<...>.
- Paste an image from the clipboard to save it and insert a Markdown image link, honoring VS
Code's own
markdown.copyFiles.destination setting.
- Inline and display math rendered with KaTeX.
- Clickable task-list checkboxes, styled nested bullets, blockquotes, and horizontal rules.
- Tab/Shift+Tab indent and outdent lines, turning a list item into a nested sub-list. Nested
ordered lists renumber automatically (
1, a, i cycling by default).
- Outline connector lines between nested list items, with the cursor's ancestor chain lit up
in color.
#tag chips that stay part of the editable text.
- Backslash escapes (
\*, \#, \!, ...) turn off Markdown syntax for a single character.
- Find and replace inside the editor (Ctrl/Cmd+F), styled like VS Code's native find widget.
- Obsidian-style
[[wiki links]] and [[target|label]] without conversion to standard links.
- Workspace-wide file completion inside
[[...]] — any file, not just Markdown.
- Ctrl/Cmd + click navigation for relative Markdown links and wiki links.
- An in-editor outline drawer that opens from a floating control, plus a native Explorer outline,
both generated from source text.
- Minimal VS Code
WorkspaceEdit synchronization with stale-update protection.
- Cursor position is remembered and restored when reopening a document.
- Reading position is remembered too: returning to a recreated editor restores the same visible
document location, including after layout-affecting rich rendering has settled, and restores
editor focus so typing can continue immediately.
- Built-in diagnostics command for inspecting the Webview, synchronization state, links, completion,
and code-block decorations.
Gallery
Nested Card Mode
Heading sections can remain plain, use colored accent rails, or form nested Cards while preserving
the exact Markdown source beneath the editor surface.
Lists And Math
Nested list guides remain visible across continuation paragraphs, ordered lists can cycle their
labels, and inline or display mathematics renders in place.
Changelog
0.5.4
- Standalone images now default to a centered, framed figure with their
[] alt text as a
caption. The optional title accepts "no-center" and "no-frame" to opt out.
- Annotation blocks now anchor their stripe and numbered badge to standalone rendered images,
matching the existing table, code-block, and display-math behavior.
- Added an image gallery covering rich rendering and Card mode variants.
0.5.3
- Restored editors retain both the cursor and the reading position. LoomMark saves a scroll
position plus a visible-line anchor, so switching back to a document or recreating its Webview
returns to the same place even when cards, images, or wrapping have changed its height.
0.5.2
- Fixed restored LoomMark tabs after an Extension Host restart. A tab that VS Code retained without
a live custom-editor provider is now closed and recreated automatically, establishing a new
TextDocument synchronization channel before the editor accepts ongoing work.
- Fixed fenced code blocks containing Markdown-looking headings. Their contents now remain code:
they do not create heading styling, heading Cards, or outline entries.
0.5.1
- Fixed a critical data-loss risk after an Extension Host restart. Existing Markdown tabs now
recreate their Webview instead of retaining a detached in-memory editor; edits reconnect to the
authoritative VS Code
TextDocument before they can appear saved.
- Added explicit persistence protection: a rejected document write or an unacknowledged sync now
blocks further editing, reports the failure, and offers recovery-text copying rather than
silently accepting edits that have not reached disk.
- Fixed Open Source Editor to resolve the current custom-editor tab instead of a stale tab
remembered before the Extension Host restart.
0.5.0
- Extracted the reusable CodeMirror Markdown editor kernel into the open-source
@llingshu/loommark-core package. LoomMark now
concentrates on VS Code integration while other hosts can use the same source-preserving editor
behavior without copying the implementation.
- Added experimental annotation blocks: source-backed
<<<[N] and >>>[N] notes attach to the
preceding document line or block, retain persistent IDs and optional fixed colors, and render as
responsive left/right margin cards.
- Improved dense annotation handling: notes sharing a target merge into one card, display a compact
target badge, collapse based on per-side visible density, and reveal a minimal connector only for
the active note.
- Added
source as an ordered-list label mode, alongside the default cycle and hierarchical
decimal modes.
Source Fidelity
The TextDocument owned by VS Code is the persistence authority. LoomMark follows these rules:
- Opening a document never changes it.
- Decorations change presentation, not text.
- Unsupported syntax remains original source text.
- Only direct user input or an explicit command may create a document edit.
- Host acknowledgements cannot overwrite newer local typing.
- Formatting, if introduced, must be explicit, previewable, and undoable.
This matters for Markdown dialects, repository conventions, external generators, note-taking
workflows, embedded HTML, deliberate escapes, and tools that depend on exact source layout.
Installation
Install LoomMark from the
Visual Studio Marketplace,
or install a packaged VSIX:
code --install-extension artifacts/loommark.vsix
Markdown files open with LoomMark by default. Use Reopen Editor With... to select another
editor for an individual document, or disable loommark.openByDefault.
Commands
| Command |
Purpose |
LoomMark: Open Source Editor |
Reopen the current document in VS Code's default text editor. |
LoomMark: Focus Markdown Outline |
Open Explorer and focus LoomMark's native outline. |
LoomMark: Copy Editor Diagnostics |
Copy structured runtime diagnostics for bug reports. |
LoomMark: Toggle Heading Card Mode |
Cycle loommark.cardMode through off/tint/accent/card. Also in the editor title bar. |
Settings
| Setting |
Default |
Description |
loommark.openByDefault |
true |
Associate *.md and *.markdown with LoomMark at user scope. |
loommark.theme |
vscode |
Select vscode, crepe, frame, or nord. |
loommark.table |
rich |
Edit table cells in place (rich) or expand to Markdown source on cursor entry (source). |
loommark.tableStyle |
grid |
Render tables as a bordered grid or a booktabs-style three-line ruled table. |
loommark.orderedListStyle |
cycle |
Cycle arabic/letters/roman numbering (cycle), or show the source marker (source) or hierarchical decimal numbering. |
loommark.listGuides |
true |
Show connector lines between nested list items and their continuation content. |
loommark.cardMode |
card |
Heading section visualization: off, tint, accent, or card. Also cycled from the editor title bar. |
loommark.cardBackgroundColors |
six-hue palette |
Colors to cycle per heading level for the Card background fill. Empty draws no background. |
loommark.cardBorderColors |
six-hue palette |
Colors to cycle per heading level for Card/accent borders and rails. Empty draws no border. |
loommark.cardBackgroundStrength |
0.06 |
Amount of heading accent mixed into the translucent Card surface. |
loommark.cardBorderStrength |
0.52 |
Accent strength in Card borders; lower values are quieter and more neutral. |
loommark.cardImage.enabled |
false |
Draw an independently selected, stable image inside each heading section (card and tint modes only). |
loommark.cardImage.path |
"" |
Card image file or directory; empty reuses loommark.background.path. |
loommark.cardImage.opacity |
0.72 |
Card image opacity from 0 to 1. |
loommark.cardImage.blur |
4 |
Card image blur radius in pixels. |
loommark.cardImage.saturation |
0.75 |
Card image color saturation multiplier. |
loommark.cardImage.overlay |
0.18 |
Editor-theme overlay above each Card image. |
loommark.background.enabled |
false |
Enable an optional image behind the editor. |
loommark.background.path |
"" |
Image file or directory. This machine-specific path is not synchronized to other devices. |
loommark.background.selection |
daily |
Directory selection mode: fixed, onOpen, daily, or perDocument. |
loommark.background.opacity |
0.72 |
Background image opacity from 0 to 1. |
loommark.background.blur |
14 |
Background blur radius in pixels. |
loommark.background.saturation |
0.7 |
Background color saturation multiplier. |
loommark.background.overlay |
0.42 |
Editor-theme overlay opacity; higher values improve readability. |
loommark.keyboardEditing |
false |
Let the cursor enter rendered images, tables, and math with the keyboard. When off, they are edited on click. |
loommark.outline |
both |
Show the outline in both, editor, explorer, or turn it off. |
loommark.syncDelay |
180 |
Debounce duration in milliseconds before syncing local typing to VS Code. |
Wiki Links
LoomMark preserves and renders both common wiki-link forms:
[[notes/project]]
[[notes/project|Project notes]]
Typing [[ opens completion for any file in the workspace, not just Markdown — scripts,
configs, images, and so on. Candidates are relative to the current document; Markdown files omit
their .md/.markdown extension (Obsidian-style), other files keep theirs since it identifies
the file type. The list refreshes when files are created, deleted, or renamed. Ctrl/Cmd + click
opens a target; an extensionless wiki link resolves as .md, one with an extension opens as-is.
Code Blocks
Fenced code blocks use the language following the opening fence:
```typescript
const message: string = "hello";
```
The code-block UI is a view layer. Copy does not include fences. Changing the language selector is
an explicit, undoable edit to the opening fence only. Terminal-like languages receive restrained
window chrome; other languages use neutral controls.
Card Mode
loommark.cardMode visually sets apart each heading's section — the heading itself plus
everything under it, up to the next heading of the same or shallower level. A sub-heading's
section nests inside its parent's, so a deeply nested section reads as several levels layered
inside each other, one per ancestor heading. This is presentation only and never touches the
document text, the same as every other progressive-rendering feature. Cycle through the four
modes with the $(layers) button in the editor title bar, or LoomMark: Toggle Heading Card Mode:
off — no heading visualization.
tint — a soft background color wash per section, no borders. The lightest-touch option.
accent — a colored left border bar per heading level, plus a faint background tint.
card (default) — each section wrapped in a bordered, rounded card. Content (paragraphs, code
blocks, blockquotes) is padded inward from the card's own border rather than sitting flush
against it, so nested code blocks and quotes keep their own visual boundary.
Background fill and border/rail color are controlled independently: loommark.cardBackgroundColors
and loommark.cardBorderColors each cycle their own list of colors per heading level, defaulting
to the same built-in six-hue palette. Each hue is treated as an accent token: Card surfaces mix
only a small amount into a translucent editor-colored base, while borders use a muted
accent/foreground mixture — loommark.cardBackgroundStrength and loommark.cardBorderStrength
tune how strongly each shows. Set either color list to an empty array to draw no color at all for
that layer (a border-only or background-only Card, or neither, while keeping the same content
inset and geometry) rather than falling back to the default palette.
Nested cards use dedicated boundary widgets for their rounded top and bottom edges, while line
decorations draw the continuous side rails and background layers between those boundaries. Cards
that close together receive progressively offset bottom spacing, so each heading level keeps a
distinct rounded edge without rewriting the Markdown source.
Image Backgrounds
LoomMark can select a fixed or rotating image without modifying the Markdown document. Set
loommark.background.path to an image or a directory and enable loommark.background.enabled.
Windows paths may use C:\\Users\\name\\Pictures\\loommark; network shares may use
//server/share/folder or an escaped UNC path such as \\\\server\\share\\folder in JSON.
The extension host resolves the selected local file through VS Code's Webview resource API. A
separate theme-colored overlay keeps text legible independently of image blur, saturation, and
opacity.
loommark.background.path (and loommark.cardImage.path) also accept an https:// URL, loaded
directly by the Webview instead of being resolved as a local file — useful for a shared team image
without distributing it alongside the workspace. A URL is always a single fixed image;
loommark.background.selection's directory-rotation modes only apply to a local directory.
Card images are configured separately with loommark.cardImage.* and work in both the card and
tint loommark.cardMode styles (not accent or off). Each heading section chooses a stable
image from the directory using the document and heading identity, so editing or reopening the
document does not reshuffle its appearance. Leave loommark.cardImage.path empty to reuse the
global background directory. Card images use a dedicated layer below editor content and are clipped
to the same inset geometry as the active mode's own borders/bands rather than being repeated on
individual editor lines.
Tables, images, and math render as widgets rather than plain decorated text. By default the text
cursor skips over them and they are edited by clicking into them; enable loommark.keyboardEditing
to let the cursor step in with the keyboard instead, for mouse-free editing.
Tables
GFM tables render as real <table> elements. loommark.table controls how cells are edited:
rich (default) — click a cell to edit its raw Markdown in place. Enter or clicking elsewhere
commits the change, Escape cancels. Arrow keys move to the adjacent cell (Up/Down always; Left/Right
once the caret reaches that side of the current cell's text), and Tab/Shift+Tab does the same in
reading order, wrapping to the next/previous row. Tab past the last cell of the last row adds a
new row. Alt+Shift+Up/Down/Left/Right inserts an empty row above/below or column
left/right of the current cell. Inline and display math ($...$, $$...$$) inside a cell
renders with KaTeX like it does everywhere else.
source — the whole table expands to Markdown source when the cursor enters it, matching the
edit style used for headings and emphasis.
loommark.tableStyle switches the visual style between a bordered grid (default) and a
booktabs-style three-line ruled table (heavy top/bottom rules, a header rule, light row
separators, no vertical lines) — useful for reference tables like a CLI command list.
Images
 renders inline or, on its own line, as a centered block image in an independent
bordered container. Add presentation tokens in the optional title only to opt out:
 keeps the block aligned to the source column,
 removes its container, and "no-center no-frame" disables both.
Other title text remains accepted and has no visual effect. Relative paths resolve
against the document's own directory and may climb into a sibling folder (../assets/x.png) as
long as the target stays inside the document's workspace folder; documents opened outside any
workspace can only reach their own directory. Paths with spaces or special characters can be
wrapped in angle brackets: . Remote http(s): and data:
sources are used as-is. An image that fails to load shows a placeholder instead of breaking the
line. Click an image to edit its Markdown source; Ctrl/Cmd + click opens it instead, whether the
image is rendered or shown as source. The raw source gets a highlighted background and a
link-colored destination while the cursor is inside it, so it stays easy to find.
Pasting an image from the clipboard saves it to disk and inserts a Markdown image link at the
cursor. The destination folder reuses VS Code's own markdown.copyFiles.destination setting (the
same one the built-in Markdown editor uses for dropped/pasted files), so existing configuration
like:
"markdown.copyFiles.destination": { "**/*": "assets/" }
applies unchanged — no separate LoomMark setting to configure. With nothing configured, the image
saves next to the document. The file is named image.png (or the appropriate extension for the
clipboard's image type), with -1, -2, ... appended if that name is already taken.
Math
Math is rendered locally with KaTeX — no network request is made. Inline
math uses $...$ and display math uses $$...$$ (including multi-line blocks). Currency-like
text ($5, $10) is left as plain text. Invalid LaTeX shows KaTeX's inline error instead of
breaking the editor.
Lists
Tab and Shift+Tab indent/outdent the current line (or every selected line) by 4 spaces, which is
how a list item becomes a nested sub-list — nesting is purely indentation, the same as CommonMark.
Four spaces, not two: CommonMark only recognizes a nested item once its content reaches its
parent's own content column, which for an ordered marker like 1. is 3+ characters (more for
10. , 100. , and so on); 2 spaces satisfies bullet markers but not ordered ones, and a
too-shallow nested ordered item is parsed as a continuation paragraph of its parent instead of a
real sub-list — which also breaks Enter part way through typing it, since there is no list for
Enter to continue. Use Tab rather than typing spaces by hand to always get a valid amount.
Shift+Enter at the end of a list item's own marker line indents the new line to match, for the
same reason — pressing it again from that new (now correctly indented) line just continues
matching its indent, without adding another level.
Nested ordered lists use cycle by default (arabic, then letters, then lowercase roman numerals
per level — 1, a, i — repeating every three levels). loommark.orderedListStyle can instead
choose source to show the Markdown marker exactly as written, or decimal
(1, 2, 2.1, 2.2, 2.2.1). Click a label to edit its source number.
loommark.listGuides (default on) draws a connector line between a list item, its nested
children, and any indented continuation content underneath it (a paragraph, blockquote, or code
block). Guides stay visible at all times, including on the line the cursor is on — there is no
raw source to reveal, only blank space, so there is nothing to switch to. Guides are gray by
default; the cursor's own line, each of its direct ancestor items' lines, and — if the cursor is
on one of several lines belonging to the same multi-line item's own paragraph (for example after
a Shift+Enter soft line break) — the whole run of those lines together, all light up in color, one
per level, while sibling branches and unrelated content stay gray even when they happen to share
part of the same connector.
A standalone #word or #nested/tag renders as a pill. The # stays part of the editable text
since it carries meaning, unlike heading or emphasis markers. Heading markers (# Heading),
hashes in the middle of a word (foo#bar), and numeric references (#123) are not treated as
tags.
Annotations (experimental)
A block opened by <<<[N]color and closed by a bare <<< attaches a left-margin note to the line
(or table/code/math block) directly above it; >>> attaches a right-margin note the same way:
this line gets a note
<<<[1]7c3aed
the note's content — can span multiple lines
<<<
Typing the 3rd < (or >) of a delimiter line on an otherwise-empty line auto-closes the block
immediately, the same way a code fence or $$ block auto-closes — a bare, unclosed opener can't
run on and silently swallow every real paragraph up to the next <<</>>> line later in the
document. Ctrl/Cmd+Shift+Left/Ctrl/Cmd+Shift+Right wrap the line the cursor is on in a fresh
left-/right-margin note and focus it directly, without needing to type the fence by hand — the
arrow you press is the side you get; Ctrl/Cmd+Shift+A toggles whether annotations render at all.
[N] is the note's persistent identity and the optional 6 hex digits after it are its fixed color.
Every note created through the extension writes both automatically, using the current document's
largest ID plus one and the next palette color. IDs are never renumbered when another note is moved
or deleted. Older bare (<<<) and color-only (<<<7c3aed) blocks remain valid for compatibility.
The block never renders as part of the document — no other Markdown construct (list numbering,
guide rails, heading sections, ...) sees it as present at all. The attached line (or block) gets a
thin color-coded accent stripe and numbered badge — real overlays, not a background painted onto
the line itself, so
it can never collide with (and erase) that line's own heading-Card background or border the way an
earlier version of this feature briefly did. The stripe is also the note's hover/click target: there
is no separate marker icon duplicating it. Each note is its own clean card tinted in its own color,
pinned flush to the true left/right edge of the editor, outside the editing area entirely and
unaffected by heading Card mode's own indentation, so notes on deeply nested headings still line up
with everything else on the same side — whenever there's enough room for it there; otherwise it
collapses to just the stripe, revealed on hover (or forced open regardless of available room via the
right-click "Pin" below). A note can be individually collapsed to a one-line preview or edited
directly; its height follows short content and scrolls once long content reaches the viewport limit.
Its rendered text supports normal pointer selection and copying. Double-click the text, or focus it
and press Enter, to edit with the standard select-all, copy, paste, undo, and redo shortcuts.
Delete, add another note at the same attachment point, and pin/unpin live in the visible ellipsis
menu (and remain available by right-click). Stacking several blocks back
to back (either side, or via "Add note") all attach to the same original line rather than to each
other. Notes sharing that target and side render in one compact card, with the first persistent ID
shown on the target badge and the remaining notes available inside the card. A block whose target
line belongs to a
table/fenced-code/display-math construct or a list item's own shift+enter continuation attaches to
the whole thing, not just one line of it.
A note only gets a connector — a short, color-matched line back to its stripe — while it is hovered,
focused, or click-locked and it actually needed to be displaced from its natural row; several notes
sharing one target inevitably need this (they can't all sit at an identical position), but a lone
note usually won't draw one at all. The connector's rail sits near the note's own stripe position
(near the text) rather than the card's edge, since the card's position can shift (resizing, pinning)
in ways the stripe never does, clearing the stripe's own footprint first so it never draws over the
color block it's pointing away from. Several connectors that are in flight at the same time —
whether several notes on one target, or several separate single-note targets crowded together —
share a bundle of lanes reassigned per height range (the same interval-scheduling a calendar view
uses to lay out overlapping events side by side, applied per slice rather than once for a
connector's whole length): each one only steps out to a further lane for as long as a competitor is
actually active alongside it, and shifts back inward the moment that competitor peels off toward its
own card. Together they read as a bundle of nested arcs — thickest near the text, thinning out one
branch at a time as each connector reaches its card — rather than a set of permanently separate
parallel lines. Notes anchored close together on the same side pack in document order, and the
active grouped card expands in place while dense areas collapse to their target badges.
This is still a first pass:
- The color-coded stripe doesn't currently reach a table target — tables replace their whole source
with one opaque widget with no per-line element left to attach an overlay to (a fenced-code block
still has one, so those get a stripe normally). The note itself still attaches and works normally
on a table target, just without the connecting stripe, and — since there's nothing to hover — only
shows when pinned (automatically or via the right-click menu), not via hover-reveal.
- A bare (untagged) annotation's color is still assigned by its position among all other untagged
annotations in the document, so adding or removing one earlier in the document can shift the
colors of unrelated ones after it — tag it (see above) to pin its color permanently instead.
Search And Replace
Ctrl/Cmd+F opens a find panel inside the editor with case-sensitive, regular-expression, and
whole-word toggles, plus replace and replace-all. Escape closes it.
Escaping
A backslash before CommonMark's escapable punctuation (!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~) hides
the backslash and renders the character as plain text instead of live Markdown syntax:
\*not bold\*, \#not a tag, \. Escapes are ignored inside code, matching
CommonMark.
Privacy
LoomMark processes documents locally inside VS Code. It includes no analytics, advertising,
telemetry, remote document service, or background upload. External links open only after an explicit
user action.
Development
Requirements: Node.js 20 or later and VS Code 1.95 or later.
npm ci
npm run check
npm run build
Press F5 to launch an Extension Development Host. Use npm run watch while editing. Package the
same artifact used for both registries with:
npm run package
Detailed documentation:
Limitations
- Host synchronization currently applies the smallest single contiguous replacement. Concurrent
overlapping edits from another editor are not rebased.
- Progressive rendering intentionally covers a defined Markdown subset. Unimplemented syntax stays
visible and editable rather than being discarded.
- Only one LoomMark custom editor is supported per text document.
- The complete language-data bundle increases the Webview bundle size; language splitting remains
future optimization work.
Acknowledgements
LoomMark is built with CodeMirror 6,
Lezer, mdast,
KaTeX, and the
Visual Studio Code Extension API. See
THIRD_PARTY_NOTICES.md and THIRD_PARTY_LICENSES.txt
for dependency license information.
Citation And License
Use CITATION.cff to cite LoomMark. The project is released under the
MIT License.