mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-14 10:38:11 +02:00
Variables resolved in the web live editor
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
6d15fb1c53
commit
eec8fd4b79
4 files changed
+187
-4
No files matched your search
+149
-2
@@ -4,12 +4,16 @@
|
||||
// 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.
|
||||
// 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 { 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";
|
||||
|
||||
@@ -394,7 +398,150 @@ const Diagrams = StateField.define({
|
||||
provide: (Field) => EditorView.decorations.from(Field),
|
||||
});
|
||||
|
||||
export const LivePreview = [
|
||||
// --- 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 (Event.target.closest("a")) 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; 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(500);
|
||||
}
|
||||
|
||||
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)];
|
||||
}
|
||||
|
||||
const LiveCore = [
|
||||
Focused,
|
||||
TrackFocus,
|
||||
PropertiesPanel,
|
||||
|
||||
Reference in new issue
Block a user