Skip to content

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

Open
cscheid wants to merge 9 commits into
mainfrom
feature/bd-sjb4pzx8-tiptap-rich-text-editor
Open

Rich-text (tiptap) block editor for q2-preview — Phase 1a (experiment, opt-in)#335
cscheid wants to merge 9 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 9 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>
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