From eec8fd4b7903d8c09ea58ac12f50a9933d87c8e6 Mon Sep 17 00:00:00 2001 From: SRHTNIN Date: Tue, 13 Oct 2026 17:33:31 +0200 Subject: [PATCH] Variables resolved in the web live editor Co-Authored-By: Claude Opus 5.5 --- Frontend/src/Api.js | 7 ++ Frontend/src/Components/PreviewPane.jsx | 5 +- Frontend/src/LivePreview.js | 151 +++++++++++++++++++++++- Frontend/src/Styles/Theme.css | 28 +++++ 4 files changed, 187 insertions(+), 4 deletions(-) diff --git a/Frontend/src/Api.js b/Frontend/src/Api.js index b092f9a..bb6de1c 100644 --- a/Frontend/src/Api.js +++ b/Frontend/src/Api.js @@ -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 }); diff --git a/Frontend/src/Components/PreviewPane.jsx b/Frontend/src/Components/PreviewPane.jsx index 8759de9..4972e60 100644 --- a/Frontend/src/Components/PreviewPane.jsx +++ b/Frontend/src/Components/PreviewPane.jsx @@ -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} diff --git a/Frontend/src/LivePreview.js b/Frontend/src/LivePreview.js index 1a24e51..b1d0336 100644 --- a/Frontend/src/LivePreview.js +++ b/Frontend/src/LivePreview.js @@ -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, diff --git a/Frontend/src/Styles/Theme.css b/Frontend/src/Styles/Theme.css index 07ad4ce..aef2c75 100644 --- a/Frontend/src/Styles/Theme.css +++ b/Frontend/src/Styles/Theme.css @@ -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); }