From d1400e1c71937f43703548a82630bb26f4db4937 Mon Sep 17 00:00:00 2001 From: Carlos Scheidegger Date: Mon, 6 Jul 2026 16:12:21 -0500 Subject: [PATCH] feat(q2-preview): rich-text editing for Plain blocks (tight list items) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tight bullet/ordered lists store each item's content as a `Plain` block (loose lists use `Para`; table cells are also `Plain`). The rich-text block editor's gate `RICHTEXT_SUPPORTED_TYPES` listed only {Para, Header}, so clicking a tight-list item resolved a `Plain` sourceNode, the availability check returned false, and the block fell back to the monospaced textarea instead of the tiptap rich editor. Add `Plain` to the set. Everything downstream is already Plain-aware: `astToProseMirror` maps both Para and Plain to a paragraph node, and the text-commit channel's `preserve_leaf_variant` (pampa `apply_node_edit.rs`) coerces the re-parsed Paragraph back to Plain so editing a tight-list item does not silently loosen the list (already covered by `text_edit_preserves_{bullet,ordered}_list_tightness`). Tests (TDD): - richTextSupport.test.ts: the availability gate (module had no direct test); the Plain assertions fail before this change, pass after. - plain-list-item-richtext.integration.test.tsx: drives the real PreviewRoot — a tight-list item opens the rich editor (toolbar present) with the flag on, the textarea with it off. Fails when the fix is reverted (true regression guard). - richtext/plainSeed.test.ts: the single-Plain seed the editor uses serializes back to the bare inline text (marks included), no dropped nodes, no list marker. Verified end-to-end via `q2 preview --allow-edit`: clicking a tight-list item opens the tiptap editor (Pl breadcrumb); a text edit round-trips and the on-disk list stays tight (all items Plain). Note: a pre-existing rich-editor bug where a select-all + bold commit can drop content (spurious hardBreak) is tracked separately as bd-hafs0qho; it is not Plain-specific (shared, type-agnostic RichTextEditor path, affects Para equally) and predates this change. Strand: bd-7pxub583 Co-Authored-By: Claude Opus 4.8 (1M context) --- ...2026-07-06-richtext-plain-block-support.md | 247 ++++++++++++++++++ ...in-list-item-richtext.integration.test.tsx | 142 ++++++++++ .../src/q2-preview/richTextSupport.test.ts | 74 ++++++ .../src/q2-preview/richTextSupport.ts | 12 +- .../src/q2-preview/richtext/plainSeed.test.ts | 54 ++++ 5 files changed, 527 insertions(+), 2 deletions(-) create mode 100644 claude-notes/plans/2026-07-06-richtext-plain-block-support.md create mode 100644 ts-packages/preview-renderer/src/q2-preview/plain-list-item-richtext.integration.test.tsx create mode 100644 ts-packages/preview-renderer/src/q2-preview/richTextSupport.test.ts create mode 100644 ts-packages/preview-renderer/src/q2-preview/richtext/plainSeed.test.ts diff --git a/claude-notes/plans/2026-07-06-richtext-plain-block-support.md b/claude-notes/plans/2026-07-06-richtext-plain-block-support.md new file mode 100644 index 000000000..6e75d27c3 --- /dev/null +++ b/claude-notes/plans/2026-07-06-richtext-plain-block-support.md @@ -0,0 +1,247 @@ +# Rich-text editor support for `Plain` blocks (tight list items) + +**Strand:** bd-7pxub583 (related to bd-sjb4pzx8 — the tiptap rich-text block editor) +**Status:** IN PROGRESS — user approved 2026-07-06 +**Date:** 2026-07-06 + +## Decisions locked with user (2026-07-06) + +- **Scope: BROAD.** Add `'Plain'` to `RICHTEXT_SUPPORTED_TYPES` so the rich-text + editor activates for *every* reachable `Plain` edit target, not just + list-item `Plain`s. Rationale (user): "the more settings where the rich text + editor works well, the better." Risk #2's narrow option is dropped. +- **Characterize tight/loose empirically with the `pampa` binary.** Q2 uses a + **custom** markdown parser whose tight-vs-loose rules are *not identical to + Pandoc's*. Do not rely on memory of Pandoc list semantics — generate each + list variant as qmd and run it through `cargo run -p pampa -- -t json` (or + `-t native`) to see the actual `Plain`/`Para` shape Q2 produces, and derive + fixtures from that ground truth. + +## Overview + +In `format: q2-preview`, the tiptap rich-text block editor (bd-sjb4pzx8) +activates only for a subset of Pandoc block types. That subset omits `Plain`. +Because **tight** bullet/ordered lists store each item's content as a `Plain` +block (loose lists use `Para`), clicking to edit a tight-list item's content +resolves a `sourceNode` of type `Plain`, the availability gate returns `false`, +and the editor falls back to the monospaced textarea instead of the rich-text +surface. + +Goal: make the content of tight-list items editable as rich text, matching the +experience already available for paragraphs and headers. + +## Root cause (confirmed by reading the code) + +The single gate is `RICHTEXT_SUPPORTED_TYPES` in +`ts-packages/preview-renderer/src/q2-preview/richTextSupport.ts:17`: + +```ts +export const RICHTEXT_SUPPORTED_TYPES = new Set(['Para', 'Header']); +``` + +`richTextAvailable(ctx, sourceNodeType)` (same file) is `ctx.richText && +RICHTEXT_SUPPORTED_TYPES.has(sourceNodeType)`. The dispatcher +(`dispatchers.tsx:517-526`, `renderBlockEditSurface`) uses exactly this +predicate to choose `` vs. the textarea. A tight-list item's +leading block is a `Plain` (see `nestingNav.ts:649-653`, `outerBlocks.ts:333`), +so it never matches. + +### Why the rest of the pipeline is (probably) already ready + +- **AST → ProseMirror** already maps `Plain` to a paragraph node: + `astToProseMirror.ts:131-135` handles `case 'Para': case 'Plain':` + identically. Tightness is inferred separately by `isTight()` + (`astToProseMirror.ts:122-126`), which checks whether any item block is a + `Para`. So seeding the rich editor from a `Plain` source node already works. +- **Commit path is shared with the textarea.** Both `` and + `` commit through the identical `commitTextEdit(destination, + newText)` byte-range-replacement channel (`usePreviewEdit.ts`, + `PreviewContext.tsx`). The textarea **already** lets a user edit a tight-list + `Plain` item today (it just isn't rich). So the byte-range resolution and + the Rust-side re-parse/`apply_node_edit` machinery are already exercised for + `Plain`. The rich editor changes only *what text* is written into that same + range, not *how* it is written. + +**Net:** the visible bug is a one-line omission, but "add `'Plain'` to the set" +is necessary, not obviously sufficient. The real work is verifying round-trip +fidelity and deciding the scope of "any `Plain`" vs. "only list-item `Plain`". + +## Key risks / open questions to resolve before/while implementing + +1. **Tight-stays-tight round-trip — largely already solved by the backend.** + When the rich editor serializes a single-`Plain`-seeded doc back to markdown + (`docToMarkdown`, `serializer.ts`), the PM node is a `paragraph` — + indistinguishable from a `Para` seed. The edit target is only the item's + *inner* byte range (no `- ` marker), and `commitTextEdit` splices new text + into that range, where it re-parses as a bare `Paragraph`. **But the Rust + commit path already anticipates exactly this:** `preserve_leaf_variant` in + `crates/pampa/src/apply_node_edit.rs:253-265` coerces a *single-block* + `Paragraph` replacement back to `Plain` when the original block was `Plain`, + precisely so an inline-text edit of a tight list item does not loosen the + list. The rich editor commits through the **same text channel** the textarea + uses, so it inherits this guard for free. The `len() == 1` condition is + load-bearing: a legitimately multi-block edit still loosens. Since a single + tight-item content edit stays one paragraph (the serializer's `_`-italic and + mark transforms don't add blocks), the guard should fire. `nestingNav.ts:184` + flags the same hazard for divs, and the textarea path already relies on this + guard today. → **Test:** interactive edit of a tight-list item, assert the + on-disk qmd stays tight and the AST item block stays `Plain` — this is a + *regression guard* on `preserve_leaf_variant` being exercised by the rich + path, not new machinery we must build. + +2. **Scope of "`Plain`".** `Plain` appears in more than tight list items: + table cells, definition-list terms/definitions, and (historically) some + figure/caption contexts. Adding `'Plain'` to `RICHTEXT_SUPPORTED_TYPES` + makes *every* reachable `Plain` edit target rich. We must decide: + - **(a) Broad:** allow rich text for any `Plain`. Simplest; but only correct + if every `Plain` edit target round-trips safely and the measured-edit box + renders acceptably in those contexts (table cells especially). + - **(b) Narrow:** allow rich text for `Plain` only when the resolved edit + target sits inside a list item. Requires threading list-context into the + availability predicate (the resolver / breadcrumb already knows the + ancestor chain — see `nestingNav.ts`, `outerBlocks.ts`). + → Determine which `Plain` contexts are even *reachable* as edit targets in + q2-preview today. If only list items are reachable, (a) and (b) converge and + we take (a). + +3. **Multi-block tight items.** A tight list item can be `Plain` + nested + `BulletList` (a tight item with a sublist). The edit target for the leading + `Plain` should cover only that `Plain`'s range, leaving the sublist intact. + Confirm the leading-`Plain` edit target does not swallow the sublist on + commit. → **Test:** nested tight list, edit the parent item text, assert the + sublist survives unchanged. + +4. **Breadcrumb / measured-edit parity.** The breadcrumb and the + measure-and-set edit box already special-case `Plain` (`outerBlocks.ts:333`, + `403`, the `Pl` glyph in `nestingNav.ts`). Adding rich-text for `Plain` + should reuse the existing `leaf-text` category (`categoryForSourceNode` + already groups `Plain` with `Para`/`Header`), so the breadcrumb should need + no change — verify, don't assume. + +## Plan (TDD — tests first, per project policy) + +### Phase 0 — Reproduce & characterize (no code change yet) +- [x] **Characterize Q2's tight/loose list AST with `pampa`.** Ran qmd variants + through `cargo run -p pampa --bin pampa -- -t json`. Findings: + - Tight bullet/ordered → items are `Plain`; loose (blank line between + items) → items are `Para`. Matches the standard model. + - Nested tight: first item = `[Plain, BulletList]`; the leading `Plain` + has its own source range distinct from the sublist, so an edit target + scopes to just that `Plain` (won't swallow the sublist). + - **Table cells → `Plain`.** Under broad scope, table-cell content becomes + rich-editable too — include in corpus + verify. + - Def lists: `Term\n: def` did **not** parse as a `DefinitionList` in Q2 + (came out as two `Para`s), so def-list `Plain` is not a live edit-target + context here. Not a concern. +- [x] Reachable `Plain` contexts: **tight list items (bullet/ordered, nested)** + and **table cells**. Broad scope covers both. +- [x] **Backend tightness guarantee already exists AND is tested.** + `preserve_leaf_variant` (`apply_node_edit.rs:253`) coerces a single + re-parsed `Paragraph` back to `Plain` on the text channel. Covered by + `text_edit_preserves_bullet_list_tightness` (node_edit_tests.rs:1308) and + `text_edit_preserves_ordered_list_tightness` (1347), which drive + `edit_nested_block` — the *exact* text-channel path `commitTextEdit` uses. + **Conclusion:** no Rust changes needed; risk #1 is already closed. The + fix is TS-gate-only. +- [ ] Live browser "before" is superseded by the failing gate test (Phase 1) as + the bug evidence; live confirmation folded into Phase 3 end-to-end. + +### Phase 1 — Tests first (TDD red → green) +- [x] **Gate unit test** — `richTextSupport.test.ts` (new; the module had no + direct test). Locks the membership set + flag/mode interactions. The + `Plain` assertions **failed before the fix** (3 red) and pass after. This + is the direct encoding of the bug fix. +- [x] **Dispatcher integration test** — `plain-list-item-richtext.integration.test.tsx` + (new). Drives the real `PreviewRoot`; clicking a tight bullet-list item + opens the **rich** editor (`.q2-rt-toolbar` present, no textarea) with the + flag on, and the **textarea** with the flag off. **Confirmed it fails with + the fix reverted** (toolbar null) → true regression guard. +- [x] **Converter seed test** — `richtext/plainSeed.test.ts` (new, pure/no + pampa). Guards the exact `astToDoc([Plain], …)` seed the RichTextEditor + uses: a lone `Plain` → one paragraph → serializes to the bare inline text + (marks incl. `**bold**`/`_italic_`), no dropped nodes, no list marker. +- [~] Tightness-on-commit is **already** covered by the Rust suite + (`text_edit_preserves_{bullet,ordered}_list_tightness`, Phase 0). The rich + editor commits through that same text channel, so no new Rust test is + needed; Phase 3 confirms it live. + +### Phase 2 — Implement the gate change (BROAD) +- [x] Added `'Plain'` to `RICHTEXT_SUPPORTED_TYPES` in `richTextSupport.ts` and + documented why (tight-list items + table cells; backend preserves + tightness). No dispatcher change needed — the existing predicate + the + already-Plain-aware `astToProseMirror` do the rest. +- [x] Phase 1 tests green with the fix in place. + +### Phase 3 — End-to-end verification (mandatory, per CLAUDE.md) +Built the real chain (`cargo xtask build-q2-preview-spa` + `cargo build --bin q2`; +no WASM/Rust change so no WASM rebuild needed) and drove `q2 preview --allow-edit` +in a browser on a tight-list fixture. + +- [x] **Rich editor opens for a tight-list `Plain` item.** Clicking "banana" + opened the tiptap editor — ProseMirror `

