Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
7221285
fix: callout widget height (margin collapse) + arrow-key navigation
ddutchie Aug 5, 2026
33efdc3
feat: inline code-block live-preview widget + shared block-widget base
ddutchie Aug 5, 2026
ac16833
feat: Raw editing mode in the note editor
ddutchie Aug 5, 2026
14208d1
docs: changelog — inline code blocks + Raw editing mode
ddutchie Aug 5, 2026
91bb7c2
fix: block widget flushSync crash when toggling Live Preview (Write/Raw)
ddutchie Aug 5, 2026
cd599f8
feat: live 'current block' preview for structured blocks while editing
ddutchie Aug 5, 2026
86cff00
docs: changelog — live current-block preview panel
ddutchie Aug 5, 2026
9bbba15
fix: clear the editor preview panel when switching notes
ddutchie Aug 5, 2026
60588ed
fix: stable controlled checkbox in markdown preview (uncontrolled→con…
ddutchie Aug 5, 2026
a247229
feat: S/M/L size toggle on the editor preview panel
ddutchie Aug 5, 2026
3ca8f09
refactor: editor modes to Edit/Read + a Live Preview toggle
ddutchie Aug 5, 2026
e156a52
docs: move editor-widget notes to v2.6.3; add What's New entry
ddutchie Aug 5, 2026
21df71a
feat: inline table, math, and mermaid live-preview widgets + range-tr…
ddutchie Aug 5, 2026
7025379
docs: changelog + What's New for inline tables/math/mermaid
ddutchie Aug 5, 2026
bd48149
fix: address PR #114 review — raw migration, findBlockWidgets cache, …
ddutchie Aug 5, 2026
a1be9a1
fix: block-widget rendering — inherited pre-wrap gap, flushSync crash…
ddutchie Aug 5, 2026
48d6161
fix: address PR #114 review — inline empty-state, CommonMark fence close
ddutchie Aug 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions changelogs/v2.6.3.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions scripts/features.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
13 changes: 13 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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. */

Expand Down
35 changes: 35 additions & 0 deletions src/components/notes/MDPreviewPanel.component.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<MDPreviewPanel text="hello" onDismiss={() => {}} />);
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(<MDPreviewPanel text="hello" onDismiss={() => {}} />);
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);
});
});
74 changes: 62 additions & 12 deletions src/components/notes/MDPreviewPanel.tsx
Original file line number Diff line number Diff line change
@@ -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<PreviewSize, string> = {
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<PreviewSize, number> = {
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<PreviewSize>(
() => storage.get<PreviewSize>(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);
Expand All @@ -20,20 +49,41 @@ export function MDPreviewPanel({ text, onDismiss }: MDPreviewPanelProps) {
<div
data-md-preview-portal
className="flex-shrink-0 border-t border-[var(--border)] animate-fade-in"
style={{ background: "var(--surface)" }}
style={{ background: "var(--surface)", ["--preview-scale" as string]: SIZE_SCALE[size] }}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-4 py-1.5 border-b border-[var(--border)]" style={{ background: "var(--surface-2)" }}>
<span className="text-[0.714rem] font-medium text-[var(--text-tertiary)] uppercase tracking-wide">Preview</span>
<button
onClick={onDismiss}
aria-label="Close preview"
className="p-0.5 rounded text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface)] transition-colors"
>
<X size={11} />
</button>
<span className="text-[0.714rem] font-medium text-[var(--text-tertiary)] uppercase tracking-wide">{label}</span>
<div className="flex items-center gap-1">
{/* Size toggle — shrink/grow the preview panel */}
<div className="flex items-center rounded bg-[var(--surface)] p-0.5" role="group" aria-label="Preview size">
{SIZES.map((s) => (
<button
key={s}
onClick={() => setSize(s)}
aria-label={`${s.toUpperCase()} preview`}
aria-pressed={size === s}
className={cn(
"px-1.5 py-0.5 rounded text-[0.65rem] font-medium uppercase transition-colors",
size === s
? "bg-[var(--surface-2)] text-[var(--text-primary)]"
: "text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]",
)}
>
{s}
</button>
))}
</div>
<button
onClick={onDismiss}
aria-label="Close preview"
className="p-0.5 rounded text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface)] transition-colors"
>
<X size={11} />
</button>
</div>
</div>
<div style={{ maxHeight: "30vh" }} className="overflow-y-auto">
<div style={{ maxHeight: SIZE_MAX_HEIGHT[size] }} className="overflow-y-auto">
<NoteMarkdownPreview content={text} className="!py-3" />
</div>
</div>
Expand Down
62 changes: 62 additions & 0 deletions src/components/notes/NoteMarkdownPreview.component.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <input type="checkbox">
* 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(
<NoteMarkdownPreview content={"- [ ] todo\n- [x] done"} />,
);
const boxes = container.querySelectorAll<HTMLInputElement>("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(<NoteMarkdownPreview content={"- [ ] a"} />);
// Change content (mirrors the live preview updating) — an unchecked box
// becoming checked must not flip the input's controlled-ness.
rerender(<NoteMarkdownPreview content={"- [x] a"} />);
rerender(<NoteMarkdownPreview content={"- [ ] a\n- [x] b"} />);
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(<NoteMarkdownPreview content={"hi"} />);
const { container: inline } = render(<NoteMarkdownPreview content={"hi"} inline />);
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");
});
});
37 changes: 35 additions & 2 deletions src/components/notes/NoteMarkdownPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<React.ImgHTMLAttributes<HTMLImageElement>, "src"> {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -185,7 +192,13 @@ function NoteMarkdownPreviewImpl({ content, className, filePath, projectRoot }:
}

return (
<div className={`prose-cairn px-6 py-5 overflow-y-auto h-full ${className ?? ""}`}>
<div
className={`prose-cairn ${
inline
? "[&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
: "px-6 py-5 overflow-y-auto h-full"
} ${className ?? ""}`}
>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
Expand Down Expand Up @@ -226,6 +239,26 @@ function NoteMarkdownPreviewImpl({ content, className, filePath, projectRoot }:
code({ className, children }: React.HTMLAttributes<HTMLElement> & ExtraProps) {
return <InlineCode className={className}>{children}</InlineCode>;
},
input({ type, checked, ...props }: React.InputHTMLAttributes<HTMLInputElement> & ExtraProps) {
// GFM task-list checkboxes come through as bare <input type="checkbox">
// 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 <input type={type} checked={checked} {...props} />;
return (
<input
type="checkbox"
checked={checked === true}
readOnly
disabled
className="accent-[var(--accent)] w-3.5 h-3.5 relative top-[1px] mr-1 cursor-default"
/>
);
},
h1({ children }: React.HTMLAttributes<HTMLHeadingElement> & ExtraProps) { return <h1>{children}</h1>; },
h2({ children }: React.HTMLAttributes<HTMLHeadingElement> & ExtraProps) { return <h2>{children}</h2>; },
h3({ children }: React.HTMLAttributes<HTMLHeadingElement> & ExtraProps) { return <h3>{children}</h3>; },
Expand Down
30 changes: 23 additions & 7 deletions src/components/notes/markdown-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,10 @@ const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorProps>(
// 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() {
Expand Down Expand Up @@ -396,15 +400,27 @@ const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorProps>(
});
}, [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
Expand Down
Loading