Files
SaraView/Frontend/src/LivePreview.js
T
2026-10-13 18:30:26 +02:00

665 lines
22 KiB
JavaScript

// 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
// 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,
];