Skip to content

Refit group on drill-out + rotation-preserving handles + multi-rotate#269

Merged
hackerwins merged 3 commits into
mainfrom
slides-group-handle-rotate-ux
May 20, 2026
Merged

Refit group on drill-out + rotation-preserving handles + multi-rotate#269
hackerwins merged 3 commits into
mainfrom
slides-group-handle-rotate-ux

Conversation

@hackerwins

@hackerwins hackerwins commented May 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Group selection box was stuck on the frame captured at group() time; child moves inside drill-in left it stale, rotated groups lost their rotation in the handle box, and multi-select rotate was a no-op despite showing a handle. This PR addresses all three with a shared rotation-preserving refit + a Google Slides-style rotate gesture (ghost preview + angle tooltip).
  • New worldTightFrame helper computes the math (O_new = O_old − R_θ(S · (C_old − shift − C_new))); used by the overlay for live tight wrapping and by refitGroup to materialize the same frame into the store. Rotation and scale are preserved; each child's world position is invariant.
  • Refit hook now covers every scope-pop entry point — Esc, click on outside element, click on empty canvas while drilled in (previously bypassed selection.click entirely → root cause of the "left/top off" regression), and right-click outside scope.
  • Multi-select rotation rotates the selection as a rigid body around the combined-bbox center (where the rotate handle visually sits). Free connector endpoints rotate around the pivot; attached endpoints follow their host.
  • Rotate gesture mirrors the shape-move ghost pattern: original stays, translucent ghost previews, commit on release. A small 45° tooltip follows the cursor (absolute rotation for single-element, delta for multi).

Test plan

  • pnpm verify:fast green (1232 slides + 1268 frontend + 792 docs + 191 sheets + 143 backend)
  • Unit tests added for worldChildrenAABB (5 cases), worldTightFrame (no-op + rotation-preserving with moved child), and refitGroup (no-op, frame shrink, world-position invariance, rotation preservation, rotated-group + moved-child end-to-end, defensive paths)
  • Manual smoke (steps in docs/tasks/active/20260520-slides-group-handle-bbox-todo.md):
    • Group 2 shapes → resize via SE corner → children scale with the group
    • Drill in → drag a child far outside → click empty canvas → re-select group → handle box tightly wraps both shapes at new positions
    • Rotate the group → tooltip shows angle, ghost previews rotation, release commits
    • Re-select the rotated group → rotated handles (not axis-aligned)
    • Shift+select 2 shapes → rotate → both shapes rotate as a rigid body around the shared pivot
    • Drill into a rotated group → move a child → Esc → group stays rotated and tight

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Multi-select rotation now supported with visual angle feedback during rotation.
    • Group boundaries automatically refit to match children's visual extent after moving or drill-out operations.
  • Bug Fixes

    • Selection handles on groups now accurately reflect the actual extent of their contents.
    • Fixed selection state alignment issue after re-selecting groups.
  • Documentation

    • Added design notes and task tracking for group handling improvements.

Review Change Stack

Group selection currently shows a stale frame whenever a child moves
inside drill-in: `group.frame` is captured at group() time and never
updates, so the next selection draws handles around the old extent.
Rotated groups additionally lose their rotation in the handle box, and
multi-select rotate is wired up visually but the implementation rejects
N>1 selections.

This change refits the group's stored frame at drill-out (Esc, click
outside the scope, click on empty canvas while drilled in, right-click
outside scope) so the data model stays consistent with what the user
sees. The refit math `worldTightFrame` preserves rotation and scale
while moving frame/refSize/children's local origins so every child's
world position is invariant.

The overlay path also runs through `worldTightFrame` so handles render
correctly even before the user triggers the refit. Rotated groups get
rotated handles via the existing `renderRotatedHandles` branch.

Multi-select rotation now rotates the selection as a rigid body around
the combined bbox center (where the rotate handle visually sits).
Connector free endpoints rotate around the same pivot; attached
endpoints stay anchored to their host. The rotate gesture mirrors the
shape-move ghost pattern — original stays in place, ghost previews the
result, commit on release — and a tooltip near the cursor shows the
current angle in degrees.

