Variables resolved in the web live editor

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
sarahandClaude Opus 5.5 committed 2026-10-13 17:33:31 +02:00
1 parent 6d15fb1c53
commit eec8fd4b79
4 files changed
+187 -4

No files matched your search

+7
View File
@@ -262,6 +262,13 @@ export async function UploadFileChunked(RootName, RelativePath, File, OnProgress
return CompleteChunkedUpload(RootName, RelativePath, UploadId, TotalChunks, File.name);
}
// Where each variable (portal, definition, loop) is in Content, and what it
// resolves to: the live editor shows the results in place (LivePreview.js).
export function FetchSpans(RootName, RelativePath, Content) {
const Params = new URLSearchParams({ Root: RootName, Path: RelativePath });
return FetchJson(`/file/spans?${Params}`, { method: "POST", ...JsonBody({ Content }) });
}
export function SaveFile(RootName, RelativePath, Content) {
const Params = new URLSearchParams({ Root: RootName, Path: RelativePath });
return FetchJson(`/file?${Params}`, { method: "PUT", body: Content });
+3 -2
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import CodeMirror from "@uiw/react-codemirror";
import { gutter, GutterMarker } from "@codemirror/view";
import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
@@ -208,6 +208,7 @@ export default function PreviewPane({
const [SaveError, SetSaveError] = useState(null);
const [BlameMap, SetBlameMap] = useState({});
const MarkdownBodyRef = useRef(null);
const LiveExtensions = useMemo(() => LivePreview(RootName, FilePath), [RootName, FilePath]);
function RefreshResolvedContent() {
FetchResolvedFile(RootName, FilePath)
@@ -386,7 +387,7 @@ export default function PreviewPane({
}}
extensions={[
markdown({ base: markdownLanguage }),
...(IsSourceMode ? [] : LivePreview),
...(IsSourceMode ? [] : LiveExtensions),
...(ShowGitBlame ? [BuildBlameGutter(BlameMap, ShowBlameAvatars)] : []),
]}
onChange={SetContent}
+149 -2
View File
@@ -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,
+28
View File
@@ -590,6 +590,34 @@ img,
cursor: text;
}
/* A variable's result in the live editor (LivePreview.js): marked so it
reads as calculated, and clickable to edit what's written. */
.LiveEditor .cm-LiveVariable {
border-bottom: 1px dotted var(--Purple);
cursor: pointer;
}
.LiveEditor .cm-LiveVariableBlock {
display: block;
margin: 2px 0;
padding-left: 8px;
border-left: 2px solid var(--Purple);
cursor: pointer;
}
/* Its Markdown sits in editor lines, so no paragraph or list gaps. */
.LiveEditor .cm-LiveVariableBlock p,
.LiveEditor .cm-LiveVariableBlock ul,
.LiveEditor .cm-LiveVariableBlock ol,
.LiveEditor .cm-LiveVariableBlock li {
margin: 0;
}
.LiveEditor .cm-LiveVariableBlock ul,
.LiveEditor .cm-LiveVariableBlock ol {
padding-left: 20px;
}
.LiveEditor .cm-LiveFrontmatter {
color: var(--TextMuted);
}