Skip to content

Rich-text (tiptap) block editor for q2-preview — Phase 1a (experiment, opt-in)#335

Merged
cscheid merged 20 commits into
mainfrom
feature/bd-sjb4pzx8-tiptap-rich-text-editor
Jun 24, 2026
Merged

Rich-text (tiptap) block editor for q2-preview — Phase 1a (experiment, opt-in)#335
cscheid merged 20 commits into
mainfrom
feature/bd-sjb4pzx8-tiptap-rich-text-editor

Conversation

@cscheid

@cscheid cscheid commented Jun 23, 2026

Copy link
Copy Markdown
Member

Experimental, opt-in WYSIWYG block editor for q2 preview / quarto-hub, built on tiptap/ProseMirror. Strand bd-sjb4pzx8. Plan: claude-notes/plans/2026-06-23-tiptap-rich-text-block-editor.md.

This is a feasibility experiment on a branch — it does not change default behavior. The rich editor only activates behind ?richText=1 (+ q2 preview --allow-edit). With the flag off, everything is byte-identical to today's monospaced textarea.

What it does

Clicking an editable paragraph opens a tiptap rich-text editor in place of the textarea, inside the same measured box and committing through the unchanged commitTextEditparse_qmd_content → splice → write-back path. Because the editor lives in the preview iframe (which already has the Bootstrap + theme CSS), its semantic tags (<p>/<em>/<strong>/<a>) are styled by the theme automatically — so editing looks like the rendered page.

  • Seed: block AST subtree → ProseMirror doc (no markdown re-lexing). Opaque Quarto constructs (shortcodes, math, @crossref, [@cite], raw inline) become verbatim "chip" atoms.
  • Commit: ProseMirror doc → markdown → existing text channel. Dirtiness from doc.eq (untouched open/close is a true no-op).
  • Edit-mode affordances in the left margin (off the text): an "Editing…" label + a rich/plain editor toggle (in-place escape hatch to the textarea).

Scope (Phase 1a)

  • Single paragraphs only; every other block type (and the flag-off default) uses the textarea.
  • Backend, Automerge, source-map, postMessage protocol: unchanged.

Tests / verification

  • New production round-trip suite (richtext/roundtrip.test.ts, 13 fixtures) — gate is semantic AST equivalence (no dropped/changed nodes); cosmetic reformatting allowed. Skips gracefully when native pampa can't be built.
  • Full preview-renderer suite green (486 tests). tsc clean across preview-renderer, q2-preview-spa, hub-client (tsc -b + vite + sandboxed iframe build).
  • Verified end-to-end in q2 preview --allow-edit with chrome-devtools (screenshots in claude-notes/richtext-shots/), including a faithful real edit writing clean qmd back to disk.