Also fixes a separate bug: clicks on empty canvas during drill-in
never popped scope (the path bypassed `selection.click` entirely), so
refit never ran and subsequent group re-selection used a stale frame.
The earlier refit was rotation-resetting; the recent rewrite preserves
rotation via `worldTightFrame`. Two comments still claimed rotation
"bakes into children" / "resets to 0", which would mislead a future
maintainer into thinking the refit zeroes group rotation.

Also, the rotation-angle tooltip lives on `overlay.parentElement` so
`renderOverlay.innerHTML` rebuilds don't wipe it mid-drag — that same
parent ownership means `detach()` must remove it explicitly, otherwise
each SlidesView remount leaves a hidden orphan div behind.
@coderabbitai

coderabbitai Bot commented May 20, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Implements group frame auto-refitting via worldTightFrame calculation, stores a new refitGroup mutation in both memory and Yorkie stores, integrates overlay tight-frame rendering for drill-scoped groups, enhances multi-select rotation with pivot-based rigid-body turning and ghost preview, adds comprehensive model and store tests, and documents the feature with task writeups and lessons.

Changes

Group frame refitting and rotation UX

Layer / File(s) Summary
Group frame calculation models
packages/slides/src/model/group.ts, packages/slides/src/index.ts
New worldLeafFrames recursively collects leaf descendants, worldChildrenAABB computes axis-aligned bounding boxes (deprecated), and worldTightFrame computes rotation-preserving tight frames with local shift and new refSize for consistent refitting. Barrel exports both public helpers.
Store interface and implementations
packages/slides/src/store/store.ts, packages/slides/src/store/memory.ts, packages/frontend/src/app/slides/yorkie-slides-store.ts
SlidesStore interface adds refitGroup(slideId, groupId) contract; MemSlidesStore and YorkieSlidesStore implement the mutator by computing tight frames via worldTightFrame, updating group frame/refSize in place, and shifting child frames to preserve world positions. Both stores batch updates for undo correctness.
Editor overlay tight-frame rendering and drill-out refit
packages/slides/src/view/editor/editor.ts
Overlay handle repositioning now recalculates group frames using worldTightFrame to match visible drill-scope extent. New refitPoppedScope helper refits groups exiting drill scope via store.refitGroup batches and triggers repaints. Refit is wired into pointer-down, right-click, and double-click drill routing to ensure popped groups are re-anchored immediately.
Multi-select rotation with ghost preview and keyboard escape refit
packages/slides/src/view/editor/editor.ts, packages/slides/src/view/editor/interactions/keyboard.ts
startRotate refactored to support multi-element rigid-body rotation around combined-bbox pivot, with special handling for connector free endpoints (rotate around pivot) vs. attached endpoints (anchored). Live ghost preview and lazily created rotation-angle tooltip with DOM lifecycle management. Escape key now refits drilled-in scope group before popping scope.
Group calculation unit tests
packages/slides/test/model/group.test.ts
New test suites for worldChildrenAABB and worldTightFrame covering identity/rotated/nested groups, out-of-bounds children, rotation preservation, and world-position invariance after refit simulation.
Store refitGroup mutation tests
packages/slides/test/store/group-mutations.test.ts
Comprehensive tests verifying refitGroup idempotency on already-tight groups, frame/refSize updates after child movement, world-position invariance across refit, rotation preservation, and defensive no-op behavior for removed groups or invalid ids.
Project writeup and task documentation
docs/tasks/README.md, docs/tasks/archive/2026/05/20260520-slides-group-handle-bbox-{todo,lessons}.md, docs/tasks/archive/README.md
Adds "slides shape move ghost" to active tasks, archives four completed tasks (handle bbox, mobile shell, mobile toolbar morph, precise shape hit test), and documents implementation plan, lessons (math invariants, DOM ownership, scope-pop architecture), and review checklist for tight-frame overlay, drill-out refit, multi-rotate pivot, and rotation ghost/tooltip UX.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • wafflebase/wafflebase#263: The PR's refitGroup/worldTightFrame implementation directly extends the same group model and store logic introduced by #263's nested group/ungroup work, notably updates to group.ts and group-aware store/editor APIs.

