Skip to content

Commit 30fbc5b

Browse files
cscheidclaude
andauthored
Preview rich-text editor: open with drag-selection preserved (bd-abo9m23f) (#383)
* Preview rich-text editor: open with drag-selection preserved (bd-abo9m23f) A mouse drag inside a single block now opens the rich-text editor with the dragged selection recreated (direction-aware, so a right-to-left drag keeps its focus at the release end), instead of a bare caret at the release point. This makes the selection-driven toolbar (bold, italic, link, ...) immediately usable after a drag. A drag whose selection spans multiple blocks no longer activates an editor at all: the previous behavior swapped the DOM under the release block and destroyed a selection the user may have made just to copy text. Mechanism: extends the bd-q9lyghv2 caret-at-click coordinate bridge from a single point to an anchor/head pair. `classifyOpenSelection` (new dragSelectionCapture.ts) classifies the DOM selection at the activate() chokepoint into caret / range / suppress; the payload rides the renamed `pendingOpenSelectionRef` (read-once at editor mount) and is replayed through `posAtCoords` x2 + `TextSelection.between` (`placeSelectionFromDrag`), falling back range -> caret -> end-of-block. Tests: 9 new capture unit tests, 5 new replay unit tests against a real EditorState, hover/editor integration tests extended (suppression, fallback chain, read-once), and a new Playwright e2e (q2-preview-spa/e2e/drag-selection-open.spec.ts) driving real trusted mouse drags against the real q2 binary: forward drag, backward drag direction, cross-block suppression. Verified end-to-end with `q2 preview --allow-edit`: drag-selecting text opens the editor with the selection highlighted; toolbar Bold applies to exactly the dragged range. `cargo xtask verify` green except the pre-existing changelog.md ~37MB / Q-2-17 WASM-render failure on main (fix already in flight in an open PR). Plan: claude-notes/plans/2026-07-07-preview-drag-selection-into-richtext.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(hub-client): changelog entry for drag-selection rich-text open (4af08ef) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: record verification outcome in drag-selection plan (bd-abo9m23f) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 3cf352b commit 30fbc5b

13 files changed

Lines changed: 1219 additions & 86 deletions
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
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.

hub-client/changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ WASM rebuild is needed for a changelog-only edit.
2525

2626
### 2026-07-07
2727

28+
- [`4af08ef3`](https://github.com/quarto-dev/q2/commits/4af08ef3): Dragging a text selection in the preview now opens the rich-text editor with that selection already active (so Bold/Italic/Link apply immediately); a selection dragged across multiple blocks no longer opens an editor, keeping the selection available for copying.
2829
- [`6cfc098f`](https://github.com/quarto-dev/q2/commits/6cfc098f): Raised the service-worker precache size limit so the (now about 37 MB) WASM module is cached again for offline use, and to stop continued WASM growth from breaking the production build.
2930

3031
### 2026-07-06

0 commit comments

Comments
 (0)