diff --git a/changelogs/v2.6.3.md b/changelogs/v2.6.3.md new file mode 100644 index 000000000..6e7777f08 --- /dev/null +++ b/changelogs/v2.6.3.md @@ -0,0 +1,14 @@ +## 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. +- **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. + +### 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..ef91aac40 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, 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 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.", + ], + }, ]; module.exports = { FEATURES }; diff --git a/src/app/globals.css b/src/app/globals.css index 3175c677a..c20d9aedc 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -494,6 +494,19 @@ select:focus { .prose-cairn td { padding: 0.35rem 0.6rem; border: 1px solid var(--border); } .prose-cairn tr:nth-child(even) td { background: var(--surface-2); } +/* ── Preview panel size scaling ──────────────────────────────────────────── */ +/* The editor's preview panel (MDPreviewPanel) has an S/M/L toggle that sets + --preview-scale on the panel. Scale the prose type with it so a smaller panel + also gets denser text. Only the rem-based sizes need scaling; `em`-based ones + (code, etc.) already follow their parent. */ +[data-md-preview-portal] { --preview-scale: 1; } +[data-md-preview-portal] .prose-cairn { font-size: calc(0.875rem * var(--preview-scale)); } +[data-md-preview-portal] .prose-cairn h1 { font-size: calc(1.5rem * var(--preview-scale)); } +[data-md-preview-portal] .prose-cairn h2 { font-size: calc(1.2rem * var(--preview-scale)); } +[data-md-preview-portal] .prose-cairn h3 { font-size: calc(1rem * var(--preview-scale)); } +[data-md-preview-portal] .prose-cairn table { font-size: calc(0.8rem * var(--preview-scale)); } +[data-md-preview-portal] .prose-cairn pre code { font-size: calc(0.8rem * var(--preview-scale)); } + /* ── CodeMirror 6 search panel ───────────────────────────────────────────── */ /* Global overrides — EditorView.theme() alone can lose to CM6's base sheet. */ diff --git a/src/components/notes/MDPreviewPanel.component.test.tsx b/src/components/notes/MDPreviewPanel.component.test.tsx new file mode 100644 index 000000000..3b9d0b6f4 --- /dev/null +++ b/src/components/notes/MDPreviewPanel.component.test.tsx @@ -0,0 +1,35 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import { MDPreviewPanel } from "./MDPreviewPanel"; + +/** + * The preview panel has an S/M/L size toggle that shrinks/grows the panel and + * (for S) densifies the text via --preview-scale. (Persistence to storage + * mirrors the editor-mode pattern; not asserted here because the component-test + * jsdom's localStorage is a no-op.) + */ +describe("MDPreviewPanel — size toggle", () => { + afterEach(cleanup); + + it("defaults to medium and exposes S/M/L controls", () => { + render( {}} />); + expect(screen.getByLabelText("S preview")).toBeInTheDocument(); + expect(screen.getByLabelText("M preview")).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByLabelText("L preview")).toBeInTheDocument(); + }); + + it("switching to S selects it and shrinks the type scale; L is full size", () => { + const { container } = render( {}} />); + const panel = () => container.querySelector("[data-md-preview-portal]") as HTMLElement; + + fireEvent.click(screen.getByLabelText("S preview")); + expect(screen.getByLabelText("S preview")).toHaveAttribute("aria-pressed", "true"); + // S sets a sub-1 scale so text is denser. + expect(parseFloat(panel().style.getPropertyValue("--preview-scale"))).toBeLessThan(1); + + fireEvent.click(screen.getByLabelText("L preview")); + expect(screen.getByLabelText("L preview")).toHaveAttribute("aria-pressed", "true"); + // L keeps full-size type (only the panel grows taller). + expect(parseFloat(panel().style.getPropertyValue("--preview-scale"))).toBe(1); + }); +}); diff --git a/src/components/notes/MDPreviewPanel.tsx b/src/components/notes/MDPreviewPanel.tsx index 619ef4571..6bc114222 100644 --- a/src/components/notes/MDPreviewPanel.tsx +++ b/src/components/notes/MDPreviewPanel.tsx @@ -1,15 +1,44 @@ "use client"; -import React, { useEffect } from "react"; +import React, { useEffect, useState } from "react"; import { X } from "lucide-react"; import { NoteMarkdownPreview } from "./NoteMarkdownPreview"; +import { storage } from "@/lib/storage"; +import { cn } from "@/lib/utils"; interface MDPreviewPanelProps { text: string; onDismiss: () => void; + /** Header label; defaults to "Preview". Used to show e.g. "table preview". */ + label?: string; } -export function MDPreviewPanel({ text, onDismiss }: MDPreviewPanelProps) { +type PreviewSize = "s" | "m" | "l"; +const PREVIEW_SIZE_KEY = "note-preview-size"; +// Panel body max-height per size. The panel scrolls internally beyond this. +const SIZE_MAX_HEIGHT: Record = { + s: "15vh", + m: "30vh", + l: "50vh", +}; +// Type scale per size (drives --preview-scale; see globals.css). Small also +// gets denser text; medium/large keep full size and just grow taller. +const SIZE_SCALE: Record = { + s: 0.82, + m: 1, + l: 1, +}; +const SIZES: PreviewSize[] = ["s", "m", "l"]; + +export function MDPreviewPanel({ text, onDismiss, label = "Preview" }: MDPreviewPanelProps) { + const [size, setSizeState] = useState( + () => storage.get(PREVIEW_SIZE_KEY) ?? "m", + ); + const setSize = (next: PreviewSize) => { + setSizeState(next); + storage.set(PREVIEW_SIZE_KEY, next); + }; + useEffect(() => { function onKey(e: KeyboardEvent) { if (e.key === "Escape") onDismiss(); } document.addEventListener("keydown", onKey); @@ -20,20 +49,41 @@ export function MDPreviewPanel({ text, onDismiss }: MDPreviewPanelProps) {
e.stopPropagation()} >
- Preview - + {label} +
+ {/* Size toggle — shrink/grow the preview panel */} +
+ {SIZES.map((s) => ( + + ))} +
+ +
-
+
diff --git a/src/components/notes/NoteMarkdownPreview.component.test.tsx b/src/components/notes/NoteMarkdownPreview.component.test.tsx new file mode 100644 index 000000000..c88c7274a --- /dev/null +++ b/src/components/notes/NoteMarkdownPreview.component.test.tsx @@ -0,0 +1,62 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, cleanup } from "@testing-library/react"; +import { NoteMarkdownPreview } from "./NoteMarkdownPreview"; + +/** + * Regression: GFM task-list checkboxes rendered by NoteMarkdownPreview must be + * STABLE controlled read-only inputs. Raw GFM emits + * with `checked` present only when checked, so re-rendering with changed + * content (e.g. the editor's live preview updating as you drag-select across + * checkboxes) flipped React from uncontrolled → controlled and warned. The + * `input` component override fixes this. + */ +describe("NoteMarkdownPreview — task-list checkboxes", () => { + afterEach(cleanup); + + it("renders task-list items as read-only, controlled checkboxes", () => { + const { container } = render( + , + ); + const boxes = container.querySelectorAll("input[type='checkbox']"); + expect(boxes.length).toBe(2); + expect(boxes[0].checked).toBe(false); + expect(boxes[1].checked).toBe(true); + // Read-only + disabled → controlled without an onChange handler is legal. + for (const b of boxes) { + expect(b.readOnly || b.disabled).toBe(true); + } + }); + + it("does not warn about uncontrolled→controlled when content changes", () => { + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { rerender } = render(); + // Change content (mirrors the live preview updating) — an unchecked box + // becoming checked must not flip the input's controlled-ness. + rerender(); + rerender(); + const controlledWarning = errSpy.mock.calls.some((args) => + String(args[0]).includes("changing an uncontrolled input to be controlled"), + ); + 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 f7ded7b12..c4229eede 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 @@ -178,14 +185,26 @@ function NoteMarkdownPreviewImpl({ content, className, filePath, projectRoot }: if (!content.trim()) { return ( -
+
Empty file
); } return ( -
+
*:first-child]:mt-0 [&>*:last-child]:mb-0" + : "px-6 py-5 overflow-y-auto h-full" + } ${className ?? ""}`} + > & ExtraProps) { return {children}; }, + input({ type, checked, ...props }: React.InputHTMLAttributes & ExtraProps) { + // GFM task-list checkboxes come through as bare + // where `checked` is present only when checked (undefined otherwise). + // Rendering that directly makes React flip the input uncontrolled↔ + // controlled when the previewed text changes (e.g. drag-selecting + // across checkboxes updates the preview), which warns. Render a + // STABLE controlled, read-only checkbox: `checked` is always a + // boolean and there's a no-op onChange. Non-checkbox inputs (rare in + // markdown) pass through unchanged. + if (type !== "checkbox") return ; + return ( + + ); + }, h1({ children }: React.HTMLAttributes & ExtraProps) { return

{children}

; }, h2({ children }: React.HTMLAttributes & ExtraProps) { return

{children}

; }, h3({ children }: React.HTMLAttributes & ExtraProps) { return

{children}

; }, diff --git a/src/components/notes/markdown-editor.tsx b/src/components/notes/markdown-editor.tsx index 157f755df..5ea76b905 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() { @@ -396,15 +400,27 @@ const MarkdownEditor = forwardRef( }); }, [readOnly]); - // Toggle Live Preview marker-hiding at runtime via its compartment. + // 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(() => { - const view = viewRef.current; - if (!view) return; - view.dispatch({ - effects: livePreviewCompartment.current.reconfigure( - livePreviewEnabled ? livePreview() : [], - ), + // 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; + if (cancelled || !view) return; + view.dispatch({ + effects: livePreviewCompartment.current.reconfigure( + livePreviewEnabled ? livePreview() : [], + ), + }); + appliedLivePreview.current = livePreviewEnabled; }); + return () => { cancelled = true; }; }, [livePreviewEnabled]); // When the note ID changes (different note selected), replace the full doc diff --git a/src/components/notes/note-editor-utils.test.ts b/src/components/notes/note-editor-utils.test.ts index 87762aecc..5b019c946 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 } 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", () => { @@ -181,3 +181,96 @@ describe("diffChangedLines", () => { expect(new Set(out).size).toBe(out.length); }); }); + +describe("extractStructuredBlockAtOffset — live current-block preview", () => { + // Cursor helper: offset of the first occurrence of `needle` in `doc`. + const at = (doc: string, needle: string) => doc.indexOf(needle); + + it("returns the table when the cursor is inside one", () => { + const doc = "intro\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\nafter"; + const block = extractStructuredBlockAtOffset(doc, at(doc, "| 1 | 2 |")); + expect(block?.kind).toBe("table"); + expect(block?.text).toBe("| a | b |\n| --- | --- |\n| 1 | 2 |"); + }); + + it("does not treat a lone pipe line (no separator row) as a table", () => { + const doc = "a | b is just prose with a pipe"; + expect(extractStructuredBlockAtOffset(doc, 3)).toBeNull(); + }); + + it("returns the callout when the cursor is inside a blockquote directive", () => { + const doc = "text\n\n> [!note] Title\n> body line\n\nafter"; + const block = extractStructuredBlockAtOffset(doc, at(doc, "body line")); + expect(block?.kind).toBe("callout"); + expect(block?.text).toBe("> [!note] Title\n> body line"); + }); + + it("does not treat an ordinary blockquote as a callout", () => { + const doc = "> just a quote\n> more"; + expect(extractStructuredBlockAtOffset(doc, 3)).toBeNull(); + }); + + it("returns the fenced code block (incl. an unclosed one being typed)", () => { + const closed = "```js\nconst x = 1;\n```\nafter"; + expect(extractStructuredBlockAtOffset(closed, at(closed, "const x"))?.kind).toBe("code"); + const open = "text\n```js\nconst x = 1;"; + const b = extractStructuredBlockAtOffset(open, at(open, "const x")); + expect(b?.kind).toBe("code"); + 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"); + }); + + it("returns null for plain prose / headings / lists", () => { + const doc = "# Heading\n\nJust a paragraph.\n\n- a list item"; + expect(extractStructuredBlockAtOffset(doc, at(doc, "paragraph"))).toBeNull(); + expect(extractStructuredBlockAtOffset(doc, at(doc, "Heading"))).toBeNull(); + expect(extractStructuredBlockAtOffset(doc, at(doc, "list item"))).toBeNull(); + }); + + it("returns null for empty content", () => { + 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 aca88215b..3f283d0e2 100644 --- a/src/components/notes/note-editor-utils.ts +++ b/src/components/notes/note-editor-utils.ts @@ -95,3 +95,151 @@ 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. */ +export type StructuredBlockKind = "table" | "callout" | "code" | "math"; + +export interface StructuredBlock { + kind: StructuredBlockKind; + /** The raw markdown of the whole block (all its lines). */ + text: string; +} + +/** Which line index (0-based) contains the given character offset. */ +function lineIndexAtOffset(lines: string[], offset: number): number { + let pos = 0; + for (let i = 0; i < lines.length; i++) { + const end = pos + lines[i].length + 1; // +1 for the newline + if (offset < end) return i; + pos = end; + } + return Math.max(0, lines.length - 1); +} + +const FENCE_RE = /^(\s*)(`{3,}|~{3,})/; +const TABLE_SEP_RE = /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)+\|?\s*$/; + +/** + * Given the note's raw markdown and a cursor offset, return the STRUCTURED block + * enclosing the cursor (table / callout / fenced code / math), or null when the + * cursor is in plain prose (paragraph, heading, list). Used to drive a live + * "current block" preview so a user editing e.g. a table can see it render — and + * immediately notice when the markdown is broken — without a selection. + * + * This is a line scanner on the raw text (not the lezer tree), deliberately + * independent of the editor so it's pure and unit-testable. It errs toward + * returning a block only when the cursor is clearly inside one. + */ +export function extractStructuredBlockAtOffset( + content: string, + offset: number, +): StructuredBlock | null { + if (!content) return null; + const lines = content.split("\n"); + const cur = lineIndexAtOffset(lines, offset); + + // 1) Fenced code (``` or ~~~). Walk fences top-down; if the cursor line falls + // within an open/close pair (or after an unclosed opener), it's code. + { + 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 run = m[2]; + const fence = run[0]; + if (openIdx === -1) { + openIdx = i; + openFence = fence; + 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. + if (openIdx !== -1 && cur >= openIdx) { + return { kind: "code", text: lines.slice(openIdx).join("\n") }; + } + } + + // 2) Math block ($$ … $$). Same paired-delimiter logic on lines that are just `$$`. + { + const isMathFence = (l: string) => l.trim() === "$$"; + let openIdx = -1; + for (let i = 0; i < lines.length; i++) { + if (!isMathFence(lines[i])) continue; + if (openIdx === -1) openIdx = i; + else { + if (cur >= openIdx && cur <= i) { + return { kind: "math", text: lines.slice(openIdx, i + 1).join("\n") }; + } + openIdx = -1; + } + } + } + + const line = lines[cur] ?? ""; + + // 3) Callout — a blockquote run (contiguous `>` lines) whose first line is a + // `[!type]` directive. Only when the cursor is on a `>` line. + if (/^\s*>/.test(line)) { + let start = cur; + while (start > 0 && /^\s*>/.test(lines[start - 1])) start--; + let end = cur; + while (end < lines.length - 1 && /^\s*>/.test(lines[end + 1])) end++; + const first = lines[start].replace(/^\s*>\s?/, ""); + if (/^\[![^\]]+\]/.test(first)) { + return { kind: "callout", text: lines.slice(start, end + 1).join("\n") }; + } + } + + // 4) Table — a contiguous run of pipe-containing lines that includes a + // separator row (`|---|`). Only when the cursor line is part of the run. + if (line.includes("|")) { + let start = cur; + while (start > 0 && lines[start - 1].includes("|") && lines[start - 1].trim() !== "") start--; + let end = cur; + while (end < lines.length - 1 && lines[end + 1].includes("|") && lines[end + 1].trim() !== "") end++; + const block = lines.slice(start, end + 1); + if (block.some((l) => TABLE_SEP_RE.test(l))) { + return { kind: "table", text: block.join("\n") }; + } + } + + return null; +} diff --git a/src/components/notes/note-editor.tsx b/src/components/notes/note-editor.tsx index 314e5df2c..0d820234c 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"; @@ -19,17 +19,17 @@ 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 } 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 } from "@/lib/constants"; +import { NOTE_EDITOR_MODE_KEY, NOTE_LIVE_PREVIEW_KEY } from "@/lib/constants"; interface NoteEditorProps { note: Note; onBack?: () => void; } -type EditorMode = "write" | "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 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. + // 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); + storage.set(NOTE_LIVE_PREVIEW_KEY, next); + }, []); const noteContent0 = note.content ?? ""; const [wordCount, setWordCount] = useState(() => countWords(noteContent0)); const [showSemanticPanel, setShowSemanticPanel] = useState(false); @@ -152,6 +164,10 @@ export function NoteEditor({ note, onBack }: NoteEditorProps) { // MD preview panel state — selected text to render in the bottom panel const [previewText, setPreviewText] = useState(null); + // Live preview of the STRUCTURED block (table/callout/code/math) the cursor is + // in, shown when there's no selection so you can see it render — and spot a + // broken table/fence — while editing. Selection preview (previewText) wins. + const [blockPreview, setBlockPreview] = useState<{ kind: string; text: string } | null>(null); // ── Wikilink picker state ────────────────────────────────────────────────── const [wikilinkPicker, setWikilinkPicker] = useState<{ @@ -289,6 +305,11 @@ export function NoteEditor({ note, onBack }: NoteEditorProps) { // eslint-disable-next-line react-hooks/set-state-in-effect setLocalTitle(note.title); titleRef.current = note.title; + // Clear any preview from the previous note — the selection/block preview is + // only refreshed on cursor/selection activity, so without this the last + // note's preview lingers until you click in the new one. + setPreviewText(null); + setBlockPreview(null); }, [note.id]); // eslint-disable-line react-hooks/exhaustive-deps const handleTitleChange = useCallback( @@ -325,6 +346,16 @@ export function NoteEditor({ note, onBack }: NoteEditorProps) { setActiveSectionTitle((prev) => (prev === section ? prev : section)); const extracted = extractSectionTextAtOffset(title, content, offset); setActiveSectionText(extracted ? extracted.text : null); + // Live "current block" preview: show the enclosing structured block so a + // user editing a table/callout/code/math block sees it render live. Prose + // returns null → no panel. Selection preview (previewText) takes priority + // in the render below. + const block = extractStructuredBlockAtOffset(content, offset); + setBlockPreview((prev) => { + if (block === null) return prev === null ? prev : null; + if (prev && prev.kind === block.kind && prev.text === block.text) return prev; + return block; + }); }, [] ); @@ -663,32 +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" && ( + + )}
@@ -844,8 +896,8 @@ export function NoteEditor({ note, onBack }: NoteEditorProps) { />
- {/* ── AI + Format toolbar — write mode only ───────────────────────────── */} - {mode === "write" && ( + {/* ── AI + Format toolbar — any editing mode (write or raw), not read ── */} + {mode !== "read" && (
{/* Wikilink autocomplete picker — rendered in a portal at fixed viewport coords */} - {wikilinkPicker && mode === "write" && ( + {wikilinkPicker && mode !== "read" && ( n.id !== note.id)} projects={projects} @@ -981,12 +1033,24 @@ export function NoteEditor({ note, onBack }: NoteEditorProps) { {/* ── MD preview panel — docked to bottom of editor ───────────────────── */} - {previewText && ( - setPreviewText(null)} - /> - )} + {/* Selection preview wins; otherwise show the current structured block + (table/callout/code/math) live while editing. Not shown in Read mode. */} + {(() => { + if (mode === "read") return null; + if (previewText) { + return setPreviewText(null)} />; + } + if (blockPreview) { + return ( + setBlockPreview(null)} + /> + ); + } + return null; + })()}
); } diff --git a/src/lib/block-preview-widget.tsx b/src/lib/block-preview-widget.tsx new file mode 100644 index 000000000..7a49ca4e0 --- /dev/null +++ b/src/lib/block-preview-widget.tsx @@ -0,0 +1,122 @@ +"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. 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 + * 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 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(); + this.root = createRoot(container); + // 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) => { + 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); + } else { + queueMicrotask(() => view.requestMeasure()); + } + 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", + // 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 2d7c930f3..b84544247 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,9 +40,7 @@ export function parseCalloutSource(raw: string): CalloutData | null { return { ...directive, body }; } -export class CalloutWidget extends WidgetType { - private root: Root | null = null; - +export class CalloutWidget extends BlockPreviewWidget { constructor(private readonly data: CalloutData) { super(); } @@ -60,67 +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. 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. - this.root = createRoot(container); - flushSync(() => { - this.root?.render( - - {this.data.body ? : null} - , - ); - }); - // 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())); - }); - } - 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 { - // 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} + + ); } } @@ -128,12 +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": { - // Callout already has my-3; keep the CM line box from adding extra height. - margin: "0", - }, -}); diff --git a/src/lib/code-block-widget.tsx b/src/lib/code-block-widget.tsx new file mode 100644 index 000000000..71ac04ad7 --- /dev/null +++ b/src/lib/code-block-widget.tsx @@ -0,0 +1,82 @@ +"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"; +import { MermaidDiagram } from "@/components/notes/MermaidDiagram"; + +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 { + // 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 ; + } +} + +/** 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/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"; diff --git a/src/lib/livePreview.component.test.tsx b/src/lib/livePreview.component.test.tsx index 3432d3528..5d973d661 100644 --- a/src/lib/livePreview.component.test.tsx +++ b/src/lib/livePreview.component.test.tsx @@ -2,8 +2,10 @@ 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, 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 @@ -112,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(); @@ -126,6 +128,136 @@ 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", () => { + // 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; + 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. 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. + 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(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(foldedBlockFromCursor(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(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(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(); + }); + + 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", () => { @@ -148,3 +280,49 @@ 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(); + }); +}); + +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 73eae15b2..d20a4528c 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,17 +22,21 @@ import { ViewPlugin, ViewUpdate, WidgetType, + keymap, } from "@codemirror/view"; import { syntaxTree } from "@codemirror/language"; -import { makeCalloutWidget, parseCalloutSource, calloutWidgetTheme } from "./callout-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; +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 +// 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 ──────────────────────────────────────────────────────────────── @@ -101,49 +105,143 @@ 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 + * 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: (CalloutBlock & { raw: string })[] = []; + const out: BlockWidget[] = []; + // Push a block spanning whole lines. `blockLineCount`, when given, clamps the + // 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: fromPos, + to: toPos, + lineStart, + lineEnd, + makeWidget: () => makeWidget(doc.sliceString(fromPos, toPos)), + }); + }; 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; + // 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, () => 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, (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, (trimmed) => makeMathBlockWidget(trimmed)); + } + 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; } -/** 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; } @@ -154,29 +252,36 @@ 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)) { + // 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 - 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); @@ -196,7 +301,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, @@ -209,7 +314,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 @@ -235,7 +340,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)); } @@ -272,7 +377,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)); @@ -282,7 +387,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`. @@ -310,7 +415,7 @@ function scanInlinePatterns( from: number, to: number, active: Set, - calloutWidgetLines: Set, + blockWidgetLines: Set, replaceDecos: Range[], otherDecos: Range[], ): void { @@ -323,7 +428,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; @@ -339,7 +444,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)); @@ -413,12 +518,71 @@ const livePreviewTheme = EditorView.theme({ }, }); +/** + * 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 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 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) block advances normally, because it's no longer folded. + * + * Only acts on a single collapsed cursor entering a FOLDED block from the + * adjacent line; every other case (selections, cursor already inside, no + * adjacent block) falls through to CM's default handling by returning false. + */ +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 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; } + } + 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 moveIntoBlock(view: EditorView, dir: 1 | -1): boolean { + const block = foldedBlockFromCursor(view.state, dir); + if (!block) return false; + // 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, + userEvent: "select", + }); + return true; +} + +const blockWidgetKeymap = Prec.high( + keymap.of([ + { key: "ArrowDown", run: (v) => moveIntoBlock(v, 1) }, + { key: "ArrowUp", run: (v) => moveIntoBlock(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 (BLOCK_WIDGETS_ENABLED) exts.unshift(blockWidgetField, blockWidgetTheme, blockWidgetKeymap); return exts; } diff --git a/src/lib/math-block-widget.tsx b/src/lib/math-block-widget.tsx new file mode 100644 index 000000000..c7a21cbcf --- /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..99dd9163f --- /dev/null +++ b/src/lib/table-block-widget.tsx @@ -0,0 +1,61 @@ +"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 { + // Trim so stray leading/trailing blank lines can't render as whitespace + // text nodes that inflate the widget's height. + return ; + } +} + +/** Convenience factory so livePreview.ts stays free of React imports. */ +export function makeTableBlockWidget(raw: string): WidgetType { + return new TableBlockWidget({ raw }); +} 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(); });