Notes for review

  • Adds tiptap + prosemirror-markdown/model as preview-renderer deps.
  • ts-packages/preview-renderer/src/q2-preview/tiptap-roundtrip-spike/ is a throwaway Phase-0 spike (marked as such); safe to delete before any non-experimental merge.
  • Known limitation (Phase 2): plain→rich toggle re-seeds from the original AST (the iframe can't parse arbitrary edited markdown without a parent round-trip). rich→plain preserves edits.

🤖 Generated with Claude Code

cscheid and others added 20 commits June 23, 2026 15:52
Throwaway Phase-0 spike proving qmd can round-trip through a ProseMirror
document for a future rich-text block editor in q2-preview. Approach: keep
all existing detection + commit machinery; replace only the EditTextarea UI.
Seed the PM doc from the untransformed Pandoc AST (not markdown-it), lift
opaque constructs (shortcodes, math, @CrossRef, [@cite], raw inline) into
verbatim "chip" nodes, serialize PM -> markdown via prosemirror-markdown.

Oracle: native pampa (-t json --json-source-location full), no WASM init.
Result: 15/16 exact, 1 benign reformat (blockquote softwrap), 0 broken.
Stock prosemirror-markdown serializer needed zero per-node overrides beyond
the chip rule. tsc clean; full preview-renderer suite (473 tests) passes.

Spike lives at ts-packages/preview-renderer/src/q2-preview/tiptap-roundtrip-spike/
(throwaway; quarantine/delete before non-experimental merge). Adds dev deps
prosemirror-model + prosemirror-markdown. Plan + verdict:
claude-notes/plans/2026-06-23-tiptap-rich-text-block-editor.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AST->ProseMirror->markdown bridge for the rich-text block editor, reusing the
Phase-0 spike's validated approach but as production modules keyed to tiptap's
node/mark names (so one bridge + serializer serve both the test schema and the
live tiptap editor).

- richtext/schema.ts: PM schema (tiptap-named) + atomic `chip` node
- richtext/astToProseMirror.ts: astToDoc — AST subtree -> PM doc, opaque
  constructs (math/cite/shortcode/raw) -> verbatim chips; list tightness from AST
- richtext/serializer.ts: docToMarkdown — prosemirror-markdown rules re-keyed to
  tiptap names; qmd-aware italic->`_` (avoids disallowed `***`)
- test-utils/pampaOracle.ts: shared native-pampa oracle; inline marks compared as
  a flat SET (ProseMirror model) so `[**x**](u)` == `**[x](u)**`; skips when the
  native binary can't be built
- richtext/roundtrip.test.ts: 13 fixtures, all pass; gate is SEMANTIC equivalence
  (no dropped/changed nodes), byte-exactness informational

Adds tiptap (core/pm/react/starter-kit) + prosemirror-markdown/model as
preview-renderer deps. tsc clean; round-trip suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…bd-sjb4pzx8)

Wires the opt-in WYSIWYG tiptap editor into the q2-preview block-edit path.

- richtext/RichTextEditor.tsx: tiptap editor seeded from the block's AST subtree
  (astToDoc -> JSON), committing markdown via the UNCHANGED commitTextEdit path;
  dirtiness from doc.eq(initialDoc) (C3, true no-op on unedited close); stale-
  target + focus-restore guards; Esc/Mod-Enter/blur; paragraph-only (Enter
  swallowed, no structural splits in 1a)
- richtext/chipExtension.ts: tiptap Chip node (inline atom, verbatim pill)
- richtext/styles.ts: one-time CSS — strip ProseMirror chrome, zero inner-block
  margin (measured box owns spacing), subtle chip pills; theme styles the rest
- dispatchers.tsx: Block renders RichTextEditor (vs EditTextarea) for Para when
  ctx.richText; same measured box
- richText flag plumbed like unlockNestingCursor: PreviewContext -> PreviewRoot ->
  entry -> Q2PreviewIframe (UPDATE_AST payload) -> SPA ?richText=1

tsc clean (preview-renderer + q2-preview-spa); full preview-renderer suite passes
(486 tests). Browser verification next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Browser verification via q2 preview --allow-edit + ?richText=1: clicking a
paragraph opens a tiptap editor visually identical to the rendered block (marks
styled by the theme via the same-iframe CSS cascade); a real inline-bold edit
committed through the unchanged commitTextEdit path and wrote clean qmd to disk
with the rest of the paragraph round-tripped byte-clean.

Adds Phase 1a evidence screenshots (rendered + editing) and marks Phase 1a
complete in the plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The WYSIWYG render is faithful enough that users couldn't tell editing was live.
Give the active rich-text editor a subtle blue background tint + ring so "edit
mode" is obvious. Padding is offset by an equal negative margin so the text does
not shift (zero reflow); marks stay theme-styled. Verified in q2 preview.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rce (bd-sjb4pzx8)

Two UX refinements + a source-mapping fix:

- "Editing…" affordance: a faint italic label parked in the LEFT MARGIN of the
  active editor (pointer-events:none, user-select:none) so it signals edit mode
  without hijacking text clicking/selecting. First of the left-margin affordances.
