Add adjustment drag handles for 9 pilot slides shapes (P3-A.1)#210
Conversation
P2 deferred the adjustments UX to P3 around Google Slides' canonical yellow-diamond drag-handle pattern. P3-A.1 (pilot) covers 9 shapes spanning all 4 axis types — radial (6 stars), linear (roundRect, chevron), point (wedgeRectCallout) — to validate the ADJUSTMENT_HANDLES registry abstraction before P3-A.2 sweeps the remaining 24 shapes mechanically.
Foundation for P3-A.1: per-shape drag-handle metadata. Lives next to AdjustmentSpec so the registry built in the next commit can co-locate position/apply with the existing spec entries.
Empty Map<ShapeKind, AdjustmentHandle[]> with consistency tests (every registered kind must also have a builder + spec). Subsequent commits register the 9 pilot shapes one at a time so each entry gets reviewed alongside its per-shape position/apply math.
Linear axis along the top edge — diamond at (r, 0) where r is the rendered corner radius. Drag horizontally to change ratio. Establishes the per-shape file pattern for the 8 remaining pilot shapes: handle constant + matching .handles.test.ts + one .set() call in shapes/index.ts.
Linear axis along the back V notch. Diamond sits at the inner V tip (inset, h/2). Vertical pointer motion is ignored — drag is purely horizontal because notch depth is one-dimensional.
Point axis — one handle controls both tail-x and tail-y adjustments in a single drag. Demonstrates the multi-index handle case that the AdjustmentHandle type was shaped to support; subsequent 2D-axis shapes follow the same pattern.
Shared factory parametrized by point count handles all 6 stars in P3-A.1; star5 is the canonical first wired up. Math runs in unit-ellipse space (pointer pre-divided by rx,ry) so non-square frames behave consistently with the path builder's ellipse inscription. Pointer projects onto the handle's ray, so perpendicular motion does not change the ratio.
Mechanical follow-up to star5 + radialStarHandle helper. Each star is a one-line export; tests verify position-in-bounds at default and round-trip identity in the clamp range. Registry now covers all 9 P3-A.1 pilot shapes. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Shared by the overlay renderer (default expansion when an element has no data.adjustments yet) and the drag loop (Shift modifier snap-to-default). All-or-nothing snap on multi-index handles matches the user's expectation that Shift produces the canonical shape, not a partially-snapped intermediate.
Yellow-diamond DOM elements with data-handle="adjust-N", appended after resize handles so they sit on top in both DOM stacking and hit-test order. Shows only on single-select; uses default adjustments when data.adjustments is missing (P1-authored shapes). Both rotated and axis-aligned branches paint via the existing overlay container. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Existing handleHitTest already reads data-handle from DOM, so the runtime change is just the type union. Editor pointerdown now routes adjust-N to startAdjustmentDrag (stubbed; real impl lands next commit). Adjustment handles take hit priority over resize handles because they paint later in the DOM.
Mirrors startResize: mousemove paints a live preview of the shape with the adjusted geometry, mouseup commits one store.updateElementData call → one undo entry. 2px threshold prevents accidental commits on click. Shift snaps every adjustment to its default when all components are within 5% of range. Tooltip uses AdjustmentSpec.format for the live readout. Live preview strategy: Option A — new paintLiveAdjustments method that builds a synthetic slide with data overrides (same forceRender+renderOverlay pattern as paintLive), keeping handles in sync during drag. Chose Option A because the renderer already has forceRender for exactly this purpose; it required adding only one private method to editor.ts with zero changes to slide-renderer.ts or any other renderer file.
Drag's paintLive path calls renderOverlay which does overlay.innerHTML = '', detaching the tooltip singleton from the DOM. The singleton ref then short-circuits subsequent show calls, making the tooltip invisible from the second move event onward. Guard with isConnected and re-append when detached. Test wipes the overlay between two show calls and asserts re-attachment.
Verifies single store update on a real drag (>2px) and zero updates when the threshold is not crossed. Catches regressions in the hit-test wiring, threshold, and store.batch invocation in one JSDOM-driven scenario. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Two-row 9-shape grid + one rotated star verifies handle painting across all 4 axis types and through the rotation transform. Top row shows default geometry; bottom row shows authored values that visibly differ so a regression in apply/position math jumps out in the diff. Baselines regenerated and all 152 profile targets pass. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Multi-axis tooltip labels were derived via .charAt(0).toLowerCase() which collided for shapes whose adjustment names share a leading word — wedgeRectCallout's "Tail x"/"Tail y" both resolved to "t", producing the unhelpful "t: 75% / t: 100%". Switching to the last whitespace-delimited word gives "x"/"y" while still working for single-word names. Also corrects the wedgeRectCallout position formula in the spec doc to match the implementation (and the path builder it adjusts): adjustments are signed thousandths relative to the frame center, not absolute fractions.
Records what shipped (15 commits across 4 axis types' worth of handles) and the deviations from the plan that came out at implementation time — most notably the DOM-vs-Canvas overlay (spec was wrong about handles being Canvas-painted), the forceRender approach to live preview, and the tooltip-detach bug + fix flagged by the spec-compliance reviewer. Lessons file calls out two non-blocking follow-ups deferred to P3-A.2: the 8px inset guard near corners (cosmetic at boundary values) and a rotated-frame paint position unit test (math is likely correct; missing the regression guard).
Origin/main extended OverlayOptions with required slideWidth +
slideHeight (for snap-guide drawing). The Task 9 overlay tests
and the Task 11 paintLiveAdjustments call passed only { scale },
which compiled before the rebase but not after. Pass SLIDE_WIDTH /
SLIDE_HEIGHT in both call sites.
Visual baselines regenerated against the rebased state — main's
slides-toolbar changes plus the new shapes-adjustments-pilot card
shifted layout for the harness root and toolbar PNGs.
📝 WalkthroughWalkthroughThis PR completes the Phase 3-A.1 adjustment-handles pilot for slides shapes, implementing interactive drag handles for shape geometry customization across nine pilot shapes (roundRect, chevron, wedgeRectCallout, and six star variants) with live preview, Shift snapping, overlay rendering, editor integration, and comprehensive tests. ChangesAdjustment Handle Pilot Implementation
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Verification: verify:selfResult: ✅ PASS in 179.7s
Verification: verify:integrationResult: ✅ PASS |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
🧹 Nitpick comments (4)
packages/slides/src/view/canvas/shapes/stars/handles.ts (1)
4-5: 💤 Low valueConsider parameterizing
STAR_MIN/STAR_MAXin future.The hardcoded constants duplicate values from each star's
ADJUSTMENT_SPEC. As noted in the lessons document, this is acceptable for the pilot since all 6 stars share the same range, but P3-A.2 should consider passing(min, max)to the factory or reading fromADJUSTMENT_SPECS.get(kind)to avoid silent desync if a future star shape has different bounds.🤖 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/canvas/shapes/stars/handles.ts` around lines 4 - 5, STAR_MIN and STAR_MAX are hardcoded duplicates of ranges defined on each star's ADJUSTMENT_SPEC; update the star handle creation so the min/max are taken from the star's ADJUSTMENT_SPEC (or from ADJUSTMENT_SPECS.get(kind)) or passed into the factory instead of using the constants STAR_MIN/STAR_MAX to avoid silent desync—locate usages in the handle factory and replace references to STAR_MIN/STAR_MAX with the adjustmentSpec.min/adjustmentSpec.max (or injected min/max parameters) for the functions that build star handles.packages/slides/src/view/editor/overlay.ts (1)
216-246: ⚡ Quick winExtract
localToWorldto avoid duplicating the rotation math.The closure at L231-235 is byte-for-byte identical to the one in
renderRotatedHandlesat L119-123. They both transform(lx, ly)from frame-local coords (origin = top-left, scale = frame.w × frame.h) into world coords via a rotation around the frame center. Future tweaks (e.g. a different anchor, or scaling for the rotated path) now have to be made in two places in lockstep.Pulling it into a file-local helper keeps both call sites in sync and shrinks the new function meaningfully. This is per coding guidelines ("Keep functions small and focused on a single responsibility").
♻️ Proposed extraction
+function frameLocalToWorld(frame: Frame) { + const cx = frame.x + frame.w / 2; + const cy = frame.y + frame.h / 2; + const cos = Math.cos(frame.rotation); + const sin = Math.sin(frame.rotation); + return (lx: number, ly: number) => { + const dx = lx - frame.w / 2; + const dy = ly - frame.h / 2; + return { x: cx + dx * cos - dy * sin, y: cy + dx * sin + dy * cos }; + }; +} function renderAdjustmentHandles( overlay: HTMLDivElement, el: Element, options: OverlayOptions, ): void { if (el.type !== 'shape') return; const handles = ADJUSTMENT_HANDLES.get(el.data.kind); if (!handles || handles.length === 0) return; const { scale } = options; const { frame } = el; - const cx = frame.x + frame.w / 2; - const cy = frame.y + frame.h / 2; - const cos = Math.cos(frame.rotation); - const sin = Math.sin(frame.rotation); - const localToWorld = (lx: number, ly: number) => { - const dx = lx - frame.w / 2; - const dy = ly - frame.h / 2; - return { x: cx + dx * cos - dy * sin, y: cy + dx * sin + dy * cos }; - }; + const localToWorld = frameLocalToWorld(frame);And the same swap inside
renderRotatedHandlesat L119-123.🤖 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 216 - 246, Extract the duplicated rotation/translation closure into a file-local helper (e.g., transformLocalToWorld or localToWorldForFrame) and replace the inline localToWorld closures in renderAdjustmentHandles and renderRotatedHandles with calls to that helper; the helper should accept the Element frame (or frame.x, frame.y, frame.w, frame.h, frame.rotation) and return either a function (lx, ly) => {x,y} or accept (frame, lx, ly) and compute cx, cy, cos, sin and the same dx/dy math so behavior remains identical to the existing localToWorld used when computing world coordinates for handles (used alongside ADJUSTMENT_HANDLES/defaultAdjustmentsFor and makeAdjustmentHandle).packages/slides/src/view/editor/editor.ts (2)
957-1012: ⚡ Quick winLocal↔world transform is computed three different ways inside one function.
Within
startAdjustmentDragyou buildcx/cy/cos/sin(L957-960), theworldToLocalclosure (L962-968), and then again the inverse (local→world) inline for the tooltip anchor at L996-1005 — all from the same capturedstartEl.frame. The forward transform also matches the closures inoverlay.ts(renderRotatedHandles/renderAdjustmentHandles). Three copies of the rotation math means a bugfix has to land in three sites to stay correct.A small
frameTransform(frame)helper returning{ localToWorld, worldToLocal }makes the inline tooltip math at L996-1005 a one-liner and aligns this file with the overlay refactor suggested inoverlay.ts. Per coding guidelines, this keepsstartAdjustmentDragfocused on the interaction loop rather than the geometry kernel.♻️ Sketch
- const handleLocal = handle.position( - { w: startEl.frame.w, h: startEl.frame.h }, - live, - ); - const handleWorld = { - x: - cx + - (handleLocal.x - startEl.frame.w / 2) * cos - - (handleLocal.y - startEl.frame.h / 2) * sin, - y: - cy + - (handleLocal.x - startEl.frame.w / 2) * sin + - (handleLocal.y - startEl.frame.h / 2) * cos, - }; + const handleLocal = handle.position( + { w: startEl.frame.w, h: startEl.frame.h }, + live, + ); + const handleWorld = localToWorld(handleLocal.x, handleLocal.y);…where
localToWorldis built alongsideworldToLocalat the top of the function (or imported from a sharedframeTransformhelper used byoverlay.tstoo).🤖 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 957 - 1012, The interaction computes rotation transforms in three places; extract a helper frameTransform(frame) that returns { worldToLocal(wx,wy), localToWorld(lx,ly) } based on startEl.frame (using cx/cy/cos/sin) and replace the inline worldToLocal closure and the tooltip local→world math in startAdjustmentDrag with calls to frameTransform.worldToLocal and frameTransform.localToWorld respectively (update the tooltip call that feeds showAdjustmentTooltip to use localToWorld(handleLocal)). Keep symbol references: startAdjustmentDrag, startEl.frame, worldToLocal, localToWorld, handle.position, paintLiveAdjustments, showAdjustmentTooltip; optionally reuse/import the same helper in overlay.ts to keep geometry consistent.
1129-1149: ⚡ Quick win
lastWordwill collapse labels for future multi-word specs that share a trailing token.For the 9 pilot shapes the spec names produce unambiguous suffixes (e.g.
x/yforwedgeRectCallout), so this is fine in P3-A.1. But the function is the canonical label formatter for every future adjustment registered in P3-A.2 (24 more shapes per PR objectives). The moment a shape ships specs like"Inner radius"+"Outer radius"or"First inset"+"Last inset", both legs render asradius: …/inset: …in the tooltip, and the user can't tell which value is which.Two options that scale cleanly:
- Disambiguate by taking the last two words when the trailing token isn't unique within
specs, or- Let each spec declare a short
labelfield and use that directly instead of slicingname.The lighter-touch fix:
♻️ Disambiguate at format time
function formatAdjustments( specs: readonly { name: string; format?: (v: number) => string }[], values: number[], ): string { if (specs.length === 1) { const v = values[0]; return specs[0].format ? specs[0].format(v) : String(v); } + const labels = specs.map((s) => lastWord(s.name)); + const hasCollision = new Set(labels).size !== labels.length; return specs .map((s, i) => { - const label = lastWord(s.name); + const label = hasCollision ? s.name.toLowerCase() : lastWord(s.name); const value = s.format ? s.format(values[i]) : String(values[i]); return `${label}: ${value}`; }) .join(' / '); }🤖 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 1129 - 1149, formatAdjustments currently calls lastWord(name) which collapses different spec names that share a trailing token; change formatting so labels are disambiguated: in formatAdjustments, for each spec use an explicit short label if provided (e.g. spec.label) otherwise compute the label by inspecting all specs' name values — if the last word is unique use that, but when duplicates exist use the last two words (or more as needed) for that group; update or replace lastWord to return either one- or two-word suffixes based on uniqueness and ensure you still apply the existing s.format when present (refer to formatAdjustments, lastWord, specs[i].name, spec.label and s.format to locate the logic).
🤖 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.
Nitpick comments:
In `@packages/slides/src/view/canvas/shapes/stars/handles.ts`:
- Around line 4-5: STAR_MIN and STAR_MAX are hardcoded duplicates of ranges
defined on each star's ADJUSTMENT_SPEC; update the star handle creation so the
min/max are taken from the star's ADJUSTMENT_SPEC (or from
ADJUSTMENT_SPECS.get(kind)) or passed into the factory instead of using the
constants STAR_MIN/STAR_MAX to avoid silent desync—locate usages in the handle
factory and replace references to STAR_MIN/STAR_MAX with the
adjustmentSpec.min/adjustmentSpec.max (or injected min/max parameters) for the
functions that build star handles.
In `@packages/slides/src/view/editor/editor.ts`:
- Around line 957-1012: The interaction computes rotation transforms in three
places; extract a helper frameTransform(frame) that returns {
worldToLocal(wx,wy), localToWorld(lx,ly) } based on startEl.frame (using
cx/cy/cos/sin) and replace the inline worldToLocal closure and the tooltip
local→world math in startAdjustmentDrag with calls to
frameTransform.worldToLocal and frameTransform.localToWorld respectively (update
the tooltip call that feeds showAdjustmentTooltip to use
localToWorld(handleLocal)). Keep symbol references: startAdjustmentDrag,
startEl.frame, worldToLocal, localToWorld, handle.position,
paintLiveAdjustments, showAdjustmentTooltip; optionally reuse/import the same
helper in overlay.ts to keep geometry consistent.
- Around line 1129-1149: formatAdjustments currently calls lastWord(name) which
collapses different spec names that share a trailing token; change formatting so
labels are disambiguated: in formatAdjustments, for each spec use an explicit
short label if provided (e.g. spec.label) otherwise compute the label by
inspecting all specs' name values — if the last word is unique use that, but
when duplicates exist use the last two words (or more as needed) for that group;
update or replace lastWord to return either one- or two-word suffixes based on
uniqueness and ensure you still apply the existing s.format when present (refer
to formatAdjustments, lastWord, specs[i].name, spec.label and s.format to locate
the logic).
In `@packages/slides/src/view/editor/overlay.ts`:
- Around line 216-246: Extract the duplicated rotation/translation closure into
a file-local helper (e.g., transformLocalToWorld or localToWorldForFrame) and
replace the inline localToWorld closures in renderAdjustmentHandles and
renderRotatedHandles with calls to that helper; the helper should accept the
Element frame (or frame.x, frame.y, frame.w, frame.h, frame.rotation) and return
either a function (lx, ly) => {x,y} or accept (frame, lx, ly) and compute cx,
cy, cos, sin and the same dx/dy math so behavior remains identical to the
existing localToWorld used when computing world coordinates for handles (used
alongside ADJUSTMENT_HANDLES/defaultAdjustmentsFor and makeAdjustmentHandle).
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: f80b2290-9d7f-49f7-9b72-37c100c6de03
⛔ Files ignored due to path filters (8)
packages/frontend/tests/visual/baselines/harness-visual.browser.desktop.dark.pngis excluded by!**/*.pngpackages/frontend/tests/visual/baselines/harness-visual.browser.mobile.dark.pngis excluded by!**/*.pngpackages/frontend/tests/visual/baselines/harness-visual.browser.mobile.pngis excluded by!**/*.pngpackages/frontend/tests/visual/baselines/harness-visual.browser.pngis excluded by!**/*.pngpackages/frontend/tests/visual/baselines/harness-visual.browser.shapes-adjustments-pilot.desktop.dark.pngis excluded by!**/*.pngpackages/frontend/tests/visual/baselines/harness-visual.browser.shapes-adjustments-pilot.mobile.dark.pngis excluded by!**/*.pngpackages/frontend/tests/visual/baselines/harness-visual.browser.shapes-adjustments-pilot.mobile.pngis excluded by!**/*.pngpackages/frontend/tests/visual/baselines/harness-visual.browser.shapes-adjustments-pilot.pngis excluded by!**/*.png
📒 Files selected for processing (38)
docs/design/README.mddocs/design/slides/slides-shapes-p3a-adjustments.mddocs/tasks/active/20260510-slides-shapes-p3a-pilot-lessons.mddocs/tasks/active/20260510-slides-shapes-p3a-pilot-todo.mdpackages/frontend/scripts/verify-visual-browser.mjspackages/frontend/src/app/harness/visual/slides-scenarios.tsxpackages/slides/src/node.tspackages/slides/src/view/canvas/shapes/arrows/chevron.handles.test.tspackages/slides/src/view/canvas/shapes/arrows/chevron.tspackages/slides/src/view/canvas/shapes/basic/round-rect.handles.test.tspackages/slides/src/view/canvas/shapes/basic/round-rect.tspackages/slides/src/view/canvas/shapes/builder.tspackages/slides/src/view/canvas/shapes/callouts/wedge-rect-callout.handles.test.tspackages/slides/src/view/canvas/shapes/callouts/wedge-rect-callout.tspackages/slides/src/view/canvas/shapes/index.test.tspackages/slides/src/view/canvas/shapes/index.tspackages/slides/src/view/canvas/shapes/stars/handles.tspackages/slides/src/view/canvas/shapes/stars/star10.handles.test.tspackages/slides/src/view/canvas/shapes/stars/star10.tspackages/slides/src/view/canvas/shapes/stars/star4.handles.test.tspackages/slides/src/view/canvas/shapes/stars/star4.tspackages/slides/src/view/canvas/shapes/stars/star5.handles.test.tspackages/slides/src/view/canvas/shapes/stars/star5.tspackages/slides/src/view/canvas/shapes/stars/star6.handles.test.tspackages/slides/src/view/canvas/shapes/stars/star6.tspackages/slides/src/view/canvas/shapes/stars/star7.handles.test.tspackages/slides/src/view/canvas/shapes/stars/star7.tspackages/slides/src/view/canvas/shapes/stars/star8.handles.test.tspackages/slides/src/view/canvas/shapes/stars/star8.tspackages/slides/src/view/editor/adjustment-tooltip.test.tspackages/slides/src/view/editor/adjustment-tooltip.tspackages/slides/src/view/editor/editor.test.tspackages/slides/src/view/editor/editor.tspackages/slides/src/view/editor/hit-test.tspackages/slides/src/view/editor/interactions/adjustment.test.tspackages/slides/src/view/editor/interactions/adjustment.tspackages/slides/src/view/editor/overlay.test.tspackages/slides/src/view/editor/overlay.ts
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
Summary
ADJUSTMENT_HANDLESregistry parallel toPATH_BUILDERS/ADJUSTMENT_SPECS; P3-A.2 will register the remaining 24 shapes mechanically.renderOverlay) and resize-drag pattern; onestore.updateElementDataper drag → one undo entry. No Yorkie schema migration; existing P1/P2 documents render bit-identical until the user drags a handle.Spec:
docs/design/slides/slides-shapes-p3a-adjustments.mdPlan + lessons:
docs/tasks/active/20260510-slides-shapes-p3a-pilot-{todo,lessons}.mdKnown limitations (deferred to P3-A.2 follow-ups)
roundRect.adjustments = [0]or callout tail lands at a corner, the yellow diamond visually overlaps the green NW resize handle. Hit priority is correct (adjust handle wins via DOM last-sibling), but the diamond is hidden behind the resize handle. Cosmetic; spec called for the inset.renderAdjustmentHandles) and inverse (worldToLocalinstartAdjustmentDrag) transforms are mirror-symmetric and correct, but a regression in either would manifest as "handle drifts off the rotated shape." Visual harness scenario includes a rotated star (canvas-only — handles are DOM, not in the screenshot).Test plan
pnpm --filter @wafflebase/slides testshapes-adjustments-pilotbaseline regenerated and matches —pnpm verify:browser:dockerpnpm verify:fastandpnpm verify:selfPASSpnpm dev→ open a slides doc, insert a star5; verify yellow diamond on selection, drag → live shape change + tooltip with %, release → one undo entry; repeat for roundRect, chevron, wedgeRectCallout; rotate a shape and verify handle co-rotates and drag still works; multi-select two shapes and verify diamonds disappear.🤖 Generated with Claude Code
Summary by CodeRabbit
Release Notes