From c8d29ad6f8b2c56f6a4b7b1fb62fcdfa3a7364b1 Mon Sep 17 00:00:00 2001 From: SRHTNIN Date: Tue, 13 Oct 2026 15:20:34 +0200 Subject: [PATCH] Properties, and ticking checkboxes without editing (web) Co-Authored-By: Claude Opus 5.5 --- Frontend/src/Components/PreviewPane.jsx | 64 ++++++++++++- Frontend/src/LivePreview.js | 116 ++++++++++++++++++++++-- Frontend/src/Properties.js | 109 ++++++++++++++++++++++ Frontend/src/Styles/Theme.css | 64 +++++++++++++ 4 files changed, 343 insertions(+), 10 deletions(-) create mode 100644 Frontend/src/Properties.js diff --git a/Frontend/src/Components/PreviewPane.jsx b/Frontend/src/Components/PreviewPane.jsx index 6c6bb01..8759de9 100644 --- a/Frontend/src/Components/PreviewPane.jsx +++ b/Frontend/src/Components/PreviewPane.jsx @@ -11,6 +11,7 @@ import MarkdownItFootnote from "markdown-it-footnote"; import { LivePreview } from "../LivePreview.js"; import { DiagramHtml, RenderDiagramsIn } from "../Mermaid.js"; +import { ParseProperties, PropertiesHtml, TaskLines, ToggleLine, WithoutProperties } from "../Properties.js"; import { FetchBlame, @@ -54,6 +55,17 @@ MarkdownRenderer.renderer.rules.fence = (Tokens, Index, Options, Env, Self) => { return DefaultFenceRenderer(Tokens, Index, Options, Env, Self); }; +function RenderPropertiesPanel(Text, CanEdit) { + const Parsed = ParseProperties(Text); + if (!Parsed || Parsed.Properties.length === 0) return ""; + return PropertiesHtml( + Parsed, + (Value) => MarkdownRenderer.renderInline(Value), + MarkdownRenderer.utils.escapeHtml, + CanEdit, + ); +} + const TextTypes = ["markdown", "text", "config", "html"]; const LinkedFileTypeMap = { @@ -221,10 +233,21 @@ export default function PreviewPane({ } }, [RootName, FilePath, FileType, ShowGitBlame]); - // Draw the rendered view's diagrams once its HTML is on the page. + // Once the rendered view's HTML is on the page: draw its diagrams, and + // switch off task checkboxes that can't be ticked here. They're matched to + // the note's lines in order, which only works when the server's version + // (with portals filled in) has the same tasks as the note itself. useEffect(() => { - if (MarkdownBodyRef.current) RenderDiagramsIn(MarkdownBodyRef.current); - }, [ResolvedContent, FileType, IsEditing, MarkdownEditing, IsGuest]); + const Body = MarkdownBodyRef.current; + if (!Body) return; + RenderDiagramsIn(Body); + const Boxes = Body.querySelectorAll(".task-list-item-checkbox"); + const Matches = Boxes.length === TaskLines(Content).length; + Boxes.forEach((Box) => { + Box.disabled = IsGuest || !Matches; + Box.title = IsGuest ? "Log in to change this" : Matches ? "" : "This note embeds others: edit it to change this"; + }); + }, [Content, ResolvedContent, FileType, IsEditing, MarkdownEditing, IsGuest]); if (!FilePath) { return
Select a file to preview.
; @@ -250,7 +273,39 @@ export default function PreviewPane({ } } + async function ToggleAndSave(LineIndex, Box) { + const Previous = Content; + const Changed = ToggleLine(Content, LineIndex); + SetContent(Changed); + SetSaveError(null); + try { + await SaveFile(RootName, FilePath, Changed); + RefreshResolvedContent(); + if (ShowGitBlame) { + FetchBlame(RootName, FilePath) + .then(SetBlameMap) + .catch(() => {}); + } + } catch (Error) { + SetContent(Previous); + Box.checked = !Box.checked; + SetSaveError(Error.message); + } + } + function HandleMarkdownClick(Event) { + const Box = Event.target.closest('input[type="checkbox"]'); + if (Box) { + if (Box.classList.contains("PropertyCheckbox")) { + ToggleAndSave(Number(Box.dataset.line), Box); + } else if (Box.classList.contains("task-list-item-checkbox")) { + const Boxes = Array.from(MarkdownBodyRef.current.querySelectorAll(".task-list-item-checkbox")); + const Line = TaskLines(Content)[Boxes.indexOf(Box)]; + if (Line !== undefined) ToggleAndSave(Line, Box); + } + return; + } + const AnchorElement = Event.target.closest("a"); if (!AnchorElement) return; @@ -349,13 +404,14 @@ export default function PreviewPane({ Download + {SaveError && {SaveError}}
{ const ResolvedPath = ResolveRelativeLink(FilePath, Src); return ResolvedPath === null ? Src : GetFileUrl(RootName, ResolvedPath); diff --git a/Frontend/src/LivePreview.js b/Frontend/src/LivePreview.js index 06c578f..1a24e51 100644 --- a/Frontend/src/LivePreview.js +++ b/Frontend/src/LivePreview.js @@ -3,13 +3,15 @@ // > and list markers) hidden. Wherever the cursor is, the syntax comes back so // 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. +// ```mermaid blocks show as their diagram until the cursor goes into them, +// and the properties block at the top (Properties.js) as its panel. import { syntaxTree } from "@codemirror/language"; -import { StateField } from "@codemirror/state"; +import { StateEffect, StateField } from "@codemirror/state"; import { Decoration, EditorView, ViewPlugin, WidgetType } from "@codemirror/view"; import { RenderDiagram } from "./Mermaid.js"; +import { ParseProperties, PropertiesHtml, ToggleLine } from "./Properties.js"; class BulletWidget extends WidgetType { eq() { @@ -103,6 +105,57 @@ class DiagramWidget extends WidgetType { } } +function EscapeHtml(Text) { + return Text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +// The properties block drawn as its panel. A true/false checkbox flips the +// value without opening the block; clicking anywhere else opens it to edit. +class PropertiesWidget extends WidgetType { + constructor(Html) { + super(); + this.Html = Html; + } + + eq(Other) { + return this.Html === Other.Html; + } + + toDOM(View) { + const Element = document.createElement("div"); + Element.className = "MarkdownBody cm-LiveProperties"; + Element.style.padding = "0"; + Element.innerHTML = this.Html; + Element.addEventListener("mousedown", (Event) => { + Event.preventDefault(); + const Box = Event.target.closest(".PropertyCheckbox"); + const Doc = View.state.doc; + if (Box) { + const Line = Doc.line(Number(Box.dataset.line) + 1); + const Changed = ToggleLine(Line.text, 0); + View.dispatch({ changes: { from: Line.from, to: Line.to, insert: Changed } }); + return; + } + View.dispatch({ selection: { anchor: Doc.line(Math.min(2, Doc.lines)).from } }); + View.focus(); + }); + return Element; + } + + ignoreEvent() { + return true; + } +} + +// Where the properties block ends (0 if there isn't one): the plugin below +// leaves it alone, since Markdown would read it as a rule and a heading. +function PropertiesEnd(State) { + const Doc = State.doc; + if (!/^---\s*$/.test(Doc.line(1).text)) return 0; + const Parsed = ParseProperties(Doc.sliceString(0, Math.min(Doc.length, 20000))); + return Parsed ? Parsed.EndOffset : 0; +} + const Hidden = Decoration.replace({}); const Bullet = Decoration.replace({ widget: new BulletWidget() }); const Rule = Decoration.replace({ widget: new RuleWidget() }); @@ -157,11 +210,15 @@ function BuildDecorations(View) { Ranges.push(Hidden.range(From, End)); } + const PropertiesTo = PropertiesEnd(State); + if (PropertiesTo > 0) AddLineClass(0, PropertiesTo, "cm-LiveFrontmatter"); + for (const { from, to } of View.visibleRanges) { syntaxTree(State).iterate({ from, to, enter(Node) { + if (Node.from < PropertiesTo) return Node.to <= PropertiesTo ? false : undefined; const Name = Node.name; const Parent = Node.node.parent; const ParentName = Parent ? Parent.name : ""; @@ -257,8 +314,8 @@ function BuildDecorations(View) { } // Diagrams replace whole lines, which CodeMirror only allows from a state -// field, not from the plugin above. Without focus info here, a block shows -// as code while the cursor (or selection) is in it, even when unfocused. +// field, not from the plugin above. A block shows as code while the cursor +// (or selection) is in it and the editor has focus. function BuildDiagrams(State) { const Doc = State.doc; const Ranges = []; @@ -271,7 +328,7 @@ function BuildDiagrams(State) { const From = Doc.lineAt(Node.from).from; const To = Doc.lineAt(Node.to).to; - if (State.selection.ranges.some((Range) => Range.from <= To && Range.to >= From)) return false; + if (CursorIn(State, From, To)) return false; const Code = Node.node.getChild("CodeText"); const Source = Code ? Doc.sliceString(Code.from, Code.to) : ""; @@ -283,17 +340,64 @@ function BuildDiagrams(State) { return Decoration.set(Ranges, true); } +// Whether the editor has focus, for the fields below (the plugin above asks +// the view; fields only see the state). Unfocused, everything shows rendered. +const SetFocused = StateEffect.define(); +const Focused = StateField.define({ + create: () => false, + update(Current, Transaction) { + for (const Effect of Transaction.effects) { + if (Effect.is(SetFocused)) return Effect.value; + } + return Current; + }, +}); +const TrackFocus = EditorView.focusChangeEffect.of((State, Focusing) => SetFocused.of(Focusing)); + +function CursorIn(State, From, To) { + if (!State.field(Focused, false)) return false; + return State.selection.ranges.some((Range) => Range.from <= To && Range.to >= From); +} + +function FocusChanged(Transaction) { + return Transaction.effects.some((Effect) => Effect.is(SetFocused)); +} + +function BuildProperties(State) { + const To = PropertiesEnd(State); + if (To === 0) return Decoration.none; + if (CursorIn(State, 0, To)) return Decoration.none; + const Parsed = ParseProperties(State.doc.sliceString(0, To)); + if (!Parsed || Parsed.Properties.length === 0) return Decoration.none; + const Html = PropertiesHtml(Parsed, EscapeHtml, EscapeHtml, true); + return Decoration.set([Decoration.replace({ widget: new PropertiesWidget(Html), block: true }).range(0, To)]); +} + +const PropertiesPanel = StateField.define({ + create: BuildProperties, + update(Current, Transaction) { + if (Transaction.docChanged || Transaction.selection || FocusChanged(Transaction)) return BuildProperties(Transaction.state); + return Current; + }, + provide: (Field) => EditorView.decorations.from(Field), +}); + const Diagrams = StateField.define({ create: BuildDiagrams, update(Current, Transaction) { const TreeChanged = syntaxTree(Transaction.state) !== syntaxTree(Transaction.startState); - if (Transaction.docChanged || Transaction.selection || TreeChanged) return BuildDiagrams(Transaction.state); + if (Transaction.docChanged || Transaction.selection || TreeChanged || FocusChanged(Transaction)) { + return BuildDiagrams(Transaction.state); + } return Current; }, provide: (Field) => EditorView.decorations.from(Field), }); export const LivePreview = [ + Focused, + TrackFocus, + PropertiesPanel, Diagrams, ViewPlugin.fromClass( class { diff --git a/Frontend/src/Properties.js b/Frontend/src/Properties.js new file mode 100644 index 0000000..3b24e7b --- /dev/null +++ b/Frontend/src/Properties.js @@ -0,0 +1,109 @@ +// Properties, like Obsidian's: a block at the very top of a note, +// +// --- +// Key: Value +// Done: true +// --- +// +// shown as a Properties panel instead of text. true/false values are +// checkboxes that can be ticked without editing the note, like task list +// items (- [ ]); the helpers for both are here, shared by the rendered view +// (PreviewPane.jsx) and the live editor (LivePreview.js). + +const OpenOrClose = /^---\s*$/; +const PropertyLine = /^([^:\s][^:]*?)\s*:\s*(.*)$/; +const TaskLine = /^(\s*(?:[-*+]|\d+[.)])\s+)\[( |x|X)\]/; +const FenceLine = /^ {0,3}(`{3,}|~{3,})/; + +// The block at the top, or null. Lines are the note's (0-based), so a +// property can be changed in place. EndLine is the closing ---. +export function ParseProperties(Text) { + const Lines = Text.split("\n"); + if (!OpenOrClose.test(Lines[0] || "")) return null; + const EndLine = Lines.findIndex((Line, Index) => Index > 0 && OpenOrClose.test(Line)); + if (EndLine === -1) return null; + + const Properties = []; + for (let Index = 1; Index < EndLine; Index++) { + const Line = Lines[Index]; + const Match = PropertyLine.exec(Line); + if (Match) { + Properties.push({ Key: Match[1], Value: Match[2].trim(), Line: Index }); + } else if (Line.trim().startsWith("- ") && Properties.length > 0) { + // A list under the previous key ("Tags:" then "- one", "- two"). + const Last = Properties[Properties.length - 1]; + Last.List = [...(Last.List || []), Line.trim().slice(2).trim()]; + } else if (Line.trim() !== "") { + // Not properties after all: a note that just starts with a --- rule. + return null; + } + } + const EndOffset = Lines.slice(0, EndLine + 1).join("\n").length; + return { Properties, EndLine, EndOffset }; +} + +export function IsBoolean(Value) { + return /^(true|false)$/i.test(Value); +} + +// The note without its properties block (what Markdown renders). +export function WithoutProperties(Text) { + const Parsed = ParseProperties(Text); + return Parsed ? Text.slice(Parsed.EndOffset).replace(/^\n/, "") : Text; +} + +// Flips a true/false property, or a task's [ ]/[x], on one line of Text. +export function ToggleLine(Text, LineIndex) { + const Lines = Text.split("\n"); + const Line = Lines[LineIndex]; + if (Line === undefined) return Text; + const Task = TaskLine.exec(Line); + if (Task) { + const Mark = Task[2] === " " ? "x" : " "; + Lines[LineIndex] = `${Task[1]}[${Mark}]${Line.slice(Task[0].length)}`; + } else { + Lines[LineIndex] = Line.replace(/:\s*(true|false)\s*$/i, (Whole, Value) => + Whole.replace(Value, Value.toLowerCase() === "true" ? "false" : "true"), + ); + } + return Lines.join("\n"); +} + +// The lines of the note's task items, in order, outside code blocks and the +// properties block: the nth checkbox the rendered view shows is TaskLines[n]. +export function TaskLines(Text) { + const Lines = Text.split("\n"); + const Parsed = ParseProperties(Text); + const Found = []; + let Fence = null; + for (let Index = Parsed ? Parsed.EndLine + 1 : 0; Index < Lines.length; Index++) { + const Line = Lines[Index]; + const FenceMatch = FenceLine.exec(Line); + if (FenceMatch) { + if (Fence === null) Fence = FenceMatch[1]; + else if (FenceMatch[1][0] === Fence[0] && FenceMatch[1].length >= Fence.length) Fence = null; + continue; + } + if (Fence === null && TaskLine.test(Line)) Found.push(Index); + } + return Found; +} + +// The rendered view's panel. RenderInline renders a value's Markdown (links, +// bold); Escape escapes text. Checkboxes carry their line in data-line. +export function PropertiesHtml(Parsed, RenderInline, Escape, CanEdit) { + const Rows = Parsed.Properties.map(({ Key, Value, Line, List }) => { + let Shown; + if (IsBoolean(Value)) { + const Checked = Value.toLowerCase() === "true" ? " checked" : ""; + const Disabled = CanEdit ? "" : " disabled"; + Shown = ``; + } else if (List) { + Shown = List.map((Item) => `${RenderInline(Item)}`).join(""); + } else { + Shown = Value ? RenderInline(Value) : `Empty`; + } + return `
${Escape(Key)}${Shown}
`; + }); + return `
Properties
${Rows.join("")}
`; +} diff --git a/Frontend/src/Styles/Theme.css b/Frontend/src/Styles/Theme.css index 48f2bf7..6d37199 100644 --- a/Frontend/src/Styles/Theme.css +++ b/Frontend/src/Styles/Theme.css @@ -510,6 +510,70 @@ img, cursor: pointer; } +.Properties { + margin: 0 0 16px; + padding: 8px 12px; + background: var(--Surface); + border: 1px solid var(--Border); + border-radius: 4px; +} + +.PropertiesTitle { + color: var(--TextMuted); + margin-bottom: 4px; +} + +.PropertyRow { + display: flex; + align-items: center; + gap: 12px; + padding: 2px 0; +} + +.PropertyKey { + flex: 0 0 30%; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--TextMuted); +} + +.PropertyValue { + flex: 1; + min-width: 0; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; +} + +.PropertyChip { + padding: 0 6px; + border-radius: 4px; + background: var(--SurfaceAlt); + color: var(--Purple); +} + +.PropertyEmpty { + color: var(--TextMuted); + font-style: italic; +} + +.PropertyCheckbox { + accent-color: var(--Purple); + margin: 0; + cursor: pointer; +} + +.LiveEditor .cm-LiveProperties { + cursor: text; +} + +.LiveEditor .cm-LiveFrontmatter { + color: var(--TextMuted); +} + .MarkdownBody blockquote { margin: 12px 0; padding: 6px 12px;