- Shortcode chip source: prefer a node's own `.l` literal location over the
  compact pool entry when slicing chip text. The pool range for a shortcode Span
  is mis-assigned (points at an adjacent space → empty chip); `.l` points at the
  actual token, so the chip now renders the verbatim `{{< meta key >}}` monospace.
  Leaf inlines (math/cite) were already correct via the pool; this fixes
  container inlines. Round-trip suite still green (native pampa carries `.l` too).

Verified in q2 preview: Editing label + math/cite/shortcode chips all render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In-place escape hatch to the textarea, parked in the left margin under "Editing…"
(absolute, off the text, so it never hijacks clicking/selecting).

- EditAffordance.tsx: shared left-margin affordance (label + rich/plain toggle),
  rendered by renderMeasuredEdit so it shows for BOTH surfaces. Toggle uses
  mousedown-preventDefault to keep editor focus.
- editorMode ('rich'|'plain') in PreviewRoot, session-sticky, default rich; the
  dispatcher renders RichTextEditor vs EditTextarea accordingly.
- editorModeSwitchRef guard: a surface swap fires the outgoing editor's
  unmount-blur, which must NOT commit/close the session — both blur handlers
  (rich + textarea) check the ref. Without it, toggling closed the editor.
- rich->plain content handoff via editDraftRef (RichTextEditor.onUpdate keeps the
  shared markdown draft current, dirty-aware so an untouched toggle never
  reformats). plain->rich re-seeds from the original AST (in-iframe can't parse
  edited markdown — Phase 2).

Verified in q2 preview: toggle rich<->plain keeps the session open, swaps
surfaces, preserves content. tsc clean; preview-renderer suite 486 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Header added to the rich-text supported types; heading node enabled in the
  tiptap editor. The AST->PM bridge already mapped Header -> heading{level}, so
  the round-trip was ready (added a heading-with-marks fixture; 14/14 green).
- enableInputRules/enablePasteRules false: 1b edits existing structure only —
  typing "## " must not convert a paragraph or change a heading level (structural
  edits are a later phase; Cmd-B/I marks still work).
- trailingNode false: a single-heading doc was getting a phantom empty trailing
  paragraph (extra editor height + a stray blank block on commit). Fixed; the
  heading edit box is now tight (verified in q2 preview).

tsc clean; preview-renderer suite 487 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ks (bd-sjb4pzx8)

A small toolbar floats above the top-left of the rich-text edit box:

- Mark buttons (bold/italic/strike/subscript/superscript) call toggleMark over the
  selection (same command as Cmd-B/I), highlight via isActive, and use
  mousedown-preventDefault so clicking never collapses the selection. Verified
  end-to-end: select word + Bold -> **word** on disk.
- Subscript/superscript are now real marks (not chips): added
  @tiptap/extension-{sub,super}script, schema marks, serializer (~x~ / ^x^), AST
  mapping (Pandoc Subscript/Superscript -> marks). Round-trip fixture green (15/15).
- Link button opens a URL input; setLink / extendMarkRange('link') / unsetLink
  (edit/remove an existing link by placing the cursor inside it). Commit was
  rescoped to focusout from the whole edit box so focusing the link input doesn't
  close the session.

KNOWN ISSUE (bd-3zp3z4jx, downstream): a NEW link's URL is corrupted on write-back
when the paragraph already has another link (gets the adjacent link's URL). The
rich editor commits CORRECT markdown (verified); the corruption is in the shared
text-channel write-back (apply_node_edit / incremental writer), so it affects the
textarea editor's link edits too. Single-link / no-other-link edits are fine.

tsc clean; preview-renderer suite 488 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-q9lyghv2)

Clicking a paragraph/heading to open the tiptap rich-text editor in
q2 preview --allow-edit (&richText=1) dropped the caret at end-of-block
(autofocus:'end'); only a SECOND click landed it where the user clicked.
Root cause: the open goes through a React state update, so the original
mouse event is consumed before the editor mounts — ProseMirror never gets
to translate the click into a doc position.

