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
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user