Add live Markdown editing, colours and files in the tree

This commit is contained in:
sarah committed 2026-10-09 09:03:23 +02:00
1 parent 0eecfba224
commit f851ecd820
15 files changed
+1025 -58

No files matched your search

+235
View File
@@ -0,0 +1,235 @@
// 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).
import { syntaxTree } from "@codemirror/language";
import { Decoration, EditorView, ViewPlugin, WidgetType } from "@codemirror/view";
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;
}
}
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));
}
for (const { from, to } of View.visibleRanges) {
syntaxTree(State).iterate({
from,
to,
enter(Node) {
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);
}
export const LivePreview = [
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,
];