From 431fa2cd5e0914cd082c6eb4786c18926420a707 Mon Sep 17 00:00:00 2001 From: Dario Mory Date: Wed, 2 Sep 2026 04:43:42 +0700 Subject: [PATCH] Support custom visual divider line widgets (___ and ---) in editor --- app/src/App.css | 51 +++++++++ app/src/components/Editor.test.tsx | 168 +++++++++++++++++++++++++++- app/src/components/Editor.tsx | 170 +++++++++++++++++++++++++++++ app/src/components/Sidebar.tsx | 11 +- app/src/store.ts | 10 +- 5 files changed, 405 insertions(+), 5 deletions(-) diff --git a/app/src/App.css b/app/src/App.css index bd8a305..0f0493d 100644 --- a/app/src/App.css +++ b/app/src/App.css @@ -3670,3 +3670,54 @@ button.status-item:hover { .backup-number span { color: var(--muted); } +/* Visual Divider Lines: ___ (bold) and --- (subtle hairline) */ +.cm-divider-line { + position: relative; + padding-top: 0.85em; + padding-bottom: 0.85em; +} + +.cm-divider-line.cm-divider-active { + padding-top: 0.35em; + padding-bottom: 0.35em; +} + +.cm-divider { + display: flex; + align-items: center; + width: 100%; + cursor: text; +} + +.cm-divider-graphic { + width: 100%; + transition: all 150ms ease; +} + +.cm-divider-bold .cm-divider-graphic { + height: 2px; + border-radius: 2px; + background: var(--accent, #f59e0b); + box-shadow: 0 0 8px var(--accent-soft, rgba(245, 158, 11, 0.45)), 0 0 2px var(--accent, #f59e0b); +} + +.cm-divider-subtle .cm-divider-graphic { + height: 1px; + background: rgba(255, 255, 255, 0.12); +} + +.cm-divider-raw { + font-family: var(--font-mono); + letter-spacing: 2px; + font-size: 0.9em; + opacity: 0.8; +} + +.cm-divider-raw-bold { + color: var(--accent, #f59e0b); + font-weight: 600; +} + +.cm-divider-raw-subtle { + color: var(--muted); +} diff --git a/app/src/components/Editor.test.tsx b/app/src/components/Editor.test.tsx index 8e5e3fd..365ada8 100644 --- a/app/src/components/Editor.test.tsx +++ b/app/src/components/Editor.test.tsx @@ -11,13 +11,18 @@ import { bubbleMetadataDecorations, bubbleModelPersistence, bubbleTagsForLabel, + buildDividerDecorations, buildHeatDecorations, computeBubbles, + getDividerType, + getFrontmatterEndLine, inferBubbleTags, + isLineSelected, modABinding, normalizeBubbleKey, resolveBubbleModel, resolveBubbleTags, + visualDividers, } from "./Editor"; /** A minimal idea-note editor with the real Ctrl+A binding and CodeMirror's @@ -122,7 +127,6 @@ describe("Ctrl+A in an idea note", () => { expect(view.state.selection.main.to).toBe(DOC.length); }); }); - describe("resilient bubble model and tag resolution", () => { it("normalizes bubble keys by stripping markdown header tokens and list markers", () => { expect(normalizeBubbleKey("# My Feature")).toBe("My Feature"); @@ -467,3 +471,165 @@ describe("deleting a bubble does not hand its metadata to a neighbour", () => { expect(active?.bubbleIssues?.["Other bubble"]).toBeUndefined(); }); }); + +describe("visual divider lines (___ and ---)", () => { + it("detects when a selection overlaps a line range", () => { + const state = EditorState.create({ + doc: "Line 1\nLine 2\nLine 3", + selection: { anchor: 8, head: 8 }, + }); + expect(isLineSelected(state, 7, 13)).toBe(true); + expect(isLineSelected(state, 0, 6)).toBe(false); + expect(isLineSelected(state, 14, 20)).toBe(false); + }); + + it("classifies divider types correctly", () => { + expect(getDividerType("___")).toBe("bold"); + expect(getDividerType("____")).toBe("bold"); + expect(getDividerType("_ _ _")).toBe("bold"); + expect(getDividerType(" ___ ")).toBe("bold"); + + expect(getDividerType("---")).toBe("subtle"); + expect(getDividerType("----")).toBe("subtle"); + expect(getDividerType("- - -")).toBe("subtle"); + expect(getDividerType(" --- ")).toBe("subtle"); + expect(getDividerType("***")).toBe("subtle"); + + expect(getDividerType("__")).toBeNull(); + expect(getDividerType("--")).toBeNull(); + expect(getDividerType("regular text")).toBeNull(); + expect(getDividerType("## Heading")).toBeNull(); + expect(getDividerType("--- title: idea")).toBeNull(); + }); + + it("detects frontmatter end line and ignores non-frontmatter documents", () => { + const fmDoc = EditorState.create({ + doc: "---\nid: 123\ntitle: Test\n---\nFirst paragraph\n---\nSecond paragraph", + }).doc; + expect(getFrontmatterEndLine(fmDoc)).toBe(4); + + const plainDoc = EditorState.create({ + doc: "First paragraph\n---\nSecond paragraph", + }).doc; + expect(getFrontmatterEndLine(plainDoc)).toBe(0); + + const unterminatedDoc = EditorState.create({ + doc: "---\nid: 123\ntitle: Test\nFirst paragraph", + }).doc; + expect(getFrontmatterEndLine(unterminatedDoc)).toBe(0); + }); + + it("builds bold widget decoration for ___ and subtle widget decoration for --- when cursor is away", () => { + const doc = "Paragraph 1\n\n___\n\nParagraph 2\n\n---\n\nParagraph 3"; + const view = new EditorView({ + parent: document.body, + state: EditorState.create({ + doc, + extensions: [markdown(), visualDividers], + selection: { anchor: 0 }, + }), + }); + + const decos = buildDividerDecorations(view); + let boldLineFound = false; + let boldWidgetFound = false; + let subtleLineFound = false; + let subtleWidgetFound = false; + + decos.between(0, doc.length, (_from, _to, value) => { + const spec = (value as unknown as { spec: Record }).spec; + if (spec.class === "cm-divider-line cm-divider-line-bold") { + boldLineFound = true; + } + if (spec.class === "cm-divider-line cm-divider-line-subtle") { + subtleLineFound = true; + } + if (spec.widget && (spec.widget as { kind?: string }).kind === "bold") { + boldWidgetFound = true; + } + if (spec.widget && (spec.widget as { kind?: string }).kind === "subtle") { + subtleWidgetFound = true; + } + }); + + expect(boldLineFound).toBe(true); + expect(boldWidgetFound).toBe(true); + expect(subtleLineFound).toBe(true); + expect(subtleWidgetFound).toBe(true); + }); + + it("reveals raw text and does not replace with widget when cursor is on the divider line", () => { + const doc = "Paragraph 1\n\n___\n\nParagraph 2"; + const dividerPos = doc.indexOf("___"); + const view = new EditorView({ + parent: document.body, + state: EditorState.create({ + doc, + extensions: [markdown(), visualDividers], + selection: { anchor: dividerPos + 1 }, // caret inside '___' + }), + }); + + const decos = buildDividerDecorations(view); + let activeLineFound = false; + let rawMarkFound = false; + let widgetFound = false; + + decos.between(0, doc.length, (_from, _to, value) => { + const spec = (value as unknown as { spec: Record }).spec; + if (spec.class === "cm-divider-line cm-divider-line-bold cm-divider-active") { + activeLineFound = true; + } + if (spec.class === "cm-divider-raw cm-divider-raw-bold") { + rawMarkFound = true; + } + if (spec.widget) { + widgetFound = true; + } + }); + + expect(activeLineFound).toBe(true); + expect(rawMarkFound).toBe(true); + expect(widgetFound).toBe(false); // Widget replacement must not be present while active + }); + + it("does not decorate frontmatter delimiters as visual dividers", () => { + const doc = "---\nid: test\ntitle: Note\n---\n\nBody paragraph\n\n---\n\nSecond paragraph"; + const view = new EditorView({ + parent: document.body, + state: EditorState.create({ + doc, + extensions: [markdown(), visualDividers], + selection: { anchor: 0 }, + }), + }); + + const decos = buildDividerDecorations(view); + const decoratedOffsets: number[] = []; + + decos.between(0, doc.length, (from) => { + decoratedOffsets.push(from); + }); + + // Line 1 (pos 0) and Line 4 (closing frontmatter) must NOT be decorated. + // Only the standalone --- inside the body (after line 4) should be decorated. + const bodyDividerPos = doc.lastIndexOf("---"); + expect(decoratedOffsets).not.toContain(0); + expect(decoratedOffsets).toContain(bodyDividerPos); + }); + + it("ensures divider lines do not break bubble counting and outline navigation", () => { + const doc = "Bubble 1\n\n___\n\nBubble 2\n\n---\n\nBubble 3"; + const state = EditorState.create({ doc, extensions: [markdown()] }); + const bubbles = computeBubbles(state); + + expect(bubbles).toHaveLength(3); + expect(state.doc.sliceString(bubbles[0].from, bubbles[0].to)).toBe("Bubble 1"); + expect(state.doc.sliceString(bubbles[1].from, bubbles[1].to)).toBe("Bubble 2"); + expect(state.doc.sliceString(bubbles[2].from, bubbles[2].to)).toBe("Bubble 3"); + + // Caret on divider line returns null for bubble selection, allowing immediate fall-through to select-all + const dividerPos = doc.indexOf("___"); + expect(bubbleForModA(dividerPos, dividerPos, dividerPos, bubbles)).toBeNull(); + }); +}); diff --git a/app/src/components/Editor.tsx b/app/src/components/Editor.tsx index ab9da71..818a687 100644 --- a/app/src/components/Editor.tsx +++ b/app/src/components/Editor.tsx @@ -617,6 +617,133 @@ const headings = ViewPlugin.fromClass( { decorations: (plugin) => plugin.decorations }, ); +export type DividerType = "bold" | "subtle"; + +/** Determine whether a markdown line represents a bold (___) or subtle (---) visual divider. */ +export function getDividerType(text: string): DividerType | null { + const trimmed = text.trim(); + if (/^(_\s*){3,}$/.test(trimmed)) { + return "bold"; + } + if (/^(-\s*){3,}$/.test(trimmed)) { + return "subtle"; + } + if (/^(\*\s*){3,}$/.test(trimmed)) { + return "subtle"; + } + return null; +} + +/** Identify the closing line number of YAML frontmatter if present, so frontmatter delimiters are not decorated as dividers. */ +export function getFrontmatterEndLine(doc: Text): number { + if (doc.lines < 2) return 0; + const firstLine = doc.line(1).text.trim(); + if (firstLine !== "---") return 0; + for (let n = 2; n <= doc.lines; n++) { + if (doc.line(n).text.trim() === "---") { + return n; + } + } + return 0; +} + +/** Check if any selection or caret overlaps the given line range. */ +export function isLineSelected(state: EditorState, lineFrom: number, lineTo: number): boolean { + for (const range of state.selection.ranges) { + if (range.from <= lineTo && range.to >= lineFrom) { + return true; + } + } + return false; +} + +export class DividerWidget extends WidgetType { + constructor(readonly kind: DividerType) { + super(); + } + + toDOM(): HTMLElement { + const el = document.createElement("div"); + el.className = `cm-divider cm-divider-${this.kind}`; + el.setAttribute("aria-hidden", "true"); + const line = document.createElement("div"); + line.className = "cm-divider-graphic"; + el.appendChild(line); + return el; + } + + eq(other: DividerWidget): boolean { + return other.kind === this.kind; + } + + ignoreEvent(): boolean { + return false; + } +} + +/** Build visual divider decorations for `___` (bold accent) and `---` (subtle hairline). + * When the cursor is on the line, the raw text remains editable cleanly. */ +export function buildDividerDecorations(view: EditorView): DecorationSet { + const builder = new RangeSetBuilder(); + const doc = view.state.doc; + const frontmatterEnd = getFrontmatterEndLine(doc); + const ranges = view.visibleRanges.length > 0 ? view.visibleRanges : [{ from: 0, to: doc.length }]; + + for (const { from, to } of ranges) { + const tree = ensureSyntaxTree(view.state, to, 50) ?? syntaxTree(view.state); + tree.iterate({ + from, + to, + enter: (node) => { + if (node.name !== "HorizontalRule") return; + const line = doc.lineAt(node.from); + if (line.number <= frontmatterEnd) return; + + const type = getDividerType(line.text); + if (!type) return; + + const active = isLineSelected(view.state, line.from, line.to); + const lineClass = `cm-divider-line cm-divider-line-${type}${active ? " cm-divider-active" : ""}`; + + builder.add(line.from, line.from, Decoration.line({ class: lineClass, side: -2 })); + + if (active) { + builder.add( + line.from, + line.to, + Decoration.mark({ class: `cm-divider-raw cm-divider-raw-${type}` }), + ); + } else { + builder.add( + line.from, + line.to, + Decoration.replace({ widget: new DividerWidget(type) }), + ); + } + }, + }); + } + + return builder.finish(); +} + +export const visualDividers = ViewPlugin.fromClass( + class { + decorations: DecorationSet; + constructor(view: EditorView) { + this.decorations = buildDividerDecorations(view); + } + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged || update.selectionSet) { + this.decorations = buildDividerDecorations(update.view); + } + } + }, + { + decorations: (plugin) => plugin.decorations, + }, +); + /** The top-level bubbles of an idea note: each block group and whether it * starts with a heading. Shared by the bubble decorations, the heat bars, * Ctrl+A, and the hover menu's copy button. */ @@ -1747,6 +1874,48 @@ const theme = EditorView.theme( paddingBottom: "0.45em", borderBottom: "1px solid rgba(255, 255, 255, 0.07)", }, + ".cm-divider-line": { + position: "relative", + paddingTop: "0.85em", + paddingBottom: "0.85em", + }, + ".cm-divider-line.cm-divider-active": { + paddingTop: "0.35em", + paddingBottom: "0.35em", + }, + ".cm-divider": { + display: "flex", + alignItems: "center", + width: "100%", + cursor: "text", + }, + ".cm-divider-graphic": { + width: "100%", + transition: "all 150ms ease", + }, + ".cm-divider-bold .cm-divider-graphic": { + height: "2px", + borderRadius: "2px", + background: "var(--accent, #f59e0b)", + boxShadow: "0 0 8px var(--accent-soft, rgba(245, 158, 11, 0.45)), 0 0 2px var(--accent, #f59e0b)", + }, + ".cm-divider-subtle .cm-divider-graphic": { + height: "1px", + background: "rgba(255, 255, 255, 0.12)", + }, + ".cm-divider-raw": { + fontFamily: "var(--font-mono)", + letterSpacing: "2px", + fontSize: "0.9em", + opacity: 0.8, + }, + ".cm-divider-raw-bold": { + color: "var(--accent, #f59e0b)", + fontWeight: 600, + }, + ".cm-divider-raw-subtle": { + color: "var(--muted)", + }, ".cm-para": { borderLeft: "1px solid rgba(255,255,255,0.08)", borderRight: "1px solid rgba(255,255,255,0.08)", @@ -2541,6 +2710,7 @@ export function Editor() { bubbleMarkers, jumpHighlight, headings, + visualDividers, paragraphBoxes, bubbleMetadataDecorations, bubbleModelPersistence, diff --git a/app/src/components/Sidebar.tsx b/app/src/components/Sidebar.tsx index 3564438..ddf79e0 100644 --- a/app/src/components/Sidebar.tsx +++ b/app/src/components/Sidebar.tsx @@ -51,8 +51,14 @@ function bubbleCountHeatStyle( }; } +function isDividerLine(line: string): boolean { + const t = line.trim(); + return /^(_\s*){3,}$/.test(t) || /^(-\s*){3,}$/.test(t) || /^(\*\s*){3,}$/.test(t); +} + /** The bubbles of an idea note: each paragraph (and the list that follows it) - * with the body position its first line starts at. */ + * with the body position its first line starts at. Visual divider lines are treated + * as separators so they do not produce empty or broken outline entries. */ function paragraphOutline(body: string): { start: number; label: string }[] { const out: { start: number; label: string }[] = []; const lines = body.split("\n"); @@ -60,7 +66,8 @@ function paragraphOutline(body: string): { start: number; label: string }[] { let buffer: string[] = []; let groupStart = 0; for (const line of lines) { - if (line.trim() === "") { + const trimmed = line.trim(); + if (trimmed === "" || isDividerLine(trimmed)) { if (buffer.length > 0) { out.push({ start: groupStart, label: buffer[0].trim() }); buffer = []; diff --git a/app/src/store.ts b/app/src/store.ts index b16f354..b5b07db 100644 --- a/app/src/store.ts +++ b/app/src/store.ts @@ -77,6 +77,11 @@ function nextDefaultTitle(noteType: NoteType, notes: NoteMeta[]): string { * per explicit `` pair. The same grouping the editor draws * bubbles around and the sidebar outlines, so a move made from either place * lands on the same block. */ +function isDividerLine(line: string): boolean { + const t = line.trim(); + return /^(_\s*){3,}$/.test(t) || /^(-\s*){3,}$/.test(t) || /^(\*\s*){3,}$/.test(t); +} + function bubbleRanges(body: string): { from: number; to: number }[] { const pairs = bubbleMarkerPairs(body); const out: { from: number; to: number }[] = []; @@ -84,6 +89,7 @@ function bubbleRanges(body: string): { from: number; to: number }[] { let offset = 0; for (const line of body.split("\n")) { + const trimmed = line.trim(); // Marker pairs own everything between their lines: never start or extend a // blank-line bubble across them. if (pairs.some((p) => offset >= p.from && offset <= p.to)) { @@ -91,7 +97,7 @@ function bubbleRanges(body: string): { from: number; to: number }[] { out.push({ from, to: offset - 1 }); from = -1; } - } else if (line.trim() === "") { + } else if (trimmed === "" || isDividerLine(trimmed)) { if (from >= 0) { // Ends at the previous line's last character, not at this blank one. out.push({ from, to: offset - 1 }); @@ -139,7 +145,7 @@ export function bubbleMarkerPairs(body: string): { from: number; to: number }[] function titleFromFirstLine(body: string): string { for (const raw of body.split("\n")) { const line = raw.trim(); - if (!line) continue; + if (!line || isDividerLine(line)) continue; return line.replace(/^#{1,6}\s+/, "").trim(); } return "";