Skip to content

Slides: live snap guides + align/distribute toolbar#209

Merged
hackerwins merged 13 commits into
mainfrom
feat/slides-guides-align
May 10, 2026
Merged

Slides: live snap guides + align/distribute toolbar#209
hackerwins merged 13 commits into
mainfrom
feat/slides-guides-align

Conversation

@hackerwins

@hackerwins hackerwins commented May 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Snap guides are now visible during drag — snapDelta emits SnapGuide[] with axis/position/kind, and the overlay paints 1px magenta lines spanning the slide canvas. Guides clear on mouseup via an explicit repaintOverlay() (since render() only repaints the canvas layer).
  • Align / distribute toolbar — new SlidesEditor.align(direction) / distribute(axis) API plus 6 align + 2 distribute buttons in the slides toolbar. Multi-select uses the combined bbox; single-select uses the slide canvas. Distribute requires ≥3 elements. Each call commits in one store.batch for atomic undo.
  • Refactor: Phase 1's "winnerIndex === 0 = slide-center" magic invariant is gone — snap candidates now carry their own kind.

Plan + lessons: docs/tasks/active/20260511-slides-guides-align-todo.md and …-lessons.md. Design doc updated at docs/design/slides/slides.md ("Snap guides + align / distribute" + Known limitations).

Known limitations (documented, follow-ups)

  • Rotated multi-select align: the reference rect is rotation-aware (combinedBoundingBox over rotated AABBs), but values are written directly to the unrotated frame.x/y, so visible left edges of rotated elements don't all coincide. Differs from Google Slides.
  • Distribute float drift: exact !== no-op detection may emit sub-pixel moves on repeated calls. Acceptable for v1; consider EPSILON later.

Test plan

  • pnpm --filter @wafflebase/slides test — 398/398 pass (was 371 before this branch; +27 new tests across snap, overlay, align, editor).
  • pnpm --filter @wafflebase/frontend lint — clean.
  • pnpm verify:fast — green.
  • Manual smoke: drag an element until it crosses slide center → magenta vertical guide appears. Drag near another element's edge → guide appears. Release → guide vanishes.
  • Manual smoke: select 1 element → "Align center" centers it on the slide. Select 3+ elements with non-uniform spacing → "Distribute horizontally" equalizes gaps. Select 0 → align/distribute buttons greyed out. Select 2 → distribute buttons greyed out, align buttons enabled.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added alignment buttons (left, center, right, top, middle, bottom) for repositioning selected slide elements
    • Added distribution controls for equal horizontal and vertical spacing of 3+ elements
    • Added snap guides displaying during drag operations to show alignment references with other elements and slide center

Review Change Stack

@coderabbitai

coderabbitai Bot commented May 10, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@hackerwins has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 38 minutes and 22 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 040796de-0198-4aa1-b900-548d8b3ddea9

📥 Commits

Reviewing files that changed from the base of the PR and between feafe10 and f6bfc86.

