Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
541 changes: 541 additions & 0 deletions claude-notes/plans/2026-06-23-tiptap-rich-text-block-editor.md

Large diffs are not rendered by default.

Binary file added claude-notes/richtext-shots/01-rendered.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added claude-notes/richtext-shots/02-editing-para1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added claude-notes/richtext-shots/04-editing-tint.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added claude-notes/richtext-shots/07-toggle-rich.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added claude-notes/richtext-shots/12-heading-tight.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added claude-notes/richtext-shots/13-toolbar.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
822 changes: 792 additions & 30 deletions package-lock.json

Large diffs are not rendered by default.

24 changes: 24 additions & 0 deletions q2-preview-spa/src/PreviewApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,12 @@ interface PreviewAppState {
* on every render. Read-at-load only (no live toggle via the SPA).
*/
nestingCursor: boolean;
/**
* Phase 1a (bd-sjb4pzx8): opt-in rich-text (tiptap) block editor. Read once
* at boot from `?richText=1`. When true the SPA passes `richText` to the
* iframe so editable paragraphs render the WYSIWYG editor. Read-at-load only.
*/
richText: boolean;
/**
* IndexDocument V2 capture sidecar (Phase C.3) — path → CaptureRef
* mapping. Populated by the server-side eager-capture driver (Phase
Expand Down Expand Up @@ -281,6 +287,19 @@ function parseNestingCursorParam(search: string): boolean {
}
}

/**
* Phase 1a (bd-sjb4pzx8): parse `?richText=1` from the boot URL. Read-at-load;
* the SPA does not react to URL changes after mount.
*/
function parseRichTextParam(search: string): boolean {
if (!search) return false;
try {
return new URLSearchParams(search).get('richText') === '1';
} catch {
return false;
}
}

/**
* P3.2: module-level empty object returned by the nestedEditBuffers memo
* when nestingCursor is off. Referentially stable so the iframe effect dep
Expand Down Expand Up @@ -349,6 +368,9 @@ function buildInitialState(): PreviewAppState {
nestingCursor: typeof window !== 'undefined'
? parseNestingCursorParam(window.location.search)
: false,
richText: typeof window !== 'undefined'
? parseRichTextParam(window.location.search)
: false,
bootAttempt: 1,
bootLastError: null,
connection: 'connected',
Expand Down Expand Up @@ -1249,6 +1271,8 @@ export default function PreviewApp() {
editingDisabled={!state.allowEdit}
// P3.2: nesting-cursor mode + per-key nested buffers.
unlockNestingCursor={state.nestingCursor}
// Phase 1a (bd-sjb4pzx8): opt-in rich-text editor (?richText=1).
richText={state.richText}
nestedEditBuffers={nestedEditBuffers}
/>
{showStaleOverlay && (
Expand Down
8 changes: 8 additions & 0 deletions ts-packages/preview-renderer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,15 @@
"@quarto/preview-runtime": "*",
"@quarto/quarto-automerge-schema": "*",
"@revealjs/react": "0.2.0",
"@tiptap/core": "^3.27.1",
"@tiptap/extension-subscript": "^3.27.1",
"@tiptap/extension-superscript": "^3.27.1",
"@tiptap/pm": "^3.27.1",
"@tiptap/react": "^3.27.1",
"@tiptap/starter-kit": "^3.27.1",
"morphdom": "^2.7.8",
"prosemirror-markdown": "^1.13.1",
"prosemirror-model": "^1.24.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"reveal.js": "6.0.0"
Expand Down
9 changes: 9 additions & 0 deletions ts-packages/preview-renderer/src/iframe/Q2PreviewIframe.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ interface Q2PreviewIframeProps {
* (undefined/false). Forwarded unchanged in the UPDATE_AST payload.
*/
unlockNestingCursor?: boolean;
/**
* Phase 1a (bd-sjb4pzx8): opt-in rich-text (tiptap) block editor. Forwarded
* unchanged in the UPDATE_AST payload into `PreviewContext.richText`.
*/
richText?: boolean;
/**
* P3.2: per-siKey clean QMD buffers for nested blocks, produced by
* the host's `regenerateNestedBuffers` call (gated on
Expand Down Expand Up @@ -143,6 +148,7 @@ export function Q2PreviewIframe({
currentActor,
editingDisabled,
unlockNestingCursor,
richText,
nestedEditBuffers,
currentSlideIndex,
onSlideChange,
Expand Down Expand Up @@ -296,6 +302,8 @@ export function Q2PreviewIframe({
editingDisabled,
// P3.2: nesting-cursor mode + per-key nested buffers.
unlockNestingCursor,
// Phase 1a (bd-sjb4pzx8): opt-in rich-text editor.
richText,
nestedEditBuffers,
},
},
Expand All @@ -314,6 +322,7 @@ export function Q2PreviewIframe({
currentActor,
editingDisabled,
unlockNestingCursor,
richText,
nestedEditBuffers,
]);

Expand Down
26 changes: 26 additions & 0 deletions ts-packages/preview-renderer/src/q2-preview/PreviewContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,32 @@ export interface PreviewContextValue {
* boot URL query param.
*/
unlockNestingCursor?: boolean;
/**
* Phase 1a (bd-sjb4pzx8): opt-in rich-text (tiptap) block editor. When true,
* an editable block renders a WYSIWYG tiptap editor instead of the monospaced
* textarea. Default-off (undefined/false). Set by the hub-client preference or
* the SPA's `?richText=1` boot URL query param, plumbed like
* `unlockNestingCursor`. v1 covers single paragraphs; other block types fall
* back to the textarea even when this is on.
*/
richText?: boolean;
/**
* Phase 1a (bd-sjb4pzx8): per-edit-session editor surface choice when
* `richText` is on. `'rich'` (default) renders the tiptap editor; `'plain'`
* renders the monospaced textarea (the escape hatch for syntax the rich
* editor can't express). Reset to `'rich'` each time a new block is opened.
* Toggled by the left-margin affordance.
*/
editorMode?: 'rich' | 'plain';
/** Set the current edit session's editor surface (rich/plain). */
setEditorMode?: (mode: 'rich' | 'plain') => void;
/**
* True while a rich/plain surface swap is in flight. The outgoing surface's
* blur (fired as it unmounts) must NOT commit/close the edit session — the
* swap is not a "done editing" gesture. Set by the toggle, cleared on the
* next tick. Both surfaces' blur handlers check it.
*/
editorModeSwitchRef?: MutableRefObject<boolean>;
/**
* §3: stable ref mirror of `unlockNestingCursor`, updated in PreviewRoot's
* render body. The hover handlers (`onPointerMove`/`onPointerLeave`) are
Expand Down
15 changes: 15 additions & 0 deletions ts-packages/preview-renderer/src/q2-preview/PreviewRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,11 @@ export interface PreviewRootProps {
* exposes nesting-cursor behaviour. Default-off (undefined/false).
*/
unlockNestingCursor?: boolean;
/**
* Phase 1a (bd-sjb4pzx8): opt-in rich-text (tiptap) block editor. When true,
* editable paragraphs render a WYSIWYG editor instead of the textarea.
*/
richText?: boolean;
/**
* P3.2: per-siKey clean QMD buffers for nested blocks, produced by
* the host's `regenerateNestedBuffers` call. Undefined when off.
Expand Down Expand Up @@ -247,6 +252,12 @@ export function PreviewRoot(props: PreviewRootProps) {
leafAnchorR0?: number;
seededDraft?: string;
} | null>(null);
// Phase 1a (bd-sjb4pzx8): the active edit surface when richText is on.
// Session-sticky (persists until the user toggles), default 'rich'.
const [editorMode, setEditorMode] = useState<'rich' | 'plain'>('rich');
// True briefly during a rich/plain swap so the outgoing surface's
// unmount-blur doesn't commit/close the session (see PreviewContext).
const editorModeSwitchRef = useRef(false);
// Root-held ref for the in-flight edit draft. Seeded with anchorSlice at
// activation; reset to null on close. Referentially stable → no extra
// re-renders from draft changes.
Expand Down Expand Up @@ -1498,6 +1509,10 @@ export function PreviewRoot(props: PreviewRootProps) {
editingDisabled: props.editingDisabled,
unlockNestingCursor: props.unlockNestingCursor,
unlockNestingCursorRef,
richText: props.richText,
editorMode,
setEditorMode,
editorModeSwitchRef,
nestedEditBuffers: props.nestedEditBuffers,
requestMove,
pendingCaretRef,
Expand Down
61 changes: 54 additions & 7 deletions ts-packages/preview-renderer/src/q2-preview/dispatchers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { normalizeLineEndings } from '../utils/normalizeLineEndings';
import { isOnFirstVisualLine, isOnLastVisualLine, getLogicalColumn, placeCaretAtColumn } from './caretGeometry';
import { buildNestingCommitDestination, classifyNestingKey, detectPlatform } from './nestingNav';
import { editBaseline } from './outerBlocks';
import { RichTextEditor } from './richtext/RichTextEditor';
import { EditAffordance } from './richtext/EditAffordance';

// P3.3 §3b: detect platform once at module load so classifyNestingKey can
// distinguish mac (Cmd+Ctrl) from other (Alt+Shift) nesting chords.
Expand Down Expand Up @@ -61,11 +63,15 @@ function renderMeasuredEdit(
node: BlockNode | CustomBlockNode,
textarea: React.ReactNode,
editTarget: NonNullable<PreviewContextValue['editTarget']>,
activeEditRegionRef?: React.MutableRefObject<HTMLDivElement | null>,
activeEditRegionRef: React.MutableRefObject<HTMLDivElement | null> | undefined,
ctx: PreviewContextValue,
richSupported: boolean,
): React.ReactNode {
const wrapperStyle: React.CSSProperties = {
...(editTarget.boxStyle as React.CSSProperties),
boxSizing: 'content-box',
// Positioning context for the left-margin edit affordance.
position: 'relative',
};
// Lists carry a large left padding (the bullet/number gutter). Replicating
// it would indent the textarea away from column 0 where the source begins;
Expand All @@ -77,7 +83,12 @@ function renderMeasuredEdit(
}
return (
<AttributionWrap node={node} as="div">
<div ref={activeEditRegionRef} id="q2-active-edit-region" style={wrapperStyle}>{textarea}</div>
<div ref={activeEditRegionRef} id="q2-active-edit-region" style={wrapperStyle}>
{/* Left-margin affordance (Editing… + rich/plain toggle), shown only
when the rich editor feature is enabled. */}
{ctx.richText && <EditAffordance ctx={ctx} richSupported={richSupported} />}
{textarea}
</div>
</AttributionWrap>
);
}
Expand Down Expand Up @@ -363,6 +374,9 @@ function EditTextarea({
onBlur={() => {
// Do not commit mid-IME-composition (e.g. mobile dismiss before compositionend).
if (isComposingRef.current) return;
// A rich/plain surface swap is not a commit (Phase 1a) — the swap
// unmounts this textarea; its blur must not close the session.
if (ctx.editorModeSwitchRef?.current) return;
// P2.4d: check for a pending click-switch FIRST. If a dirty switch is in
// progress, handleClickSwitchBlur commits A, stashes B's landing, and closes
// the editor — all in one shot. Returns true when consumed (skip normal path).
Expand Down Expand Up @@ -491,6 +505,33 @@ function renderBlockTextarea(
return <EditTextarea ctx={ctx} resolved={resolved} />;
}

/**
* Block types the rich-text editor can handle. Everything else falls back to the
* textarea even when `richText` is on. 1a: Para. 1b: + Header. (1c: lists/quotes.)
*/
const RICHTEXT_SUPPORTED_TYPES = new Set<string>(['Para', 'Header']);

/** True when the rich editor is available for this block (flag on + supported type). */
function richTextAvailable(ctx: PreviewContextValue, sourceNodeType: string): boolean {
return !!ctx.richText && RICHTEXT_SUPPORTED_TYPES.has(sourceNodeType);
}

/**
* Which edit surface to render for the active block: the tiptap editor when rich
* text is available AND the session mode is 'rich' (the default); otherwise the
* monospaced textarea (flag off, unsupported type, or the user toggled to plain).
*/
function renderBlockEditSurface(
ctx: PreviewContextValue,
resolved: ResolvedSource,
sourceNodeType: string,
): React.ReactNode {
if (richTextAvailable(ctx, sourceNodeType) && (ctx.editorMode ?? 'rich') !== 'plain') {
return <RichTextEditor ctx={ctx} resolved={resolved} />;
}
return renderBlockTextarea(ctx, resolved);
}

// ---------------------------------------------------------------------------

/**
Expand Down Expand Up @@ -529,12 +570,17 @@ export const Block = (args: NodeArgs<BlockNode>) => {
const resolved = ctx?.resolveSource ? ctx.resolveSource(args.node) : null;

if (isBlockEditTarget(ctx, resolved) && ctx) {
// Editing: replace the block with a measure-and-set textarea wrapper
// that reproduces the element's exact box (see renderMeasuredEdit).
// Editing: replace the block with a measure-and-set wrapper that
// reproduces the element's exact box (see renderMeasuredEdit). The inner
// surface is the rich-text editor (opt-in, paragraphs) or the textarea.
// Pass activeEditRegionRef so the wrapper div is tracked — used by
// useBlockEditHover's onPointerUp to suppress parent-climb activation.
const textarea = renderBlockTextarea(ctx, resolved!);
return renderMeasuredEdit(args.node, textarea, ctx.editTarget!, ctx.activeEditRegionRef);
const sourceNodeType = (resolved!.sourceNode as { t: string }).t;
const surface = renderBlockEditSurface(ctx, resolved!, sourceNodeType);
return renderMeasuredEdit(
args.node, surface, ctx.editTarget!, ctx.activeEditRegionRef,
ctx, richTextAvailable(ctx, sourceNodeType),
);
}

const Component = registry[args.node.t];
Expand Down Expand Up @@ -593,7 +639,8 @@ export const CustomBlock = (args: NodeArgs<CustomBlockNode>) => {

if (isBlockEditTarget(ctx, resolved) && ctx) {
const textarea = renderBlockTextarea(ctx, resolved!);
return renderMeasuredEdit(args.node, textarea, ctx.editTarget!, ctx.activeEditRegionRef);
// CustomBlocks are not rich-editable in v1 → always textarea, no toggle.
return renderMeasuredEdit(args.node, textarea, ctx.editTarget!, ctx.activeEditRegionRef, ctx, false);
}

const Component =
Expand Down
7 changes: 7 additions & 0 deletions ts-packages/preview-renderer/src/q2-preview/entry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,11 @@ interface UpdateAstPayload {
* `PreviewContext.unlockNestingCursor`. Default-off (undefined/false).
*/
unlockNestingCursor?: boolean;
/**
* Phase 1a (bd-sjb4pzx8): opt-in rich-text editor. Forwarded into
* `PreviewContext.richText`. Default-off (undefined/false).
*/
richText?: boolean;
/**
* P3.2: per-siKey clean QMD buffers for nested blocks. Forwarded
* into `PreviewContext.nestedEditBuffers`. Undefined when flag is off.
Expand Down Expand Up @@ -334,6 +339,7 @@ function updateAst(payload: UpdateAstPayload) {
currentActor,
editingDisabled,
unlockNestingCursor,
richText,
nestedEditBuffers,
} = payload;
const rootElement = document.getElementById('root');
Expand All @@ -359,6 +365,7 @@ function updateAst(payload: UpdateAstPayload) {
currentActor={currentActor ?? null}
editingDisabled={editingDisabled}
unlockNestingCursor={unlockNestingCursor}
richText={richText}
nestedEditBuffers={nestedEditBuffers}
customRegistry={customRegistry}
scrollToAnchor={scrollToAnchorInDocument}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Phase 1a (bd-sjb4pzx8) — left-margin edit affordance.
//
// Parked in the LEFT MARGIN of the active edit box (absolute-positioned, off the
// text) so it never hijacks clicking/selecting. Shows "Editing…" plus — when the
// block supports the rich editor — a rich/plain surface toggle (the escape hatch
// to the monospaced textarea for syntax the rich editor can't express).
//
// Rendered by `renderMeasuredEdit` for BOTH surfaces, so the toggle is reachable
// from rich AND plain mode. Only shown when `ctx.richText` is on.

import type { MouseEvent } from 'react';
import type { PreviewContextValue } from './../PreviewContext';
import { ensureRichTextStyles } from './styles';

export function EditAffordance({
ctx,
richSupported,
}: {
ctx: PreviewContextValue;
richSupported: boolean;
}) {
// Inject the shared stylesheet here too — the affordance renders in plain mode
// (textarea), where RichTextEditor (the other injection site) isn't mounted.
ensureRichTextStyles();
const mode = ctx.editorMode ?? 'rich';

// mousedown + preventDefault: keep focus in the editor (a real blur would
// commit/close the surface before the mode switch lands).
const choose = (m: 'rich' | 'plain') => (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (m === mode) return;
// Mark the swap so the outgoing surface's unmount-blur doesn't commit/close.
// Cleared on the next tick, after the synchronous unmount-blur has passed.
if (ctx.editorModeSwitchRef) ctx.editorModeSwitchRef.current = true;
ctx.setEditorMode?.(m);
setTimeout(() => {
if (ctx.editorModeSwitchRef) ctx.editorModeSwitchRef.current = false;
}, 0);
};

return (
<div className="q2-edit-affordance" contentEditable={false}>
<div className="q2-edit-affordance-label">Editing…</div>
{richSupported && (
<div className="q2-edit-mode-toggle" role="group" aria-label="Editor mode">
<button
type="button"
className={mode === 'rich' ? 'q2-edit-mode-active' : ''}
aria-pressed={mode === 'rich'}
onMouseDown={choose('rich')}
>
rich text
</button>
<button
type="button"
className={mode === 'plain' ? 'q2-edit-mode-active' : ''}
aria-pressed={mode === 'plain'}
onMouseDown={choose('plain')}
>
plain text
</button>
</div>
)}
</div>
);
}
Loading
Loading