// 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, // and the properties block at the top (Properties.js) as its panel. import { syntaxTree } from "@codemirror/language"; import { StateEffect, StateField } from "@codemirror/state"; import { Decoration, EditorView, ViewPlugin, WidgetType } from "@codemirror/view"; import { RenderDiagram } from "./Mermaid.js"; import { ParseProperties, PropertiesHtml, ToggleLine } from "./Properties.js"; 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) => { 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 (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, EscapeHtml, 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), }); export const LivePreview = [ 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, ];