⛔ Files ignored due to path filters (38)
  • packages/frontend/tests/visual/baselines/harness-visual.browser.desktop.dark.png is excluded by !**/*.png
  • packages/frontend/tests/visual/baselines/harness-visual.browser.mobile.dark.png is excluded by !**/*.png
  • packages/frontend/tests/visual/baselines/harness-visual.browser.mobile.png is excluded by !**/*.png
  • packages/frontend/tests/visual/baselines/harness-visual.browser.png is excluded by !**/*.png
  • packages/frontend/tests/visual/baselines/harness-visual.browser.slides-canvas-callout-tail.mobile.dark.png is excluded by !**/*.png
  • packages/frontend/tests/visual/baselines/harness-visual.browser.slides-canvas-callout-tail.mobile.png is excluded by !**/*.png
  • packages/frontend/tests/visual/baselines/harness-visual.browser.slides-canvas-default-dark.mobile.dark.png is excluded by !**/*.png
  • packages/frontend/tests/visual/baselines/harness-visual.browser.slides-canvas-default-dark.mobile.png is excluded by !**/*.png
  • packages/frontend/tests/visual/baselines/harness-visual.browser.slides-canvas-default-light.mobile.dark.png is excluded by !**/*.png
  • packages/frontend/tests/visual/baselines/harness-visual.browser.slides-canvas-default-light.mobile.png is excluded by !**/*.png
  • packages/frontend/tests/visual/baselines/harness-visual.browser.slides-canvas-donut-evenodd.mobile.dark.png is excluded by !**/*.png
  • packages/frontend/tests/visual/baselines/harness-visual.browser.slides-canvas-donut-evenodd.mobile.png is excluded by !**/*.png
  • packages/frontend/tests/visual/baselines/harness-visual.browser.slides-canvas-focus.mobile.dark.png is excluded by !**/*.png
  • packages/frontend/tests/visual/baselines/harness-visual.browser.slides-canvas-focus.mobile.png is excluded by !**/*.png
  • packages/frontend/tests/visual/baselines/harness-visual.browser.slides-canvas-layout-big-number.mobile.dark.png is excluded by !**/*.png
  • packages/frontend/tests/visual/baselines/harness-visual.browser.slides-canvas-layout-big-number.mobile.png is excluded by !**/*.png
  • packages/frontend/tests/visual/baselines/harness-visual.browser.slides-canvas-layout-section-header.mobile.dark.png is excluded by !**/*.png
  • packages/frontend/tests/visual/baselines/harness-visual.browser.slides-canvas-layout-section-header.mobile.png is excluded by !**/*.png
  • packages/frontend/tests/visual/baselines/harness-visual.browser.slides-canvas-layout-title-body.mobile.dark.png is excluded by !**/*.png
  • packages/frontend/tests/visual/baselines/harness-visual.browser.slides-canvas-layout-title-body.mobile.png is excluded by !**/*.png
  • packages/frontend/tests/visual/baselines/harness-visual.browser.slides-canvas-material.mobile.dark.png is excluded by !**/*.png
  • packages/frontend/tests/visual/baselines/harness-visual.browser.slides-canvas-material.mobile.png is excluded by !**/*.png
  • packages/frontend/tests/visual/baselines/harness-visual.browser.slides-canvas-shapes-catalog-dark.mobile.dark.png is excluded by !**/*.png
  • packages/frontend/tests/visual/baselines/harness-visual.browser.slides-canvas-shapes-catalog-dark.mobile.png is excluded by !**/*.png
  • packages/frontend/tests/visual/baselines/harness-visual.browser.slides-canvas-shapes-catalog-light.mobile.dark.png is excluded by !**/*.png
  • packages/frontend/tests/visual/baselines/harness-visual.browser.slides-canvas-shapes-catalog-light.mobile.png is excluded by !**/*.png
  • packages/frontend/tests/visual/baselines/harness-visual.browser.slides-canvas-shapes-catalog-material.mobile.dark.png is excluded by !**/*.png
  • packages/frontend/tests/visual/baselines/harness-visual.browser.slides-canvas-shapes-catalog-material.mobile.png is excluded by !**/*.png
  • packages/frontend/tests/visual/baselines/harness-visual.browser.slides-canvas-streamline.mobile.dark.png is excluded by !**/*.png
  • packages/frontend/tests/visual/baselines/harness-visual.browser.slides-canvas-streamline.mobile.png is excluded by !**/*.png
  • packages/frontend/tests/visual/baselines/harness-visual.browser.slides-pickers.mobile.dark.png is excluded by !**/*.png
  • packages/frontend/tests/visual/baselines/harness-visual.browser.slides-pickers.mobile.png is excluded by !**/*.png
  • packages/frontend/tests/visual/baselines/harness-visual.browser.slides-theme-panel.mobile.dark.png is excluded by !**/*.png
  • packages/frontend/tests/visual/baselines/harness-visual.browser.slides-theme-panel.mobile.png is excluded by !**/*.png
  • packages/frontend/tests/visual/baselines/harness-visual.browser.slides-toolbar.desktop.dark.png is excluded by !**/*.png
  • packages/frontend/tests/visual/baselines/harness-visual.browser.slides-toolbar.mobile.dark.png is excluded by !**/*.png
  • packages/frontend/tests/visual/baselines/harness-visual.browser.slides-toolbar.mobile.png is excluded by !**/*.png
  • packages/frontend/tests/visual/baselines/harness-visual.browser.slides-toolbar.png is excluded by !**/*.png
