Display ghost-preview drag-move + move cursor on hover#267
Conversation
Drag-move on a selected shape today repaints the actual element to its live position. Switch to a translucent ghost that follows the cursor while the original shape + selection handles stay put; the commit happens on pointerup. Add a move cursor on hover over a selected shape so the drag affordance is discoverable before the user clicks. Reuse the existing GHOST_ALPHA path that already drives the shape-insert hover preview; extend it from a single optional element to an array. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The shape-insert hover preview renders a single optional ghost element through `drawSlide` / `SlideRenderer.forceRender`. The drag-move preview needs to paint one ghost per element in the selection, so the ghost slot is generalized from `ghost?: Element` to a `readonly Element[]` array. Painting each ghost inside its own `save / globalAlpha = GHOST_ALPHA / restore` band — rather than one outer band around the whole loop — keeps the alpha guarantee local to each `drawElement` call, so a future renderer that mutates ctx state without restoring can't leak past the ghost boundary. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Three small fixes from the post-Task-1 code review: - Switch the ghost-array param's type form from `ReadonlyArray<Element>` to `readonly Element[]` — matches the convention used elsewhere in `packages/slides/src/view/`. - Refresh two doc comments in editor.ts that still referenced the pre-array `forceRender(slide, doc, ghost)` signature, so they no longer lie about the API. - Replace the new renderer test's save/restore counting assertion with a behavioral check that `globalAlpha` was actually set to `GHOST_ALPHA` during the ghost paint, via an instrumented setter on the spy context. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
`paintLive` synthesizes a slide with overridden frames, which is the right affordance for resize / rotate / adjustment where the user expects to see the live frame as it changes. Drag-move wants the opposite contract: the original slide stays unchanged on the canvas, translucent ghosts ride on top at the dragged offset, and the selection handles anchor to the starting frame. Adding a sibling helper keeps each paint path single-purpose rather than threading a flag through `paintLive`. This commit only adds the helper; `startDrag` continues to use `paintLive` and switches over in the next commit. A short-lived `@ts-expect-error TS6133` directive absorbs `noUnusedLocals` while the producer lives alone, and is removed in the same commit that wires up the consumer. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Switch `startDrag` from the synthesized-slide `paintLive` path to the new `paintMoveGhost` helper. The mousemove loop now: - Filters the selection to non-connector elements (`ghostSources`). - Builds a `ghosts` array with `(x + dx, y + dy)` frames so the original slide is left untouched while the ghosts ride above at GHOST_ALPHA. - Maintains a `committed` Map keyed by element id with the latest dragged frame so `onUp` can replay the snap result into one `store.batch` without re-running snap math. `paintLive` itself stays untouched — it is still the correct affordance for resize / rotate / adjustment where the user expects the live frame. The stale `@ts-expect-error TS6133` directive above `paintMoveGhost` from the previous commit is removed now that there is a real caller. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The previous commit's `ghostSources` filter carved connectors out of the drag preview, but the commit loop still walked the unfiltered `originals`. `store.updateElementFrame` throws for connector elements (they must be updated via endpoints, not frames), so any multi-select drag that included a connector would error on pointerup — and the "connectors excluded" comment in the code became a false promise. Source the `committed` Map from `ghostSources` instead, so connectors are excluded symmetrically: no ghost during the preview, no commit on release, no throw. Add a regression test that drags a shape + connector multi-selection and asserts both that the commit does not throw and that the connector frame is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Show `cursor: move` while the mouse pointer is over the bounding box of a selected shape so the drag affordance is discoverable before the user clicks. The check runs in the existing canvas `pointermove` listener (no new event subscription) and short-circuits whenever something else owns the cursor: - non-mouse pointers (touch has no cursor concept) - insert mode (`crosshair` wins) - text-edit mode (the embedded editor owns the box) - pointer over a resize / rotate handle (those cursors already render) `pointermove` fires at frame rate, so a `lastHoverCursor` cache makes the DOM write only happen when the value actually changes. The cache is reset on `pointerleave` and on `setInsertMode(null)` so a follow-up hover always gets a fresh write. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The first cut of the hover hit-test used a hand-rolled axis-aligned bbox check. On a rotated shape that flips the cursor to `move` over empty quadrants of the AABB and misses the rotated extent — so the move-cursor affordance and the click-to-select hit-test (which already uses `containsPoint`) disagreed. Route through `containsPoint` so both paths share one rotation-aware implementation. The existing import already pulled `combinedBoundingBox` from `model/frame`, so no new import surface is needed. A regression test exercises the failing axis-aligned path with a 45deg-rotated rect. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
A stale `move` cursor persisted for one frame when the user double-clicked a selected text box to enter edit mode: the entry path ran before any pointermove would clear the cache. Drop the cached cursor at the entry point so the text-edit affordance owns the canvas cursor immediately. Also reconcile the design doc with what shipped: move ESC mid-drag cancel into Non-Goals (deferred to follow-up; undo is the v1 fallback), correct the affected-files table to reflect that overlay.ts was not modified, and add the connector-in-selection and rotation- aware-hover tests to the listed unit-test coverage. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Three small adjustments needed to integrate the ghost-preview drag-move with origin/main's group-aware drag that landed on top of the original plan: - Skip the commit batch when (liveDx, liveDy) is (0, 0). An empty `store.batch` still pushes an undo snapshot and clears the redo stack, which would silently corrupt history on a pure click-without- drag. Covers the original "connector-only drag" case as well as a mousedown-mouseup with no movement. - Wrap the connector endpoint drag's `forceRender(..., ghostConnector)` in `[ghostConnector]` to match the new ghost-array signature — a call site that origin/main added between the original commit and the rebase target. - Pull `containsPoint` into the existing `'../../model/frame'` import (re-establishing the rotation-aware hover hit-test that an earlier rebase resolution accidentally dropped from the import line). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
📝 WalkthroughWalkthroughThis PR implements shape drag-move UX for the Slides editor. The renderer is generalized to render multiple ghost elements; the editor adds a move cursor affordance on hover, updates all ghost call sites to use arrays, and refactors drag logic to exclude connectors from ghosts while anchoring handles to original frames and ghosts to snapped positions. Commits occur only on pointerup, skipping empty batches. ChangesShape Move with Ghost Drag & Hover Cursor
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/design/slides/slides-shape-move.md`:
- Around line 33-34: Update the API reference to use the new ghost-array
signature instead of the old optional boolean: replace mentions of
drawSlide(..., ghost?) with drawSlide(..., ghosts?: ReadonlyArray<Element>) and
adjust the surrounding text to describe that ghosts is a readonly array of
Element (not a boolean flag) so implementations use the standardized signature
(e.g., references to drawSlide should point to drawSlide(..., ghosts?:
ReadonlyArray<Element>)).
- Around line 124-126: Clarify the pointerup commit semantics by stating that
the per-element frame commit (pointerup -> store.batch(() =>
store.updateElement(id, { x, y })) ... then clear ghost state, markDirty(),
render(), repaintOverlay()) applies only to non-connector elements; connectors
are excluded and instead use their connector-specific commit flow (e.g.,
endpoint-translate and any connector-specific update/commit logic). Update the
sentence to explicitly call out both paths: non-connectors perform the frame
commits via store.updateElement, while connectors use
endpoint-translate/connector commit handling and should not be processed with
the per-element frame update.
In `@docs/tasks/active/20260519-slides-shape-move-ghost-todo.md`:
- Around line 395-410: The current commit loop applies updateElementFrame for
every id in committed (committed, updateElementFrame), which will incorrectly
persist connector frames; change the commit logic to branch per element type:
for non-connectors call updateElementFrame(slideId, id, frame) as before, but
for connectors perform the connector-specific commit path (e.g., call the
connector translate/endpoint update functions or the existing connector commit
routine used elsewhere) so connectors update their endpoints rather than having
raw frames saved; locate the commit loop that iterates committed and split it by
element.type === 'connector' (or the connector detection used in your code) and
call the appropriate connector commit API instead of updateElementFrame.
In `@packages/slides/src/view/editor/editor.ts`:
- Around line 1403-1413: isPointerOverSelected currently iterates slide.elements
and tests raw frames, which misses nested/drilled-in selected elements; update
isPointerOverSelected to resolve each selected id (from this.selection.get())
via findElement(id), convert that element's frame to world coordinates using
toWorldFrame(element) and then call containsPoint(worldFrame, x, y) (after using
clientToLogical to get x,y) so nested elements report pointer-over correctly;
keep early returns when slide is null or selection empty and skip missing
elements returned by findElement.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 26f504a7-7aa9-4168-bf40-b619d0339170
📒 Files selected for processing (7)
docs/design/README.mddocs/design/slides/slides-shape-move.mddocs/tasks/active/20260519-slides-shape-move-ghost-todo.mdpackages/slides/src/view/canvas/slide-renderer.tspackages/slides/src/view/editor/editor.tspackages/slides/test/view/canvas/slide-renderer.test.tspackages/slides/test/view/editor/editor.test.ts
Verification: verify:selfResult: ✅ PASS in 209.7s
Verification: verify:integrationResult: ✅ PASS |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Two real bugs surfaced by code review: - Connector (Line) drag preview was invisible because connectors were filtered out of `ghostSources` entirely. Their frame is derived from endpoints, so a frame-only ghost would paint at the wrong place — but applying the `translateElement` pattern (free endpoints by (dx, dy); attached endpoints stay anchored to their host) produces a ghost that exactly matches the post-`commitTranslate` visual. - `isPointerOverSelected` walked the top-level `slide.elements` array with raw frames, so a selected element inside a drilled-in group never triggered the `move` cursor. Switch to `findElement` (recursive) + `toWorldFrame(el.frame, scope, slide)` so the hover hit-test agrees with the actual selection regardless of group nesting. Plus three documentation cleanups CodeRabbit flagged on the PR: - Refresh the `drawSlide` signature reference in the design doc's Goals bullet from `ghost?` to `ghosts?: readonly Element[]`. - Spell out the type-aware commit semantics in "Commit and cancel": non-connectors via `updateElementFrame(fromWorldFrame(...))`, connectors via `commitTranslate`, and the zero-delta skip. - Add a post-rebase note to the active todo file pointing readers to the design doc since the original snippets were authored against an earlier `main` (pre group-aware drag). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
|
Pushed Behavior fix:
CodeRabbit follow-ups:
|
When a connector with an `attached` endpoint and its host shape are both in the drag selection, the ghost connector kept its endpoint as `attached` and the renderer resolved it through the unmodified slide lookup — so the line ghost pointed back at the **original** shape position while the shape ghost moved with the cursor. Pre-resolve such endpoints at ghost build time: when the attached endpoint's `elementId` is in the dragged selection, look up the connection site in world coords via `resolveEndpoint`, then turn the endpoint into a `kind: 'free'` at `(world.x + dx, world.y + dy)`. The ghost line now meets the ghost shape's connection site, matching the post-commit visual (the renderer recomputes connector frames from endpoints after `store.updateElementFrame` / `commitTranslate`). Attached endpoints to non-dragged elements stay as-is so the ghost stays anchored to a stationary host shape — also matching the commit-path behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
|
CI status note Initial run 26100834096 failed on a single sheets test unrelated to this PR:
Re-running the same workflow passed cleanly — the PR's diff doesn't touch Suggested follow-up (out of scope for this PR): either bump |
These tasks' features merged to main (PRs #267 #271 #277 #284 #285) but their plan checkboxes were never ticked, so the todo files lingered in tasks/active. Added a status note citing each merge commit, marked the checkboxes complete, and ran tasks:archive to move them under archive/2026/05 with regenerated indexes. Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
Summary
(at
GHOST_ALPHA) following the cursor while the original shape +selection handles stay anchored. Store commits in a single
store.batch()onpointerup.cursor: 'move'(mouse pointersonly; cached to avoid layout-thrash; rotation-aware via
containsPoint).ghost?: Elementslot is generalizedto
readonly Element[]so the sameGHOST_ALPHApipeline drivesshape-insert hover preview (1 ghost), connector endpoint drag
preview (1 ghost), and shape-move drag preview (N ghosts).
commits go through
fromWorldFrame→updateElementFramefornon-connectors and
commitTranslate(endpoints) for connectors.Design:
docs/design/slides/slides-shape-move.mdDeferred follow-ups: live connector re-routing during ghost preview,
ESC mid-drag cancel, alt-drag clone, keyboard-nudge ghosting.
Test plan
pnpm verify:fast(lint + unit; 792 docs, 1271 sheets, 1051+ slides, 143 backend)pointerupmovecursor toggles on selected/unselected/touch/insert-mode/no-flickermovecursor only inside the rotated body, not the AABB corners🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Documentation
Tests