import { syntaxTree } from "@codemirror/language"; import type { Extension, Range } from "@codemirror/state"; import { RangeSetBuilder } from "@codemirror/state"; import { Decoration, type DecorationSet, EditorView, ViewPlugin, type ViewUpdate, WidgetType, } from "@codemirror/view"; import type { SyntaxNode, SyntaxNodeRef } from "@lezer/common"; /** * Obsidian/Typora-style live preview for Markdown. * * The underlying document text is never modified: decorations are purely * presentational and are rebuilt from the syntax tree on every doc change, * selection change and viewport change. Because remote peers (and agents) * can edit the document concurrently, this file must never assume offsets * or text captured in one pass are still valid in a later pass — every * rebuild re-reads `view.state` from scratch. */ // --------------------------------------------------------------------------- // Widgets // --------------------------------------------------------------------------- class BulletWidget extends WidgetType { eq(other: BulletWidget): boolean { return other instanceof BulletWidget; } toDOM(): HTMLElement { const span = document.createElement("span"); span.className = "cm-nib-bullet"; span.textContent = "•"; return span; } ignoreEvent(): boolean { return true; } } class CheckboxWidget extends WidgetType { constructor(readonly checked: boolean) { super(); } eq(other: CheckboxWidget): boolean { return other.checked === this.checked; } toDOM(view: EditorView): HTMLElement { const input = document.createElement("input"); input.type = "checkbox"; input.className = "cm-nib-checkbox"; input.checked = this.checked; input.setAttribute("aria-label", this.checked ? "Mark as not done" : "Mark as done"); // Toggling edits the source ("[ ]" <-> "[x]"); mousedown is prevented so the line stays rendered. input.addEventListener("mousedown", (event) => { event.preventDefault(); const pos = view.posAtDOM(input); const marker = view.state.doc.sliceString(pos, pos + 3); if (!/^\[[ xX]\]$/.test(marker)) return; view.dispatch({ changes: { from: pos + 1, to: pos + 2, insert: this.checked ? " " : "x" } }); }); input.addEventListener("click", (event) => event.preventDefault()); return input; } ignoreEvent(): boolean { return true; } } class ImageWidget extends WidgetType { constructor( readonly src: string, readonly alt: string, ) { super(); } eq(other: ImageWidget): boolean { return other.src === this.src && other.alt === this.alt; } toDOM(): HTMLElement { const img = document.createElement("img"); img.className = "cm-nib-img"; img.src = this.src; img.alt = this.alt; img.loading = "lazy"; return img; } ignoreEvent(): boolean { return false; } } class HorizontalRuleWidget extends WidgetType { eq(other: HorizontalRuleWidget): boolean { return other instanceof HorizontalRuleWidget; } toDOM(): HTMLElement { const hr = document.createElement("div"); hr.className = "cm-nib-hr"; return hr; } ignoreEvent(): boolean { return true; } } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Line numbers (1-based) touched by any selection range, including bare cursors. */ function activeLines(view: EditorView): Set { const lines = new Set(); for (const range of view.state.selection.ranges) { const startLine = view.state.doc.lineAt(range.from).number; const endLine = view.state.doc.lineAt(range.to).number; for (let n = startLine; n <= endLine; n++) lines.add(n); } return lines; } function lineIsActive(view: EditorView, active: Set, pos: number): boolean { return active.has(view.state.doc.lineAt(pos).number); } /** True if [from, to) stays within a single line — required for non-block replace decorations. */ function sameLine(view: EditorView, from: number, to: number): boolean { if (from === to) return true; return view.state.doc.lineAt(from).number === view.state.doc.lineAt(to).number; } const HEADING_NODE_LEVEL: Record = { ATXHeading1: 1, ATXHeading2: 2, ATXHeading3: 3, ATXHeading4: 4, ATXHeading5: 5, ATXHeading6: 6, }; const BULLET_CHARS = new Set(["-", "*", "+"]); /** Detects a leading YAML-ish frontmatter block (--- ... ---) via plain text scan. */ function frontmatterEnd(view: EditorView): number | null { const doc = view.state.doc; if (doc.lines < 2) return null; if (doc.line(1).text.trim() !== "---") return null; const cap = Math.min(doc.lines, 200); for (let n = 2; n <= cap; n++) { if (doc.line(n).text.trim() === "---") return n; } return null; } // --------------------------------------------------------------------------- // Decoration builder // --------------------------------------------------------------------------- interface Piece { from: number; to: number; deco: Decoration; } function buildDecorations(view: EditorView): DecorationSet { const active = activeLines(view); const pieces: Piece[] = []; const linePieces: Piece[] = []; const addMark = (from: number, to: number, deco: Decoration) => { if (from >= to) return; pieces.push({ from, to, deco }); }; const addReplace = (from: number, to: number, deco: Decoration) => { if (from > to) return; if (!sameLine(view, from, to)) return; pieces.push({ from, to, deco }); }; const addLine = (linePos: number, deco: Decoration) => { linePieces.push({ from: linePos, to: linePos, deco }); }; // Frontmatter: styled, never hidden. const fmEnd = frontmatterEnd(view); // lezer reads frontmatter as Markdown (an hr, then a setext heading), so its nodes are skipped. const fmEndPos = fmEnd === null ? 0 : view.state.doc.line(fmEnd).to; if (fmEnd !== null) { const doc = view.state.doc; for (let n = 1; n <= fmEnd; n++) { addLine(doc.line(n).from, Decoration.line({ class: "cm-nib-frontmatter" })); } } const tree = syntaxTree(view.state); for (const { from, to } of view.visibleRanges) { tree.iterate({ from, to, enter(node: SyntaxNodeRef) { const name = node.name; if (node.from < fmEndPos && name !== "Document") return false; // Headings ----------------------------------------------------- const level = HEADING_NODE_LEVEL[name]; if (level) { const line = view.state.doc.lineAt(node.from); addLine(line.from, Decoration.line({ class: `cm-nib-h${level}` })); if (!lineIsActive(view, active, node.from)) { const mark = node.node.getChild("HeaderMark"); if (mark) { let hideTo = mark.to; // Swallow the single space after the mark, if present. if (view.state.doc.sliceString(hideTo, hideTo + 1) === " ") hideTo += 1; addReplace(mark.from, hideTo, Decoration.replace({})); } } return; } // Emphasis / strong / strikethrough / inline code --------------- if (name === "Emphasis" || name === "StrongEmphasis" || name === "Strikethrough" || name === "InlineCode") { const cls = name === "Emphasis" ? "cm-nib-em" : name === "StrongEmphasis" ? "cm-nib-strong" : name === "Strikethrough" ? "cm-nib-strike" : "cm-nib-code"; addMark(node.from, node.to, Decoration.mark({ class: cls })); if (!lineIsActive(view, active, node.from)) { const markName = name === "InlineCode" ? "CodeMark" : "EmphasisMark"; const nodeRef = node.node; for (const child of nodeRef.getChildren(markName)) { addReplace(child.from, child.to, Decoration.replace({})); } } return; } // Links ---------------------------------------------------------- if (name === "Link") { const nodeRef = node.node; const marks = nodeRef.getChildren("LinkMark"); const urlNode = nodeRef.getChild("URL"); // Text range: between the first "[" mark's end and the first "]" mark's start. const openMark = marks[0] ?? null; const closeMark = marks[1] ?? null; const textFrom = openMark ? openMark.to : node.from; const textTo = closeMark ? closeMark.from : node.to; const href = urlNode ? view.state.doc.sliceString(urlNode.from, urlNode.to) : ""; addMark(textFrom, textTo, linkMark(href)); if (!lineIsActive(view, active, node.from)) { if (openMark) addReplace(openMark.from, openMark.to, Decoration.replace({})); // Hide "](url)" as one chunk: from the closing "]" mark through the end of the link node. if (closeMark) addReplace(closeMark.from, node.to, Decoration.replace({})); } return; } if (name === "URL") { // Bare autolinks / URL tokens not already covered by a Link parent. if (node.node.parent?.name !== "Link" && node.node.parent?.name !== "Image") { addMark(node.from, node.to, linkMark(view.state.doc.sliceString(node.from, node.to))); } return; } // Images: rendered inline off the cursor line, raw on it. ------- if (name === "Image") { const nodeRef = node.node; const urlNode = nodeRef.getChild("URL"); const marks = nodeRef.getChildren("LinkMark"); if (!lineIsActive(view, active, node.from) && urlNode && marks.length >= 2) { const src = view.state.doc.sliceString(urlNode.from, urlNode.to); const alt = view.state.doc.sliceString(marks[0]!.to, marks[1]!.from); addReplace(node.from, node.to, Decoration.replace({ widget: new ImageWidget(src, alt) })); } else { addMark(node.from, node.to, Decoration.mark({ class: "cm-nib-image" })); } return false; } // Blockquote ------------------------------------------------- if (name === "Blockquote") { const doc = view.state.doc; const startLine = doc.lineAt(node.from).number; const endLine = doc.lineAt(node.to).number; for (let n = startLine; n <= endLine; n++) { addLine(doc.line(n).from, Decoration.line({ class: "cm-nib-quote" })); } return; } if (name === "QuoteMark") { if (!lineIsActive(view, active, node.from)) { let hideTo = node.to; if (view.state.doc.sliceString(hideTo, hideTo + 1) === " ") hideTo += 1; addReplace(node.from, hideTo, Decoration.replace({})); } return; } // Lists ---------------------------------------------------------- if (name === "ListMark") { const parentName = node.node.parent?.name; const text = view.state.doc.sliceString(node.from, node.to); const isBullet = parentName === "ListItem" && BULLET_CHARS.has(text.trim()); const isTask = parentName === "ListItem" && node.node.parent?.getChild("Task") != null; if (isBullet && isTask && !lineIsActive(view, active, node.from)) { // A task's checkbox stands in for its bullet. let hideTo = node.to; if (view.state.doc.sliceString(hideTo, hideTo + 1) === " ") hideTo += 1; addReplace(node.from, hideTo, Decoration.replace({})); } else if (isBullet) { if (!lineIsActive(view, active, node.from)) { addReplace(node.from, node.to, Decoration.replace({ widget: new BulletWidget() })); } else { addMark(node.from, node.to, Decoration.mark({ class: "cm-nib-listmark" })); } } else { // Ordered list marks stay visible, just styled. addMark(node.from, node.to, Decoration.mark({ class: "cm-nib-listmark" })); } return; } if (name === "TaskMarker") { if (!lineIsActive(view, active, node.from)) { const text = view.state.doc.sliceString(node.from, node.to); const checked = /\[x\]/i.test(text); addReplace(node.from, node.to, Decoration.replace({ widget: new CheckboxWidget(checked) })); } else { addMark(node.from, node.to, Decoration.mark({ class: "cm-nib-taskmark" })); } return; } // Horizontal rule -------------------------------------------- if (name === "HorizontalRule") { if (!lineIsActive(view, active, node.from)) { addReplace(node.from, node.to, Decoration.replace({ widget: new HorizontalRuleWidget(), block: false })); } else { addMark(node.from, node.to, Decoration.mark({ class: "cm-nib-hr-raw" })); } return; } // Fenced code blocks ------------------------------------------ if (name === "FencedCode") { const doc = view.state.doc; const startLine = doc.lineAt(node.from).number; const endLine = doc.lineAt(node.to).number; for (let n = startLine; n <= endLine; n++) { const classes = ["cm-nib-codeblock"]; if (n === startLine) classes.push("cm-nib-codeblock-first"); if (n === endLine) classes.push("cm-nib-codeblock-last"); addLine(doc.line(n).from, Decoration.line({ class: classes.join(" ") })); } // Dim the fence lines/marks, never hide them. const nodeRef = node.node; for (const child of nodeRef.getChildren("CodeMark")) { addMark(child.from, child.to, Decoration.mark({ class: "cm-nib-fence" })); } const infoNode = nodeRef.getChild("CodeInfo"); if (infoNode) addMark(infoNode.from, infoNode.to, Decoration.mark({ class: "cm-nib-fence" })); return; } }, }); } linePieces.sort((a, b) => a.from - b.from); pieces.sort((a, b) => a.from - b.from || a.to - b.to); const builder = new RangeSetBuilder(); const all: Range[] = []; for (const p of linePieces) all.push(p.deco.range(p.from)); for (const p of pieces) all.push(p.deco.range(p.from, p.to)); all.sort((a, b) => a.from - b.from || a.to - b.to); for (const r of all) builder.add(r.from, r.to, r.value); return builder.finish(); } const linkMark = (href: string) => Decoration.mark({ class: "cm-nib-link", attributes: { title: `${href}\n${MOD_KEY}-click to open` } }); const MOD_KEY = typeof navigator !== "undefined" && /Mac|iP(hone|ad)/.test(navigator.platform) ? "⌘" : "Ctrl"; /** The URL of the link or autolink at `pos`, if any. */ function linkAt(view: EditorView, pos: number): string | undefined { for (let node: SyntaxNode | null = syntaxTree(view.state).resolveInner(pos, 1); node; node = node.parent) { if (node.name === "Link") { const url = node.getChild("URL"); return url ? view.state.doc.sliceString(url.from, url.to) : undefined; } if (node.name === "URL" || node.name === "Autolink") { return view.state.doc.sliceString(node.from, node.to).replace(/^<|>$/g, ""); } } return undefined; } /** Mod-click opens a link; a plain click places the cursor so the link can be edited. */ const openLinks = EditorView.domEventHandlers({ mousedown(event, view) { if (event.button !== 0 || !(event.metaKey || event.ctrlKey)) return false; const target = event.target instanceof Element ? event.target.closest(".cm-nib-link") : null; const pos = target ? view.posAtDOM(target) : view.posAtCoords({ x: event.clientX, y: event.clientY }); const href = pos === null ? undefined : linkAt(view, pos); if (!href) return false; event.preventDefault(); window.open(href, "_blank", "noopener,noreferrer"); return true; }, }); // --------------------------------------------------------------------------- // Plugin + theme // --------------------------------------------------------------------------- const livePreviewPlugin = ViewPlugin.fromClass( class { decorations: DecorationSet; constructor(view: EditorView) { this.decorations = buildDecorations(view); } update(update: ViewUpdate): void { if ( update.docChanged || update.selectionSet || update.viewportChanged || syntaxTree(update.startState) !== syntaxTree(update.state) ) { this.decorations = buildDecorations(update.view); } } }, { decorations: (v) => v.decorations, }, ); const livePreviewTheme = EditorView.theme({ "&": { color: "var(--nib-doc-fg, light-dark(#1d1d1f, #e8e8ea))", backgroundColor: "var(--nib-doc-bg, light-dark(#ffffff, #1b1b1d))", fontFamily: "var(--nib-doc-font, -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif)", colorScheme: "light dark", }, "&.cm-editor": { height: "100%", }, "&.cm-focused": { outline: "none", }, ".cm-gutters": { display: "none", }, ".cm-activeLine": { backgroundColor: "transparent", }, ".cm-activeLineGutter": { backgroundColor: "transparent", }, ".cm-scroller": { fontFamily: "inherit", }, ".cm-content": { maxWidth: "72ch", margin: "0 auto", padding: "2rem 1.5rem", lineHeight: "1.6", }, ".cm-line": { padding: "0", }, // Headings ------------------------------------------------------------ ".cm-nib-h1": { fontSize: "1.9em", fontWeight: "700", lineHeight: "1.3" }, ".cm-nib-h2": { fontSize: "1.6em", fontWeight: "700", lineHeight: "1.3" }, ".cm-nib-h3": { fontSize: "1.35em", fontWeight: "600", lineHeight: "1.35" }, ".cm-nib-h4": { fontSize: "1.15em", fontWeight: "600", lineHeight: "1.4" }, ".cm-nib-h5": { fontSize: "1.05em", fontWeight: "600", lineHeight: "1.4" }, ".cm-nib-h6": { fontSize: "1em", fontWeight: "600", lineHeight: "1.4", color: "var(--nib-doc-muted, light-dark(#6e6e73, #9a9aa0))" }, // Inline styles --------------------------------------------------------- ".cm-nib-em": { fontStyle: "italic" }, ".cm-nib-strong": { fontWeight: "700" }, ".cm-nib-strike": { textDecoration: "line-through" }, ".cm-nib-code": { fontFamily: "var(--nib-doc-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace)", backgroundColor: "var(--nib-doc-code-bg, light-dark(#f0f0f2, #2a2a2d))", borderRadius: "4px", padding: "0.1em 0.3em", fontSize: "0.9em", }, ".cm-nib-link": { color: "var(--nib-doc-accent, light-dark(#0969da, #58a6ff))", textDecoration: "underline", textDecorationColor: "var(--nib-doc-muted, light-dark(#aeb0b4, #55585e))", }, ".cm-nib-img": { display: "block", maxWidth: "100%", maxHeight: "28em", borderRadius: "6px", margin: "0.25em 0", }, ".cm-nib-image": { color: "var(--nib-doc-muted, light-dark(#6e6e73, #9a9aa0))", }, // Blockquote -------------------------------------------------------- ".cm-nib-quote": { borderLeft: "3px solid var(--nib-doc-muted, light-dark(#d0d0d4, #45454a))", paddingLeft: "1em", color: "var(--nib-doc-muted, light-dark(#6e6e73, #9a9aa0))", }, // Lists --------------------------------------------------------------- ".cm-nib-bullet": { display: "inline-block", width: "1.2em", color: "var(--nib-doc-muted, light-dark(#6e6e73, #9a9aa0))", }, ".cm-nib-listmark": { color: "var(--nib-doc-muted, light-dark(#6e6e73, #9a9aa0))", }, ".cm-nib-checkbox": { marginRight: "0.4em", verticalAlign: "middle", cursor: "pointer", }, ".cm-nib-taskmark": { fontFamily: "var(--nib-doc-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace)", }, // Horizontal rule ----------------------------------------------------- ".cm-nib-hr": { display: "block", height: "1px", margin: "1em 0", backgroundColor: "var(--nib-doc-muted, light-dark(#d0d0d4, #45454a))", }, ".cm-nib-hr-raw": { color: "var(--nib-doc-muted, light-dark(#6e6e73, #9a9aa0))", }, // Fenced code blocks ---------------------------------------------------- ".cm-nib-codeblock": { backgroundColor: "var(--nib-doc-code-bg, light-dark(#f0f0f2, #2a2a2d))", fontFamily: "var(--nib-doc-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace)", fontSize: "0.9em", }, ".cm-nib-codeblock-first": { borderTopLeftRadius: "6px", borderTopRightRadius: "6px", paddingTop: "0.4em", }, ".cm-nib-codeblock-last": { borderBottomLeftRadius: "6px", borderBottomRightRadius: "6px", paddingBottom: "0.4em", }, ".cm-nib-fence": { color: "var(--nib-doc-muted, light-dark(#aeb0b4, #55585e))", }, // Frontmatter ----------------------------------------------------------- ".cm-nib-frontmatter": { fontFamily: "var(--nib-doc-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace)", color: "var(--nib-doc-muted, light-dark(#6e6e73, #9a9aa0))", fontSize: "0.85em", }, }); /** Obsidian/Typora-style live preview decorations + prose theme for Markdown. */ export function livePreview(): Extension { return [livePreviewPlugin, livePreviewTheme, openLinks]; }