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({
{
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;