Capture the activating mouse click's viewport coords and replay them at
mount via posAtCoords:

- New pendingClickCoordsRef on PreviewContext (allocated in PreviewRoot).
- useBlockEditHover threads the coords into activate() at the single open
  chokepoint (right before setEditTarget). Mouse passes coords;
  keyboard/touch pass none -> ref nulled (keeps end-of-block, also clears
  any stale coords). One site covers both fresh-open and click-switch.
- RichTextEditor reads+clears the ref once at mount and places the caret
  via the new placeCaretFromClick helper; autofocus:'end' remains the
  fallback (keyboard/touch, or posAtCoords miss). Read-once means a
  self-heal re-anchor remount falls back to end-of-block rather than
  replaying a now-stale click.

Tests (jsdom verifies the capture->consume->fallback wiring; geometry is
browser-verified separately):
- useBlockEditHover.caret-coords.integration: coords captured on mouse,
  not keyboard/touch.
- caretFromClick.test: posAtCoords hit -> setTextSelection; miss -> false.
- RichTextEditor.caret.integration: ref consumed/cleared at mount.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s (bd-q9lyghv2)

Browser verification of the caret-at-click fix revealed that the caret still
pinned to end-of-block on the first click. Root cause was NOT geometry
(caretRangeFromPoint resolved the clicked position immediately) but a race:
tiptap's autofocus:'end' applies its end-selection inside a requestAnimationFrame
that lands on the same frame as our placement and beats it.

Fix: set autofocus:false and have the mount effect own the opening caret as the
single source of truth — place at the click via posAtCoords when coords were
captured, else focus('end') (the historical default). The placement runs in a
requestAnimationFrame (layout settled) and, with autofocus off, nothing competes.

Also polyfill getClientRects/getBoundingClientRect on Text and Range in the
preview-renderer test setup: mounting a tiptap editor in jsdom drives
ProseMirror's coordsAtPos (via focus -> scrollIntoView), which jsdom doesn't
implement on those node types. Geometry is meaningless in jsdom (browser-verified
separately); the stub keeps the editor's focus machinery from throwing.

End-to-end verified in q2 preview --allow-edit (&richText=1): mouse clicks at
40%/10% of a paragraph and 50% of a heading land the caret exactly at the click
(delta 0/-1); clean editor-to-editor click-switch lands at the click; keyboard
activation still lands at end-of-block. See the plan doc for the result table and
screenshot (claude-notes/richtext-shots/14-caret-at-click.png).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…em> italics (bd-dg8x84bu)

RevealDeck.tsx imported reveal.js's reset.css as a side-effecting CSS module.
Bundlers hoist every CSS import in the module graph into the q2-preview SPA's
single global stylesheet, regardless of whether the importing component renders.
reset.css is reveal's global Meyer reset (html, body, ..., em, i, cite,
var { font: inherit; ... }), so it applied to ALL preview content — including
format:html documents with no deck — resetting font-style on <em>/<i>/<cite> to
inherit and overriding the UA default em{font-style:italic}. Result: emphasis
rendered upright in q2 preview and hub-client. (Verified: computed font-style on
a preview <em> was "normal"; after the fix it is "italic".)

reveal.css and quarto-reveal.css are reveal-namespaced (no bare element-type
selectors), so they don't leak. reset.css was the sole offender.

Fix: add resources/revealjs/reset-scoped.css — the same Meyer reset with every
selector scoped under .reveal (derived from reset.css; reset.css itself is left
byte-identical to npm for the vendoring check). RevealDeck.tsx and the q2-debug
entry import the scoped version. Deck slide content (all under .reveal) gets the
identical reset; non-deck content is untouched.

Regression guard: reveal-reset-scope.test.ts asserts none of the three reveal
CSS files RevealDeck imports carries a global bare-element selector.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…jb4pzx8)

