|
| 1 | +# Preview rich-text editor: open with drag-selection preserved |
| 2 | + |
| 3 | +**Strand:** bd-abo9m23f |
| 4 | +**Builds on:** bd-q9lyghv2 (caret-at-click), whose design comments live in |
| 5 | +`ts-packages/preview-renderer/src/q2-preview/richtext/caretFromClick.ts` and |
| 6 | +`RichTextEditor.tsx`. |
| 7 | + |
| 8 | +## Overview |
| 9 | + |
| 10 | +In `q2 preview --allow-edit` (and `format: q2-preview` in hub-client), clicking |
| 11 | +a block opens the rich-text editor with the caret placed at the click point |
| 12 | +(bd-q9lyghv2). But when the user *drags* a selection inside a single block, the |
| 13 | +`pointerup` at the end of the drag activates the editor with only a **caret at |
| 14 | +the release point** — the selection the user just made is discarded. Since the |
| 15 | +rich-text toolbar (bold, italic, link, …) operates on the selection, this |
| 16 | +forces a re-select inside the freshly opened editor. |
| 17 | + |
| 18 | +**Goal:** when the DOM selection at activation time is non-collapsed and fully |
| 19 | +contained in the block being activated, open the rich-text editor with an |
| 20 | +equivalent editor selection (anchor and head mapped from the DOM selection, |
| 21 | +direction preserved). |
| 22 | + |
| 23 | +**Also in scope** (decided 2026-07-07, was open question 1): when the |
| 24 | +selection at a mouse activation is non-collapsed but NOT contained in the |
| 25 | +activated block (cross-block drag), **suppress activation entirely** — today |
| 26 | +the release block opens with a caret and the DOM swap destroys the user's |
| 27 | +selection, which breaks plain copy gestures. Mouse-open paths only; keyboard |
| 28 | +and touch activation are untouched. (The user has separate follow-ups planned |
| 29 | +around rich-text activation mechanisms; this stands on its own.) |
| 30 | + |
| 31 | +**Out of scope** (current behavior stays): |
| 32 | +- The plain-text `EditTextarea` surface → caret behavior as today (possible |
| 33 | + follow-up: map to `selectionStart`/`selectionEnd`, but it needs a |
| 34 | + coords→textarea-offset mapping that doesn't exist; not worth it now). |
| 35 | +- Keyboard/touch activation → end-of-block, as today. |
| 36 | + |
| 37 | +## Reproduction (2026-07-07) |
| 38 | + |
| 39 | +Fixture: a `.qmd` with a few paragraphs/lists (any multi-word paragraph works). |
| 40 | + |
| 41 | +``` |
| 42 | +cargo run --bin q2 -- preview repro.qmd --allow-edit --no-browser |
| 43 | +# → http://127.0.0.1:61115/?page=repro.qmd |
| 44 | +``` |
| 45 | + |
| 46 | +Scripted repro against the live preview (chrome-devtools MCP; CDP's `drag` is |
| 47 | +an HTML5-DnD gesture and never builds a text selection, so the drag end-state |
| 48 | +was synthesized — a real `Range` over "quick brown fox jumps over the lazy" |
| 49 | +set as the window selection, then a `pointerup` dispatched at the coordinates |
| 50 | +of the end of "lazy", exactly the state a completed mouse drag leaves): |
| 51 | + |
| 52 | +- **Before `pointerup`:** `getSelection().toString()` = `"quick brown fox |
| 53 | + jumps over the lazy"`, fully inside the paragraph (`data-block-pool-id="5"`). |
| 54 | +- **After `pointerup`:** rich-text editor open and focused; selection is a |
| 55 | + collapsed `Caret` with context `…fox jumps over the lazy‸ dog, and then…` — |
| 56 | + i.e. caret at the release point, drag selection lost. (Screenshot inspected: |
| 57 | + caret visible between "lazy" and "dog", no highlight.) |
| 58 | + |
| 59 | +Feasibility probes (same session): |
| 60 | +1. The native selection **is intact and readable at `pointerup` time** in the |
| 61 | + capture phase (verified with capture-phase listeners). |
| 62 | +2. Collapsed clone-ranges at the selection endpoints give usable viewport |
| 63 | + rects (`range.cloneRange(); collapse(); getClientRects()[0]`). |
| 64 | +3. **Geometric stability across the swap:** after the editor mounted, the |
| 65 | + pre-swap endpoint coordinates resolved (via `caretRangeFromPoint`) to |
| 66 | + exactly `|quick` and `lazy|` **inside the ProseMirror editor** — the same |
| 67 | + invariant caret-at-click relies on (same text, same measured box, same |
| 68 | + theme CSS). |
| 69 | + |
| 70 | +## Design |
| 71 | + |
| 72 | +Extend the existing coordinate bridge from one point to an anchor/head pair. |
| 73 | +Same architecture as bd-q9lyghv2 — no new mechanism, just a richer payload: |
| 74 | + |
| 75 | +``` |
| 76 | +useBlockEditHover.activate() RichTextEditor mount effect |
| 77 | + capture selection endpoint coords → pendingClickCoordsRef → posAtCoords ×2 |
| 78 | + (viewport space, direction-aware) (read-once + clear) TextSelection(anchor, head) |
| 79 | +``` |
| 80 | + |
| 81 | +### 1. Payload type (PreviewContext.tsx, PreviewRoot.tsx) |
| 82 | + |
| 83 | +Rename/retype `pendingClickCoordsRef` payload from `{x, y} | null` to: |
| 84 | + |
| 85 | +```ts |
| 86 | +type PendingOpenSelection = |
| 87 | + | { kind: 'caret'; head: { x: number; y: number } } |
| 88 | + | { kind: 'range'; anchor: { x: number; y: number }; head: { x: number; y: number } } |
| 89 | + | null; |
| 90 | +``` |
| 91 | + |
| 92 | +Read-once-and-clear semantics unchanged (self-heal remounts still fall back to |
| 93 | +end-of-block; after a reflow *both* endpoints are stale, so the same reasoning |
| 94 | +applies a fortiori). |
| 95 | + |
| 96 | +### 2. Capture (useBlockEditHover.tsx, inside `activate()`) |
| 97 | + |
| 98 | +`activate()` is the single open chokepoint and already resolves `outerBlock`, |
| 99 | +so containment is checked there (not in `onPointerUp`, which doesn't know the |
| 100 | +resolved block). When `opts.clickCoords` is present (mouse open): |
| 101 | + |
| 102 | +1. `const sel = outerBlock.ownerDocument.defaultView.getSelection()` — |
| 103 | + `ownerDocument`-relative, not bare `window`, since the preview runs inside |
| 104 | + an iframe. |
| 105 | +2. If `sel` is non-collapsed, `rangeCount > 0`, and **both** the range's |
| 106 | + `startContainer` and `endContainer` are inside `outerBlock` → compute |
| 107 | + viewport coords of the **anchor** and **focus** points (selection API, not |
| 108 | + range start/end — preserves drag direction for backward drags) via |
| 109 | + collapsed clone-ranges + `getClientRects()[0]`, and stash |
| 110 | + `{ kind: 'range', anchor, head }`. |
| 111 | +3. If `sel` is non-collapsed but NOT contained in `outerBlock` (cross-block |
| 112 | + drag) → **abort the activation**: return from `activate()` before any side |
| 113 | + effect (before `cancelPendingLand`/`captureGeometry`/draft seeding), leaving |
| 114 | + the user's selection intact for copying. A plain click can't false-trigger |
| 115 | + this: the browser collapses/clears the selection on `mousedown`, so by |
| 116 | + `pointerup` a non-drag click always sees a collapsed selection. |
| 117 | +4. Otherwise (collapsed / no rects / `rangeCount === 0`) → stash |
| 118 | + `{ kind: 'caret', head: opts.clickCoords }` — exactly today's behavior. |
| 119 | + |
| 120 | +Note the degenerate case: a drag contained in block A but *released* over |
| 121 | +block B classifies as cross-block relative to B (the activated block) → |
| 122 | +suppressed, selection preserved. Consistent with decision 2 below (we don't |
| 123 | +hunt for the selection's home block). |
| 124 | + |
| 125 | +The capture is a pure helper (own module, e.g. `dragSelectionCapture.ts`) so |
| 126 | +it unit-tests without a real layout — a three-way classification: |
| 127 | +`classifyOpenSelection(outerBlock, clickCoords): PendingOpenSelection | |
| 128 | +'suppress'` (where `'suppress'` makes `activate()` return early; only for |
| 129 | +mouse opens — keyboard/touch never reach this code since they carry no |
| 130 | +`clickCoords`). |
| 131 | + |
| 132 | +Containment against `outerBlock` means the check is automatically mode-aware |
| 133 | +(leaf in unlock-nesting mode, outer block in locked mode) — it tests the |
| 134 | +element actually being activated. |
| 135 | + |
| 136 | +### 3. Replay (caretFromClick.ts + RichTextEditor.tsx mount effect) |
| 137 | + |
| 138 | +New sibling to `placeCaretFromClick`: |
| 139 | + |
| 140 | +```ts |
| 141 | +placeSelectionFromDrag(editor, anchor, head): boolean |
| 142 | + // posAtCoords both endpoints; if BOTH hit: |
| 143 | + // TextSelection.create(doc, anchorHit.pos, headHit.pos) — direction preserved |
| 144 | + // dispatch + focus; return true |
| 145 | + // else return false |
| 146 | +``` |
| 147 | + |
| 148 | +Note: tiptap's `setTextSelection({from, to})` normalizes/clamps and does not |
| 149 | +document direction preservation — use ProseMirror directly |
| 150 | +(`view.dispatch(state.tr.setSelection(TextSelection.create(...)))` + |
| 151 | +`editor.commands.focus()`), matching the file's existing "single source of |
| 152 | +truth" stance. |
| 153 | + |
| 154 | +Mount-effect fallback chain (inside the existing rAF): |
| 155 | + |
| 156 | +1. `kind: 'range'` → `placeSelectionFromDrag`; on miss ↓ |
| 157 | +2. head coords → `placeCaretFromClick` (caret at release point, today's |
| 158 | + behavior); on miss ↓ |
| 159 | +3. `editor.commands.focus('end')` (historical default). |
| 160 | + |
| 161 | +### 4. Edge cases |
| 162 | + |
| 163 | +| Case | Behavior | |
| 164 | +| --- | --- | |
| 165 | +| Backward drag (right→left) | anchor/focus coords keep direction; head = release point, so shift-arrow keeps extending naturally | |
| 166 | +| Multi-line drag within block | endpoints on different lines — two independent `posAtCoords`, fine | |
| 167 | +| Selection exactly = whole block (triple-click) | contained → range replay; if the browser range leaks outside the block element, containment fails → suppressed (selection preserved). Note triple-click on an *unopened* block never reaches this path anyway: click #1's `pointerup` already opened the editor, clicks #2/#3 hit the active-region guard and ProseMirror handles word/para select natively | |
| 168 | +| Cross-block drag (selection spans blocks) | activation suppressed, selection preserved for copying (decision 1, 2026-07-07) | |
| 169 | +| Drag contained in block A, released over block B | classifies cross-block relative to B → suppressed, selection preserved | |
| 170 | +| Drag across styled inlines (bold/em) | coords land on glyphs; `posAtCoords` doesn't care about inline structure | |
| 171 | +| Double-click word-select | first click's `pointerup` already opened the editor, so the second click hits the active-region guard and ProseMirror handles word-select natively — not this path | |
| 172 | +| Drag ends outside any block | `findEditTarget` returns null → no activation (unchanged) | |
| 173 | +| Click-switch (editor A open, drag in block B) | flows through the same `activate()` chokepoint → works | |
| 174 | +| Self-heal remount | ref already cleared → end-of-block (unchanged, per read-once) | |
| 175 | +| `richTextAvailable` false → textarea surface | textarea ignores the ref (today it already ignores coords); unchanged | |
| 176 | + |
| 177 | +### Decisions (2026-07-07, user) |
| 178 | + |
| 179 | +1. **Cross-block drags: suppress activation** (included in this work, § 2 |
| 180 | + above). Today the release block activates and the DOM swap destroys the |
| 181 | + selection — bad for plain copy gestures. The user has separate follow-ups |
| 182 | + planned around rich-text activation mechanisms; this improvement stands on |
| 183 | + its own. *(Within-block copy-drags are incidentally fixed too: the |
| 184 | + selection survives into the editor.)* |
| 185 | +2. **Drag releasing outside every block: leave as-is** (nothing activates). |
| 186 | + The dominant use cases are short "drag, then bold / link" selections; |
| 187 | + resolving the target from the selection's ancestor is corner-casey, |
| 188 | + brittle, and too early in the feature's life. |
| 189 | + |
| 190 | +## Work items (TDD ordering) |
| 191 | + |
| 192 | +### Phase 0 — tests first |
| 193 | + |
| 194 | +- [x] Unit: `dragSelectionCapture.test.ts` — mocked selection/ranges: |
| 195 | + contained → `{kind:'range'}` with direction-aware anchor/head; cross-block → |
| 196 | + `'suppress'`; collapsed → caret; `rangeCount === 0` → caret; empty |
| 197 | + `getClientRects()` → caret; invalid endpoint offset → caret. |
| 198 | +- [x] Unit: extend `caretFromClick.test.ts` — `placeSelectionFromDrag` against |
| 199 | + a REAL EditorState (richTextSchema): both-hit → selection dispatched with |
| 200 | + direction preserved + focus, `true`; either-miss → nothing moved, `false`; |
| 201 | + degenerate same-pos → `false`. |
| 202 | +- [x] Integration: `RichTextEditor.caret.integration.test.tsx` — mount with a |
| 203 | + `range` payload → `placeSelectionFromDrag` called with both coord pairs; |
| 204 | + range-miss → caret fallback at head; ref read-and-cleared; `caret` payload |
| 205 | + keeps existing path (regression). |
| 206 | +- [x] Integration: `useBlockEditHover.caret-coords.integration.test.tsx` — |
| 207 | + mouse `pointerup` with stubbed contained selection stashes a `range` |
| 208 | + payload; with cross-block selection `setEditTarget` is NOT called |
| 209 | + (suppression); keyboard activation with a live selection still opens |
| 210 | + (suppression is mouse-only); keyboard/touch stash nothing (regression). |
| 211 | +- [x] Run new tests, verify they fail (2026-07-07: 11 fail for missing |
| 212 | + module/export + old ref name; pre-existing caret tests still pass). |
| 213 | + Note: integration tests run via `npm run test:integration` |
| 214 | + (`vitest.integration.config.ts`) — the default config excludes them. |
| 215 | + |
| 216 | +### Phase 1 — implementation |
| 217 | + |
| 218 | +- [x] `PreviewContext.tsx` / `PreviewRoot.tsx`: ref renamed |
| 219 | + `pendingClickCoordsRef` → `pendingOpenSelectionRef`, union payload |
| 220 | + (`PendingOpenSelection`), doc comments updated. |
| 221 | +- [x] New `dragSelectionCapture.ts` pure helper (`classifyOpenSelection` + |
| 222 | + the `PendingOpenSelection` type; endpoint coords via collapsed clone-range |
| 223 | + rects, `ownerDocument`-relative selection lookup for iframe safety). |
| 224 | +- [x] `useBlockEditHover.tsx` `activate()`: classify after the dedup guard and |
| 225 | + before any side effect; `'suppress'` returns early; stash payload at the |
| 226 | + single chokepoint. |
| 227 | +- [x] `caretFromClick.ts`: `placeSelectionFromDrag` via |
| 228 | + `TextSelection.between(resolve(anchor), resolve(head))` (direction |
| 229 | + preserved, endpoints nudged to valid text positions; empty ⇒ false so the |
| 230 | + caret fallback owns collapsed cases). |
| 231 | +- [x] `RichTextEditor.tsx` mount effect: fallback chain range → caret → end. |
| 232 | +- [x] preview-renderer suites green (2026-07-07: 508 unit + 533 integration |
| 233 | + passed, 0 failed). |
| 234 | +- [x] `npm run test:ci` (hub-client): unit + integration legs green; the |
| 235 | + `test:wasm` leg fails on the PRE-EXISTING `changelog.md renders |
| 236 | + successfully` test — the 2026-07-07 changelog entry's literal `~37MB` |
| 237 | + parses as an unclosed subscript (Q-2-17). Broken on main (commit |
| 238 | + `6cd4dd5a`), unrelated to this strand; an open PR already fixes it |
| 239 | + (bd-5px05kui filed and closed as duplicate). |
| 240 | + |
| 241 | +### Phase 2 — end-to-end verification |
| 242 | + |
| 243 | +- [x] Playwright e2e `q2-preview-spa/e2e/drag-selection-open.spec.ts` — real |
| 244 | + trusted mouse drags via `mouse.down/move/up` against the real `q2` binary |
| 245 | + (embedded SPA rebuilt first: `cargo xtask build-q2-preview-spa` + |
| 246 | + `cargo build -p quarto --bin q2`). Three tests, all passed 2026-07-07: |
| 247 | + forward drag (editor selection === selection recorded at `pointerup` in a |
| 248 | + capture-phase listener), backward drag (direction preserved), cross-block |
| 249 | + drag (no editor, selection intact). `npx playwright test |
| 250 | + drag-selection-open` → `3 passed (2.4s)`. |
| 251 | +- [x] Manual (2026-07-07): `./target/debug/q2 preview repro.qmd --allow-edit` |
| 252 | + (fresh binary), drove the preview at `http://127.0.0.1:62037/` via |
| 253 | + chrome-devtools MCP. With the DOM selection "quick brown fox jumps over the |
| 254 | + lazy" in the first paragraph, `pointerup` opened the rich-text editor with |
| 255 | + `selectionType: "Range"`, `selectionText: "quick brown fox jumps over the |
| 256 | + lazy"`, selection anchored inside `.ProseMirror` (screenshot inspected — |
| 257 | + highlight visible in the open editor). Clicking the toolbar **B** on the |
| 258 | + carried selection produced exactly |
| 259 | + `<strong>quick brown fox jumps over the lazy</strong>` — the |
| 260 | + selection-driven-toolbar fluidity this strand is for. |
| 261 | +- [x] Full `cargo xtask verify` (2026-07-07): Rust build + tests, ts-packages |
| 262 | + builds, hub-client `build:all` all green; hub-client tests green except the |
| 263 | + pre-existing changelog.md Q-2-17 failure noted above (fix in flight in an |
| 264 | + open PR, independent of this change). |
| 265 | + |
| 266 | +## Outcome |
| 267 | + |
| 268 | +Shipped as `4af08ef3` (feature) + `d116a917` (hub-client changelog entry) on |
| 269 | +branch `braid/bd-abo9m23f-drag-selection-richtext`, 2026-07-07, rebased onto |
| 270 | +main's `658026d0` (which fixed the pre-existing changelog Q-2-17 failure noted |
| 271 | +above). Strand bd-abo9m23f closed. |
| 272 | + |
| 273 | +## Notes |
| 274 | + |
| 275 | +- `posAtCoords` endpoint bias: the PoC resolved `|quick` / `lazy|` exactly at |
| 276 | + word boundaries, so no bias correction is planned. If e2e shows a |
| 277 | + one-position drift at line wraps, clamp/bias there — not speculatively. |
| 278 | +- Timing: selection is final by `pointerup` for drags (browsers build it |
| 279 | + during `pointermove`; verified readable in capture phase). The e2e test |
| 280 | + locks this in with trusted events. |
| 281 | +- jsdom can't do geometry (`posAtCoords`/`getClientRects` are null/empty) — |
| 282 | + unit tests mock the view/ranges, geometry truth lives in the e2e test. Same |
| 283 | + split bd-q9lyghv2 used. |
0 commit comments