Refit group on drill-out + rotation-preserving handles + multi-rotate#269
Conversation
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.
📝 WalkthroughWalkthroughImplements group frame auto-refitting via ChangesGroup frame refitting and rotation UX
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 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: 1
🧹 Nitpick comments (2)
packages/slides/src/view/editor/editor.ts (1)
2509-2540: 💤 Low valueField declaration placement is inconsistent with class structure.
The
rotateTooltipElfield 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
acquireRotateTooltipandreleaseRotateTooltip.🤖 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 valueMinor 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
📒 Files selected for processing (10)
docs/tasks/active/20260520-slides-group-handle-bbox-todo.mdpackages/frontend/src/app/slides/yorkie-slides-store.tspackages/slides/src/index.tspackages/slides/src/model/group.tspackages/slides/src/store/memory.tspackages/slides/src/store/store.tspackages/slides/src/view/editor/editor.tspackages/slides/src/view/editor/interactions/keyboard.tspackages/slides/test/model/group.test.tspackages/slides/test/store/group-mutations.test.ts
| 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); | ||
| }); |
There was a problem hiding this comment.
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.
Verification: verify:selfResult: ✅ PASS in 207.6s
Verification: verify:integrationResult: ✅ PASS |
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.
There was a problem hiding this comment.
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
📒 Files selected for processing (7)
docs/tasks/README.mddocs/tasks/archive/2026/05/20260519-slides-mobile-shell-todo.mddocs/tasks/archive/2026/05/20260519-slides-mobile-toolbar-morph-todo.mddocs/tasks/archive/2026/05/20260519-slides-precise-shape-hit-test-todo.mddocs/tasks/archive/2026/05/20260520-slides-group-handle-bbox-lessons.mddocs/tasks/archive/2026/05/20260520-slides-group-handle-bbox-todo.mddocs/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
| ``` | ||
| T_old(P) = R_θ(S · (P − C_old)) + O_old | ||
| T_new(P) = R_θ(S · (P − C_new)) + O_new | ||
| ``` |
There was a problem hiding this comment.
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.
Summary
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).worldTightFramehelper computes the math (O_new = O_old − R_θ(S · (C_old − shift − C_new))); used by the overlay for live tight wrapping and byrefitGroupto materialize the same frame into the store. Rotation and scale are preserved; each child's world position is invariant.selection.clickentirely → root cause of the "left/top off" regression), and right-click outside scope.45°tooltip follows the cursor (absolute rotation for single-element, delta for multi).Test plan
pnpm verify:fastgreen (1232 slides + 1268 frontend + 792 docs + 191 sheets + 143 backend)worldChildrenAABB(5 cases),worldTightFrame(no-op + rotation-preserving with moved child), andrefitGroup(no-op, frame shrink, world-position invariance, rotation preservation, rotated-group + moved-child end-to-end, defensive paths)docs/tasks/active/20260520-slides-group-handle-bbox-todo.md):🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
Documentation