Skip to content

Display ghost-preview drag-move + move cursor on hover#267

Merged
hackerwins merged 12 commits into
mainfrom
slides-shape-move-ghost
May 19, 2026
Merged

Display ghost-preview drag-move + move cursor on hover#267
hackerwins merged 12 commits into
mainfrom
slides-shape-move-ghost

Conversation

@hackerwins

@hackerwins hackerwins commented May 19, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Drag-move on a selected slide shape now paints a translucent ghost
    (at GHOST_ALPHA) following the cursor while the original shape +
    selection handles stay anchored. Store commits in a single
    store.batch() on pointerup.
  • Hover over a selected shape sets cursor: 'move' (mouse pointers
    only; cached to avoid layout-thrash; rotation-aware via
    containsPoint).
  • The renderer's existing single ghost?: Element slot is generalized
    to readonly Element[] so the same GHOST_ALPHA pipeline drives
    shape-insert hover preview (1 ghost), connector endpoint drag
    preview (1 ghost), and shape-move drag preview (N ghosts).
  • Group-aware: world-space frames feed ghost positioning + snap math;
    commits go through fromWorldFrameupdateElementFrame for
    non-connectors and commitTranslate (endpoints) for connectors.

Design: docs/design/slides/slides-shape-move.md

Deferred 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)
  • Unit: ghost-array renderer + GHOST_ALPHA invariant
  • Unit: store untouched mid-drag; commit batches on pointerup
  • Unit: multi-select handles anchored to original bbox during drag
  • Unit: connector in multi-selection translates via endpoints, no throw
  • Unit: click-without-drag (zero delta) does not push undo or wipe redo
  • Unit: move cursor toggles on selected/unselected/touch/insert-mode/no-flicker
  • Unit: rotation-aware hit-test (point inside AABB but outside rotated rect)
  • Manual: single-shape drag — ghost follows cursor, handles anchored, release commits
  • Manual: multi-select drag — both ghosts at same offset, snap guides against ghost bbox
  • Manual: connector + shape multi-selection — connector translates with shape on commit
  • Manual: insert-mode crosshair is not overridden by hover-move
  • Manual: rotated shape — move cursor only inside the rotated body, not the AABB corners

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Shape drag-move with semi-transparent preview that follows the cursor
    • Move cursor affordance when hovering over selected shapes
  • Documentation

    • Added design documentation for shape drag-move behavior, including cursor interactions and rendering pipeline specifications
  • Tests

    • Added test coverage for ghost rendering and hover cursor behavior in the editor

Review Change Stack

hackerwins and others added 10 commits May 19, 2026 21:54
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]>
@coderabbitai

coderabbitai Bot commented May 19, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This 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.

Changes

Shape Move with Ghost Drag & Hover Cursor

Layer / File(s) Summary
Renderer Ghost Array Generalization
packages/slides/src/view/canvas/slide-renderer.ts, packages/slides/test/view/canvas/slide-renderer.test.ts
SlideRenderer.forceRender and drawSlide are updated to accept ghosts?: readonly Element[] instead of a single ghost. Rendering logic loops through each ghost with isolated globalAlpha save/restore scopes. Test suite validates multiple ghosts render with correct alpha blending.
Hover Cursor on Selected Shapes
packages/slides/src/view/editor/editor.ts (lines 0–1263), packages/slides/test/view/editor/editor.test.ts (lines 93–239)
A new lastHoverCursor cache and onSelectionHoverMove handler set canvas.style.cursor to move when the pointer is inside a selected element's frame (excluding handles), only for mouse pointers and when not inserting/editing. Cursor state is reset on pointer leave and mode transitions. Tests verify cursor is set/cleared correctly, not repeatedly rewritten, and respects rotated element geometry.
Ghost Array API Updates
packages/slides/src/view/editor/editor.ts (lines 1454–1995)
All renderer.forceRender calls are updated to pass ghosts as single-element arrays (e.g., [ghost]) instead of individual elements, ensuring consistency across hover preview, shape drag-to-size, connector insert, and connector endpoint drag operations.
Drag Preview & Commit Logic
packages/slides/src/view/editor/editor.ts (lines 1674–1872), packages/slides/test/view/editor/editor.test.ts (lines 292–437)
During shape drag, connectors are excluded from the ghost set; ghosts are constructed at snapped world positions while handles remain anchored to original frames and rendered via paintMoveGhost. On pointerup, the editor commits only if movement occurred (liveDx or liveDy non-zero), skipping empty undo entries. Tests verify ghost-only preview (no mid-drag store mutation), multi-select handle anchoring, connector translation, and no-op drag behavior.
Design & Implementation Documentation
docs/design/README.md, docs/design/slides/slides-shape-move.md, docs/tasks/active/20260519-slides-shape-move-ghost-todo.md
Design spec details cursor rules, ghost rendering pipeline changes, overlay anchoring (handles to original, snap guides to ghost), commit semantics, test expectations, and risks/mitigations. Task plan breaks implementation into four chunks with proposed code changes and verification steps.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • wafflebase/wafflebase#239: Both PRs modify the Slides canvas rendering/preview "ghost" pipeline and SlideRenderer.forceRender usage (main PR generalizes ghostghosts[] and the editor passes ghost arrays; retrieved PR adds insert-mode cursor + ghost previews that call into the same forceRender/ghost rendering path).
  • wafflebase/wafflebase#261: Both PRs modify packages/slides/src/view/editor/editor.ts's interaction/drag event wiring (notably pointer move/drag flows in attachInteractions), so the main PR's hover/ghost drag behavior is code-adjacent to the retrieved PR's Pointer Events migration changes.

