From 7221285fef9b0c854aef8763a806c4e2070ad62e Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Tue, 4 Aug 2026 22:07:29 -0400 Subject: [PATCH 01/17] fix: callout widget height (margin collapse) + arrow-key navigation - calloutWidgetTheme: display:flow-root on .cm-lp-callout establishes a block formatting context so 's my-3 margins are contained in the measured box. Without it those child margins collapsed through the container, so CM measured a height ~24px shorter than the callout actually paints and mapped clicks/cursor a line or two too low. - ResizeObserver on the widget container re-measures when height settles after the synchronous mount (async markdown/image resolution, fonts, collapsible toggle); guarded for environments without ResizeObserver. - Prec.high ArrowDown/ArrowUp keymap: a block replace decoration is atomic, so default vertical motion skipped the whole callout and it never unfolded. The handler redirects a move from the adjacent line into the callout's first line, which unfolds it via the existing activeLines mechanism. - foldedCalloutFromCursor extracted + tested; ResizeObserver stub added to the component test setup. --- src/lib/callout-widget.tsx | 53 +++++++++++++-------- src/lib/livePreview.component.test.tsx | 66 +++++++++++++++++++++++++- src/lib/livePreview.ts | 61 +++++++++++++++++++++++- vitest.setup.components.ts | 11 +++++ 4 files changed, 168 insertions(+), 23 deletions(-) diff --git a/src/lib/callout-widget.tsx b/src/lib/callout-widget.tsx index 2d7c930f3..f4c4df9c0 100644 --- a/src/lib/callout-widget.tsx +++ b/src/lib/callout-widget.tsx @@ -43,6 +43,7 @@ export function parseCalloutSource(raw: string): CalloutData | null { export class CalloutWidget extends WidgetType { private root: Root | null = null; + private resizeObserver: ResizeObserver | null = null; constructor(private readonly data: CalloutData) { super(); @@ -63,16 +64,9 @@ export class CalloutWidget extends WidgetType { toDOM(view: EditorView): HTMLElement { const container = document.createElement("div"); container.className = "cm-lp-callout"; - // Mount the React callout SYNCHRONOUSLY. This is the crux of the cursor-drift - // fix: CodeMirror measures the widget's height immediately after toDOM() - // returns, and it uses that height to position every line below it. If React - // painted asynchronously (a plain createRoot().render()), the container would - // still be ~0px tall at measure time, so CM placed the cursor several lines - // off — and no amount of requestMeasure() after the fact fully recovered, - // because the first (wrong) layout had already mapped click coordinates. - // flushSync forces the render to commit before we return, so the DOM has its - // real height the first time CM measures. `estimatedHeight = -1` keeps CM - // from ever assuming the widget matches the replaced source lines. + // Mount the React callout SYNCHRONOUSLY so CodeMirror's first height measure + // (which happens right after toDOM returns, and positions every line below) + // sees real content rather than a 0px shell. this.root = createRoot(container); flushSync(() => { this.root?.render( @@ -86,16 +80,22 @@ export class CalloutWidget extends WidgetType { , ); }); - // The collapsible header toggles height at runtime (open/closed), which - // changes the widget's size AFTER the initial synchronous measure. The - // toggle is a React onClick inside ; listen on the container (fires - // for the same click) and re-measure once React has committed the new - // height — a double rAF waits out that commit. This is only for the toggle - // transition; initial layout is already correct from the flushSync above. - if (this.data.collapsible) { - container.addEventListener("click", () => { - requestAnimationFrame(() => requestAnimationFrame(() => view.requestMeasure())); + // flushSync commits the React tree, but the widget's height keeps changing + // AFTER that first measure: NoteMarkdownPreview resolves images/embeds in a + // useEffect, fonts finish loading, and the collapsible header toggles open/ + // closed. Any of these shifts the height CM already committed, which is what + // desyncs clicks/cursor from the lines below. A ResizeObserver tells CM to + // re-measure on every size change, so the layout self-corrects once the + // content settles instead of being frozen at the (often wrong) first height. + let lastHeight = -1; + if (typeof ResizeObserver !== "undefined") { + this.resizeObserver = new ResizeObserver((entries) => { + const h = entries[0]?.contentRect.height ?? 0; + if (h === lastHeight) return; // ignore width-only / no-op notifications + lastHeight = h; + view.requestMeasure(); }); + this.resizeObserver.observe(container); } return container; } @@ -107,6 +107,10 @@ export class CalloutWidget extends WidgetType { } destroy(): void { + // Stop observing before unmount so a teardown-time resize can't queue a + // measure against a destroyed widget. + this.resizeObserver?.disconnect(); + this.resizeObserver = null; // Unmount asynchronously — React forbids unmounting synchronously from // within a render/commit cycle, which CM's DOM updates can be inside of. const root = this.root; @@ -133,7 +137,16 @@ export function makeCalloutWidget(data: CalloutData): WidgetType { // styling; this only manages block spacing so it sits like a paragraph. export const calloutWidgetTheme = EditorView.theme({ ".cm-lp-callout": { - // Callout already has my-3; keep the CM line box from adding extra height. margin: "0", + // CRITICAL for correct cursor/click mapping: has `my-3` (top+ + // bottom margins). Without a block formatting context those child margins + // COLLAPSE THROUGH this container, so its border-box height (what CM and the + // ResizeObserver measure) is ~24px shorter than the space it actually paints. + // CM then lays out the lines below at the short height while the browser + // paints the callout taller — so clicks/cursor land a line or two too low. + // `flow-root` contains the child margins inside the measured box, making the + // measured height match the painted height. (overflow:hidden would also work + // but risks clipping; flow-root is the intent-revealing choice.) + display: "flow-root", }, }); diff --git a/src/lib/livePreview.component.test.tsx b/src/lib/livePreview.component.test.tsx index 3432d3528..56267cc43 100644 --- a/src/lib/livePreview.component.test.tsx +++ b/src/lib/livePreview.component.test.tsx @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; -import { livePreview } from "@/lib/livePreview"; +import { livePreview, foldedCalloutFromCursor } from "@/lib/livePreview"; import { parseCalloutSource } from "@/lib/callout-widget"; /** @@ -126,6 +126,70 @@ describe("livePreview decorations", () => { expect(view.state.doc.lineAt(block.from).number).toBe(line.number); view.destroy(); }); + + it("observes the callout widget for later height changes and stops on destroy", () => { + // flushSync only fixes the FIRST measure; the widget's height keeps shifting + // afterwards (async markdown/image resolution, font load, collapsible toggle), + // which is what desyncs the cursor. A ResizeObserver must watch the widget so + // CM re-measures once the content settles — and disconnect on teardown. + const observed: Element[] = []; + let disconnects = 0; + const RealRO = globalThis.ResizeObserver; + class SpyRO { + observe(el: Element) { observed.push(el); } + unobserve() {} + disconnect() { disconnects += 1; } + } + globalThis.ResizeObserver = SpyRO as unknown as typeof ResizeObserver; + try { + const doc = "intro\n\n> [!note] Title\n> body\n\nafter"; + const view = mount(doc, 0); + const widget = view.contentDOM.querySelector(".cm-lp-callout"); + expect(widget).toBeTruthy(); + // The rendered widget container is being observed for resize. + expect(observed).toContain(widget); + view.destroy(); + expect(disconnects).toBeGreaterThan(0); + } finally { + globalThis.ResizeObserver = RealRO; + } + }); + + it("detects a folded callout the cursor would skip when moving down/up", () => { + // A block widget is atomic, so CM's default ArrowDown skips the whole + // callout and it never unfolds. foldedCalloutFromCursor is the decision that + // redirects the move into the callout's first line instead. Callouts sit + // between blank lines (a blockquote needs a blank line to terminate), so the + // adjacent line is the blank line directly above/below the widget. + const doc = "intro\n\n> [!note] Title\n> body\n\nafter"; + const view = mount(doc, 0); + const block = view.state.doc.lineAt(doc.indexOf("> [!note]")).number; // 3 + const blankAbove = block - 1; // line 2 + const blankBelow = view.state.doc.lineAt(doc.indexOf("after")).number - 1; // line 5 + + // Cursor on the blank line directly above → ArrowDown enters the callout. + view.dispatch({ selection: { anchor: view.state.doc.line(blankAbove).from } }); + expect(foldedCalloutFromCursor(view.state, 1)?.lineStart).toBe(block); + + // Cursor on the blank line directly below → ArrowUp enters the callout. + view.dispatch({ selection: { anchor: view.state.doc.line(blankBelow).from } }); + expect(foldedCalloutFromCursor(view.state, -1)?.lineStart).toBe(block); + view.destroy(); + }); + + it("does not redirect when the cursor is already inside (unfolded) callout or none is adjacent", () => { + const doc = "intro\n\n> [!note] Title\n> body\n\nafter"; + // Cursor inside the callout source → it's unfolded, so no redirect. + const inside = mount(doc, doc.indexOf("Title")); + expect(foldedCalloutFromCursor(inside.state, 1)).toBeNull(); + expect(foldedCalloutFromCursor(inside.state, -1)).toBeNull(); + inside.destroy(); + + // A plain document with no callout adjacent to the cursor. + const plain = mount("line one\nline two\nline three", 0); + expect(foldedCalloutFromCursor(plain.state, 1)).toBeNull(); + plain.destroy(); + }); }); describe("parseCalloutSource", () => { diff --git a/src/lib/livePreview.ts b/src/lib/livePreview.ts index 73eae15b2..c257b95aa 100644 --- a/src/lib/livePreview.ts +++ b/src/lib/livePreview.ts @@ -14,7 +14,7 @@ * mutated — only the *rendering* is decorated. Raw markdown stays on disk. */ -import { EditorState, Range, StateField } from "@codemirror/state"; +import { EditorState, EditorSelection, Prec, Range, StateField } from "@codemirror/state"; import { Decoration, DecorationSet, @@ -22,6 +22,7 @@ import { ViewPlugin, ViewUpdate, WidgetType, + keymap, } from "@codemirror/view"; import { syntaxTree } from "@codemirror/language"; import { makeCalloutWidget, parseCalloutSource, calloutWidgetTheme } from "./callout-widget"; @@ -413,12 +414,68 @@ const livePreviewTheme = EditorView.theme({ }, }); +/** + * Vertical-cursor motion across a folded callout widget. + * + * A `block: true` replace decoration is one atomic unit, so CodeMirror's default + * ArrowUp/ArrowDown jumps clean OVER the whole callout — the selection never + * lands on a line inside it, so the "cursor inside → show raw source" unfold + * (driven by `activeLines`) never fires and the widget appears un-enterable. + * + * These handlers make a vertical move that would cross a folded callout instead + * land the cursor at the callout's first line, which unfolds it for editing — so + * the widget behaves like the text it stands in for. Moving again from inside + * the (now unfolded) callout advances normally, because it's no longer folded. + * + * Only acts on a single collapsed cursor entering a FOLDED callout from the + * adjacent line; every other case (selections, cursor already inside, no + * adjacent callout) falls through to CM's default handling by returning false. + */ +export function foldedCalloutFromCursor(state: EditorState, dir: 1 | -1): CalloutBlock | null { + const sel = state.selection.main; + if (!sel.empty) return null; + const curLine = state.doc.lineAt(sel.head).number; + const active = activeLines(state); + for (const block of findCalloutBlocks(state)) { + // A callout the cursor is already inside is unfolded — let CM move normally. + let inside = false; + for (let ln = block.lineStart; ln <= block.lineEnd; ln++) { + if (active.has(ln)) { inside = true; break; } + } + if (inside) continue; + // Entering from the line directly above (moving down) or below (moving up). + if (dir === 1 && curLine === block.lineStart - 1) return block; + if (dir === -1 && curLine === block.lineEnd + 1) return block; + } + return null; +} + +function moveIntoCallout(view: EditorView, dir: 1 | -1): boolean { + const block = foldedCalloutFromCursor(view.state, dir); + if (!block) return false; + // Land on the callout's first line so activeLines picks it up and it unfolds. + const target = view.state.doc.line(block.lineStart).from; + view.dispatch({ + selection: EditorSelection.cursor(target), + scrollIntoView: true, + userEvent: "select", + }); + return true; +} + +const calloutCursorKeymap = Prec.high( + keymap.of([ + { key: "ArrowDown", run: (v) => moveIntoCallout(v, 1) }, + { key: "ArrowUp", run: (v) => moveIntoCallout(v, -1) }, + ]), +); + /** * Live Preview extension. Add to the editor's extension array. Reversible via a * Compartment if you want to toggle it at runtime. */ export function livePreview() { const exts = [livePreviewPlugin, livePreviewTheme]; - if (CALLOUTS_ENABLED) exts.unshift(calloutField, calloutWidgetTheme); + if (CALLOUTS_ENABLED) exts.unshift(calloutField, calloutWidgetTheme, calloutCursorKeymap); return exts; } diff --git a/vitest.setup.components.ts b/vitest.setup.components.ts index 00aa4a2d4..53d7fbdeb 100644 --- a/vitest.setup.components.ts +++ b/vitest.setup.components.ts @@ -10,6 +10,17 @@ import "@testing-library/jest-dom/vitest"; import { afterEach } from "vitest"; import { cleanup } from "@testing-library/react"; +// jsdom has no ResizeObserver, but components (e.g. the callout widget, which +// re-measures on height changes) reference it. A no-op stub lets them mount; +// tests that need to assert re-measurement can spy on requestMeasure instead. +if (typeof globalThis.ResizeObserver === "undefined") { + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; +} + afterEach(() => { cleanup(); }); From 33efdc3b6db96ae06af66041324c6fbd49c36a7d Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Tue, 4 Aug 2026 22:21:12 -0400 Subject: [PATCH 02/17] feat: inline code-block live-preview widget + shared block-widget base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier 2 block widgets, continued (callouts already shipped). - BlockPreviewWidget: shared base encoding the callout POC's hard-won lessons — synchronous flushSync mount, ResizeObserver re-measure, estimatedHeight=-1, ignoreEvent=false, and a flow-root container (contains child margins so the measured height matches the painted height). Each widget only supplies its React element + an eq() check. - CalloutWidget refactored onto the base (behavior unchanged). - CodeBlockWidget: renders fenced code (```lang) inline via the existing component (syntax highlighting + copy); parseFencedCode handles ```/~~~, info strings, and unclosed-while-typing fences. - livePreview.ts generalized: findCalloutBlocks -> findBlockWidgets (callouts + FencedCode), calloutField -> blockWidgetField, and the arrow-key nav (foldedCalloutFromCursor -> foldedBlockFromCursor) now enters any folded block widget. CALLOUTS_ENABLED -> BLOCK_WIDGETS_ENABLED kill-switch. - Tests: parseFencedCode unit cases; code-block render/cursor-inside/arrow-nav. --- src/lib/block-preview-widget.tsx | 112 ++++++++++++++++++ src/lib/callout-widget.tsx | 115 ++++--------------- src/lib/code-block-widget.tsx | 75 ++++++++++++ src/lib/livePreview.component.test.tsx | 73 ++++++++++-- src/lib/livePreview.ts | 153 ++++++++++++++----------- 5 files changed, 362 insertions(+), 166 deletions(-) create mode 100644 src/lib/block-preview-widget.tsx create mode 100644 src/lib/code-block-widget.tsx diff --git a/src/lib/block-preview-widget.tsx b/src/lib/block-preview-widget.tsx new file mode 100644 index 000000000..3729a133c --- /dev/null +++ b/src/lib/block-preview-widget.tsx @@ -0,0 +1,112 @@ +"use client"; + +/** + * BlockPreviewWidget — shared base for Live Preview "Tier 2" block widgets that + * render a read-only React preview of a markdown block inline in the CodeMirror + * editor (callouts, code blocks, and later tables/mermaid/math). + * + * It bakes in the three hard-won lessons from the callout POC (see the note + * "Live Preview Editor — Tier 2 Block Widgets"), so each new widget type only + * has to supply its own React element and an equality check: + * + * 1. Synchronous mount (`flushSync`) so CodeMirror's first height measure — + * which positions every line below the widget — sees real content, not a + * 0px shell. + * 2. A `ResizeObserver` that calls `view.requestMeasure()` when the height + * settles later (async syntax/markdown/image resolution, fonts, toggles). + * 3. `estimatedHeight = -1` (never assume the widget matches the replaced + * source lines), `ignoreEvent() = false` (let clicks fall through so the + * cursor enters the source range and the block unfolds to raw markdown), + * and a `display: flow-root` container so child margins are contained in + * the measured box (otherwise they collapse through and the measured height + * is short, drifting the cursor below). + * + * The "click-to-edit" model means no edit round-trip inside the widget: when the + * cursor enters the block's source range, livePreview.ts skips the widget and + * shows the raw markdown lines for editing. + */ + +import { EditorView, WidgetType } from "@codemirror/view"; +import { createRoot, type Root } from "react-dom/client"; +import { flushSync } from "react-dom"; +import type { ReactNode } from "react"; + +/** Shared container class; styled by blockWidgetTheme (flow-root + no margin). */ +export const BLOCK_WIDGET_CLASS = "cm-lp-block"; + +export abstract class BlockPreviewWidget extends WidgetType { + private root: Root | null = null; + private resizeObserver: ResizeObserver | null = null; + + /** The React element to render inside the widget container. */ + protected abstract render(): ReactNode; + + /** Extra class(es) for the container (e.g. "cm-lp-callout") for theming. */ + protected extraClass(): string { + return ""; + } + + toDOM(view: EditorView): HTMLElement { + const container = document.createElement("div"); + container.className = `${BLOCK_WIDGET_CLASS} ${this.extraClass()}`.trim(); + // (1) Mount synchronously so the first CM measure sees real height. + this.root = createRoot(container); + flushSync(() => { + this.root?.render(this.render()); + }); + // (2) Re-measure when the height changes after the initial commit. + let lastHeight = -1; + if (typeof ResizeObserver !== "undefined") { + this.resizeObserver = new ResizeObserver((entries) => { + const h = entries[0]?.contentRect.height ?? 0; + if (h === lastHeight) return; // ignore width-only / no-op notifications + lastHeight = h; + view.requestMeasure(); + }); + this.resizeObserver.observe(container); + } + return container; + } + + // (3a) The rendered block's height differs from the source lines it replaces. + get estimatedHeight(): number { + return -1; + } + + destroy(): void { + // Stop observing before unmount so a teardown-time resize can't queue a + // measure against a destroyed widget. + this.resizeObserver?.disconnect(); + this.resizeObserver = null; + // Unmount asynchronously — React forbids unmounting synchronously from + // within a render/commit cycle, which CM's DOM updates can be inside of. + const root = this.root; + this.root = null; + if (root) queueMicrotask(() => root.unmount()); + } + + // (3b) Let clicks fall through so the cursor enters the source range (which + // reveals raw markdown for editing). Returning true would swallow the click + // and make the block un-selectable ("can't click in"). + ignoreEvent(): boolean { + return false; + } +} + +/** + * Theme for the shared block-widget container. Each widget's own React component + * brings its styling; this only manages block spacing + a block formatting + * context so child margins are contained in the measured box (see lesson 3). + */ +export const blockWidgetTheme = EditorView.theme({ + [`.${BLOCK_WIDGET_CLASS}`]: { + margin: "0", + // CRITICAL for correct cursor/click mapping: child components have vertical + // margins (e.g. has `my-3`). Without a block formatting context + // those margins COLLAPSE THROUGH this container, so its measured border-box + // height is shorter than the space it actually paints — CM then lays out the + // lines below at the short height and clicks/cursor land too low. `flow-root` + // contains the child margins inside the measured box. + display: "flow-root", + }, +}); diff --git a/src/lib/callout-widget.tsx b/src/lib/callout-widget.tsx index f4c4df9c0..f3a92488e 100644 --- a/src/lib/callout-widget.tsx +++ b/src/lib/callout-widget.tsx @@ -1,19 +1,18 @@ "use client"; /** - * CalloutWidget — a CodeMirror block widget that renders an Obsidian-style + * CalloutWidget — a Live Preview block widget that renders an Obsidian-style * callout (`> [!note] …`) inline in the editor, reusing the same - * React component the Read-mode preview uses. + * React component the Read-mode preview uses. Built on BlockPreviewWidget, which + * handles the mount/measure/teardown lifecycle common to all block widgets. * - * This is the "click-to-edit" Tier 2 approach: the widget is a read-only - * rendering. When the cursor enters the callout's source range the livePreview - * extension skips the widget entirely and shows the raw `>` lines, so editing - * happens on plain markdown. No edit-round-trip inside the widget is needed. + * Click-to-edit: cursor outside the callout → widget; cursor inside → raw `>` + * source (livePreview.ts skips the widget for the active block). */ -import { EditorView, WidgetType } from "@codemirror/view"; -import { createRoot, type Root } from "react-dom/client"; -import { flushSync } from "react-dom"; +import type { WidgetType } from "@codemirror/view"; +import type { ReactNode } from "react"; +import { BlockPreviewWidget } from "./block-preview-widget"; import { Callout, parseCalloutDirective } from "@/components/notes/Callout"; import { NoteMarkdownPreview } from "@/components/notes/NoteMarkdownPreview"; @@ -41,10 +40,7 @@ export function parseCalloutSource(raw: string): CalloutData | null { return { ...directive, body }; } -export class CalloutWidget extends WidgetType { - private root: Root | null = null; - private resizeObserver: ResizeObserver | null = null; - +export class CalloutWidget extends BlockPreviewWidget { constructor(private readonly data: CalloutData) { super(); } @@ -61,70 +57,21 @@ export class CalloutWidget extends WidgetType { ); } - toDOM(view: EditorView): HTMLElement { - const container = document.createElement("div"); - container.className = "cm-lp-callout"; - // Mount the React callout SYNCHRONOUSLY so CodeMirror's first height measure - // (which happens right after toDOM returns, and positions every line below) - // sees real content rather than a 0px shell. - this.root = createRoot(container); - flushSync(() => { - this.root?.render( - - {this.data.body ? : null} - , - ); - }); - // flushSync commits the React tree, but the widget's height keeps changing - // AFTER that first measure: NoteMarkdownPreview resolves images/embeds in a - // useEffect, fonts finish loading, and the collapsible header toggles open/ - // closed. Any of these shifts the height CM already committed, which is what - // desyncs clicks/cursor from the lines below. A ResizeObserver tells CM to - // re-measure on every size change, so the layout self-corrects once the - // content settles instead of being frozen at the (often wrong) first height. - let lastHeight = -1; - if (typeof ResizeObserver !== "undefined") { - this.resizeObserver = new ResizeObserver((entries) => { - const h = entries[0]?.contentRect.height ?? 0; - if (h === lastHeight) return; // ignore width-only / no-op notifications - lastHeight = h; - view.requestMeasure(); - }); - this.resizeObserver.observe(container); - } - return container; - } - - // The rendered callout height differs from the source lines it replaces, so - // CM must always re-measure rather than assume the widget matches text. - get estimatedHeight(): number { - return -1; - } - - destroy(): void { - // Stop observing before unmount so a teardown-time resize can't queue a - // measure against a destroyed widget. - this.resizeObserver?.disconnect(); - this.resizeObserver = null; - // Unmount asynchronously — React forbids unmounting synchronously from - // within a render/commit cycle, which CM's DOM updates can be inside of. - const root = this.root; - this.root = null; - if (root) queueMicrotask(() => root.unmount()); + protected extraClass(): string { + return "cm-lp-callout"; } - // Let clicks fall through to CodeMirror so clicking the callout places the - // cursor into its source range (which then reveals the raw markdown for - // editing). The collapsible header's own React onClick still fires on the - // way through. Returning true here would swallow the click and make the - // callout un-selectable ("can't click in"). - ignoreEvent(): boolean { - return false; + protected render(): ReactNode { + return ( + + {this.data.body ? : null} + + ); } } @@ -132,21 +79,3 @@ export class CalloutWidget extends WidgetType { export function makeCalloutWidget(data: CalloutData): WidgetType { return new CalloutWidget(data); } - -// Theme for the widget container. The component brings its own -// styling; this only manages block spacing so it sits like a paragraph. -export const calloutWidgetTheme = EditorView.theme({ - ".cm-lp-callout": { - margin: "0", - // CRITICAL for correct cursor/click mapping: has `my-3` (top+ - // bottom margins). Without a block formatting context those child margins - // COLLAPSE THROUGH this container, so its border-box height (what CM and the - // ResizeObserver measure) is ~24px shorter than the space it actually paints. - // CM then lays out the lines below at the short height while the browser - // paints the callout taller — so clicks/cursor land a line or two too low. - // `flow-root` contains the child margins inside the measured box, making the - // measured height match the painted height. (overflow:hidden would also work - // but risks clipping; flow-root is the intent-revealing choice.) - display: "flow-root", - }, -}); diff --git a/src/lib/code-block-widget.tsx b/src/lib/code-block-widget.tsx new file mode 100644 index 000000000..ef65264b6 --- /dev/null +++ b/src/lib/code-block-widget.tsx @@ -0,0 +1,75 @@ +"use client"; + +/** + * CodeBlockWidget — a Live Preview block widget that renders a fenced code block + * (```lang … ```) inline in the editor using the same component the + * Read-mode preview uses (syntax highlighting + copy button). Built on + * BlockPreviewWidget. + * + * Click-to-edit: cursor outside the fence → highlighted widget; cursor inside → + * raw ``` source (livePreview.ts skips the widget for the active block). + */ + +import type { WidgetType } from "@codemirror/view"; +import type { ReactNode } from "react"; +import { BlockPreviewWidget } from "./block-preview-widget"; +import { CodeBlock } from "@/components/notes/CodeBlock"; + +export interface CodeBlockData { + language: string; + code: string; +} + +/** + * Parse the raw source of a fenced code block into { language, code }. + * `raw` is the full block including the opening/closing fence lines. Returns + * null if it isn't a well-formed fence. Supports ``` and ~~~ fences, and an + * info string after the opening fence (only the first token is the language). + */ +export function parseFencedCode(raw: string): CodeBlockData | null { + const lines = raw.split("\n"); + if (lines.length < 1) return null; + const open = lines[0].match(/^(\s*)(`{3,}|~{3,})\s*([^\s`~]*)/); + if (!open) return null; + const fence = open[2][0]; // ` or ~ + // Body = everything between the opening fence and the closing fence (a line + // that is only the same fence char, >= 3). If no closing fence (block still + // being typed), take the rest. + const body: string[] = []; + let closed = false; + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + if (new RegExp(`^\\s*${fence === "`" ? "`" : "~"}{3,}\\s*$`).test(line)) { + closed = true; + break; + } + body.push(line); + } + // A block that has no body AND no close is just an opening fence being typed — + // let it stay raw (return null so no widget renders yet). + if (!closed && body.length === 0) return null; + return { language: open[3] || "", code: body.join("\n") }; +} + +export class CodeBlockWidget extends BlockPreviewWidget { + constructor(private readonly data: CodeBlockData) { + super(); + } + + eq(other: CodeBlockWidget): boolean { + return other.data.language === this.data.language && other.data.code === this.data.code; + } + + protected extraClass(): string { + return "cm-lp-codeblock"; + } + + protected render(): ReactNode { + return ; + } +} + +/** Convenience factory so livePreview.ts stays free of React imports. */ +export function makeCodeBlockWidget(data: CodeBlockData): WidgetType { + return new CodeBlockWidget(data); +} diff --git a/src/lib/livePreview.component.test.tsx b/src/lib/livePreview.component.test.tsx index 56267cc43..4b0fbedab 100644 --- a/src/lib/livePreview.component.test.tsx +++ b/src/lib/livePreview.component.test.tsx @@ -2,8 +2,9 @@ import { describe, it, expect } from "vitest"; import { EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; -import { livePreview, foldedCalloutFromCursor } from "@/lib/livePreview"; +import { livePreview, foldedBlockFromCursor } from "@/lib/livePreview"; import { parseCalloutSource } from "@/lib/callout-widget"; +import { parseFencedCode } from "@/lib/code-block-widget"; /** * Live Preview decoration tests. The main risk in mixing replace + mark + line @@ -157,7 +158,7 @@ describe("livePreview decorations", () => { it("detects a folded callout the cursor would skip when moving down/up", () => { // A block widget is atomic, so CM's default ArrowDown skips the whole - // callout and it never unfolds. foldedCalloutFromCursor is the decision that + // callout and it never unfolds. foldedBlockFromCursor is the decision that // redirects the move into the callout's first line instead. Callouts sit // between blank lines (a blockquote needs a blank line to terminate), so the // adjacent line is the blank line directly above/below the widget. @@ -169,11 +170,11 @@ describe("livePreview decorations", () => { // Cursor on the blank line directly above → ArrowDown enters the callout. view.dispatch({ selection: { anchor: view.state.doc.line(blankAbove).from } }); - expect(foldedCalloutFromCursor(view.state, 1)?.lineStart).toBe(block); + expect(foldedBlockFromCursor(view.state, 1)?.lineStart).toBe(block); // Cursor on the blank line directly below → ArrowUp enters the callout. view.dispatch({ selection: { anchor: view.state.doc.line(blankBelow).from } }); - expect(foldedCalloutFromCursor(view.state, -1)?.lineStart).toBe(block); + expect(foldedBlockFromCursor(view.state, -1)?.lineStart).toBe(block); view.destroy(); }); @@ -181,15 +182,40 @@ describe("livePreview decorations", () => { const doc = "intro\n\n> [!note] Title\n> body\n\nafter"; // Cursor inside the callout source → it's unfolded, so no redirect. const inside = mount(doc, doc.indexOf("Title")); - expect(foldedCalloutFromCursor(inside.state, 1)).toBeNull(); - expect(foldedCalloutFromCursor(inside.state, -1)).toBeNull(); + expect(foldedBlockFromCursor(inside.state, 1)).toBeNull(); + expect(foldedBlockFromCursor(inside.state, -1)).toBeNull(); inside.destroy(); // A plain document with no callout adjacent to the cursor. const plain = mount("line one\nline two\nline three", 0); - expect(foldedCalloutFromCursor(plain.state, 1)).toBeNull(); + expect(foldedBlockFromCursor(plain.state, 1)).toBeNull(); plain.destroy(); }); + + it("renders a code-block widget when the cursor is outside the fence", () => { + const doc = "text\n\n```js\nconst x = 1;\n```\n\nafter"; + const view = mount(doc, 0); // cursor on line 1, outside the fence + expect(view.contentDOM.querySelector(".cm-lp-codeblock")).toBeTruthy(); + view.destroy(); + }); + + it("shows raw source (no code widget) when the cursor is inside the fence", () => { + const doc = "text\n\n```js\nconst x = 1;\n```\n\nafter"; + const view = mount(doc, doc.indexOf("const x")); // cursor inside the code + expect(view.contentDOM.querySelector(".cm-lp-codeblock")).toBeNull(); + expect(view.contentDOM.textContent).toContain("const x = 1;"); + view.destroy(); + }); + + it("arrow-navigates into a folded code block from the adjacent line", () => { + const doc = "text\n\n```js\nconst x = 1;\n```\n\nafter"; + const view = mount(doc, 0); + const fenceLine = view.state.doc.lineAt(doc.indexOf("```js")).number; + // Cursor on the blank line directly above the fence → ArrowDown enters it. + view.dispatch({ selection: { anchor: view.state.doc.line(fenceLine - 1).from } }); + expect(foldedBlockFromCursor(view.state, 1)?.lineStart).toBe(fenceLine); + view.destroy(); + }); }); describe("parseCalloutSource", () => { @@ -212,3 +238,36 @@ describe("parseCalloutSource", () => { expect(parseCalloutSource("> just a quote\n> more")).toBeNull(); }); }); + +describe("parseFencedCode", () => { + it("parses language and code from a closed fence", () => { + const data = parseFencedCode("```js\nconst x = 1;\nconst y = 2;\n```"); + expect(data).not.toBeNull(); + expect(data?.language).toBe("js"); + expect(data?.code).toBe("const x = 1;\nconst y = 2;"); + }); + + it("handles a fence with no language", () => { + expect(parseFencedCode("```\nplain\n```")).toEqual({ language: "", code: "plain" }); + }); + + it("takes only the first info-string token as the language", () => { + expect(parseFencedCode("```ts title=foo\ncode\n```")?.language).toBe("ts"); + }); + + it("supports ~~~ fences", () => { + expect(parseFencedCode("~~~python\nprint(1)\n~~~")).toEqual({ language: "python", code: "print(1)" }); + }); + + it("renders an unclosed fence that already has body (block being typed)", () => { + expect(parseFencedCode("```js\nconst x = 1;")).toEqual({ language: "js", code: "const x = 1;" }); + }); + + it("returns null for a bare opening fence with no body or close yet", () => { + expect(parseFencedCode("```js")).toBeNull(); + }); + + it("returns null for non-fence input", () => { + expect(parseFencedCode("not a fence")).toBeNull(); + }); +}); diff --git a/src/lib/livePreview.ts b/src/lib/livePreview.ts index c257b95aa..6d8274b97 100644 --- a/src/lib/livePreview.ts +++ b/src/lib/livePreview.ts @@ -25,15 +25,16 @@ import { keymap, } from "@codemirror/view"; import { syntaxTree } from "@codemirror/language"; -import { makeCalloutWidget, parseCalloutSource, calloutWidgetTheme } from "./callout-widget"; +import { makeCalloutWidget, parseCalloutSource } from "./callout-widget"; +import { makeCodeBlockWidget, parseFencedCode } from "./code-block-widget"; +import { blockWidgetTheme } from "./block-preview-widget"; -// TEMPORARILY DISABLED — inline callout block widgets have a layout/cursor bug -// (cursor drifts below the widget; clicking in is unreliable). The plumbing is -// left intact behind this flag so it can be re-enabled once the block-widget -// height/measurement issue is solved. See the Cairn "Notes Editor" board task. -// When false: callout blockquotes render as ordinary blockquotes (Tier 1 border) -// and the calloutField / widget are not added to the editor. -const CALLOUTS_ENABLED = true; +// Master kill-switch for Tier 2 inline block widgets (callouts + fenced code +// blocks; tables/mermaid/math later). Block widgets are the finicky part +// (height measurement, cursor enter/exit), so this stays as an escape hatch. +// When false: callouts render as ordinary blockquotes (Tier 1 border) and code +// fences stay raw — the block StateField/widgets/keymap are not added. +const BLOCK_WIDGETS_ENABLED = true; // ── Widgets ──────────────────────────────────────────────────────────────── @@ -102,49 +103,70 @@ function activeLines(state: EditorState): Set { // the ViewPlugin below. Both share `findCalloutBlocks` so the inline passes can // skip lines a widget covers. -interface CalloutBlock { +interface BlockRange { from: number; to: number; lineStart: number; lineEnd: number; } -/** Find every callout blockquote in the document, with its char + line range. */ -function findCalloutBlocks(state: EditorState): (CalloutBlock & { raw: string })[] { +/** A detected block-widget candidate: its range plus a factory for its widget. */ +interface BlockWidget extends BlockRange { + makeWidget: () => WidgetType; +} + +/** + * Find every block that should render as an inline widget (callouts + fenced + * code blocks), each with its char/line range and a widget factory. Both kinds + * share the same StateField + click-to-edit machinery; adding a new block type + * (tables, mermaid, …) means adding another branch here. + */ +function findBlockWidgets(state: EditorState): BlockWidget[] { const { doc } = state; - const out: (CalloutBlock & { raw: string })[] = []; + const out: BlockWidget[] = []; + const push = (from: number, toRaw: number, raw: string, makeWidget: () => WidgetType) => { + const lineStart = doc.lineAt(from).number; + const lineEnd = doc.lineAt(toRaw).number; + void raw; + out.push({ + from: doc.line(lineStart).from, + to: doc.line(lineEnd).to, + lineStart, + lineEnd, + makeWidget, + }); + }; syntaxTree(state).iterate({ enter: (node) => { - if (node.name !== "Blockquote") return; - // Skip a blockquote nested inside another blockquote: a nested callout - // (`> [!note]` containing `> > [!tip]`) would otherwise emit overlapping - // block ranges, which throw when added to a Decoration.set(sorted) during - // measurement. Only the outermost blockquote becomes a callout widget. - if (node.node.parent?.name === "Blockquote") return; - const nodeTo = Math.min(node.to, doc.length); - const raw = doc.sliceString(node.from, nodeTo); - if (!parseCalloutSource(raw)) return; - const lineStart = doc.lineAt(node.from).number; - const lineEnd = doc.lineAt(nodeTo).number; - out.push({ - from: doc.line(lineStart).from, - to: doc.line(lineEnd).to, - lineStart, - lineEnd, - raw, - }); + if (node.name === "Blockquote") { + // Skip a blockquote nested inside another blockquote: a nested callout + // would emit overlapping block ranges, which throw when added to a + // Decoration.set(sorted). Only the outermost becomes a callout widget. + if (node.node.parent?.name === "Blockquote") return; + const nodeTo = Math.min(node.to, doc.length); + const raw = doc.sliceString(node.from, nodeTo); + const data = parseCalloutSource(raw); + if (!data) return; + push(node.from, nodeTo, raw, () => makeCalloutWidget(data)); + } else if (node.name === "FencedCode") { + const nodeTo = Math.min(node.to, doc.length); + const raw = doc.sliceString(node.from, nodeTo); + const data = parseFencedCode(raw); + if (!data) return; + push(node.from, nodeTo, raw, () => makeCodeBlockWidget(data)); + } }, }); return out; } -/** Line numbers covered by a callout widget (cursor outside) — used by the +/** Line numbers covered by a block widget (cursor outside) — used by the * inline ViewPlugin to skip those lines. */ -function calloutWidgetLineSet(state: EditorState): Set { +function blockWidgetLineSet(state: EditorState): Set { const set = new Set(); - if (!CALLOUTS_ENABLED) return set; // feature disabled → treat as ordinary blockquotes + if (!BLOCK_WIDGETS_ENABLED) return set; // feature disabled → treat blocks as plain const active = activeLines(state); - for (const block of findCalloutBlocks(state)) { + for (const block of findBlockWidgets(state)) { let cursorInside = false; for (let ln = block.lineStart; ln <= block.lineEnd; ln++) { if (active.has(ln)) { cursorInside = true; break; } @@ -155,29 +177,28 @@ function calloutWidgetLineSet(state: EditorState): Set { return set; } -const calloutField = StateField.define({ +const blockWidgetField = StateField.define({ create(state) { - return buildCalloutDecorations(state); + return buildBlockWidgetDecorations(state); }, update(deco, tr) { - if (tr.docChanged || tr.selection) return buildCalloutDecorations(tr.state); + if (tr.docChanged || tr.selection) return buildBlockWidgetDecorations(tr.state); return deco.map(tr.changes); }, provide: (f) => EditorView.decorations.from(f), }); -function buildCalloutDecorations(state: EditorState): DecorationSet { +function buildBlockWidgetDecorations(state: EditorState): DecorationSet { const active = activeLines(state); const decos: Range[] = []; - for (const block of findCalloutBlocks(state)) { + for (const block of findBlockWidgets(state)) { let cursorInside = false; for (let ln = block.lineStart; ln <= block.lineEnd; ln++) { if (active.has(ln)) { cursorInside = true; break; } } if (cursorInside) continue; // show raw source for editing - const data = parseCalloutSource(block.raw)!; decos.push( - Decoration.replace({ widget: makeCalloutWidget(data), block: true }).range(block.from, block.to), + Decoration.replace({ widget: block.makeWidget(), block: true }).range(block.from, block.to), ); } return Decoration.set(decos, true); @@ -197,7 +218,7 @@ function buildDecorations(view: EditorView): DecorationSet { // Lines covered by a callout widget (from the StateField) — the inline passes // below skip these so we don't emit decorations that overlap the block // widget's replaced range. - const calloutWidgetLines = calloutWidgetLineSet(view.state); + const blockWidgetLines = blockWidgetLineSet(view.state); for (const { from, to } of view.visibleRanges) { syntaxTree(view.state).iterate({ from, @@ -210,7 +231,7 @@ function buildDecorations(view: EditorView): DecorationSet { const line = doc.lineAt(node.from).number; if (active.has(line)) return; // Skip marks inside a callout that's being widget-replaced. - if (calloutWidgetLines.has(line)) return; + if (blockWidgetLines.has(line)) return; if (name === "HeaderMark" || name === "QuoteMark") { // Hide the mark AND the single trailing space so the heading/quote @@ -236,7 +257,7 @@ function buildDecorations(view: EditorView): DecorationSet { const startLine = doc.lineAt(node.from).number; const endLine = doc.lineAt(Math.min(node.to, doc.length)).number; for (let ln = startLine; ln <= endLine; ln++) { - if (calloutWidgetLines.has(ln)) continue; + if (blockWidgetLines.has(ln)) continue; const line = doc.line(ln); otherDecos.push(blockquoteLine.range(line.from)); } @@ -273,7 +294,7 @@ function buildDecorations(view: EditorView): DecorationSet { if (node.name !== "ListMark") return; const line = doc.lineAt(node.from).number; if (active.has(line)) return; - if (calloutWidgetLines.has(line)) return; + if (blockWidgetLines.has(line)) return; const text = doc.sliceString(node.from, node.to); if (text === "-" || text === "*" || text === "+") { replaceDecos.push(hideBulletMark.range(node.from, node.to)); @@ -283,7 +304,7 @@ function buildDecorations(view: EditorView): DecorationSet { // Highlight (`==text==`) and wikilinks (`[[Title]]`) — scanned via regex on // the visible text since neither is a distinct node in the base grammar. - scanInlinePatterns(view, from, to, active, calloutWidgetLines, replaceDecos, otherDecos); + scanInlinePatterns(view, from, to, active, blockWidgetLines, replaceDecos, otherDecos); } // RangeSet requires ascending, non-overlapping ranges sorted by `from`. @@ -311,7 +332,7 @@ function scanInlinePatterns( from: number, to: number, active: Set, - calloutWidgetLines: Set, + blockWidgetLines: Set, replaceDecos: Range[], otherDecos: Range[], ): void { @@ -324,7 +345,7 @@ function scanInlinePatterns( while ((m = HIGHLIGHT_RE.exec(text)) !== null) { const start = from + m.index; const end = start + m[0].length; - if (calloutWidgetLines.has(doc.lineAt(start).number)) continue; + if (blockWidgetLines.has(doc.lineAt(start).number)) continue; // Always style the inner text; only hide the "==" markers off-active-line. const innerFrom = start + 2; const innerTo = end - 2; @@ -340,7 +361,7 @@ function scanInlinePatterns( while ((m = WIKILINK_RE.exec(text)) !== null) { const start = from + m.index; const end = start + m[0].length; - if (calloutWidgetLines.has(doc.lineAt(start).number)) continue; + if (blockWidgetLines.has(doc.lineAt(start).number)) continue; const innerFrom = start + 2; const innerTo = end - 2; otherDecos.push(wikilinkMark.range(innerFrom, innerTo)); @@ -415,29 +436,29 @@ const livePreviewTheme = EditorView.theme({ }); /** - * Vertical-cursor motion across a folded callout widget. + * Vertical-cursor motion across a folded block widget (callout or code block). * * A `block: true` replace decoration is one atomic unit, so CodeMirror's default - * ArrowUp/ArrowDown jumps clean OVER the whole callout — the selection never + * ArrowUp/ArrowDown jumps clean OVER the whole widget — the selection never * lands on a line inside it, so the "cursor inside → show raw source" unfold * (driven by `activeLines`) never fires and the widget appears un-enterable. * - * These handlers make a vertical move that would cross a folded callout instead - * land the cursor at the callout's first line, which unfolds it for editing — so + * These handlers make a vertical move that would cross a folded widget instead + * land the cursor at the widget's first line, which unfolds it for editing — so * the widget behaves like the text it stands in for. Moving again from inside - * the (now unfolded) callout advances normally, because it's no longer folded. + * the (now unfolded) block advances normally, because it's no longer folded. * - * Only acts on a single collapsed cursor entering a FOLDED callout from the + * Only acts on a single collapsed cursor entering a FOLDED block from the * adjacent line; every other case (selections, cursor already inside, no - * adjacent callout) falls through to CM's default handling by returning false. + * adjacent block) falls through to CM's default handling by returning false. */ -export function foldedCalloutFromCursor(state: EditorState, dir: 1 | -1): CalloutBlock | null { +export function foldedBlockFromCursor(state: EditorState, dir: 1 | -1): BlockRange | null { const sel = state.selection.main; if (!sel.empty) return null; const curLine = state.doc.lineAt(sel.head).number; const active = activeLines(state); - for (const block of findCalloutBlocks(state)) { - // A callout the cursor is already inside is unfolded — let CM move normally. + for (const block of findBlockWidgets(state)) { + // A block the cursor is already inside is unfolded — let CM move normally. let inside = false; for (let ln = block.lineStart; ln <= block.lineEnd; ln++) { if (active.has(ln)) { inside = true; break; } @@ -450,10 +471,10 @@ export function foldedCalloutFromCursor(state: EditorState, dir: 1 | -1): Callou return null; } -function moveIntoCallout(view: EditorView, dir: 1 | -1): boolean { - const block = foldedCalloutFromCursor(view.state, dir); +function moveIntoBlock(view: EditorView, dir: 1 | -1): boolean { + const block = foldedBlockFromCursor(view.state, dir); if (!block) return false; - // Land on the callout's first line so activeLines picks it up and it unfolds. + // Land on the block's first line so activeLines picks it up and it unfolds. const target = view.state.doc.line(block.lineStart).from; view.dispatch({ selection: EditorSelection.cursor(target), @@ -463,10 +484,10 @@ function moveIntoCallout(view: EditorView, dir: 1 | -1): boolean { return true; } -const calloutCursorKeymap = Prec.high( +const blockWidgetKeymap = Prec.high( keymap.of([ - { key: "ArrowDown", run: (v) => moveIntoCallout(v, 1) }, - { key: "ArrowUp", run: (v) => moveIntoCallout(v, -1) }, + { key: "ArrowDown", run: (v) => moveIntoBlock(v, 1) }, + { key: "ArrowUp", run: (v) => moveIntoBlock(v, -1) }, ]), ); @@ -476,6 +497,6 @@ const calloutCursorKeymap = Prec.high( */ export function livePreview() { const exts = [livePreviewPlugin, livePreviewTheme]; - if (CALLOUTS_ENABLED) exts.unshift(calloutField, calloutWidgetTheme, calloutCursorKeymap); + if (BLOCK_WIDGETS_ENABLED) exts.unshift(blockWidgetField, blockWidgetTheme, blockWidgetKeymap); return exts; } From ac16833a8f64f54485f7b19db0014e970a5f71f2 Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Tue, 4 Aug 2026 22:21:25 -0400 Subject: [PATCH 03/17] feat: Raw editing mode in the note editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a third mode between Write and Read: Raw turns Live Preview off (livePreview={false}) so you edit the plain markdown source — no inline block widgets or marker hiding — without leaving the editor. Useful when a callout/code-block widget gets in the way of editing the underlying syntax. - EditorMode: 'write' | 'raw' | 'read'; Raw button (Code icon) in the mode toggle, persisted like the other modes. - Reuses the existing livePreviewCompartment (livePreview prop already drives it) — Raw is just Write with live preview off. - AI/format toolbar and the wikilink picker now show in any editing mode (mode !== 'read') rather than write-only, so they work in Raw too. --- src/components/notes/note-editor.tsx | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/components/notes/note-editor.tsx b/src/components/notes/note-editor.tsx index 314e5df2c..747077a83 100644 --- a/src/components/notes/note-editor.tsx +++ b/src/components/notes/note-editor.tsx @@ -3,7 +3,7 @@ import React, { useRef, useCallback, useState, useEffect, useMemo } from "react"; import ReactMarkdown from "react-markdown"; import "katex/dist/katex.min.css"; -import { Pin, PinOff, Calendar, Eye, Pencil, Wand2, Loader2, CheckCircle2, FileDown, FileText, ChevronLeft, Sparkles, Sun, Moon, Maximize2, Minimize2, ChevronDown as Chevron } from "lucide-react";import { WikilinkPicker } from "./WikilinkPicker"; +import { Pin, PinOff, Calendar, Eye, Pencil, Code, Wand2, Loader2, CheckCircle2, FileDown, FileText, ChevronLeft, Sparkles, Sun, Moon, Maximize2, Minimize2, ChevronDown as Chevron } from "lucide-react";import { WikilinkPicker } from "./WikilinkPicker"; import { getActiveWikilink } from "@/lib/wikilink-parser"; import { useCairnStore } from "@/store"; import { useShallow } from "zustand/react/shallow"; @@ -29,7 +29,7 @@ interface NoteEditorProps { onBack?: () => void; } -type EditorMode = "write" | "read"; +type EditorMode = "write" | "raw" | "read"; export function NoteEditor({ note, onBack }: NoteEditorProps) { const { updateNote, aiConfig, activeProjectId, getProjectColumns, tags, createTag, getTagById, activeWorkspaceId, setView, notes, projects, noteChangeMarks, clearNoteChangeMark, notesFullscreen, toggleNotesFullscreen } = useCairnStore(useShallow((s) => ({ @@ -677,6 +677,19 @@ export function NoteEditor({ note, onBack }: NoteEditorProps) { Write + +
+ {/* Size toggle — shrink/grow the preview panel */} +
+ {SIZES.map((s) => ( + + ))} +
+ +
-
+
From 3ca8f098481a5cc4379a0258033b33edc24ba24c Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Wed, 5 Aug 2026 08:21:42 -0400 Subject: [PATCH 11/17] refactor: editor modes to Edit/Read + a Live Preview toggle Raw and Write were both editing modes differing only in whether Live Preview was on, so collapse the top bar to Edit | Read and move Live Preview to its own toggle (shown while editing, beside the mode switch). - EditorMode: 'edit' | 'read' (was write/raw/read). Legacy persisted 'write'/'raw' migrate to 'edit'; 'read' unchanged. - livePreviewOn state, default on, persisted separately (NOTE_LIVE_PREVIEW_KEY). livePreview prop = mode === 'edit' && livePreviewOn. - Toolbar/wikilink/preview gating already keyed on mode !== 'read', so still correct for the merged edit mode. --- changelogs/v2.6.2.md | 2 +- src/components/notes/note-editor.tsx | 114 ++++++++++++++++----------- src/lib/constants.ts | 5 +- 3 files changed, 72 insertions(+), 49 deletions(-) diff --git a/changelogs/v2.6.2.md b/changelogs/v2.6.2.md index f47a3d399..7714fb816 100644 --- a/changelogs/v2.6.2.md +++ b/changelogs/v2.6.2.md @@ -4,7 +4,7 @@ - **Cairn now warns when another device is on an older sync version.** Device Sync records the sync-protocol version each of your devices is using. When one of them reports a *lower* version than this device, Settings → Sync (and the mobile Sync screen) show a "device needs updating" hint — because an older version may not honour deletions, so a note you delete can reappear from it. Devices on the same version see nothing new. - **Callouts now render inline in the editor.** In the note editor's live preview, `> [!note]`-style callouts show as the same styled callout you see in reading mode, and click into raw Markdown to edit. This was implemented earlier but switched off because the cursor landed in the wrong place when you clicked near or below a callout — text after one was effectively unselectable. The callout now mounts with its real height before the editor measures the surrounding lines, so positions below it stay accurate and you can edit around a callout normally. Arrow keys now step into a callout instead of skipping over it. - **Code blocks now render inline in the editor too.** Fenced code (```` ```lang ````) shows with syntax highlighting and a copy button right in the live-preview editor, and clicks into raw Markdown to edit — the same click-to-edit model as callouts. -- **New Raw editing mode.** The note editor's mode switch now offers **Write / Raw / Read**. Raw turns Live Preview off so you see and edit the plain Markdown source — handy when an inline callout or code-block preview is in the way of editing the underlying syntax. +- **Editor modes simplified to Edit / Read, with a Live Preview toggle.** The note editor's mode switch is now just **Edit** and **Read**. While editing, a **Live Preview** toggle turns the inline rendering (callouts, code blocks, marker hiding) on or off — off shows the plain Markdown source, handy when an inline preview is in the way of editing the underlying syntax. Your Live Preview choice is remembered. - **The preview panel now follows what you're editing.** When your cursor is inside a table, callout, code block, or math block, the panel at the bottom of the editor renders that block live as you type — so you can see it take shape and instantly notice when the Markdown is broken (a misaligned table row, an unclosed fence). Selecting text still previews the selection; plain paragraphs don't trigger the panel. - **A note deleted on another device is no longer offered for restore twice.** When you delete a note on one device and edit that same note on another before it has seen the deletion, Device Sync keeps your edit as a conflict copy — and previously it *also* listed the note as "deleted on another device" with a Restore button. Restoring then left you with two identical notes to clean up. Cairn now recognises that your edit is already safe in the conflict copy and points you to the conflicts view to resolve it, instead of offering a redundant restore. Notes deleted with no competing edit still show Restore as before. - **Agent tool approvals read correctly on Windows and Linux.** The confirmation shown before the agent runs a tool described where the action reaches as "this Mac" regardless of platform; it now says "this device." diff --git a/src/components/notes/note-editor.tsx b/src/components/notes/note-editor.tsx index a45ce3d7f..fddda1725 100644 --- a/src/components/notes/note-editor.tsx +++ b/src/components/notes/note-editor.tsx @@ -22,14 +22,14 @@ import { MDPreviewPanel } from "./MDPreviewPanel"; import { countWords, stripMarkdown, toggleCheckboxInSource, diffChangedLines, extractStructuredBlockAtOffset } from "./note-editor-utils"; import { useNoteMarkdownComponents } from "./note-markdown-components"; import { storage } from "@/lib/storage"; -import { NOTE_EDITOR_MODE_KEY } from "@/lib/constants"; +import { NOTE_EDITOR_MODE_KEY, NOTE_LIVE_PREVIEW_KEY } from "@/lib/constants"; interface NoteEditorProps { note: Note; onBack?: () => void; } -type EditorMode = "write" | "raw" | "read"; +type EditorMode = "edit" | "read"; export function NoteEditor({ note, onBack }: NoteEditorProps) { const { updateNote, aiConfig, activeProjectId, getProjectColumns, tags, createTag, getTagById, activeWorkspaceId, setView, notes, projects, noteChangeMarks, clearNoteChangeMark, notesFullscreen, toggleNotesFullscreen } = useCairnStore(useShallow((s) => ({ @@ -54,15 +54,27 @@ export function NoteEditor({ note, onBack }: NoteEditorProps) { const containerRef = useRef(null); const saveTimer = useRef | null>(null); - // Remember the last-used editor mode across notes/sessions so readers stay - // in Read and writers stay in Write without re-toggling each time. - const [mode, setModeState] = useState( - () => storage.get(NOTE_EDITOR_MODE_KEY) ?? "write" - ); + // Remember the last-used editor mode across notes/sessions so readers stay in + // Read and writers stay in Edit without re-toggling each time. Migrate the + // legacy three-mode value: "write"/"raw" both collapse to "edit" (raw was just + // "edit with Live Preview off", now a separate toggle below). + const [mode, setModeState] = useState(() => { + const saved = storage.get(NOTE_EDITOR_MODE_KEY); + return saved === "read" ? "read" : "edit"; + }); const setMode = useCallback((next: EditorMode) => { setModeState(next); storage.set(NOTE_EDITOR_MODE_KEY, next); }, []); + // Live Preview (inline widgets + marker hiding) on/off within Edit mode. + // Defaults on; persisted separately so the choice survives note/session change. + const [livePreviewOn, setLivePreviewOnState] = useState( + () => storage.get(NOTE_LIVE_PREVIEW_KEY) ?? true, + ); + const setLivePreviewOn = useCallback((next: boolean) => { + setLivePreviewOnState(next); + storage.set(NOTE_LIVE_PREVIEW_KEY, next); + }, []); const noteContent0 = note.content ?? ""; const [wordCount, setWordCount] = useState(() => countWords(noteContent0)); const [showSemanticPanel, setShowSemanticPanel] = useState(false); @@ -682,45 +694,53 @@ export function NoteEditor({ note, onBack }: NoteEditorProps) { )} - {/* Mode toggle */} -
- - - + {/* Mode toggle — Edit / Read. Live Preview is a separate toggle + (right) since Raw was just "Edit with Live Preview off". */} +
+
+ + +
+ {/* Live Preview toggle — only meaningful while editing. On = inline + widgets + marker hiding; off = raw Markdown. */} + {mode === "edit" && ( + + )}
@@ -947,7 +967,7 @@ export function NoteEditor({ note, onBack }: NoteEditorProps) { placeholder="Write here…" readOnly={isAiWriting} changedLines={changedLines} - livePreview={mode === "write"} + livePreview={mode === "edit" && livePreviewOn} /> diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 1cb1e29f8..b85906121 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -147,5 +147,8 @@ export const NOTES_COLLAPSED_FOLDERS_KEY = "notesCollapsedFolders"; /** localStorage key for per-project collapsed Project Overview sections (Record<`${projectId}:${sectionId}`, true>). */ export const OVERVIEW_COLLAPSED_KEY = "overviewCollapsedSections"; -/** localStorage key for the last-used note editor mode ("write" | "read"). */ +/** localStorage key for the last-used note editor mode ("edit" | "read"). */ export const NOTE_EDITOR_MODE_KEY = "noteEditorMode"; + +/** localStorage key for whether Live Preview is on within Edit mode. */ +export const NOTE_LIVE_PREVIEW_KEY = "noteLivePreview"; From e156a52a6007f728d9a1f85383a46a1cc9bba3e4 Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Wed, 5 Aug 2026 08:26:21 -0400 Subject: [PATCH 12/17] docs: move editor-widget notes to v2.6.3; add What's New entry v2.6.2 already shipped (package.json is 2.6.2, released), so the inline code-block / Edit-Read-LivePreview / current-block-preview entries I'd appended to changelogs/v2.6.2.md were editing a released changelog. Per the changelog rule, move them to a new changelogs/v2.6.3.md (one patch ahead) and restore v2.6.2.md byte-identical to the release. Also add a 'Live Preview Editor' What's New feature entry (v2.6.3) for the headline editor work. --- changelogs/v2.6.2.md | 5 +---- changelogs/v2.6.3.md | 13 +++++++++++++ scripts/features.config.js | 13 +++++++++++++ 3 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 changelogs/v2.6.3.md diff --git a/changelogs/v2.6.2.md b/changelogs/v2.6.2.md index 7714fb816..cbad763da 100644 --- a/changelogs/v2.6.2.md +++ b/changelogs/v2.6.2.md @@ -2,10 +2,7 @@ ### Fixes - **Cairn now warns when another device is on an older sync version.** Device Sync records the sync-protocol version each of your devices is using. When one of them reports a *lower* version than this device, Settings → Sync (and the mobile Sync screen) show a "device needs updating" hint — because an older version may not honour deletions, so a note you delete can reappear from it. Devices on the same version see nothing new. -- **Callouts now render inline in the editor.** In the note editor's live preview, `> [!note]`-style callouts show as the same styled callout you see in reading mode, and click into raw Markdown to edit. This was implemented earlier but switched off because the cursor landed in the wrong place when you clicked near or below a callout — text after one was effectively unselectable. The callout now mounts with its real height before the editor measures the surrounding lines, so positions below it stay accurate and you can edit around a callout normally. Arrow keys now step into a callout instead of skipping over it. -- **Code blocks now render inline in the editor too.** Fenced code (```` ```lang ````) shows with syntax highlighting and a copy button right in the live-preview editor, and clicks into raw Markdown to edit — the same click-to-edit model as callouts. -- **Editor modes simplified to Edit / Read, with a Live Preview toggle.** The note editor's mode switch is now just **Edit** and **Read**. While editing, a **Live Preview** toggle turns the inline rendering (callouts, code blocks, marker hiding) on or off — off shows the plain Markdown source, handy when an inline preview is in the way of editing the underlying syntax. Your Live Preview choice is remembered. -- **The preview panel now follows what you're editing.** When your cursor is inside a table, callout, code block, or math block, the panel at the bottom of the editor renders that block live as you type — so you can see it take shape and instantly notice when the Markdown is broken (a misaligned table row, an unclosed fence). Selecting text still previews the selection; plain paragraphs don't trigger the panel. +- **Callouts now render inline in the editor.** In the note editor's live preview, `> [!note]`-style callouts show as the same styled callout you see in reading mode, and click into raw Markdown to edit. This was implemented earlier but switched off because the cursor landed in the wrong place when you clicked near or below a callout — text after one was effectively unselectable. The callout now mounts with its real height before the editor measures the surrounding lines, so positions below it stay accurate and you can edit around a callout normally. - **A note deleted on another device is no longer offered for restore twice.** When you delete a note on one device and edit that same note on another before it has seen the deletion, Device Sync keeps your edit as a conflict copy — and previously it *also* listed the note as "deleted on another device" with a Restore button. Restoring then left you with two identical notes to clean up. Cairn now recognises that your edit is already safe in the conflict copy and points you to the conflicts view to resolve it, instead of offering a redundant restore. Notes deleted with no competing edit still show Restore as before. - **Agent tool approvals read correctly on Windows and Linux.** The confirmation shown before the agent runs a tool described where the action reaches as "this Mac" regardless of platform; it now says "this device." - **Reasoning ("thinking") models no longer break the chat.** Cairn used to cap every reply at 4,096 output tokens. A reasoning model counts its private thinking against that budget, so on a hard question it could spend the whole cap thinking and stop before writing any answer — leaving an empty reply. Worse, sending your next message then failed outright with an "invalid request" error, because the empty turn was replayed to the provider. Cairn now lets models finish naturally by default (no artificial cap), so they think as long as they need and still answer. A new **Max output tokens** control in Settings → AI lets you set a deliberate cap if you want to bound cost per reply; leave it on **Auto** (the default) for the fix. As a safety net, if a reply ever is cut off mid-thought, Cairn shows the captured reasoning instead of a blank message, and a stalled empty turn can no longer poison your next message. diff --git a/changelogs/v2.6.3.md b/changelogs/v2.6.3.md new file mode 100644 index 000000000..53fea1d4a --- /dev/null +++ b/changelogs/v2.6.3.md @@ -0,0 +1,13 @@ +## What's new + +### Editor + +- **Code blocks now render inline while editing.** Fenced code (```` ```lang ````) shows with syntax highlighting and a copy button right in the editor's Live Preview, and clicks into raw Markdown to edit — the same click-to-edit model as callouts. +- **Editor modes simplified to Edit / Read, with a Live Preview toggle.** The note editor's mode switch is now just **Edit** and **Read**. While editing, a **Live Preview** toggle turns the inline rendering (callouts, code blocks, marker hiding) on or off — off shows the plain Markdown source, handy when an inline preview is in the way of editing the underlying syntax. Your choice is remembered. +- **The preview panel follows what you're editing.** When your cursor is inside a table, callout, code block, or math block, the panel at the bottom of the editor renders that block live as you type — so you can see it take shape and instantly notice when the Markdown is broken (a misaligned table row, an unclosed fence). Selecting text still previews the selection; plain paragraphs don't trigger it. The panel has an **S / M / L** size toggle to shrink or grow it. + +### Fixes + +- **Callouts render and navigate correctly in the editor.** Fixed the callout live-preview widget mis-measuring its height (which drifted the cursor a line or two below where you clicked) and arrow keys skipping over a callout instead of stepping into it. Editing text around a callout now behaves normally. +- **Switching notes clears the preview panel.** The bottom preview no longer lingers showing the previous note's content until you click into the new one. +- **Markdown task-list checkboxes no longer trigger a console warning** when the preview updates (e.g. drag-selecting across checkboxes). They render as stable, read-only checkboxes. diff --git a/scripts/features.config.js b/scripts/features.config.js index 1e1fb7983..f8240830a 100644 --- a/scripts/features.config.js +++ b/scripts/features.config.js @@ -193,6 +193,19 @@ const FEATURES = [ "Backup Reminder: The preview explains that Cairn adds a small frontmatter block on first touch and suggests a backup or git commit first.", ], }, + { + id: "v2.6.3-live-preview-editor", + version: "v2.6.3", + title: "Live Preview Editor", + category: "Editor", + description: + "The note editor now renders rich blocks inline as you write — callouts and code blocks show the way they will in reading mode, and click straight into raw Markdown to edit. A preview panel follows your cursor so you can watch a table or callout take shape, and instantly spot broken Markdown.", + highlights: [ + "Inline Callouts & Code: `> [!note]` callouts and fenced code blocks render live in the editor, with syntax highlighting and copy — click any block to edit its raw Markdown.", + "Edit / Read + Live Preview: Two clear modes, plus a Live Preview toggle to flip inline rendering off and edit the plain source when you need to.", + "Follow-the-Cursor Preview: The bottom panel live-renders the table, callout, code, or math block you're editing — a broken table or unclosed fence shows immediately. Resize it S / M / L.", + ], + }, ]; module.exports = { FEATURES }; From 21df71a15a6c9907bd12f751cf2a686661bb0422 Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Wed, 5 Aug 2026 08:37:14 -0400 Subject: [PATCH 13/17] feat: inline table, math, and mermaid live-preview widgets + range-trim fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the Tier 2 block widgets so the editing story needs no follow-up: - Tables: GFM Table nodes render inline via NoteMarkdownPreview (matches read mode); isTableSource guards against lone-pipe prose. - Math: $$…$$ display math renders inline. It isn't a distinct lezer node (lands in a Paragraph), so findMathBlocks scans delimiter pairs separately. - Mermaid: ```mermaid fences render as a diagram via MermaidDiagram (mirrors read-mode's code-fence routing) instead of a code listing. - Parser range-trim: a callout/blockquote with no trailing blank line no longer swallows the following paragraph — countLeadingQuoteLines clamps the widget to the actual `>` lines. buildBlockWidgetDecorations now sorts + drops overlapping candidates defensively so a stray overlap can't throw. All three reuse BlockPreviewWidget (sync mount, ResizeObserver, flow-root, click-to-edit) and the existing arrow-key nav. Tests cover render/cursor-inside for each, the range-trim, and isTableSource. --- src/lib/code-block-widget.tsx | 7 +++ src/lib/livePreview.component.test.tsx | 55 +++++++++++++++++++ src/lib/livePreview.ts | 76 ++++++++++++++++++++++---- src/lib/math-block-widget.tsx | 45 +++++++++++++++ src/lib/table-block-widget.tsx | 59 ++++++++++++++++++++ 5 files changed, 232 insertions(+), 10 deletions(-) create mode 100644 src/lib/math-block-widget.tsx create mode 100644 src/lib/table-block-widget.tsx diff --git a/src/lib/code-block-widget.tsx b/src/lib/code-block-widget.tsx index ef65264b6..71ac04ad7 100644 --- a/src/lib/code-block-widget.tsx +++ b/src/lib/code-block-widget.tsx @@ -14,6 +14,7 @@ import type { WidgetType } from "@codemirror/view"; import type { ReactNode } from "react"; import { BlockPreviewWidget } from "./block-preview-widget"; import { CodeBlock } from "@/components/notes/CodeBlock"; +import { MermaidDiagram } from "@/components/notes/MermaidDiagram"; export interface CodeBlockData { language: string; @@ -65,6 +66,12 @@ export class CodeBlockWidget extends BlockPreviewWidget { } protected render(): ReactNode { + // A ```mermaid fence renders as a diagram (mirrors read-mode, where + // markdown-code-fence routes mermaid to MermaidDiagram); every other + // language renders as a highlighted code listing. + if (this.data.language.toLowerCase() === "mermaid") { + return ; + } return ; } } diff --git a/src/lib/livePreview.component.test.tsx b/src/lib/livePreview.component.test.tsx index 4b0fbedab..8bd87ed10 100644 --- a/src/lib/livePreview.component.test.tsx +++ b/src/lib/livePreview.component.test.tsx @@ -5,6 +5,7 @@ import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; import { livePreview, foldedBlockFromCursor } from "@/lib/livePreview"; import { parseCalloutSource } from "@/lib/callout-widget"; import { parseFencedCode } from "@/lib/code-block-widget"; +import { isTableSource } from "@/lib/table-block-widget"; /** * Live Preview decoration tests. The main risk in mixing replace + mark + line @@ -216,6 +217,47 @@ describe("livePreview decorations", () => { expect(foldedBlockFromCursor(view.state, 1)?.lineStart).toBe(fenceLine); view.destroy(); }); + + it("renders a table widget when the cursor is outside it", () => { + const doc = "intro\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\nafter"; + const view = mount(doc, 0); + expect(view.contentDOM.querySelector(".cm-lp-tableblock")).toBeTruthy(); + // Cursor inside → raw pipe source, no widget. + view.dispatch({ selection: { anchor: doc.indexOf("| 1 | 2 |") } }); + expect(view.contentDOM.querySelector(".cm-lp-tableblock")).toBeNull(); + expect(view.contentDOM.textContent).toContain("| 1 | 2 |"); + view.destroy(); + }); + + it("renders a math widget for a $$ block when the cursor is outside it", () => { + const doc = "intro\n\n$$\nx = y^2\n$$\n\nafter"; + const view = mount(doc, 0); + expect(view.contentDOM.querySelector(".cm-lp-mathblock")).toBeTruthy(); + view.dispatch({ selection: { anchor: doc.indexOf("x = y^2") } }); + expect(view.contentDOM.querySelector(".cm-lp-mathblock")).toBeNull(); + view.destroy(); + }); + + it("renders a mermaid fence as a code widget (diagram), not lingering raw", () => { + const doc = "intro\n\n```mermaid\ngraph TD\nA-->B\n```\n\nafter"; + const view = mount(doc, 0); + // Mermaid renders through the code widget container. + expect(view.contentDOM.querySelector(".cm-lp-codeblock")).toBeTruthy(); + view.destroy(); + }); + + it("does not let a callout with no trailing blank line swallow the next paragraph", () => { + // The blockquote node can over-extend into the following paragraph when + // there's no blank line; the widget range must stop at the `>` lines so + // "after" stays editable text below the callout. + const doc = "> [!note] Title\n> body\nafter paragraph"; + const view = mount(doc, doc.indexOf("after paragraph") + 2); // cursor in the paragraph + // The callout is folded (cursor outside it) … + expect(view.contentDOM.querySelector(".cm-lp-callout")).toBeTruthy(); + // … but the paragraph after it is NOT part of the widget — its text shows. + expect(view.contentDOM.textContent).toContain("after paragraph"); + view.destroy(); + }); }); describe("parseCalloutSource", () => { @@ -271,3 +313,16 @@ describe("parseFencedCode", () => { expect(parseFencedCode("not a fence")).toBeNull(); }); }); + +describe("isTableSource", () => { + it("accepts a well-formed GFM table (header + delimiter row)", () => { + expect(isTableSource("| a | b |\n| --- | --- |\n| 1 | 2 |")).toBe(true); + expect(isTableSource("| a | b |\n| :-- | --: |")).toBe(true); + }); + + it("rejects prose with a stray pipe and single-line input", () => { + expect(isTableSource("a | b just prose")).toBe(false); + expect(isTableSource("| only one row |")).toBe(false); + expect(isTableSource("| a | b |\nno delimiter here")).toBe(false); + }); +}); diff --git a/src/lib/livePreview.ts b/src/lib/livePreview.ts index 6d8274b97..a104fbb6f 100644 --- a/src/lib/livePreview.ts +++ b/src/lib/livePreview.ts @@ -27,6 +27,8 @@ import { import { syntaxTree } from "@codemirror/language"; import { makeCalloutWidget, parseCalloutSource } from "./callout-widget"; import { makeCodeBlockWidget, parseFencedCode } from "./code-block-widget"; +import { makeMathBlockWidget } from "./math-block-widget"; +import { makeTableBlockWidget, isTableSource } from "./table-block-widget"; import { blockWidgetTheme } from "./block-preview-widget"; // Master kill-switch for Tier 2 inline block widgets (callouts + fenced code @@ -116,18 +118,22 @@ interface BlockWidget extends BlockRange { } /** - * Find every block that should render as an inline widget (callouts + fenced - * code blocks), each with its char/line range and a widget factory. Both kinds - * share the same StateField + click-to-edit machinery; adding a new block type - * (tables, mermaid, …) means adding another branch here. + * Find every block that should render as an inline widget (callouts, fenced code + * incl. mermaid, GFM tables, and $$ math), each with its char/line range and a + * widget factory. All kinds share the same StateField + click-to-edit + * machinery; adding a new block type means adding another branch here. */ function findBlockWidgets(state: EditorState): BlockWidget[] { const { doc } = state; const out: BlockWidget[] = []; - const push = (from: number, toRaw: number, raw: string, makeWidget: () => WidgetType) => { + // Push a block spanning whole lines. `blockLineCount`, when given, clamps the + // range to that many lines from `from` — used to trim a callout/blockquote or + // table whose lezer node over-extends into a following paragraph when there's + // no blank line between them (otherwise the widget would swallow that text). + const push = (from: number, toRaw: number, makeWidget: () => WidgetType, blockLineCount?: number) => { const lineStart = doc.lineAt(from).number; - const lineEnd = doc.lineAt(toRaw).number; - void raw; + let lineEnd = doc.lineAt(Math.min(toRaw, doc.length)).number; + if (blockLineCount != null) lineEnd = Math.min(lineEnd, lineStart + blockLineCount - 1); out.push({ from: doc.line(lineStart).from, to: doc.line(lineEnd).to, @@ -147,16 +153,58 @@ function findBlockWidgets(state: EditorState): BlockWidget[] { const raw = doc.sliceString(node.from, nodeTo); const data = parseCalloutSource(raw); if (!data) return; - push(node.from, nodeTo, raw, () => makeCalloutWidget(data)); + // A blockquote directly followed by a paragraph (no blank line) can have + // its node range extend past the `>` lines. Count the actual leading + // `>` lines so the widget covers only the callout, not the next para. + const quoteLines = countLeadingQuoteLines(raw); + push(node.from, nodeTo, () => makeCalloutWidget(data), quoteLines); } else if (node.name === "FencedCode") { const nodeTo = Math.min(node.to, doc.length); const raw = doc.sliceString(node.from, nodeTo); const data = parseFencedCode(raw); if (!data) return; - push(node.from, nodeTo, raw, () => makeCodeBlockWidget(data)); + push(node.from, nodeTo, () => makeCodeBlockWidget(data)); + } else if (node.name === "Table") { + const nodeTo = Math.min(node.to, doc.length); + const raw = doc.sliceString(node.from, nodeTo); + if (!isTableSource(raw)) return; + push(node.from, nodeTo, () => makeTableBlockWidget(raw)); } }, }); + // $$ … $$ display-math isn't a distinct node in the base grammar (it lands in + // a Paragraph), so scan for delimiter pairs separately. + for (const m of findMathBlocks(doc)) { + push(m.from, m.to, () => makeMathBlockWidget(doc.sliceString(m.from, m.to))); + } + return out; +} + +/** Count the leading contiguous `>` blockquote lines in a raw block. */ +function countLeadingQuoteLines(raw: string): number { + const lines = raw.split("\n"); + let n = 0; + for (const l of lines) { + if (/^\s*>/.test(l)) n++; + else break; + } + return n || lines.length; +} + +/** Find $$ … $$ display-math blocks (char ranges) by scanning delimiter pairs. */ +function findMathBlocks(doc: EditorState["doc"]): { from: number; to: number }[] { + const out: { from: number; to: number }[] = []; + let openLine = -1; + for (let i = 1; i <= doc.lines; i++) { + const line = doc.line(i); + if (line.text.trim() !== "$$") continue; + if (openLine === -1) { + openLine = i; + } else { + out.push({ from: doc.line(openLine).from, to: line.to }); + openLine = -1; + } + } return out; } @@ -191,11 +239,19 @@ const blockWidgetField = StateField.define({ function buildBlockWidgetDecorations(state: EditorState): DecorationSet { const active = activeLines(state); const decos: Range[] = []; - for (const block of findBlockWidgets(state)) { + // Sort by start so overlapping candidates can be dropped deterministically — + // block replace decorations must never overlap or Decoration.set throws. + const blocks = findBlockWidgets(state).sort((a, b) => a.from - b.from); + let lastTo = -1; + for (const block of blocks) { + if (block.from < lastTo) continue; // overlaps a kept block — skip defensively let cursorInside = false; for (let ln = block.lineStart; ln <= block.lineEnd; ln++) { if (active.has(ln)) { cursorInside = true; break; } } + // Even when the cursor is inside (widget hidden), advance lastTo so a later + // overlapping candidate is still dropped. + lastTo = Math.max(lastTo, block.to); if (cursorInside) continue; // show raw source for editing decos.push( Decoration.replace({ widget: block.makeWidget(), block: true }).range(block.from, block.to), diff --git a/src/lib/math-block-widget.tsx b/src/lib/math-block-widget.tsx new file mode 100644 index 000000000..baca62510 --- /dev/null +++ b/src/lib/math-block-widget.tsx @@ -0,0 +1,45 @@ +"use client"; + +/** + * MathBlockWidget — a Live Preview block widget that renders a display-math + * block ($$ … $$) inline in the editor. It renders the raw block through the + * same NoteMarkdownPreview pipeline the read view uses (remark-math + + * rehype-katex), so the KaTeX output matches reading mode exactly. Built on + * BlockPreviewWidget. + * + * Click-to-edit: cursor outside the block → rendered math; cursor inside → raw + * `$$` source (livePreview.ts skips the widget for the active block). + */ + +import type { WidgetType } from "@codemirror/view"; +import type { ReactNode } from "react"; +import { BlockPreviewWidget } from "./block-preview-widget"; +import { NoteMarkdownPreview } from "@/components/notes/NoteMarkdownPreview"; + +export interface MathBlockData { + /** The raw block including the `$$` fences. */ + raw: string; +} + +export class MathBlockWidget extends BlockPreviewWidget { + constructor(private readonly data: MathBlockData) { + super(); + } + + eq(other: MathBlockWidget): boolean { + return other.data.raw === this.data.raw; + } + + protected extraClass(): string { + return "cm-lp-mathblock"; + } + + protected render(): ReactNode { + return ; + } +} + +/** Convenience factory so livePreview.ts stays free of React imports. */ +export function makeMathBlockWidget(raw: string): WidgetType { + return new MathBlockWidget({ raw }); +} diff --git a/src/lib/table-block-widget.tsx b/src/lib/table-block-widget.tsx new file mode 100644 index 000000000..b9b40e057 --- /dev/null +++ b/src/lib/table-block-widget.tsx @@ -0,0 +1,59 @@ +"use client"; + +/** + * TableBlockWidget — a Live Preview block widget that renders a GFM pipe table + * inline in the editor via the same NoteMarkdownPreview pipeline the read view + * uses (remark-gfm), so the rendered table matches reading mode. Built on + * BlockPreviewWidget. + * + * Click-to-edit: cursor outside the table → rendered table; cursor inside → raw + * pipe source (livePreview.ts skips the widget for the active block). A broken + * table (e.g. a column-count mismatch) simply renders differently, which is the + * signal that the source needs fixing. + */ + +import type { WidgetType } from "@codemirror/view"; +import type { ReactNode } from "react"; +import { BlockPreviewWidget } from "./block-preview-widget"; +import { NoteMarkdownPreview } from "@/components/notes/NoteMarkdownPreview"; + +export interface TableBlockData { + /** The raw table source (all its pipe lines). */ + raw: string; +} + +/** + * True when `raw` is a well-formed GFM table: a header row, a delimiter row + * (`| --- | :--: |`), and at least the two of them. Guards against treating a + * lone pipe-containing prose line as a table. + */ +export function isTableSource(raw: string): boolean { + const lines = raw.split("\n").filter((l) => l.trim() !== ""); + if (lines.length < 2) return false; + const sep = /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)+\|?\s*$/; + // The second non-blank line must be the delimiter row. + return lines[1] !== undefined && sep.test(lines[1]) && lines[0].includes("|"); +} + +export class TableBlockWidget extends BlockPreviewWidget { + constructor(private readonly data: TableBlockData) { + super(); + } + + eq(other: TableBlockWidget): boolean { + return other.data.raw === this.data.raw; + } + + protected extraClass(): string { + return "cm-lp-tableblock"; + } + + protected render(): ReactNode { + return ; + } +} + +/** Convenience factory so livePreview.ts stays free of React imports. */ +export function makeTableBlockWidget(raw: string): WidgetType { + return new TableBlockWidget({ raw }); +} From 7025379b770715fcb14f16c1c1dd59ce82de2e96 Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Wed, 5 Aug 2026 08:39:06 -0400 Subject: [PATCH 14/17] docs: changelog + What's New for inline tables/math/mermaid --- changelogs/v2.6.3.md | 1 + scripts/features.config.js | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/changelogs/v2.6.3.md b/changelogs/v2.6.3.md index 53fea1d4a..6e7777f08 100644 --- a/changelogs/v2.6.3.md +++ b/changelogs/v2.6.3.md @@ -3,6 +3,7 @@ ### Editor - **Code blocks now render inline while editing.** Fenced code (```` ```lang ````) shows with syntax highlighting and a copy button right in the editor's Live Preview, and clicks into raw Markdown to edit — the same click-to-edit model as callouts. +- **Tables, math, and Mermaid diagrams also render inline.** GFM tables, `$$…$$` display math, and ```` ```mermaid ```` diagrams now render live in the editor too, each clicking into its raw source to edit. Combined with the follow-the-cursor preview below, you can build a table or diagram and watch it take shape — and immediately see when the Markdown breaks. - **Editor modes simplified to Edit / Read, with a Live Preview toggle.** The note editor's mode switch is now just **Edit** and **Read**. While editing, a **Live Preview** toggle turns the inline rendering (callouts, code blocks, marker hiding) on or off — off shows the plain Markdown source, handy when an inline preview is in the way of editing the underlying syntax. Your choice is remembered. - **The preview panel follows what you're editing.** When your cursor is inside a table, callout, code block, or math block, the panel at the bottom of the editor renders that block live as you type — so you can see it take shape and instantly notice when the Markdown is broken (a misaligned table row, an unclosed fence). Selecting text still previews the selection; plain paragraphs don't trigger it. The panel has an **S / M / L** size toggle to shrink or grow it. diff --git a/scripts/features.config.js b/scripts/features.config.js index f8240830a..ef91aac40 100644 --- a/scripts/features.config.js +++ b/scripts/features.config.js @@ -199,9 +199,9 @@ const FEATURES = [ title: "Live Preview Editor", category: "Editor", description: - "The note editor now renders rich blocks inline as you write — callouts and code blocks show the way they will in reading mode, and click straight into raw Markdown to edit. A preview panel follows your cursor so you can watch a table or callout take shape, and instantly spot broken Markdown.", + "The note editor now renders rich blocks inline as you write — callouts, code blocks, tables, math, and Mermaid diagrams all show the way they will in reading mode, and click straight into raw Markdown to edit. A preview panel follows your cursor so you can watch a table or diagram take shape, and instantly spot broken Markdown.", highlights: [ - "Inline Callouts & Code: `> [!note]` callouts and fenced code blocks render live in the editor, with syntax highlighting and copy — click any block to edit its raw Markdown.", + "Inline Everything: `> [!note]` callouts, fenced code (with highlighting + copy), GFM tables, $$ math, and ```mermaid diagrams render live in the editor — click any block to edit its raw Markdown.", "Edit / Read + Live Preview: Two clear modes, plus a Live Preview toggle to flip inline rendering off and edit the plain source when you need to.", "Follow-the-Cursor Preview: The bottom panel live-renders the table, callout, code, or math block you're editing — a broken table or unclosed fence shows immediately. Resize it S / M / L.", ], From bd48149fce90d845c6c405dfd5e07f1178ff8a5c Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Wed, 5 Aug 2026 08:56:23 -0400 Subject: [PATCH 15/17] =?UTF-8?q?fix:=20address=20PR=20#114=20review=20?= =?UTF-8?q?=E2=80=94=20raw=20migration,=20findBlockWidgets=20cache,=20arro?= =?UTF-8?q?w=20near-edge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - note-editor: a legacy saved 'raw' mode now migrates to Edit with Live Preview OFF (was defaulting on, losing the user's raw preference) when no explicit Live Preview preference exists. Extracted migrateEditorMode / initialLivePreviewOn as pure helpers + regression tests. - livePreview: cache findBlockWidgets per EditorState (WeakMap) so the StateField, ViewPlugin, and arrow keymap don't each re-scan the full syntax tree within an update cycle (2–3 scans → 1 per state; states are immutable so instance-keyed caching is safe/self-evicting). - moveIntoBlock: enter a folded block at its NEAR edge — lineEnd when moving up, lineStart when moving down — instead of always jumping to the first line. - markdown-editor: skip the Live Preview compartment reconfigure when the enabled state hasn't changed since what's applied (notably on mount), avoiding a redundant decoration rebuild + widget remount. --- src/components/notes/markdown-editor.tsx | 9 ++++++ .../notes/note-editor-utils.test.ts | 32 ++++++++++++++++++- src/components/notes/note-editor-utils.ts | 24 ++++++++++++++ src/components/notes/note-editor.tsx | 14 ++++---- src/lib/livePreview.ts | 22 +++++++++++-- 5 files changed, 91 insertions(+), 10 deletions(-) diff --git a/src/components/notes/markdown-editor.tsx b/src/components/notes/markdown-editor.tsx index 013df00a8..58064df90 100644 --- a/src/components/notes/markdown-editor.tsx +++ b/src/components/notes/markdown-editor.tsx @@ -222,6 +222,10 @@ const MarkdownEditor = forwardRef( // Compartment for the Live Preview extension so it can be toggled at // runtime (Live Preview ↔ raw) without rebuilding editor state. const livePreviewCompartment = useRef(new Compartment()); + // The livePreviewEnabled value currently applied to the compartment. Seeded + // from the mount value below so the toggle effect can skip the redundant + // reconfigure (+ widget remount) on first render. + const appliedLivePreview = useRef(livePreviewEnabled); useImperativeHandle(ref, () => ({ getSelection() { @@ -402,6 +406,10 @@ const MarkdownEditor = forwardRef( // toDOM uses flushSync — illegal while React is still flushing this effect. // Deferring runs the reconfigure in CM's own context where flushSync is ok. useEffect(() => { + // Skip when the enabled state hasn't actually changed since what's applied + // — notably on mount, where the compartment was already initialized to + // this value (avoids a needless decoration rebuild + block-widget remount). + if (appliedLivePreview.current === livePreviewEnabled) return; let cancelled = false; queueMicrotask(() => { const view = viewRef.current; @@ -411,6 +419,7 @@ const MarkdownEditor = forwardRef( livePreviewEnabled ? livePreview() : [], ), }); + appliedLivePreview.current = livePreviewEnabled; }); return () => { cancelled = true; }; }, [livePreviewEnabled]); diff --git a/src/components/notes/note-editor-utils.test.ts b/src/components/notes/note-editor-utils.test.ts index e33e0e213..0c0b7ad0c 100644 --- a/src/components/notes/note-editor-utils.test.ts +++ b/src/components/notes/note-editor-utils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { toggleCheckboxInSource, diffChangedLines, extractStructuredBlockAtOffset } from "./note-editor-utils"; +import { toggleCheckboxInSource, diffChangedLines, extractStructuredBlockAtOffset, migrateEditorMode, initialLivePreviewOn } from "./note-editor-utils"; describe("toggleCheckboxInSource — list-item checkboxes", () => { it("toggles an unchecked list item to checked", () => { @@ -235,3 +235,33 @@ describe("extractStructuredBlockAtOffset — live current-block preview", () => expect(extractStructuredBlockAtOffset("", 0)).toBeNull(); }); }); + +describe("editor mode migration (Write/Raw/Read → Edit/Read + Live Preview)", () => { + it("migrates legacy modes to edit/read", () => { + expect(migrateEditorMode("write")).toBe("edit"); + expect(migrateEditorMode("raw")).toBe("edit"); + expect(migrateEditorMode("read")).toBe("read"); + expect(migrateEditorMode("edit")).toBe("edit"); + expect(migrateEditorMode(null)).toBe("edit"); + expect(migrateEditorMode(undefined)).toBe("edit"); + }); + + it("initial Live Preview honours an explicit stored preference over everything", () => { + expect(initialLivePreviewOn(true, "raw")).toBe(true); + expect(initialLivePreviewOn(false, "write")).toBe(false); + }); + + it("with no stored preference, a legacy 'raw' mode starts Live Preview OFF", () => { + // Regression: raw meant "edit with Live Preview off"; migrating must not + // silently turn Live Preview back on for those users. + expect(initialLivePreviewOn(null, "raw")).toBe(false); + expect(initialLivePreviewOn(undefined, "raw")).toBe(false); + }); + + it("with no stored preference and a non-raw legacy mode, defaults ON", () => { + expect(initialLivePreviewOn(null, "write")).toBe(true); + expect(initialLivePreviewOn(null, "read")).toBe(true); + expect(initialLivePreviewOn(null, null)).toBe(true); + expect(initialLivePreviewOn(undefined, undefined)).toBe(true); + }); +}); diff --git a/src/components/notes/note-editor-utils.ts b/src/components/notes/note-editor-utils.ts index bf875408c..879d83706 100644 --- a/src/components/notes/note-editor-utils.ts +++ b/src/components/notes/note-editor-utils.ts @@ -96,6 +96,30 @@ export function diffChangedLines(prev: string, next: string): number[] { return changed; } +// ── Editor-mode migration (Write/Raw/Read → Edit/Read + Live Preview toggle) ── + +/** + * Migrate a persisted editor mode to the current two-mode model. The legacy + * value could be "write" | "raw" | "read"; "write"/"raw" both become "edit" + * (raw was just "edit with Live Preview off", now a separate toggle). + */ +export function migrateEditorMode(saved: string | null | undefined): "edit" | "read" { + return saved === "read" ? "read" : "edit"; +} + +/** + * Initial Live Preview on/off. An explicit stored preference always wins. + * Otherwise default on — EXCEPT when the user's legacy mode was "raw" (Live + * Preview off), which we honour so their raw-editing preference isn't lost. + */ +export function initialLivePreviewOn( + savedPref: boolean | null | undefined, + savedMode: string | null | undefined, +): boolean { + if (savedPref != null) return savedPref; + return savedMode !== "raw"; +} + // ── Structured-block extraction (for the live "current block" preview) ──────── /** Kinds of structured markdown block worth previewing live while editing. */ diff --git a/src/components/notes/note-editor.tsx b/src/components/notes/note-editor.tsx index fddda1725..0d820234c 100644 --- a/src/components/notes/note-editor.tsx +++ b/src/components/notes/note-editor.tsx @@ -19,7 +19,7 @@ import { MarkdownEditor, type MarkdownEditorHandle } from "./markdown-editor"; import { makeLatexPlugins, makeRehypeChangedLines, buildNoteRemarkPlugins, buildNoteRehypePlugins, contentHasMath, contentHasHighlight } from "@/lib/markdown/pipeline"; import { BacklinksPanel, NoteTagBar } from "./BacklinksPanel"; import { MDPreviewPanel } from "./MDPreviewPanel"; -import { countWords, stripMarkdown, toggleCheckboxInSource, diffChangedLines, extractStructuredBlockAtOffset } from "./note-editor-utils"; +import { countWords, stripMarkdown, toggleCheckboxInSource, diffChangedLines, extractStructuredBlockAtOffset, migrateEditorMode, initialLivePreviewOn } from "./note-editor-utils"; import { useNoteMarkdownComponents } from "./note-markdown-components"; import { storage } from "@/lib/storage"; import { NOTE_EDITOR_MODE_KEY, NOTE_LIVE_PREVIEW_KEY } from "@/lib/constants"; @@ -58,18 +58,18 @@ export function NoteEditor({ note, onBack }: NoteEditorProps) { // Read and writers stay in Edit without re-toggling each time. Migrate the // legacy three-mode value: "write"/"raw" both collapse to "edit" (raw was just // "edit with Live Preview off", now a separate toggle below). - const [mode, setModeState] = useState(() => { - const saved = storage.get(NOTE_EDITOR_MODE_KEY); - return saved === "read" ? "read" : "edit"; - }); + const legacyMode = storage.get(NOTE_EDITOR_MODE_KEY); + const [mode, setModeState] = useState(() => migrateEditorMode(legacyMode)); const setMode = useCallback((next: EditorMode) => { setModeState(next); storage.set(NOTE_EDITOR_MODE_KEY, next); }, []); // Live Preview (inline widgets + marker hiding) on/off within Edit mode. // Defaults on; persisted separately so the choice survives note/session change. - const [livePreviewOn, setLivePreviewOnState] = useState( - () => storage.get(NOTE_LIVE_PREVIEW_KEY) ?? true, + // Migration: if a user's legacy mode was "raw" and they have no explicit Live + // Preview preference yet, honour that intent by starting with it OFF. + const [livePreviewOn, setLivePreviewOnState] = useState(() => + initialLivePreviewOn(storage.get(NOTE_LIVE_PREVIEW_KEY), legacyMode), ); const setLivePreviewOn = useCallback((next: boolean) => { setLivePreviewOnState(next); diff --git a/src/lib/livePreview.ts b/src/lib/livePreview.ts index a104fbb6f..7975c8ed4 100644 --- a/src/lib/livePreview.ts +++ b/src/lib/livePreview.ts @@ -122,8 +122,23 @@ interface BlockWidget extends BlockRange { * incl. mermaid, GFM tables, and $$ math), each with its char/line range and a * widget factory. All kinds share the same StateField + click-to-edit * machinery; adding a new block type means adding another branch here. + * + * Result is cached per EditorState (WeakMap): within one update cycle the + * StateField (decorations), the ViewPlugin (blockWidgetLineSet), and the arrow + * keymap all ask for the same computation, which otherwise means 2–3 full + * syntax-tree scans per keystroke. EditorStates are immutable and short-lived, + * so instance-keyed caching is safe and self-evicting. */ +const blockWidgetCache = new WeakMap(); function findBlockWidgets(state: EditorState): BlockWidget[] { + const cached = blockWidgetCache.get(state); + if (cached) return cached; + const result = computeBlockWidgets(state); + blockWidgetCache.set(state, result); + return result; +} + +function computeBlockWidgets(state: EditorState): BlockWidget[] { const { doc } = state; const out: BlockWidget[] = []; // Push a block spanning whole lines. `blockLineCount`, when given, clamps the @@ -530,8 +545,11 @@ export function foldedBlockFromCursor(state: EditorState, dir: 1 | -1): BlockRan function moveIntoBlock(view: EditorView, dir: 1 | -1): boolean { const block = foldedBlockFromCursor(view.state, dir); if (!block) return false; - // Land on the block's first line so activeLines picks it up and it unfolds. - const target = view.state.doc.line(block.lineStart).from; + // Land on the block's NEAR edge for natural vertical motion: moving down + // enters at its first line, moving up enters at its last line. Either way the + // cursor is inside the block's range, so activeLines unfolds it. + const targetLine = dir === -1 ? block.lineEnd : block.lineStart; + const target = view.state.doc.line(targetLine).from; view.dispatch({ selection: EditorSelection.cursor(target), scrollIntoView: true, From a1be9a1a17face577a957d0e0c6bcc09df943a43 Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Wed, 5 Aug 2026 09:47:09 -0400 Subject: [PATCH 16/17] =?UTF-8?q?fix:=20block-widget=20rendering=20?= =?UTF-8?q?=E2=80=94=20inherited=20pre-wrap=20gap,=20flushSync=20crash,=20?= =?UTF-8?q?inline=20chrome?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes reported for tables/callouts in the Live Preview editor: - Huge empty gap above tables/callouts: the widget lives inside .cm-content.cm-lineWrapping (CM sets white-space: pre-wrap), which was inherited by the widget's prose-cairn. rehypeRaw leaves structural newlines between blocks as text nodes; under pre-wrap those rendered as real blank lines. Reset white-space: normal on the .cm-lp-block container so inter-block whitespace collapses like read mode. (Read mode was unaffected — its preview isn't inside .cm-content.) - flushSync crash opening a note with a table / toggling Live Preview: dropped flushSync in the widget toDOM entirely (illegal when CM calls toDOM from inside a React commit). Height now settles via the ResizeObserver + estimatedHeight=-1. - Callout/table/math rendered differently from read mode: NoteMarkdownPreview gains an mode that drops the full-pane chrome (px-6 py-5, h-full, overflow) and zeroes first/last child margins, so embedded blocks sit compact. - Trim widget ranges of trailing blank lines + trim raw before rendering. - markdown-editor: skip the Live Preview reconfigure when unchanged (mount). --- .../NoteMarkdownPreview.component.test.tsx | 19 ++++++++ src/components/notes/NoteMarkdownPreview.tsx | 17 ++++++- src/components/notes/markdown-editor.tsx | 9 ++-- src/lib/block-preview-widget.tsx | 44 +++++++++---------- src/lib/callout-widget.tsx | 2 +- src/lib/livePreview.component.test.tsx | 10 ++--- src/lib/livePreview.ts | 30 +++++++++---- src/lib/math-block-widget.tsx | 2 +- src/lib/table-block-widget.tsx | 4 +- 9 files changed, 91 insertions(+), 46 deletions(-) diff --git a/src/components/notes/NoteMarkdownPreview.component.test.tsx b/src/components/notes/NoteMarkdownPreview.component.test.tsx index 5828e944b..c88c7274a 100644 --- a/src/components/notes/NoteMarkdownPreview.component.test.tsx +++ b/src/components/notes/NoteMarkdownPreview.component.test.tsx @@ -40,4 +40,23 @@ describe("NoteMarkdownPreview — task-list checkboxes", () => { expect(controlledWarning).toBe(false); errSpy.mockRestore(); }); + + it("inline mode drops the full-pane chrome (padding / h-full / overflow)", () => { + // Embedded inside a callout body or block widget, the preview must sit + // compactly — not carry the standalone pane's px-6 py-5 h-full overflow, + // which was inflating callout/table widgets and making them look different + // from read mode. + const { container: pane } = render(); + const { container: inline } = render(); + const paneRoot = pane.querySelector(".prose-cairn") as HTMLElement; + const inlineRoot = inline.querySelector(".prose-cairn") as HTMLElement; + expect(paneRoot.className).toContain("h-full"); + expect(paneRoot.className).toContain("px-6"); + expect(inlineRoot.className).not.toContain("h-full"); + expect(inlineRoot.className).not.toContain("px-6"); + expect(inlineRoot.className).not.toContain("overflow-y-auto"); + // Inline zeroes the first/last child margins so the block (e.g. a table + // wrapper's my-3) hugs the top of its host — no empty space above it. + expect(inlineRoot.className).toContain("[&>*:first-child]:mt-0"); + }); }); diff --git a/src/components/notes/NoteMarkdownPreview.tsx b/src/components/notes/NoteMarkdownPreview.tsx index 3db05a654..5d1a80cc7 100644 --- a/src/components/notes/NoteMarkdownPreview.tsx +++ b/src/components/notes/NoteMarkdownPreview.tsx @@ -25,6 +25,13 @@ interface NoteMarkdownPreviewProps { className?: string; filePath?: string; projectRoot?: string; + /** + * Inline/embedded rendering (e.g. inside a callout body or a Live Preview + * block widget): drops the full-pane chrome (`px-6 py-5`, `h-full`, + * `overflow-y-auto`) so the content sits compactly in its host container the + * way it does in read mode. Default false = the standalone preview pane. + */ + inline?: boolean; } interface MarkdownImageProps extends Omit, "src"> { @@ -153,7 +160,7 @@ function MarkdownImage({ src, alt, title, filePath, projectRoot, ...props }: Mar ); } -function NoteMarkdownPreviewImpl({ content, className, filePath, projectRoot }: NoteMarkdownPreviewProps) { +function NoteMarkdownPreviewImpl({ content, className, filePath, projectRoot, inline }: NoteMarkdownPreviewProps) { // Content-aware plugins: omit the math stack (remark-math + rehype-katex + the // two custom LaTeX passes ≈ 12ms on a large note) when the source has no `$`. // Keyed on math/highlight *presence* (booleans), not raw content, so the @@ -185,7 +192,13 @@ function NoteMarkdownPreviewImpl({ content, className, filePath, projectRoot }: } return ( -
+
*:first-child]:mt-0 [&>*:last-child]:mb-0" + : "px-6 py-5 overflow-y-auto h-full" + } ${className ?? ""}`} + > ( }); }, [readOnly]); - // Toggle Live Preview marker-hiding at runtime via its compartment. - // Dispatch on a microtask, not synchronously in the effect: the dispatch - // rebuilds decorations, which mounts block widgets (callout/code) whose - // toDOM uses flushSync — illegal while React is still flushing this effect. - // Deferring runs the reconfigure in CM's own context where flushSync is ok. + // Toggle Live Preview at runtime via its compartment. Dispatch on a + // microtask, not synchronously in the effect: the dispatch rebuilds + // decorations and mounts React-backed block widgets, which is better done + // outside React's own commit phase than synchronously within it. useEffect(() => { // Skip when the enabled state hasn't actually changed since what's applied // — notably on mount, where the compartment was already initialized to diff --git a/src/lib/block-preview-widget.tsx b/src/lib/block-preview-widget.tsx index 2b3428903..7a49ca4e0 100644 --- a/src/lib/block-preview-widget.tsx +++ b/src/lib/block-preview-widget.tsx @@ -9,12 +9,12 @@ * "Live Preview Editor — Tier 2 Block Widgets"), so each new widget type only * has to supply its own React element and an equality check: * - * 1. Synchronous mount (`flushSync`) so CodeMirror's first height measure — - * which positions every line below the widget — sees real content, not a - * 0px shell. - * 2. A `ResizeObserver` that calls `view.requestMeasure()` when the height - * settles later (async syntax/markdown/image resolution, fonts, toggles). - * 3. `estimatedHeight = -1` (never assume the widget matches the replaced + * 1. A `ResizeObserver` that calls `view.requestMeasure()` on mount and when + * the height settles later (async syntax/markdown/image resolution, fonts, + * toggles) — so CodeMirror positions the lines below at the real height. + * (We can't measure synchronously via flushSync: CM calls toDOM from inside + * React commits, where flushSync is illegal.) + * 2. `estimatedHeight = -1` (never assume the widget matches the replaced * source lines), `ignoreEvent() = false` (let clicks fall through so the * cursor enters the source range and the block unfolds to raw markdown), * and a `display: flow-root` container so child margins are contained in @@ -28,7 +28,6 @@ import { EditorView, WidgetType } from "@codemirror/view"; import { createRoot, type Root } from "react-dom/client"; -import { flushSync } from "react-dom"; import type { ReactNode } from "react"; /** Shared container class; styled by blockWidgetTheme (flow-root + no margin). */ @@ -50,21 +49,14 @@ export abstract class BlockPreviewWidget extends WidgetType { const container = document.createElement("div"); container.className = `${BLOCK_WIDGET_CLASS} ${this.extraClass()}`.trim(); this.root = createRoot(container); - // (1) Prefer a SYNCHRONOUS mount so CodeMirror's first height measure sees - // real content, not a 0px shell. But `flushSync` is illegal while React is - // already rendering — and CM can rebuild decorations (calling toDOM) from - // inside a React commit, e.g. when the Live Preview compartment is - // reconfigured in a useEffect (Write⇄Raw toggle). In that case flushSync - // throws. Fall back to a plain async render then; the ResizeObserver in (2) - // catches the height once React paints, so layout still self-corrects. - try { - flushSync(() => { - this.root?.render(this.render()); - }); - } catch { - this.root.render(this.render()); - } - // (2) Re-measure when the height changes after the initial commit. + // Render asynchronously (flushSync is illegal here — CM calls toDOM from + // inside React commits). Correct height comes from re-measuring once the + // content has actually laid out. + this.root.render(this.render()); + // Re-measure once the content lays out: CM measures the widget's height when + // the block is laid out, which can be before React has painted the (async) + // content. The ResizeObserver fires on first observe and on every later size + // change (fonts, images, collapsible toggles), asking CM to re-read it. let lastHeight = -1; if (typeof ResizeObserver !== "undefined") { this.resizeObserver = new ResizeObserver((entries) => { @@ -74,6 +66,8 @@ export abstract class BlockPreviewWidget extends WidgetType { view.requestMeasure(); }); this.resizeObserver.observe(container); + } else { + queueMicrotask(() => view.requestMeasure()); } return container; } @@ -118,5 +112,11 @@ export const blockWidgetTheme = EditorView.theme({ // lines below at the short height and clicks/cursor land too low. `flow-root` // contains the child margins inside the measured box. display: "flow-root", + // CRITICAL: the widget lives inside `.cm-content.cm-lineWrapping`, which CM + // styles `white-space: pre-wrap`. That's inherited here, so the structural + // newlines rehypeRaw leaves as text nodes between blocks would render as + // real blank lines — a huge phantom gap above a table/callout. Reset to + // normal so inter-block whitespace collapses the way it does in read mode. + whiteSpace: "normal", }, }); diff --git a/src/lib/callout-widget.tsx b/src/lib/callout-widget.tsx index f3a92488e..b84544247 100644 --- a/src/lib/callout-widget.tsx +++ b/src/lib/callout-widget.tsx @@ -69,7 +69,7 @@ export class CalloutWidget extends BlockPreviewWidget { collapsible={this.data.collapsible} defaultOpen={this.data.defaultOpen} > - {this.data.body ? : null} + {this.data.body ? : null} ); } diff --git a/src/lib/livePreview.component.test.tsx b/src/lib/livePreview.component.test.tsx index 8bd87ed10..5d973d661 100644 --- a/src/lib/livePreview.component.test.tsx +++ b/src/lib/livePreview.component.test.tsx @@ -114,8 +114,8 @@ describe("livePreview decorations", () => { it("keeps document positions after a callout addressable (cursor-drift guard)", () => { // The regression this whole card exists for: a callout widget must not // desync the lines below it, so a position AFTER the callout must still map - // back to the correct line. flushSync in toDOM gives the widget real height - // at measure time, so coordsAt/lineBlockAt for a later position stay sane. + // back to the correct line. estimatedHeight=-1 + the ResizeObserver keep the + // widget's measured height honest, so lineBlockAt for a later position stays sane. const doc = "intro\n\n> [!note] Title\n> body\n\ntarget line after callout"; const view = mount(doc, 0); expect(view.contentDOM.querySelector(".cm-lp-callout")).toBeTruthy(); @@ -130,9 +130,9 @@ describe("livePreview decorations", () => { }); it("observes the callout widget for later height changes and stops on destroy", () => { - // flushSync only fixes the FIRST measure; the widget's height keeps shifting - // afterwards (async markdown/image resolution, font load, collapsible toggle), - // which is what desyncs the cursor. A ResizeObserver must watch the widget so + // The widget renders async and its height keeps shifting (markdown/image + // resolution, font load, collapsible toggle), which is what desyncs the + // cursor. A ResizeObserver must watch the widget so // CM re-measures once the content settles — and disconnect on teardown. const observed: Element[] = []; let disconnects = 0; diff --git a/src/lib/livePreview.ts b/src/lib/livePreview.ts index 7975c8ed4..d20a4528c 100644 --- a/src/lib/livePreview.ts +++ b/src/lib/livePreview.ts @@ -142,19 +142,31 @@ function computeBlockWidgets(state: EditorState): BlockWidget[] { const { doc } = state; const out: BlockWidget[] = []; // Push a block spanning whole lines. `blockLineCount`, when given, clamps the - // range to that many lines from `from` — used to trim a callout/blockquote or - // table whose lezer node over-extends into a following paragraph when there's - // no blank line between them (otherwise the widget would swallow that text). - const push = (from: number, toRaw: number, makeWidget: () => WidgetType, blockLineCount?: number) => { + // range to that many lines from `from` (used for a callout/blockquote whose + // lezer node over-extends into a following paragraph). Trailing BLANK lines + // are always trimmed off the range: a lezer node can include trailing empty + // lines, which otherwise (a) get covered by the widget and (b) render as a run + // of whitespace text nodes inside the widget → a big empty gap above the + // content. `makeWidget` receives the trimmed raw so its render matches. + const push = ( + from: number, + toRaw: number, + makeWidget: (trimmedRaw: string) => WidgetType, + blockLineCount?: number, + ) => { const lineStart = doc.lineAt(from).number; let lineEnd = doc.lineAt(Math.min(toRaw, doc.length)).number; if (blockLineCount != null) lineEnd = Math.min(lineEnd, lineStart + blockLineCount - 1); + // Trim trailing blank lines. + while (lineEnd > lineStart && doc.line(lineEnd).text.trim() === "") lineEnd--; + const fromPos = doc.line(lineStart).from; + const toPos = doc.line(lineEnd).to; out.push({ - from: doc.line(lineStart).from, - to: doc.line(lineEnd).to, + from: fromPos, + to: toPos, lineStart, lineEnd, - makeWidget, + makeWidget: () => makeWidget(doc.sliceString(fromPos, toPos)), }); }; syntaxTree(state).iterate({ @@ -183,14 +195,14 @@ function computeBlockWidgets(state: EditorState): BlockWidget[] { const nodeTo = Math.min(node.to, doc.length); const raw = doc.sliceString(node.from, nodeTo); if (!isTableSource(raw)) return; - push(node.from, nodeTo, () => makeTableBlockWidget(raw)); + push(node.from, nodeTo, (trimmed) => makeTableBlockWidget(trimmed)); } }, }); // $$ … $$ display-math isn't a distinct node in the base grammar (it lands in // a Paragraph), so scan for delimiter pairs separately. for (const m of findMathBlocks(doc)) { - push(m.from, m.to, () => makeMathBlockWidget(doc.sliceString(m.from, m.to))); + push(m.from, m.to, (trimmed) => makeMathBlockWidget(trimmed)); } return out; } diff --git a/src/lib/math-block-widget.tsx b/src/lib/math-block-widget.tsx index baca62510..c7a21cbcf 100644 --- a/src/lib/math-block-widget.tsx +++ b/src/lib/math-block-widget.tsx @@ -35,7 +35,7 @@ export class MathBlockWidget extends BlockPreviewWidget { } protected render(): ReactNode { - return ; + return ; } } diff --git a/src/lib/table-block-widget.tsx b/src/lib/table-block-widget.tsx index b9b40e057..99dd9163f 100644 --- a/src/lib/table-block-widget.tsx +++ b/src/lib/table-block-widget.tsx @@ -49,7 +49,9 @@ export class TableBlockWidget extends BlockPreviewWidget { } protected render(): ReactNode { - return ; + // Trim so stray leading/trailing blank lines can't render as whitespace + // text nodes that inflate the widget's height. + return ; } } From 48d6161323a2ff8bed8da01d5fb94781191e482f Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Wed, 5 Aug 2026 10:03:42 -0400 Subject: [PATCH 17/17] =?UTF-8?q?fix:=20address=20PR=20#114=20review=20?= =?UTF-8?q?=E2=80=94=20inline=20empty-state,=20CommonMark=20fence=20close?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NoteMarkdownPreview: the empty-content early return now honours inline mode (drops h-full/p-8) instead of always using the standalone padded layout. - extractStructuredBlockAtOffset: a fenced-code block only closes on a fence that uses the SAME marker char AND is at least as long as the opener (CommonMark). A shorter/embedded same-char fence is content, not a close — fixes early-closing for nested code fences. Tracks the opener's fence length; unclosed-opener behaviour preserved. + regression test. --- src/components/notes/NoteMarkdownPreview.tsx | 8 +++++++- src/components/notes/note-editor-utils.test.ts | 9 +++++++++ src/components/notes/note-editor-utils.ts | 12 +++++++++--- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/components/notes/NoteMarkdownPreview.tsx b/src/components/notes/NoteMarkdownPreview.tsx index 5d1a80cc7..c4229eede 100644 --- a/src/components/notes/NoteMarkdownPreview.tsx +++ b/src/components/notes/NoteMarkdownPreview.tsx @@ -185,7 +185,13 @@ function NoteMarkdownPreviewImpl({ content, className, filePath, projectRoot, in if (!content.trim()) { return ( -
+
Empty file
); diff --git a/src/components/notes/note-editor-utils.test.ts b/src/components/notes/note-editor-utils.test.ts index 0c0b7ad0c..5b019c946 100644 --- a/src/components/notes/note-editor-utils.test.ts +++ b/src/components/notes/note-editor-utils.test.ts @@ -219,6 +219,15 @@ describe("extractStructuredBlockAtOffset — live current-block preview", () => expect(b?.text).toBe("```js\nconst x = 1;"); }); + it("does not close a fence on a shorter same-char fence inside it (CommonMark)", () => { + // A 4-backtick fence can CONTAIN a 3-backtick line; that inner line is + // content, not a close. The block must run to the real 4-backtick close. + const doc = "````md\n```\ninner\n```\n````\nafter"; + const b = extractStructuredBlockAtOffset(doc, at(doc, "inner")); + expect(b?.kind).toBe("code"); + expect(b?.text).toBe("````md\n```\ninner\n```\n````"); + }); + it("returns a math block between $$ fences", () => { const doc = "text\n\n$$\nx = y^2\n$$\n\nafter"; expect(extractStructuredBlockAtOffset(doc, at(doc, "x = y^2"))?.kind).toBe("math"); diff --git a/src/components/notes/note-editor-utils.ts b/src/components/notes/note-editor-utils.ts index 879d83706..3f283d0e2 100644 --- a/src/components/notes/note-editor-utils.ts +++ b/src/components/notes/note-editor-utils.ts @@ -169,20 +169,26 @@ export function extractStructuredBlockAtOffset( { let openIdx = -1; let openFence = ""; + let openLen = 0; for (let i = 0; i < lines.length; i++) { const m = lines[i].match(FENCE_RE); if (!m) continue; - const fence = m[2][0]; + const run = m[2]; + const fence = run[0]; if (openIdx === -1) { openIdx = i; openFence = fence; - } else if (fence === openFence) { - // Closing fence at i — the block spans [openIdx, i]. + openLen = run.length; + } else if (fence === openFence && run.length >= openLen) { + // CommonMark: a closing fence must use the same marker char AND be at + // least as long as the opener. A shorter/embedded fence-like line is + // part of the code content, not a close. if (cur >= openIdx && cur <= i) { return { kind: "code", text: lines.slice(openIdx, i + 1).join("\n") }; } openIdx = -1; openFence = ""; + openLen = 0; } } // Unclosed opener that reaches the cursor.