Poem

🐰 A rabbit hops through frame-refit dreams,
Where groups snap tight to children's seams,
Rotation spins 'round pivot true,
Tooltips bloom before the view,
Scope-pop refits make all things new!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the three main changes: group refit on drill-out, rotation-preserving handles, and multi-select rotation support.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch slides-group-handle-rotate-ux

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: 1

🧹 Nitpick comments (2)
packages/slides/src/view/editor/editor.ts (1)

2509-2540: 💤 Low value

Field declaration placement is inconsistent with class structure.

The rotateTooltipEl field is declared between method implementations rather than at the top of the class with other private fields (e.g., lastHoverCursor, renderer, listeners). While valid TypeScript, this makes the field harder to discover when reading the class.

🔧 Suggested location

Move the field declaration to join the other private fields near the top of SlidesEditorImpl:

// Near line 371, with other private fields:
private activeTextEditor: SlidesTextBoxEditor | null = null;
private rotateTooltipEl: HTMLDivElement | null = null;

Then remove the declaration from line 2509 and keep only the methods acquireRotateTooltip and releaseRotateTooltip.

🤖 Prompt for 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.

In `@packages/slides/src/view/editor/editor.ts` around lines 2509 - 2540, The
private field rotateTooltipEl is declared mid-class between methods which breaks
consistency; move the declaration "private rotateTooltipEl: HTMLDivElement |
null = null;" up into the block of other private fields (near symbols like
activeTextEditor, lastHoverCursor, renderer, listeners) so all fields are
grouped together, then remove the duplicate declaration currently above
acquireRotateTooltip(), leaving only the methods acquireRotateTooltip() and
releaseRotateTooltip() intact; ensure no other references are changed.
packages/slides/src/view/editor/interactions/keyboard.ts (1)

488-507: 💤 Low value

Minor redundancy: getScope() is called twice.

The scope is checked at line 488 and then re-captured at line 497. Since nothing mutates the scope between these calls, the second call could be avoided by capturing it once.

♻️ Suggested simplification
-      run: (e) => {
-        // If we are drilled into a group, pop one scope level and stop —
-        // do NOT also clear the id-selection so the group itself appears
-        // selected at the parent scope level (matching Google Slides).
-        if (ctx.selection.getScope().length > 0) {
-          e.preventDefault();
+      run: (e) => {
+        // If we are drilled into a group, pop one scope level and stop —
+        // do NOT also clear the id-selection so the group itself appears
+        // selected at the parent scope level (matching Google Slides).
+        const scope = ctx.selection.getScope();
+        if (scope.length > 0) {
+          e.preventDefault();
           // Refit the innermost scoped group so its frame matches the
           // children's current visual extent — children may have moved
           // inside drill-in, leaving `group.frame` stale. The refit
           // preserves the group's rotation and scale (see
           // `worldTightFrame` in `model/group.ts`); only position +
           // dimensions move to wrap the children. Wrapped in `batch`
           // so undo restores the pre-refit state in one step.
-          const scope = ctx.selection.getScope();
           const slideId = ctx.currentSlideId();
-          if (slideId !== undefined && scope.length > 0) {
+          if (slideId !== undefined) {
             const innermost = scope[scope.length - 1];
             ctx.store.batch(() => {
               ctx.store.refitGroup(slideId, innermost);
             });
           }
🤖 Prompt for 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.

In `@packages/slides/src/view/editor/interactions/keyboard.ts` around lines 488 -
507, Capture the selection scope once instead of calling
ctx.selection.getScope() twice: store it in a local variable (e.g., const scope
= ctx.selection.getScope()) before the first length check, use that variable for
the subsequent checks and to derive innermost, then proceed to call
ctx.currentSlideId(), ctx.store.batch(() => ctx.store.refitGroup(slideId,
innermost)); remove the redundant second getScope() call and its duplicate
length check so the code uses the single scope variable throughout.
🤖 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 `@packages/slides/test/store/group-mutations.test.ts`:
- Around line 910-916: The test currently asserts world-frame invariance for
child "a" (x/y/w/h) but only checks x/y for child "b", risking unnoticed
regressions in b's width/height; update the assertions in the test (references:
aAfter, aBefore, bAfter, bBefore) to also include
expect(bAfter.w).toBeCloseTo(bBefore.w, 4) and
expect(bAfter.h).toBeCloseTo(bBefore.h, 4) so both children are fully validated.

---

Nitpick comments:
In `@packages/slides/src/view/editor/editor.ts`:
- Around line 2509-2540: The private field rotateTooltipEl is declared mid-class
between methods which breaks consistency; move the declaration "private
rotateTooltipEl: HTMLDivElement | null = null;" up into the block of other
private fields (near symbols like activeTextEditor, lastHoverCursor, renderer,
listeners) so all fields are grouped together, then remove the duplicate
declaration currently above acquireRotateTooltip(), leaving only the methods
acquireRotateTooltip() and releaseRotateTooltip() intact; ensure no other
references are changed.

In `@packages/slides/src/view/editor/interactions/keyboard.ts`:
- Around line 488-507: Capture the selection scope once instead of calling
ctx.selection.getScope() twice: store it in a local variable (e.g., const scope
= ctx.selection.getScope()) before the first length check, use that variable for
the subsequent checks and to derive innermost, then proceed to call
ctx.currentSlideId(), ctx.store.batch(() => ctx.store.refitGroup(slideId,
innermost)); remove the redundant second getScope() call and its duplicate
length check so the code uses the single scope variable throughout.
🪄 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: d8336e6d-ad60-4e99-b7b2-71f91db8ba9e

📥 Commits

Reviewing files that changed from the base of the PR and between 0bd93a5 and 81dbce6.

📒 Files selected for processing (10)
  • docs/tasks/active/20260520-slides-group-handle-bbox-todo.md
  • packages/frontend/src/app/slides/yorkie-slides-store.ts
  • packages/slides/src/index.ts
  • packages/slides/src/model/group.ts
  • packages/slides/src/store/memory.ts
  • packages/slides/src/store/store.ts
  • packages/slides/src/view/editor/editor.ts
  • packages/slides/src/view/editor/interactions/keyboard.ts
  • packages/slides/test/model/group.test.ts
  • packages/slides/test/store/group-mutations.test.ts

Comment on lines +910 to +916
expect(aAfter.x).toBeCloseTo(aBefore.x, 4);
expect(aAfter.y).toBeCloseTo(aBefore.y, 4);
expect(aAfter.w).toBeCloseTo(aBefore.w, 4);
expect(aAfter.h).toBeCloseTo(aBefore.h, 4);
expect(bAfter.x).toBeCloseTo(bBefore.x, 4);
expect(bAfter.y).toBeCloseTo(bBefore.y, 4);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Assert full world-frame invariance for child b too.

This case checks a with x/y/w/h, but for b it checks only x/y. A width/height regression on b would pass unnoticed.

✅ Suggested patch
     expect(bAfter.x).toBeCloseTo(bBefore.x, 4);
     expect(bAfter.y).toBeCloseTo(bBefore.y, 4);
+    expect(bAfter.w).toBeCloseTo(bBefore.w, 4);
+    expect(bAfter.h).toBeCloseTo(bBefore.h, 4);

As per coding guidelines, "Write tests for critical business logic and edge cases".

🤖 Prompt for 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.

In `@packages/slides/test/store/group-mutations.test.ts` around lines 910 - 916,
The test currently asserts world-frame invariance for child "a" (x/y/w/h) but
only checks x/y for child "b", risking unnoticed regressions in b's
width/height; update the assertions in the test (references: aAfter, aBefore,
bAfter, bBefore) to also include expect(bAfter.w).toBeCloseTo(bBefore.w, 4) and
expect(bAfter.h).toBeCloseTo(bBefore.h, 4) so both children are fully validated.

@github-actions

github-actions Bot commented May 20, 2026

Copy link
Copy Markdown
Contributor

Verification: verify:self

Result: ✅ PASS in 207.6s

Lane Status Duration
sheets:build ✅ pass 13.3s
docs:build ✅ pass 12.0s
slides:build ✅ pass 13.2s
verify:fast ✅ pass 123.4s
frontend:build ✅ pass 18.2s
verify:frontend:chunks ✅ pass 0.3s
backend:build ✅ pass 4.7s
cli:build ✅ pass 2.1s
verify:entropy ✅ pass 20.5s

Verification: verify:integration

Result: ✅ PASS

@codecov

codecov Bot commented May 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Captures lessons from PR #269 (diagnosis-by-instrumentation,
rotation-preserving refit math, ghost-pattern reuse, DOM ownership
across renderOverlay rebuilds) and archives the three May 19 slides
tasks (mobile-shell, mobile-toolbar-morph, precise-shape-hit-test)
that shipped in PRs #266 / #268 but stayed in `active/` until now.

@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: 1

🤖 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/tasks/archive/2026/05/20260520-slides-group-handle-bbox-lessons.md`:
- Around line 22-25: The fenced math/code blocks containing the formulas (e.g.,
the blocks with T_old(P), T_new(P) and the block with O_new = O_old − R_θ(...))
are missing a language tag and trigger MD040; update each triple-backtick fence
to include a language identifier such as "text" (e.g., ```text) so the blocks
become fenced as ```text ... ```, ensuring all occurrences (including the block
containing T_old(P)/T_new(P) and the block containing O_new = O_old − R_θ(...),
plus the similar block at lines 31–33) are updated.
🪄 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: f06e0e62-f63b-46ae-b11f-d7ab23e177ba

📥 Commits

Reviewing files that changed from the base of the PR and between 81dbce6 and 79e394d.

📒 Files selected for processing (7)
  • docs/tasks/README.md
  • docs/tasks/archive/2026/05/20260519-slides-mobile-shell-todo.md
  • docs/tasks/archive/2026/05/20260519-slides-mobile-toolbar-morph-todo.md
  • docs/tasks/archive/2026/05/20260519-slides-precise-shape-hit-test-todo.md
  • docs/tasks/archive/2026/05/20260520-slides-group-handle-bbox-lessons.md
  • docs/tasks/archive/2026/05/20260520-slides-group-handle-bbox-todo.md
  • docs/tasks/archive/README.md
💤 Files with no reviewable changes (1)
  • docs/tasks/archive/2026/05/20260520-slides-group-handle-bbox-todo.md
✅ Files skipped from review due to trivial changes (1)
  • docs/tasks/archive/README.md

Comment on lines +22 to +25
```
T_old(P) = R_θ(S · (P − C_old)) + O_old
T_new(P) = R_θ(S · (P − C_new)) + O_new
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add language tags to fenced code blocks to satisfy markdownlint.

Both formula/code fences are missing a language identifier (MD040).

Suggested fix
-```
+```text
 T_old(P) = R_θ(S · (P − C_old)) + O_old
 T_new(P) = R_θ(S · (P − C_new)) + O_new

- +text
O_new = O_old − R_θ(S · (C_old − shift − C_new))

Also applies to: 31-33

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 22-22: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for 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.

In `@docs/tasks/archive/2026/05/20260520-slides-group-handle-bbox-lessons.md`
around lines 22 - 25, The fenced math/code blocks containing the formulas (e.g.,
the blocks with T_old(P), T_new(P) and the block with O_new = O_old − R_θ(...))
are missing a language tag and trigger MD040; update each triple-backtick fence
to include a language identifier such as "text" (e.g., ```text) so the blocks
become fenced as ```text ... ```, ensuring all occurrences (including the block
containing T_old(P)/T_new(P) and the block containing O_new = O_old − R_θ(...),
plus the similar block at lines 31–33) are updated.

@hackerwins
hackerwins merged commit 08bb636 into main May 20, 2026
4 checks passed
@hackerwins
hackerwins deleted the slides-group-handle-rotate-ux branch May 20, 2026 23:52
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