Flip the q2-preview SPA boot flag so the tiptap rich-text editor is ON by
default; only an explicit ?richText=0 opts out (previously it was opt-in via
?richText=1). parseRichTextParam now returns true for an absent param / any
value other than "0".

Scope: this changes only the q2 preview SPA default. The richText flag is still
off unless a host sets it at the PreviewContext level, so hub-client behavior is
unchanged (it does not read the boot param).

Tests: parseRichTextParam.test.ts (exported the helper) covers default-on,
?richText=1 on, ?richText=0 off, other values on, and mixed params. Verified
end-to-end in q2 preview --allow-edit: no param mounts .ProseMirror; ?richText=0
mounts the plain textarea.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…d-j1nto6eq)

The tiptap rich-text block editor was wired in the q2-preview iframe
(Q2PreviewIframe already forwards a `richText` flag into the UPDATE_AST
payload → PreviewContext.richText) and enabled in the standalone q2 preview
SPA, but hub-client never passed the flag, so it always used the textarea.

Plumb `richText` from a new user preference (default ON) through to the iframe,
mirroring the `unlockNestingCursor` preference end-to-end:

- preferences/schema.ts: richText z.boolean().default(true) + DEFAULT_PREFERENCES.
  The .default(true) keeps prefs written before this key existed parsing cleanly
  (no migration / version bump) — guarded by a regression test mirroring the
  unlockNestingCursor one.
- ReactPreview: usePreference('richText') → passed to ReactRenderer.
- ReactRenderer: new richText prop forwarded to Q2PreviewIframe only (q2-debug
  and slides ignore it, like unlockNestingCursor).
- SettingsTab: "Rich-text editor" toggle in the Preview section (opt-out;
  takes effect live via the preference-change broadcast).

Q2PreviewIframe needs no change (already wired).

Tests (TDD, red-first): schema.test.ts (default-on + old-prefs-without-key
preserved); ReactRenderer.integration.test.tsx (richText forwarded to the
mocked Q2PreviewIframe). Full hub-client suites green (660 unit + 76
integration); build:all passes.

End-to-end: not driven as a full authenticated hub session (needs backend +
auth + project); verified by layers — the iframe→PreviewContext→tiptap path is
the same component already verified end-to-end in the q2 preview SPA. See
claude-notes/plans/2026-06-24-hub-client-richtext-preference.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…38tnyqy)

Enabling the rich-text editor by default (bd-j1nto6eq) broke ~25 q2-preview
editing e2e specs: they click a block and waitFor('textarea'), but the rich
editor (.ProseMirror) now opens instead, so the textarea locator times out.

These specs target the plain-textarea inline editor specifically (caret-column
geometry, visual-line nav, nesting cursor, delete-by-emptying, self-heal, ...),
so they should run with richText off — exactly as they already pin
unlockNestingCursor. Fix at the single chokepoint every editing spec routes
through (its openFile -> bootstrapProjectSet): an addInitScript that MERGES
richText:false into the seeded preferences only when a spec hasn't set it
explicitly. A spec's own beforeEach preference seed runs first and is preserved
(richText filled in as false); a spec with no seed gets the full default object
(incl. the schema-required `version`) with richText:false; a future rich-text
spec can opt IN by seeding richText:true. No spec files touched.

Verified locally (the e2e harness starts its own hub + serves the build):
inline-edit (seeds prefs) + delete-by-emptying (no seed) → 9 passed; block-nav,
breadcrumb-geometry, expand-on-edit, self-heal → 25 passed / 1 skipped. Both
merge paths exercised.

Not addressed (pre-existing, unrelated to richText): q2-debug-render-components,
share-link-project-set (expect.poll timeout), render-components-comment/kanban
(Path-not-found / EDITOR_NO_PREVIEW) — infra/peer-connection flakiness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cscheid cscheid merged commit 9a3add9 into main Jun 24, 2026
5 checks passed
@cscheid cscheid deleted the feature/bd-sjb4pzx8-tiptap-rich-text-editor branch June 24, 2026 20:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant