mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-13 18:28:14 +02:00
418 lines
13 KiB
JavaScript
418 lines
13 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,
|
|
// 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, ">").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,
|
|
];
|