Something went wrong. Try again.
Monorepo for the nibling.run platform
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586import { 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<number> { const lines = new Set<number>(); 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<number>, 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<string, number> = { 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<Decoration>(); const all: Range<Decoration>[] = []; 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];}