banana

`, the formatting + toolbar (B/I/S/sub/sup/link), the "Editing… rich text / plain text" + toggle, and the `Pl` breadcrumb glyph. Before the fix this opened the + monospaced textarea. **This is the core deliverable and it works.** +- [x] **Simple text edit round-trips and preserves tightness.** Edited + "banana" → "banana XX" and committed; on-disk file kept a **tight** list + (`pampa -t json` → all three items `Plain`). Instrumented commit confirmed + `sourceNodeType: "Plain"`, committed markdown `"banana XX"`. +- [x] Suites green after the change (unit 484, integration 517, tsc clean; the + pampa-oracle round-trip 15/15). No regressions in the existing Para/Header + rich-text tests. +- [x] **`cargo xtask verify` (Rust+hub) still owed** before push (TS-only change, + but run it per policy). + +#### ⚠️ Bug found during E2E — filed as **bd-hafs0qho** (not caused by this change) +Committing a **bold** edit (select-all + bold + commit) could **drop the item's +content** — e.g. `- banana` → `-` (empty). Instrumented capture showed the +committed ProseMirror doc was `paragraph[hardBreak]` (text gone), so the +serializer correctly produced an empty string. + +Root cause is **upstream** of the serializer and the Rust commit path, both of +which I verified correct: +- serializer: `paragraph[strong "x"]` → `**x**`, `paragraph[strong "x", hardBreak]` + → `**x**` (deterministic tests); +- pampa `apply_node_edit`: replacing a tight-list `Plain` with `**BOLD**` → + `* **BOLD**`, tight preserved (node_edit_tests.rs repro). + +It is **not `Plain`-specific**: the RichTextEditor, `astToProseMirror`, and the +serializer are type-agnostic (Para and Plain both map to a `paragraph` node and +share seed/edit/serialize/commit). A synthetic repro on a **Para** also injected +a spurious `hardBreak`, so the behavior is shared with the pre-existing Para path +(bd-sjb4pzx8) and predates this change. My browser repros were partly confounded +(couldn't drive tiptap's internal selection from outside), so bd-hafs0qho needs a +clean real-user reproduction + root-cause of the spurious hardBreak/text-drop. + +**Decision point for the user:** the Plain-gate change is safe and delivers the +feature for text editing, and the bold anomaly is pre-existing (affects Para +equally). Options: (a) merge this change now and fix bd-hafs0qho separately, or +(b) hold this change until bd-hafs0qho is root-caused. Awaiting direction. + +### Phase 4 — Wrap up +- [ ] Record the exact invocation + inspected output in this plan and the + strand, per the end-to-end-verification policy. +- [ ] Update `hub-client/changelog.md` if any `hub-client/` file changed + (the two-commit workflow). +- [ ] Close bd-7pxub583 with a reason once all tests pass and the feature is + verified end-to-end. + +## Files in scope + +- `ts-packages/preview-renderer/src/q2-preview/richTextSupport.ts` — the gate + (primary change). +- `ts-packages/preview-renderer/src/q2-preview/richtext/roundtrip.test.ts` — + new fixtures/assertions. +- `ts-packages/preview-renderer/src/q2-preview/richtext/astToProseMirror.ts` / + `serializer.ts` — likely **no change** (already handle `Plain`), but the + serializer is where a tightness-preservation fix would land if risk #1 bites. +- Possibly `dispatchers.tsx` — only if the narrow (context-aware) option is + chosen and the predicate needs more than the node type. + +## Non-goals + +- Structural list editing (Enter to split items, Tab to nest) — that is + bd-sjb4pzx8's Phase 1c and is out of scope here. This strand is specifically + about making the *content* of an existing tight-list item rich-text editable. +- Table-cell / definition-list rich editing beyond whatever falls out of the + broad-vs-narrow decision. diff --git a/ts-packages/preview-renderer/src/q2-preview/plain-list-item-richtext.integration.test.tsx b/ts-packages/preview-renderer/src/q2-preview/plain-list-item-richtext.integration.test.tsx new file mode 100644 index 000000000..e2ca4b309 --- /dev/null +++ b/ts-packages/preview-renderer/src/q2-preview/plain-list-item-richtext.integration.test.tsx @@ -0,0 +1,142 @@ +/** + * bd-7pxub583 — a tight-list item (a `Plain` block) opens the RICH-TEXT editor. + * + * Tight bullet/ordered lists store each item's content as a `Plain` block. Before + * this change `RICHTEXT_SUPPORTED_TYPES` was {Para, Header}, so clicking a + * tight-list item resolved a `Plain` sourceNode, `richTextAvailable` returned + * false, and the block fell back to the monospaced textarea. This drives the REAL + * PreviewRoot (so the dispatcher makes the real surface choice and the tiptap + * editor mounts in jsdom) and asserts the rich-text toolbar is present for a + * clicked tight-list item — the observable signal that the rich editor, not the + * textarea, is the active surface. + * + * Mirrors the harness in p3-4-inline-breadcrumb.integration.test.tsx. + */ + +// @vitest-environment jsdom + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, cleanup, act, fireEvent } from '@testing-library/react'; +import { PreviewRoot } from './PreviewRoot'; +import type { PreviewRootProps } from './PreviewRoot'; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +/* ─── PointerEvent helper (verbatim from p3-4-inline-breadcrumb) ────────────── */ +function ptrEvent( + type: string, + opts: PointerEventInit & { clientX?: number; clientY?: number } = {}, +): Event { + const PE = (window as any).PointerEvent ?? Event; + const evt = new PE(type, { bubbles: true, cancelable: true, ...opts }); + for (const [key, val] of Object.entries({ + ...(opts.pointerType !== undefined ? { pointerType: opts.pointerType } : {}), + } as Record)) { + Object.defineProperty(evt, key, { value: val, configurable: true }); + } + return evt; +} + +/* ─── Fixture: a tight 2-item bullet list (items are Plain) ─────────────────── */ +// content bytes: "- apple\n- banana\n" +// "apple" → r=[2, 7] "banana" → r=[10, 16] BulletList → r=[0, 17] +const CONTENT = '- apple\n- banana\n'; +const POOL = [ + { t: 0, r: [2, 7], d: 0 }, // pool[0] Plain "apple" (borrowed onto
  • ) + { t: 0, r: [10, 16], d: 0 }, // pool[1] Plain "banana" + { t: 0, r: [0, 17], d: 0 }, // pool[2] BulletList +]; + +function makeAstJson(): string { + return JSON.stringify({ + 'pandoc-api-version': [1, 23, 0], + meta: {}, + blocks: [ + { + t: 'BulletList', + s: 2, + c: [ + [{ t: 'Plain', s: 0, c: [{ t: 'Str', c: 'apple' }] }], + [{ t: 'Plain', s: 1, c: [{ t: 'Str', c: 'banana' }] }], + ], + }, + ], + astContext: { p: POOL }, + }); +} + +function mountFixture(opts: { richText?: boolean } = {}) { + const astJson = makeAstJson(); + const props: PreviewRootProps = { + astJson, + untransformedAstJson: astJson, + renderedContent: CONTENT, + currentFilePath: '/test.qmd', + assetManifest: {}, + setAst: vi.fn(), + // Unlocked so a click resolves to the innermost item (the Plain), rather + // than climbing to the whole
      . + unlockNestingCursor: true, + richText: opts.richText, + onNavigateToDocument: () => {}, + }; + return render(); +} + +function mockTileRects(container: HTMLElement) { + container.querySelectorAll('[data-block-pool-id]').forEach((tile) => { + const pid = Number(tile.getAttribute('data-block-pool-id')); + vi.spyOn(tile, 'getBoundingClientRect').mockReturnValue({ + left: 0, top: pid * 80, right: 300, bottom: pid * 80 + 60, + width: 300, height: 60, x: 0, y: pid * 80, toJSON: () => ({}), + } as DOMRect); + }); +} + +async function openEditor(container: HTMLElement, poolId: string) { + const el = container.querySelector(`[data-block-pool-id="${poolId}"]`)!; + await act(async () => { + fireEvent(el, ptrEvent('pointerdown', { pointerType: 'mouse' })); + fireEvent(el, ptrEvent('pointerup', { pointerType: 'mouse' })); + }); +} + +const toolbar = (c: HTMLElement) => c.querySelector('.q2-rt-toolbar'); +const textarea = (c: HTMLElement) => c.querySelector('textarea'); + +describe('bd-7pxub583 — tight-list item (Plain) opens the rich-text editor', () => { + it('opens the RICH editor (toolbar present) when clicking a tight-list item with richText on', async () => { + const { container } = mountFixture({ richText: true }); + await act(async () => {}); + mockTileRects(container); + + // The
    • borrows the leading Plain's pool-id (=0); clicking it targets + // the Plain block. + await openEditor(container, '0'); + + expect( + toolbar(container), + 'rich-text toolbar must render for a Plain tight-list item in rich mode', + ).not.toBeNull(); + // And it is the rich surface, not the fallback textarea. + expect(textarea(container), 'no monospaced textarea when the rich editor is active').toBeNull(); + }); + + it('falls back to the textarea (no toolbar) for the same item when richText is off', async () => { + const { container } = mountFixture({ richText: false }); + await act(async () => {}); + mockTileRects(container); + + await openEditor(container, '0'); + + expect(toolbar(container), 'no rich toolbar when the flag is off').toBeNull(); + expect( + textarea(container), + 'the textarea is the edit surface when richText is off', + ).not.toBeNull(); + }); +}); diff --git a/ts-packages/preview-renderer/src/q2-preview/richTextSupport.test.ts b/ts-packages/preview-renderer/src/q2-preview/richTextSupport.test.ts new file mode 100644 index 000000000..d43a12858 --- /dev/null +++ b/ts-packages/preview-renderer/src/q2-preview/richTextSupport.test.ts @@ -0,0 +1,74 @@ +// Unit tests for the rich-text availability gate (bd-7pxub583). +// +// `richTextSupport.ts` is the single source of truth for "which block types the +// rich-text editor handles". It had no direct test before; these lock the +// membership set and the flag/mode interactions. The load-bearing case for +// bd-7pxub583 is `Plain` — tight bullet/ordered list items are stored as `Plain` +// blocks, and omitting `Plain` from the set left their content non-rich-editable. + +import { describe, it, expect } from 'vitest'; +import { + RICHTEXT_SUPPORTED_TYPES, + richTextAvailable, + richEditorActiveForType, +} from './richTextSupport'; +import type { PreviewContextValue } from './PreviewContext'; + +/** Minimal ctx — the predicates only read `richText` and `editorMode`. */ +function ctx(over: Partial): PreviewContextValue { + return over as PreviewContextValue; +} + +describe('RICHTEXT_SUPPORTED_TYPES', () => { + it('includes the leaf-text block types the editor can seed', () => { + // Para + Header were the original set; Plain (tight list items, table cells) + // is the bd-7pxub583 addition. + expect(RICHTEXT_SUPPORTED_TYPES.has('Para')).toBe(true); + expect(RICHTEXT_SUPPORTED_TYPES.has('Header')).toBe(true); + expect(RICHTEXT_SUPPORTED_TYPES.has('Plain')).toBe(true); + }); + + it('excludes container / non-leaf-text block types', () => { + for (const t of ['BulletList', 'OrderedList', 'CodeBlock', 'BlockQuote', 'Div', 'Table']) { + expect(RICHTEXT_SUPPORTED_TYPES.has(t)).toBe(false); + } + }); +}); + +describe('richTextAvailable', () => { + it('is true for a supported type when the flag is on', () => { + expect(richTextAvailable(ctx({ richText: true }), 'Para')).toBe(true); + expect(richTextAvailable(ctx({ richText: true }), 'Header')).toBe(true); + // The bug: a tight-list item resolves a `Plain` sourceNode and must be + // rich-editable. + expect(richTextAvailable(ctx({ richText: true }), 'Plain')).toBe(true); + }); + + it('is false for an unsupported type even when the flag is on', () => { + expect(richTextAvailable(ctx({ richText: true }), 'CodeBlock')).toBe(false); + expect(richTextAvailable(ctx({ richText: true }), 'BulletList')).toBe(false); + }); + + it('is false whenever the flag is off', () => { + expect(richTextAvailable(ctx({ richText: false }), 'Plain')).toBe(false); + expect(richTextAvailable(ctx({ richText: false }), 'Para')).toBe(false); + expect(richTextAvailable(ctx({}), 'Plain')).toBe(false); + }); +}); + +describe('richEditorActiveForType', () => { + it('is true for a Plain target with the flag on and mode rich (the default)', () => { + expect(richEditorActiveForType(ctx({ richText: true }), 'Plain')).toBe(true); + expect(richEditorActiveForType(ctx({ richText: true, editorMode: 'rich' }), 'Plain')).toBe(true); + }); + + it('is false for a Plain target when the user toggled to plain mode', () => { + expect(richEditorActiveForType(ctx({ richText: true, editorMode: 'plain' }), 'Plain')).toBe( + false, + ); + }); + + it('is false for a Plain target when the flag is off', () => { + expect(richEditorActiveForType(ctx({ richText: false }), 'Plain')).toBe(false); + }); +}); diff --git a/ts-packages/preview-renderer/src/q2-preview/richTextSupport.ts b/ts-packages/preview-renderer/src/q2-preview/richTextSupport.ts index 562dd1874..8f3ad709a 100644 --- a/ts-packages/preview-renderer/src/q2-preview/richTextSupport.ts +++ b/ts-packages/preview-renderer/src/q2-preview/richTextSupport.ts @@ -12,9 +12,17 @@ import type { PreviewContextValue } from './PreviewContext'; /** * 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.) + * textarea even when `richText` is on. 1a: Para. 1b: + Header. + * + * `Plain` (bd-7pxub583): a tight bullet/ordered list stores each item's content + * as a `Plain` block (loose lists use `Para`); table cells are also `Plain`. + * `Plain` seeds and round-trips exactly like `Para` — `astToProseMirror` maps + * both to a paragraph node, and the text-commit channel's `preserve_leaf_variant` + * (pampa `apply_node_edit.rs`) coerces the re-parsed `Paragraph` back to `Plain` + * so editing a tight-list item does not silently loosen the list. Adding it here + * makes tight-list-item and table-cell content rich-editable. */ -export const RICHTEXT_SUPPORTED_TYPES = new Set(['Para', 'Header']); +export const RICHTEXT_SUPPORTED_TYPES = new Set(['Para', 'Header', 'Plain']); /** True when the rich editor is available for this block (flag on + supported type). */ export function richTextAvailable(ctx: PreviewContextValue, sourceNodeType: string): boolean { diff --git a/ts-packages/preview-renderer/src/q2-preview/richtext/plainSeed.test.ts b/ts-packages/preview-renderer/src/q2-preview/richtext/plainSeed.test.ts new file mode 100644 index 000000000..168250221 --- /dev/null +++ b/ts-packages/preview-renderer/src/q2-preview/richtext/plainSeed.test.ts @@ -0,0 +1,54 @@ +// bd-7pxub583 — the single-`Plain` seed path used by RichTextEditor. +// +// When the rich editor opens on a tight-list item (or a table cell), its +// `resolved.sourceNode` is a `Plain` block, and it seeds the editor with +// `astToDoc([Plain], ...)` (RichTextEditor.tsx). This guards that exact call: +// a lone `Plain` maps to a single paragraph node and serializes back to the bare +// inline text — no wrapper block, no dropped inlines, no list markers. Tightness +// itself is preserved by the backend on commit (pampa `preserve_leaf_variant`, +// covered by node_edit_tests.rs); here we only assert the TS seed→serialize. +// +// Pure (no pampa oracle) so it runs in the default `vitest run` suite. + +import { describe, it, expect } from 'vitest'; +import { astToDoc } from './astToProseMirror'; +import { docToMarkdown } from './serializer'; +import type { AstNode } from './ast'; + +const STR = (c: string): AstNode => ({ t: 'Str', c } as unknown as AstNode); +const SPACE: AstNode = { t: 'Space' } as unknown as AstNode; +const STRONG = (...c: AstNode[]): AstNode => ({ t: 'Strong', c } as unknown as AstNode); +const EMPH = (...c: AstNode[]): AstNode => ({ t: 'Emph', c } as unknown as AstNode); +const PLAIN = (...c: AstNode[]): AstNode => ({ t: 'Plain', c } as unknown as AstNode); + +describe('bd-7pxub583 — astToDoc seeds a lone Plain as one paragraph', () => { + it('maps a plain-text Plain to a single paragraph with no unknown nodes', () => { + const { doc, unknown } = astToDoc([PLAIN(STR('apple'))], [], '- apple\n'); + expect(unknown).toEqual([]); + expect(doc.childCount).toBe(1); + expect(doc.firstChild?.type.name).toBe('paragraph'); + expect(doc.textContent).toBe('apple'); + }); + + it('serializes the seeded paragraph back to the bare inline text (no marker/block)', () => { + const { doc } = astToDoc([PLAIN(STR('apple'))], [], '- apple\n'); + const md = docToMarkdown(doc); + // No leading "- ", no wrapping — just the item's inline content. + expect(md.trim()).toBe('apple'); + }); + + it('round-trips inline marks inside a tight-list item', () => { + // "some **bold** and _italic_ text" + const plain = PLAIN( + STR('some'), SPACE, STRONG(STR('bold')), SPACE, STR('and'), SPACE, + EMPH(STR('italic')), SPACE, STR('text'), + ); + const { doc, unknown } = astToDoc([plain], [], ''); + expect(unknown).toEqual([]); + const md = docToMarkdown(doc).trim(); + expect(md).toContain('**bold**'); + // qmd italic serializes with `_` (serializer.ts), not `*`. + expect(md).toContain('_italic_'); + expect(md).toBe('some **bold** and _italic_ text'); + }); +});