// Live Markdown editing, like Obsidian's live preview: the editor shows the // file styled as it renders, with the Markdown syntax (#s, **, `, link URLs, // > and list markers) hidden. Wherever the cursor is, the syntax comes back so // it can be edited: a heading's #s on its line, a bold's ** inside it. // The colours are the same roles as .MarkdownBody (Styles/Theme.css). // ```mermaid blocks show as their diagram until the cursor goes into them, // the properties block at the top (Properties.js) as its panel, and // SaraView's variables (portals, loops; the server's VarEngine.py) as what // they resolve to. import { syntaxTree } from "@codemirror/language"; import MarkdownIt from "markdown-it"; import { Facet, StateEffect, StateField } from "@codemirror/state"; import { Decoration, EditorView, ViewPlugin, WidgetType } from "@codemirror/view"; import { FetchSpans } from "./Api.js"; import { RenderDiagram } from "./Mermaid.js"; import { ParseProperties, PropertiesHtml, ToggleLine } from "./Properties.js"; // How the live editor opens what's clicked: PreviewPane gives it, so notes // open in SaraView and web links in a new tab. ImageUrl turns an image's src // (relative to the note) into a URL the browser can load. export const LinkActions = Facet.define({ combine: (Values) => Values[0] || { Open: () => {}, ImageUrl: (Src) => Src }, }); // A click on a link inside a widget (properties, a variable's result) opens it. function OpenFromWidget(View, Event) { const Anchor = Event.target.closest("a"); if (!Anchor) return false; Event.preventDefault(); View.state.facet(LinkActions).Open(Anchor.getAttribute("href")); return true; } // An image, shown; clicking it opens it. class ImageWidget extends WidgetType { constructor(Src, Alt) { super(); this.Src = Src; this.Alt = Alt; } eq(Other) { return this.Src === Other.Src && this.Alt === Other.Alt; } toDOM(View) { const Image = document.createElement("img"); Image.className = "cm-LiveImage"; Image.src = View.state.facet(LinkActions).ImageUrl(this.Src); Image.alt = this.Alt; Image.title = this.Alt || this.Src; Image.addEventListener("mousedown", (Event) => { Event.preventDefault(); View.state.facet(LinkActions).Open(this.Src); }); return Image; } ignoreEvent() { return true; } } class BulletWidget extends WidgetType { eq() { return true; } toDOM() { const Element = document.createElement("span"); Element.className = "cm-LiveBullet"; Element.textContent = "•"; return Element; } } class RuleWidget extends WidgetType { eq() { return true; } toDOM() { const Element = document.createElement("span"); Element.className = "cm-LiveRule"; return Element; } } // "[ ]" / "[x]" as a checkbox; clicking it ticks the task in the text. class CheckboxWidget extends WidgetType { constructor(Checked, Position) { super(); this.Checked = Checked; this.Position = Position; } eq(Other) { return this.Checked === Other.Checked && this.Position === Other.Position; } toDOM(View) { const Box = document.createElement("input"); Box.type = "checkbox"; Box.className = "cm-LiveCheckbox"; Box.checked = this.Checked; Box.addEventListener("mousedown", (Event) => { Event.preventDefault(); View.dispatch({ changes: { from: this.Position, to: this.Position + 3, insert: this.Checked ? "[ ]" : "[x]" }, }); }); return Box; } ignoreEvent() { return true; } } // A ```mermaid block drawn as its diagram; clicking it puts the cursor in // the block, which brings the code back. class DiagramWidget extends WidgetType { constructor(Source) { super(); this.Source = Source; } eq(Other) { return this.Source === Other.Source; } toDOM(View) { const Element = document.createElement("div"); Element.className = "MermaidDiagram cm-LiveDiagram"; Element.dataset.source = this.Source; Element.addEventListener("mousedown", (Event) => { Event.preventDefault(); const Line = View.state.doc.lineAt(View.posAtDOM(Element)); const Inside = Math.min(Line.to + 1, View.state.doc.length); View.dispatch({ selection: { anchor: Inside } }); View.focus(); }); RenderDiagram(Element); return Element; } get estimatedHeight() { return 200; } ignoreEvent() { return true; } } function EscapeHtml(Text) { return Text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } // The properties block drawn as its panel. A true/false checkbox flips the // value without opening the block; clicking anywhere else opens it to edit. class PropertiesWidget extends WidgetType { constructor(Html) { super(); this.Html = Html; } eq(Other) { return this.Html === Other.Html; } toDOM(View) { const Element = document.createElement("div"); Element.className = "MarkdownBody cm-LiveProperties"; Element.style.padding = "0"; Element.innerHTML = this.Html; Element.addEventListener("mousedown", (Event) => { if (OpenFromWidget(View, Event)) return; Event.preventDefault(); const Box = Event.target.closest(".PropertyCheckbox"); const Doc = View.state.doc; if (Box) { const Line = Doc.line(Number(Box.dataset.line) + 1); const Changed = ToggleLine(Line.text, 0); View.dispatch({ changes: { from: Line.from, to: Line.to, insert: Changed } }); return; } View.dispatch({ selection: { anchor: Doc.line(Math.min(2, Doc.lines)).from } }); View.focus(); }); return Element; } ignoreEvent() { return true; } } // Where the properties block ends (0 if there isn't one): the plugin below // leaves it alone, since Markdown would read it as a rule and a heading. function PropertiesEnd(State) { const Doc = State.doc; if (!/^---\s*$/.test(Doc.line(1).text)) return 0; const Parsed = ParseProperties(Doc.sliceString(0, Math.min(Doc.length, 20000))); return Parsed ? Parsed.EndOffset : 0; } const Hidden = Decoration.replace({}); const Bullet = Decoration.replace({ widget: new BulletWidget() }); const Rule = Decoration.replace({ widget: new RuleWidget() }); const InlineClasses = { Emphasis: "cm-LiveEm", StrongEmphasis: "cm-LiveStrong", Strikethrough: "cm-LiveStrike", InlineCode: "cm-LiveCode", Link: "cm-LiveLink", TableHeader: "cm-LiveTableHead", }; // Syntax that hides unless the cursor is inside its parent (keyed by parent). const InlineMarks = { EmphasisMark: ["Emphasis", "StrongEmphasis"], StrikethroughMark: ["Strikethrough"], CodeMark: ["InlineCode"], LinkMark: ["Link"], URL: ["Link"], LinkTitle: ["Link"], }; function BuildDecorations(View) { const State = View.state; const Doc = State.doc; const Ranges = []; const Focused = View.hasFocus; // Nothing is "active" while the editor isn't focused: the whole file shows rendered. function Touches(From, To) { if (!Focused) return false; return State.selection.ranges.some((Range) => Range.from <= To && Range.to >= From); } function LineActive(Position) { const Line = Doc.lineAt(Position); return Touches(Line.from, Line.to); } function AddLineClass(From, To, ClassName) { for (let Position = From; Position <= To; ) { const Line = Doc.lineAt(Position); Ranges.push(Decoration.line({ class: ClassName }).range(Line.from)); Position = Line.to + 1; } } // A mark plus the space that separates it from the text ("# ", "> ", "- "). function HideWithSpace(From, To) { const End = To < Doc.length && Doc.sliceString(To, To + 1) === " " ? To + 1 : To; Ranges.push(Hidden.range(From, End)); } const PropertiesTo = PropertiesEnd(State); if (PropertiesTo > 0) AddLineClass(0, PropertiesTo, "cm-LiveFrontmatter"); for (const { from, to } of View.visibleRanges) { syntaxTree(State).iterate({ from, to, enter(Node) { if (Node.from < PropertiesTo) return Node.to <= PropertiesTo ? false : undefined; const Name = Node.name; const Parent = Node.node.parent; const ParentName = Parent ? Parent.name : ""; const HeadingMatch = /^(?:ATX|Setext)Heading(\d)$/.exec(Name); if (HeadingMatch) { const Line = Doc.lineAt(Node.from); Ranges.push(Decoration.line({ class: `cm-LiveHeading cm-LiveH${HeadingMatch[1]}` }).range(Line.from)); return; } if (Name === "HeaderMark") { if (ParentName.startsWith("ATXHeading") && !LineActive(Node.from)) { const Line = Doc.lineAt(Node.from); if (Node.from === Line.from || Doc.sliceString(Line.from, Node.from).trim() === "") { HideWithSpace(Node.from, Node.to); } else { // Closing #s: hide them and the space before. const Start = Doc.sliceString(Node.from - 1, Node.from) === " " ? Node.from - 1 : Node.from; Ranges.push(Hidden.range(Start, Node.to)); } } else if (ParentName.startsWith("SetextHeading")) { Ranges.push(Decoration.mark({ class: "cm-LiveMuted" }).range(Node.from, Node.to)); } return; } if (Name === "Image") { if (!Touches(Node.from, Node.to)) { const Url = Node.node.getChild("URL"); const Text = Doc.sliceString(Node.from, Node.to); const Alt = (/^!\[([^\]]*)\]/.exec(Text) || [, ""])[1]; if (Url) { const Widget = new ImageWidget(Doc.sliceString(Url.from, Url.to), Alt); Ranges.push(Decoration.replace({ widget: Widget }).range(Node.from, Node.to)); return false; } } return; } if (Name === "URL" && ParentName !== "Link" && ParentName !== "Image" && Node.to > Node.from) { Ranges.push(Decoration.mark({ class: "cm-LiveLink" }).range(Node.from, Node.to)); } if (InlineClasses[Name] && Node.to > Node.from) { Ranges.push(Decoration.mark({ class: InlineClasses[Name] }).range(Node.from, Node.to)); } if (InlineMarks[Name]) { if (InlineMarks[Name].includes(ParentName)) { if (!Touches(Parent.from, Parent.to) && Node.to > Node.from) { Ranges.push(Hidden.range(Node.from, Node.to)); } } else if (Name === "CodeMark" && ParentName === "FencedCode") { Ranges.push(Decoration.mark({ class: "cm-LiveMuted" }).range(Node.from, Node.to)); } return; } if (Name === "Blockquote") { AddLineClass(Node.from, Node.to, "cm-LiveQuote"); return; } if (Name === "QuoteMark") { if (!LineActive(Node.from)) HideWithSpace(Node.from, Node.to); return; } if (Name === "ListMark") { const IsTask = Parent && Parent.getChild("Task"); if (LineActive(Node.from)) { Ranges.push(Decoration.mark({ class: "cm-LiveListMark" }).range(Node.from, Node.to)); } else if (IsTask) { HideWithSpace(Node.from, Node.to); } else if (Parent && Parent.parent && Parent.parent.name === "BulletList") { Ranges.push(Bullet.range(Node.from, Node.to)); } else { Ranges.push(Decoration.mark({ class: "cm-LiveListMark" }).range(Node.from, Node.to)); } return; } if (Name === "TaskMarker") { if (!LineActive(Node.from)) { const Checked = /x/i.test(Doc.sliceString(Node.from, Node.to)); Ranges.push(Decoration.replace({ widget: new CheckboxWidget(Checked, Node.from) }).range(Node.from, Node.to)); } return; } if (Name === "HorizontalRule") { if (!LineActive(Node.from)) Ranges.push(Rule.range(Node.from, Node.to)); return; } if (Name === "FencedCode") { AddLineClass(Node.from, Node.to, "cm-LiveCodeBlock"); return; } if (Name === "CodeInfo" || Name === "TableDelimiter") { Ranges.push(Decoration.mark({ class: "cm-LiveMuted" }).range(Node.from, Node.to)); } }, }); } return Decoration.set(Ranges, true); } // Diagrams replace whole lines, which CodeMirror only allows from a state // field, not from the plugin above. A block shows as code while the cursor // (or selection) is in it and the editor has focus. function BuildDiagrams(State) { const Doc = State.doc; const Ranges = []; syntaxTree(State).iterate({ enter(Node) { if (Node.name !== "FencedCode") return; const Info = Node.node.getChild("CodeInfo"); if (!Info || Doc.sliceString(Info.from, Info.to).trim().toLowerCase() !== "mermaid") return false; const From = Doc.lineAt(Node.from).from; const To = Doc.lineAt(Node.to).to; if (CursorIn(State, From, To)) return false; const Code = Node.node.getChild("CodeText"); const Source = Code ? Doc.sliceString(Code.from, Code.to) : ""; Ranges.push(Decoration.replace({ widget: new DiagramWidget(Source), block: true }).range(From, To)); return false; }, }); return Decoration.set(Ranges, true); } // Whether the editor has focus, for the fields below (the plugin above asks // the view; fields only see the state). Unfocused, everything shows rendered. const SetFocused = StateEffect.define(); const Focused = StateField.define({ create: () => false, update(Current, Transaction) { for (const Effect of Transaction.effects) { if (Effect.is(SetFocused)) return Effect.value; } return Current; }, }); const TrackFocus = EditorView.focusChangeEffect.of((State, Focusing) => SetFocused.of(Focusing)); function CursorIn(State, From, To) { if (!State.field(Focused, false)) return false; return State.selection.ranges.some((Range) => Range.from <= To && Range.to >= From); } function FocusChanged(Transaction) { return Transaction.effects.some((Effect) => Effect.is(SetFocused)); } function BuildProperties(State) { const To = PropertiesEnd(State); if (To === 0) return Decoration.none; if (CursorIn(State, 0, To)) return Decoration.none; const Parsed = ParseProperties(State.doc.sliceString(0, To)); if (!Parsed || Parsed.Properties.length === 0) return Decoration.none; const Html = PropertiesHtml(Parsed, (Value) => VariableRenderer.renderInline(Value), EscapeHtml, true); return Decoration.set([Decoration.replace({ widget: new PropertiesWidget(Html), block: true }).range(0, To)]); } const PropertiesPanel = StateField.define({ create: BuildProperties, update(Current, Transaction) { if (Transaction.docChanged || Transaction.selection || FocusChanged(Transaction)) return BuildProperties(Transaction.state); return Current; }, provide: (Field) => EditorView.decorations.from(Field), }); const Diagrams = StateField.define({ create: BuildDiagrams, update(Current, Transaction) { const TreeChanged = syntaxTree(Transaction.state) !== syntaxTree(Transaction.startState); if (Transaction.docChanged || Transaction.selection || TreeChanged || FocusChanged(Transaction)) { return BuildDiagrams(Transaction.state); } return Current; }, provide: (Field) => EditorView.decorations.from(Field), }); // --- Variables ------------------------------------------------------------- // The server says where each variable is and what it becomes (FetchSpans: // From/To in characters, Text). Each shows as its result, rendered, until // the cursor goes into it; a definition's own marker ("" Text) just hides. const VariableRenderer = new MarkdownIt({ breaks: true }); const SetSpans = StateEffect.define(); // The spans, kept in place as the text changes; ones an edit touches go // until the next answer from the server. const Spans = StateField.define({ create: () => [], update(Current, Transaction) { for (const Effect of Transaction.effects) { if (Effect.is(SetSpans)) return Effect.value; } if (!Transaction.docChanged) return Current; const Kept = []; for (const Span of Current) { let Touched = false; Transaction.changes.iterChangedRanges((From, To) => { if (From < Span.To && To > Span.From) Touched = true; }); if (!Touched) { Kept.push({ ...Span, From: Transaction.changes.mapPos(Span.From, 1), To: Transaction.changes.mapPos(Span.To, -1) }); } } return Kept; }, }); class VariableWidget extends WidgetType { constructor(Text) { super(); this.Text = Text; } eq(Other) { return this.Text === Other.Text; } toDOM(View) { const Lines = this.Text.includes("\n"); const Element = document.createElement(Lines ? "div" : "span"); Element.className = Lines ? "cm-LiveVariableBlock" : "cm-LiveVariable"; Element.innerHTML = Lines ? VariableRenderer.render(this.Text) : VariableRenderer.renderInline(this.Text); Element.addEventListener("mousedown", (Event) => { if (OpenFromWidget(View, Event)) return; Event.preventDefault(); View.dispatch({ selection: { anchor: View.posAtDOM(Element) } }); View.focus(); }); return Element; } ignoreEvent() { return true; } } function BuildVariables(State) { const Ranges = []; const PropertiesTo = PropertiesEnd(State); for (const Span of State.field(Spans)) { if (Span.To > State.doc.length || Span.From < PropertiesTo || CursorIn(State, Span.From, Span.To)) continue; // Code blocks show what's written, variables too. let InCode = false; syntaxTree(State).iterate({ from: Span.From, to: Span.To, enter(Node) { if (Node.name === "FencedCode" || Node.name === "InlineCode") InCode = true; }, }); if (InCode) continue; const Shown = Span.Text === "" ? Decoration.replace({}) : Decoration.replace({ widget: new VariableWidget(Span.Text) }); Ranges.push(Shown.range(Span.From, Span.To)); } return Decoration.set(Ranges, true); } const Variables = StateField.define({ create: BuildVariables, update(Current, Transaction) { const NewSpans = Transaction.effects.some((Effect) => Effect.is(SetSpans)); if (Transaction.docChanged || Transaction.selection || FocusChanged(Transaction) || NewSpans) { return BuildVariables(Transaction.state); } return Current; }, provide: (Field) => EditorView.decorations.from(Field), }); // The server counts characters (code points), the editor UTF-16 units. function ToEditorOffsets(Text, Found) { if (!/[\uD800-\uDFFF]/.test(Text)) return Found; const Units = [0]; for (const Character of Text) Units.push(Units[Units.length - 1] + Character.length); return Found.map((Span) => ({ ...Span, From: Units[Span.From], To: Units[Span.To] })); } // Asks the server for the spans when the note opens, and again once typing // pauses (0.3 s); an answer for text that's changed since is dropped. function FetchVariables(RootName, FilePath) { return ViewPlugin.fromClass( class { constructor(View) { this.View = View; this.Timer = null; this.Request(0); } update(Update) { if (Update.docChanged) this.Request(300); } Request(Delay) { clearTimeout(this.Timer); this.Timer = setTimeout(async () => { const Sent = this.View.state.doc.toString(); try { const Result = await FetchSpans(RootName, FilePath, Sent); if (this.Gone || this.View.state.doc.toString() !== Sent) return; this.View.dispatch({ effects: SetSpans.of(ToEditorOffsets(Sent, Result.Spans || [])) }); } catch { // An older server, or offline: variables stay as written. } }, Delay); } destroy() { this.Gone = true; clearTimeout(this.Timer); } }, ); } // The live editor's extensions, for one note (variables resolve relative to it). export function LivePreview(RootName, FilePath) { return [...LiveCore, Spans, Variables, FetchVariables(RootName, FilePath)]; } function LinkAt(State, Position) { for (let Node = syntaxTree(State).resolveInner(Position, 1); Node; Node = Node.parent) { if (Node.name === "Link") { const Url = Node.getChild("URL"); return Url ? { From: Node.from, To: Node.to, Href: State.doc.sliceString(Url.from, Url.to) } : null; } if (Node.name === "URL" || Node.name === "Autolink") { const Href = State.doc.sliceString(Node.from, Node.to).replace(/^<|>$/g, ""); return { From: Node.from, To: Node.to, Href }; } } return null; } // Where in the text the clicked link is comes from its element (posAtDOM), // not the mouse position: the editor maps coordinates through estimated line // heights, which a tall widget above (the properties panel) throws off. const ClickLinks = EditorView.domEventHandlers({ mousedown(Event, View) { if (Event.button !== 0) return false; const Mark = Event.target.closest(".cm-LiveLink"); if (!Mark || !View.contentDOM.contains(Mark)) return false; const Position = View.posAtDOM(Mark, 0); const Link = LinkAt(View.state, Position); if (!Link) return false; const Editing = View.hasFocus && View.state.selection.ranges.some((Range) => Range.from <= Link.To && Range.to >= Link.From); if (Editing && !(Event.ctrlKey || Event.metaKey)) return false; Event.preventDefault(); View.state.facet(LinkActions).Open(Link.Href); return true; }, }); const LiveCore = [ ClickLinks, Focused, TrackFocus, PropertiesPanel, Diagrams, ViewPlugin.fromClass( class { constructor(View) { this.decorations = BuildDecorations(View); } update(Update) { if (Update.docChanged || Update.selectionSet || Update.viewportChanged || Update.focusChanged) { this.decorations = BuildDecorations(Update.view); } } }, { decorations: (Plugin) => Plugin.decorations }, ), EditorView.lineWrapping, ];