📒 Files selected for processing (8)
  • docs/tasks/README.md
  • docs/tasks/archive/2026/05/20260511-slides-guides-align-lessons.md
  • docs/tasks/archive/2026/05/20260511-slides-guides-align-todo.md
  • docs/tasks/archive/README.md
  • packages/frontend/src/app/slides/slides-formatting-toolbar.tsx
  • packages/slides/src/view/editor/editor.ts
  • packages/slides/src/view/editor/overlay.ts
  • packages/slides/src/view/editor/text-box-editor.test.ts
📝 Walkthrough

Walkthrough

This PR implements snap guides, alignment, and distribution features for the slides editor. Snap guides appear as magenta lines during drag, indicating alignment targets. A new align/distribute toolbar provides buttons to align elements (left/center/right/top/middle/bottom) or distribute them equally (horizontal/vertical), with proper enable/disable logic based on selection size.

Changes

Snap Guides and Alignment/Distribution for Slides Editor

Layer / File(s) Summary
Snap Guide Data Contract
packages/slides/src/view/editor/snap.ts
SnapGuide type introduced with axis, position, and kind fields; snapDelta return signature updated to include guides array alongside dx/dy.
Alignment/Distribution Geometry
packages/slides/src/view/editor/align.ts
New module exports AlignDirection, DistributeAxis, AlignReference types and pure functions alignFrames (single-select centering or multi-select bounding-box alignment) and distributeFrames (equal-gap distribution for ≥3 frames).
Snap Guide Implementation
packages/slides/src/view/editor/snap.ts (implementation)
packages/slides/src/view/editor/snap.test.ts
snapDelta now tags candidates as slide-center or edge, selects best per axis, and returns guide metadata; tests verify slide-center, edge, and empty guide emission.
Alignment/Distribution Tests
packages/slides/src/view/editor/align.test.ts
Comprehensive test suite validates single/multi-frame alignment, equal-gap distribution, no-op skipping, rotation preservation, and edge cases.
Overlay Guide Rendering
packages/slides/src/view/editor/overlay.ts
OverlayOptions now requires slideWidth/slideHeight and accepts optional guides array; renderOverlay conditionally appends guide-line DOM elements; makeGuide helper positions vertical/horizontal lines.
Overlay Test Updates
packages/slides/src/view/editor/overlay.test.ts
All existing tests updated to pass slide dimensions; new tests verify empty guides result in no DOM nodes and validate vertical/horizontal guide positioning at slide-center.
Editor Snap Guide Plumbing
packages/slides/src/view/editor/editor.ts
Drag handling calls snapDelta with guide output, threads guides into paintLive, and explicitly calls repaintOverlay() after commit to clear nodes.
Editor Alignment/Distribution API
packages/slides/src/view/editor/editor.ts
New public methods align(direction) and distribute(axis) collect selected frames, compute updates via pure functions, apply atomically via store.batch, and refresh canvas/overlay.
Editor Integration Tests
packages/slides/src/view/editor/editor.test.ts
Tests verify multi-select alignment, single-select centering, no-op behavior, rotation preservation, and atomic undo batching.
Public Type Exports
packages/slides/src/index.ts
Re-exports alignment types (AlignDirection, DistributeAxis, AlignReference).
Toolbar UI Integration
packages/frontend/src/app/slides/slides-formatting-toolbar.tsx
Adds selectionSize state, wires onAlign/onDistribute callbacks, renders align buttons (disabled when no selection) and distribute buttons (disabled when <3 elements).
Design and Task Documentation
docs/design/slides/slides.md, docs/tasks/README.md, docs/tasks/active/*
Design doc adds "Snap guides + align/distribute" section with semantics and known limitations; task README updated with new active task entry; detailed todo and lessons documents record implementation decisions and outcomes.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • wafflebase/wafflebase#190: This PR extends the slides editor with snap-guide emission, overlay rendering, and align/distribute geometry and toolbar wiring, directly touching the same editor/overlay/snap/toolbar code paths.

Poem

🐰 Snap guides and distribute,
Align the slides so true!
Batch those frames with care,
Multi-phase work shines through—
Layout magic, pixel-fair! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.46% 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Slides: live snap guides + align/distribute toolbar' accurately and concisely captures the two main features added: live snap guides during drag operations and an align/distribute toolbar for the Slides editor.
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 feat/slides-guides-align

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.

hackerwins and others added 10 commits May 11, 2026 03:33
Adds the task plan for making the existing slides snap engine visible
(live alignment guides drawn during drag) and a multi-select
align/distribute toolbar group. Snap math already lives in
view/editor/snap.ts; this plan turns its winning candidate into a
SnapGuide rendered on the overlay layer, then layers Google-Slides-
parity align/distribute commands on top using the same store.batch
commit path as drag.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Phase 1 of the alignment-guides feature. snapDelta now returns a
`guides` array alongside `{dx, dy}`, describing which alignment
line (slide centre or element edge) won the snap on each axis.
A future overlay layer needs this so it can draw the visible guide
lines that appear during a drag — without it, the renderer would
have to recompute the snap candidates itself.

The internal helper bestSnapAdjust now returns the winning
candidate index, so snapDelta can distinguish the slide-centre
candidate (always at index 0 by construction) from element-edge
candidates and pull the right `position` for each.

The existing caller in editor.ts destructures `{dx, dy}` and is
unchanged in this phase — it silently ignores the new `guides`
field. Phase 3 will wire it through.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Code review on the snapDelta refactor flagged two issues. The guide
classification branched on `winnerIndex === 0` to decide slide-center
vs. edge — correct only because the slide-center candidate happened to
be pushed first. Reordering or inserting candidates would silently
mis-classify guides with no test failure. The X/Y guide-building blocks
were also mirror-image duplicates, easy to "fix X but forget Y".

Attach the guide kind to the candidate itself, then `bestSnapAdjust`
returns the winning candidate (with kind) instead of an index. Guide
construction collapses to one shared `toGuide(axis, winner)` helper
with no special-case for slide-center, since the slide-center
candidate's `to` already equals slide.w/2 or slide.h/2.

Also tighten the new snap.test.ts guide assertions from
`toContainEqual` to full-array `toEqual`. This caught a phantom y-axis
guide in the edge-snap test (the other element shared y=0 with the
dragged bbox, accidentally emitting a zero-diff y snap); fixed by
moving the test fixture to y=400 so only the intended axis snaps.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Phase 1 added a `guides: SnapGuide[]` field to the result of
`snapDelta` so callers can know which alignment line(s) won the snap.
This phase makes that information visible: `renderOverlay` now accepts
optional `guides` plus the (now required) slide dimensions and draws
one 1px magenta line per guide spanning the full slide width / height.

The guides are rendered as DOM children of the existing overlay layer
(absolute-positioned, `pointer-events: none`) so the canvas paint path
is untouched and clearing the overlay on the next render naturally
removes them. Only the axis-aligned `renderOverlay` path emits guides
— rotated single-element selection skips them, since the drag handler
always operates on the axis-aligned bbox and rotated handles are only
shown while idle.

`slideWidth`/`slideHeight` are required on `OverlayOptions` rather
than defaulted; every call site (3 in `editor.ts`, several in tests)
already has access to `SLIDE_WIDTH` / `SLIDE_HEIGHT` so plumbing them
through is cheap and keeps the helper free of presentation-model
imports.

Phase 3 will wire `snapDelta(...).guides` through the drag handler
and into this new field.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Phases 1 + 2 added snap guide info to `snapDelta()` and made the
overlay renderer accept a `guides` array, but the drag handler in
`editor.ts` still ignored both. Phase 3 connects them: capture
`guides` from `snapDelta` on every mousemove, pass them through
`paintLive` to `renderOverlay`, and refresh the overlay on
mouseup so the magenta guide nodes disappear when the drop
commits.

`render()` only repaints the canvas, so an explicit
`repaintOverlay()` is needed in `onUp` — without it the guide
DOM nodes from the last `paintLive` would persist after the
drag ends.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Phase 4 of the slides-guides-align plan. Pure helpers that compute
new frames for the upcoming editor.align() / editor.distribute() API
in Phase 5.

alignFrames takes a reference rectangle (caller picks: combined bbox
for multi-select, slide canvas for single-select) and one of six
directions. distributeFrames spreads inner frames so consecutive gaps
are equal while pinning the endpoints; it no-ops for fewer than three
frames. Both return id->new-frame ONLY for frames that actually moved
so the editor can keep store batches tight.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Phase 5 of slides-guides-align. Wires Phase 4's pure alignFrames /
distributeFrames into the SlidesEditor surface so the upcoming Phase 6
toolbar can call editor.align(direction) and editor.distribute(axis)
directly. Both methods follow the same store.batch + markDirty + render
pattern that drag/resize already use, ensuring align/distribute land as
a single atomic undo step rather than one undo per moved element.

Behavior:
  - align: aligns to the combined bbox when ≥ 2 elements are selected,
    and to the slide canvas (1920×1080) when exactly 1 is selected.
    No-op for an empty selection.
  - distribute: equalizes gaps between consecutive elements on the
    given axis. No-op for fewer than 3 elements (undefined for 0/1/2).
  - Both methods skip the store.batch entirely when the pure function
    returns no moved frames, so we don't pollute undo history with
    empty transactions.

The collectSelectedFrames helper defends against ids that may have been
removed remotely between selection and the toolbar action.

AlignDirection / DistributeAxis / AlignReference are re-exported from
the package barrel so the toolbar can type-check its dispatch table;
the alignFrames / distributeFrames helpers themselves stay internal.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Address two Important review follow-ups on the slides align/distribute
editor API:

- markDirty consistency: applyFrameUpdates was the only call site in
  editor.ts using `this.markDirty()`. Every other site goes through
  `this.renderer.markDirty()`. Switch it to match.
- Rotation contract: the JSDoc on `align` and `distribute` did not say
  what happens to rotated elements. Add one sentence to each saying
  the assigned position is written directly to `frame.x/y` and
  `frame.rotation` is preserved (matches Google Slides). Lock that
  contract in with one editor-level test that aligns two elements at
  different rotations and asserts both `frame.rotation` values are
  unchanged after `align('left')`.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Phase 6 of slides-guides-align: surfaces the SlidesEditor `align`
and `distribute` actions landed in Phase 5 as a row of 8 toolbar
buttons (6 align + 2 distribute) in the slides formatting
toolbar. Align buttons are disabled when nothing is selected;
distribute buttons require 3+ selected elements, mirroring
Google Slides' ergonomics and the editor's no-op semantics so
the disabled state communicates the requirement before the click.

Selection size is tracked via a new state slot updated inside
the existing selection/store refresh callback so remote peer
edits (e.g., a collaborator deleting a selected element) keep
the disabled state in sync without a second effect.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Document the snap-guide rendering (overlay layer, magenta 1px lines,
explicit repaintOverlay clear) and the SlidesEditor.align/distribute
API (multi-select bbox vs single-select slide reference, atomic
store.batch, toolbar disabled-state rules). Capture two known v1
limitations: rotated multi-select align writes to unrotated frame
fields with a rotation-aware reference, so rotated elements' visible
left edges may not coincide; and idempotent distribute may emit
sub-pixel float drift into the undo stack.

Lessons file captures the seven cross-phase learnings, including
the magic-index refactor, render() vs repaintOverlay asymmetry, and
the implementer pushback on a wrong spec assumption that surfaced
the rotated-align divergence.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@hackerwins
hackerwins force-pushed the feat/slides-guides-align branch from 9e228a7 to feafe10 Compare May 10, 2026 18:36

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/slides/src/view/editor/overlay.ts (1)

40-43: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Snap guides are skipped for rotated single-element selections.

At Line 40 the rotated path returns at Line 42, so the guide rendering block at Line 85 is never reached. This drops live snap-guide feedback when dragging a single rotated element.

🔧 Suggested fix
 export function renderOverlay(
   overlay: HTMLDivElement,
   selectedElements: readonly Element[],
   options: OverlayOptions,
 ): void {
   overlay.innerHTML = '';
   if (selectedElements.length === 0) return;

-  if (selectedElements.length === 1 && selectedElements[0].frame.rotation !== 0) {
-    renderRotatedHandles(overlay, selectedElements[0].frame, options);
-    return;
-  }
-
-  const bbox = combinedBoundingBox(selectedElements.map((e) => e.frame));
-  if (!bbox) return;
-
-  const { scale } = options;
-  const left = bbox.x * scale;
-  const top = bbox.y * scale;
-  const width = bbox.w * scale;
-  const height = bbox.h * scale;
-
-  // Selection frame outline (no data-handle — purely decorative).
-  const frame = document.createElement('div');
-  frame.className = 'wfb-slides-selection-frame';
-  frame.style.position = 'absolute';
-  frame.style.left = `${left}px`;
-  frame.style.top = `${top}px`;
-  frame.style.width = `${width}px`;
-  frame.style.height = `${height}px`;
-  frame.style.pointerEvents = 'none';
-  frame.style.boxSizing = 'border-box';
-  frame.style.border = '1px solid `#3a7`';
-  overlay.appendChild(frame);
-
-  const positions: Array<[string, number, number]> = [
-    ['nw', left,                top],
-    ['n',  left + width / 2,    top],
-    ['ne', left + width,        top],
-    ['e',  left + width,        top + height / 2],
-    ['se', left + width,        top + height],
-    ['s',  left + width / 2,    top + height],
-    ['sw', left,                top + height],
-    ['w',  left,                top + height / 2],
-    ['rotate', left + width / 2, top - ROTATE_HANDLE_OFFSET],
-  ];
-  for (const [kind, cx, cy] of positions) {
-    overlay.appendChild(makeHandle(kind, cx, cy));
+  if (selectedElements.length === 1 && selectedElements[0].frame.rotation !== 0) {
+    renderRotatedHandles(overlay, selectedElements[0].frame, options);
+  } else {
+    const bbox = combinedBoundingBox(selectedElements.map((e) => e.frame));
+    if (!bbox) return;
+
+    const { scale } = options;
+    const left = bbox.x * scale;
+    const top = bbox.y * scale;
+    const width = bbox.w * scale;
+    const height = bbox.h * scale;
+
+    const frame = document.createElement('div');
+    frame.className = 'wfb-slides-selection-frame';
+    frame.style.position = 'absolute';
+    frame.style.left = `${left}px`;
+    frame.style.top = `${top}px`;
+    frame.style.width = `${width}px`;
+    frame.style.height = `${height}px`;
+    frame.style.pointerEvents = 'none';
+    frame.style.boxSizing = 'border-box';
+    frame.style.border = '1px solid `#3a7`';
+    overlay.appendChild(frame);
+
+    const positions: Array<[string, number, number]> = [
+      ['nw', left,                top],
+      ['n',  left + width / 2,    top],
+      ['ne', left + width,        top],
+      ['e',  left + width,        top + height / 2],
+      ['se', left + width,        top + height],
+      ['s',  left + width / 2,    top + height],
+      ['sw', left,                top + height],
+      ['w',  left,                top + height / 2],
+      ['rotate', left + width / 2, top - ROTATE_HANDLE_OFFSET],
+    ];
+    for (const [kind, cx, cy] of positions) {
+      overlay.appendChild(makeHandle(kind, cx, cy));
+    }
   }

   // Snap guide lines ...
   if (options.guides && options.guides.length > 0) {
🤖 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/overlay.ts` around lines 40 - 43, The early
return after renderRotatedHandles skips the snap-guide rendering for single
rotated selections; change the logic in the overlay handling so rotated
single-element cases still run the snap-guide block — either remove the return
after renderRotatedHandles or invoke the snap-guide renderer (e.g.,
renderSnapGuides or the existing guide rendering function) after calling
renderRotatedHandles, ensuring you pass the same overlay and frame/options
(selectedElements[0].frame, options) so live snap guides appear for rotated
selections.
🤖 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/frontend/src/app/slides/slides-formatting-toolbar.tsx`:
- Around line 136-139: The refresh() handler currently sets selectionSize from
editor.getSelection().length which can include ids removed remotely; change it
to compute the count from the current slide snapshot (the same collection used
by collectSelectedFrames/readSingleSelectedElement) so the toolbar reflects only
still-existing selected elements. In practice, inside refresh() use the existing
selection-reading helpers (e.g., call collectSelectedFrames(store, editor) or
filter editor.getSelection() against the current slide elements) and pass that
filtered length to setSelectionSize while still calling
setSelected(readSingleSelectedElement(store, editor)) so enablement for
align/distribute matches what the editor can act on.

In `@packages/slides/src/view/editor/editor.ts`:
- Around line 313-337: Both align() and distribute() can mutate frame geometry
while a text editor is mounted, causing the on-screen editor to diverge; add a
guard at the start of align() and distribute() that returns early when
this.editingElementId !== null (or call the existing finish/commitEditing method
if you prefer to end edit mode first). Locate the methods align and distribute,
check editingElementId, and skip performing
collectSelectedFrames/applyFrameUpdates when editingElementId is set to avoid
updating persisted frame positions while the inline text editor is active.

---

Outside diff comments:
In `@packages/slides/src/view/editor/overlay.ts`:
- Around line 40-43: The early return after renderRotatedHandles skips the
snap-guide rendering for single rotated selections; change the logic in the
overlay handling so rotated single-element cases still run the snap-guide block
— either remove the return after renderRotatedHandles or invoke the snap-guide
renderer (e.g., renderSnapGuides or the existing guide rendering function) after
calling renderRotatedHandles, ensuring you pass the same overlay and
frame/options (selectedElements[0].frame, options) so live snap guides appear
for rotated selections.
🪄 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: 41f8500b-e3b0-4860-9460-ce834a8cf790

📥 Commits

Reviewing files that changed from the base of the PR and between 20b13a5 and feafe10.

📒 Files selected for processing (14)
  • docs/design/slides/slides.md
  • docs/tasks/README.md
  • docs/tasks/active/20260511-slides-guides-align-lessons.md
  • docs/tasks/active/20260511-slides-guides-align-todo.md
  • packages/frontend/src/app/slides/slides-formatting-toolbar.tsx
  • packages/slides/src/index.ts
  • packages/slides/src/view/editor/align.test.ts
  • packages/slides/src/view/editor/align.ts
  • packages/slides/src/view/editor/editor.test.ts
  • packages/slides/src/view/editor/editor.ts
  • packages/slides/src/view/editor/overlay.test.ts
  • packages/slides/src/view/editor/overlay.ts
  • packages/slides/src/view/editor/snap.test.ts
  • packages/slides/src/view/editor/snap.ts

Comment thread packages/frontend/src/app/slides/slides-formatting-toolbar.tsx
Comment thread packages/slides/src/view/editor/editor.ts
Mark plan items complete and run pnpm tasks:archive — moves the todo
and lessons files to docs/tasks/archive/2026/05/ and refreshes the
active + archive indexes. The implementation work is shipped on this
branch; the docs follow.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@github-actions

github-actions Bot commented May 10, 2026

Copy link
Copy Markdown
Contributor

Verification: verify:self

Result: ✅ PASS in 172.4s

Lane Status Duration
sheets:build ✅ pass 12.6s
docs:build ✅ pass 11.9s
slides:build ✅ pass 9.3s
verify:fast ✅ pass 94.7s
frontend:build ✅ pass 18.0s
verify:frontend:chunks ✅ pass 0.3s
backend:build ✅ pass 4.7s
cli:build ✅ pass 2.0s
verify:entropy ✅ pass 18.8s

Verification: verify:integration

Result: ✅ PASS

@codecov

codecov Bot commented May 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

hackerwins and others added 2 commits May 11, 2026 03:49
Three findings from CodeRabbit:

1. align/distribute could mutate frame.x/y while a text-box editor was
   mounted, leaving the editor DOM at stale coords until the next mount
   cycle. Guard both methods with `editingElementId !== null`. New
   integration test in text-box-editor.test.ts pins the invariant via
   the existing mountTextBox mock.

2. The toolbar's selectionSize was the raw editor.getSelection().length,
   so a remote delete could leave align/distribute buttons enabled even
   though the editor would no-op. Filter against the current slide's
   elements in the refresh callback so the count matches what
   collectSelectedFrames in editor.ts will actually act on.

3. renderOverlay's rotated-handles branch returned before the guide
   render block, so a single rotated element being dragged got snap
   correction without visual feedback. Extract the axis-aligned handle
   path into renderAxisAlignedHandles and let the guide loop run after
   either branch.

Slides tests: 399 (was 398). Frontend lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Adding 8 align + 2 distribute buttons to the slides formatting
toolbar reflows every mobile slides harness scenario (toolbar height
wraps differently) and shifts the dedicated slides-toolbar desktop
capture. Same kind of refresh as PR #207 did for #206.

37 baselines updated: 33 slides mobile scenarios + 2 slides-toolbar
desktop (light + dark.dark profile) + 4 root harness pages
(`harness-visual.browser.{,desktop.dark,mobile,mobile.dark}.png`).
Generated by `pnpm verify:browser:docker:update`.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@hackerwins
hackerwins merged commit d657c52 into main May 10, 2026
1 check passed
@hackerwins
hackerwins deleted the feat/slides-guides-align branch May 10, 2026 18:58
@hackerwins hackerwins mentioned this pull request May 11, 2026
6 tasks
hackerwins added a commit that referenced this pull request May 11, 2026
Adds @wafflebase/slides as a third surface alongside Sheets and Docs,
plus 53-shape library (Phase 1+2), adjustment handles for 9 pilot
shapes (P3-A.1), live snap guides, align/distribute toolbar, themed
authoring, and layout-change UI. Also ships Sheets cell comments
(Phase B), docs peer-avatar caret jump, yorkie-js-sdk 0.7.8 upgrade,
and CLI/REST API improvements (docs export imageFetcher, expired
session refresh, REST docs split). Minor bump because slides is a
new top-level package.

Highlights: #184 #185 #186 #187 #188 #189 #190 #191 #192 #197 #198
#201 #202 #203 #204 #205 #206 #207 #209 #210
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