mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-15 02:43:17 +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);
|
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) {
|
export function SaveFile(RootName, RelativePath, Content) {
|
||||||
const Params = new URLSearchParams({ Root: RootName, Path: RelativePath });
|
const Params = new URLSearchParams({ Root: RootName, Path: RelativePath });
|
||||||
return FetchJson(`/file?${Params}`, { method: "PUT", body: Content });
|
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 CodeMirror from "@uiw/react-codemirror";
|
||||||
import { gutter, GutterMarker } from "@codemirror/view";
|
import { gutter, GutterMarker } from "@codemirror/view";
|
||||||
import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
|
import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
|
||||||
@@ -208,6 +208,7 @@ export default function PreviewPane({
|
|||||||
const [SaveError, SetSaveError] = useState(null);
|
const [SaveError, SetSaveError] = useState(null);
|
||||||
const [BlameMap, SetBlameMap] = useState({});
|
const [BlameMap, SetBlameMap] = useState({});
|
||||||
const MarkdownBodyRef = useRef(null);
|
const MarkdownBodyRef = useRef(null);
|
||||||
|
const LiveExtensions = useMemo(() => LivePreview(RootName, FilePath), [RootName, FilePath]);
|
||||||
|
|
||||||
function RefreshResolvedContent() {
|
function RefreshResolvedContent() {
|
||||||
FetchResolvedFile(RootName, FilePath)
|
FetchResolvedFile(RootName, FilePath)
|
||||||
@@ -386,7 +387,7 @@ export default function PreviewPane({
|
|||||||
}}
|
}}
|
||||||
extensions={[
|
extensions={[
|
||||||
markdown({ base: markdownLanguage }),
|
markdown({ base: markdownLanguage }),
|
||||||
...(IsSourceMode ? [] : LivePreview),
|
...(IsSourceMode ? [] : LiveExtensions),
|
||||||
...(ShowGitBlame ? [BuildBlameGutter(BlameMap, ShowBlameAvatars)] : []),
|
...(ShowGitBlame ? [BuildBlameGutter(BlameMap, ShowBlameAvatars)] : []),
|
||||||
]}
|
]}
|
||||||
onChange={SetContent}
|
onChange={SetContent}
|
||||||
|
|||||||
+149
-2
@@ -4,12 +4,16 @@
|
|||||||
// it can be edited: a heading's #s on its line, a bold's ** inside it.
|
// 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).
|
// The colours are the same roles as .MarkdownBody (Styles/Theme.css).
|
||||||
// ```mermaid blocks show as their diagram until the cursor goes into them,
|
// ```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 { syntaxTree } from "@codemirror/language";
|
||||||
|
import MarkdownIt from "markdown-it";
|
||||||
import { StateEffect, StateField } from "@codemirror/state";
|
import { StateEffect, StateField } from "@codemirror/state";
|
||||||
import { Decoration, EditorView, ViewPlugin, WidgetType } from "@codemirror/view";
|
import { Decoration, EditorView, ViewPlugin, WidgetType } from "@codemirror/view";
|
||||||
|
|
||||||
|
import { FetchSpans } from "./Api.js";
|
||||||
import { RenderDiagram } from "./Mermaid.js";
|
import { RenderDiagram } from "./Mermaid.js";
|
||||||
import { ParseProperties, PropertiesHtml, ToggleLine } from "./Properties.js";
|
import { ParseProperties, PropertiesHtml, ToggleLine } from "./Properties.js";
|
||||||
|
|
||||||
@@ -394,7 +398,150 @@ const Diagrams = StateField.define({
|
|||||||
provide: (Field) => EditorView.decorations.from(Field),
|
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,
|
Focused,
|
||||||
TrackFocus,
|
TrackFocus,
|
||||||
PropertiesPanel,
|
PropertiesPanel,
|
||||||
|
|||||||
@@ -590,6 +590,34 @@ img,
|
|||||||
cursor: text;
|
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 {
|
.LiveEditor .cm-LiveFrontmatter {
|
||||||
color: var(--TextMuted);
|
color: var(--TextMuted);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user