Poem

🐰 A rabbit's ode to sliding shapes:
Ghost whispers follow where the mouse doth tread,
Handles stay home while copies drift ahead,
The "move" cursor beckons with a gentle hand,
Drag, snap, and commit—a shape obeys command! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically summarizes the main change: adding a ghost-preview drag-move behavior with a move cursor shown on hover over selected shapes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch slides-shape-move-ghost

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 5b6197e and 0a2f2f6.

📒 Files selected for processing (7)
  • docs/design/README.md
  • docs/design/slides/slides-shape-move.md
  • docs/tasks/active/20260519-slides-shape-move-ghost-todo.md
  • packages/slides/src/view/canvas/slide-renderer.ts
  • packages/slides/src/view/editor/editor.ts
  • packages/slides/test/view/canvas/slide-renderer.test.ts
  • packages/slides/test/view/editor/editor.test.ts

Comment thread docs/design/slides/slides-shape-move.md Outdated
Comment thread docs/design/slides/slides-shape-move.md Outdated
Comment thread docs/tasks/active/20260519-slides-shape-move-ghost-todo.md
Comment thread packages/slides/src/view/editor/editor.ts
@github-actions

github-actions Bot commented May 19, 2026

Copy link
Copy Markdown
Contributor

Verification: verify:self

Result: ✅ PASS in 209.7s

Lane Status Duration
sheets:build ✅ pass 12.5s
docs:build ✅ pass 12.3s
slides:build ✅ pass 13.6s
verify:fast ✅ pass 124.6s
frontend:build ✅ pass 19.3s
verify:frontend:chunks ✅ pass 0.3s
backend:build ✅ pass 4.8s
cli:build ✅ pass 2.1s
verify:entropy ✅ pass 20.2s

Verification: verify:integration

Result: ✅ PASS

@codecov

codecov Bot commented May 19, 2026

Copy link
Copy Markdown

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]>
@hackerwins

Copy link
Copy Markdown
Collaborator Author

Pushed c9611834 to address the CodeRabbit comments + a separate behavior issue caught during local smoke:

Behavior fix:

  • Connector (Line) ghost was invisible during drag — connectors were excluded from ghostSources entirely. Connectors render via endpoint lookup, so a frame-only ghost paints at the wrong place. Fix: apply the translateElement pattern when building the ghost — free endpoints by (dx, dy), attached endpoints stay anchored. The ghost preview now exactly matches the post-commitTranslate visual.

CodeRabbit follow-ups:

  • isPointerOverSelected now uses findElement + toWorldFrame so drilled-in group selections also flip the cursor to move.
  • Design doc's Goals bullet refreshed to reference ghosts?: readonly Element[].
  • "Commit and cancel" section spells out the type-routed commit: non-connectors via updateElementFrame(fromWorldFrame(...)), connectors via commitTranslate, zero-delta skip.
  • Added a post-rebase note to the todo file pointing readers to the design doc.

pnpm verify:fast still green (792 tests).

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]>
@hackerwins hackerwins changed the title Slides: ghost-preview drag-move + move cursor on hover Display ghost-preview drag-move + move cursor on hover May 19, 2026
@hackerwins

Copy link
Copy Markdown
Collaborator Author

CI status note

Initial run 26100834096 failed on a single sheets test unrelated to this PR:

test/model/worksheet-record.test.ts > createWorksheetAxisId > should generate unique IDs across typical batch size
AssertionError: expected 99 to be 100

createWorksheetAxisId (packages/sheets/src/model/workbook/worksheet-record.ts:80-87) generates 4-char IDs from a 36-char alphabet — 36⁴ ≈ 1.68M combinations. The test draws 100 IDs and asserts the set size is 100. The inline comment claims "100 IDs should never collide", but the birthday-paradox probability of at least one collision is ≈ 1 − exp(−100·99 / (2·1,679,616))0.30%, i.e. about 1 in 340 runs.

Re-running the same workflow passed cleanly — the PR's diff doesn't touch packages/sheets/ (see PR diff), so this is a pre-existing flake on main.

Suggested follow-up (out of scope for this PR): either bump AXIS_ID_LENGTH to 6 (36⁶ ≈ 2.2B, collision risk negligible for any realistic batch) or have the test retry once on collision. Happy to open a separate PR for it.

@hackerwins
hackerwins merged commit 3d265a2 into main May 19, 2026
1 check passed
@hackerwins
hackerwins deleted the slides-shape-move-ghost branch May 19, 2026 14:14
hackerwins added a commit that referenced this pull request May 24, 2026
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]>
@hackerwins hackerwins mentioned this pull request May 24, 2026
2 tasks
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