Add slides Format options right panel (v1)#309
Conversation
📝 WalkthroughWalkthroughThis PR implements a comprehensive Format options panel for the slides editor, enabling right-side editing of slide element properties (size, position, text fitting, image adjustments, alt text). It introduces a unified right-panel state, adds unit preference persistence, enhances text autofit to 3-state behavior, and includes full test coverage and documentation. ChangesFormat Options Panel Feature
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 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 265.1s
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.
Actionable comments posted: 2
🧹 Nitpick comments (5)
packages/slides/src/model/migrate.ts (1)
18-21: ⚡ Quick winConsider expanding the comment to explain why explicit preservation is needed.
The current comment states what the code does, but the lessons doc reveals an important why:
migrateDocumentreconstructsmetafrom scratch (lines 13-17), so any new optional field will be dropped unless explicitly preserved here. This is a recurring pattern that future contributors adding Meta fields should understand.📚 Suggested enhancement
- // Preserve the optional unit field if present and valid. + // migrateDocument reconstructs meta from a fixed literal (lines 13-17), + // so optional fields like unit are dropped unless explicitly preserved. + // Every new Meta field needs a similar preservation block. if (raw?.meta?.unit === 'in' || raw?.meta?.unit === 'cm') { meta.unit = raw.meta.unit; }As per coding guidelines, "Write clear comments explaining the 'why' behind complex logic, not just the 'what'". The lesson doc at
docs/tasks/archive/2026/05/20260529-slides-format-options-panel-lessons.mdlines 5-16 identifies this as a recurring pattern that surprised the implementer.🤖 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/model/migrate.ts` around lines 18 - 21, The comment above the unit-preservation branch is too terse; update it to explain why explicit preservation is required by noting that migrateDocument reconstructs meta from scratch (see migrateDocument) so any optional fields like meta.unit would be dropped unless explicitly carried over; modify the comment near the if (raw?.meta?.unit === 'in' || raw?.meta?.unit === 'cm') block to state that this preserves optional Meta fields across migration because migrateDocument rebuilds meta and would otherwise omit newly added optional properties.packages/slides/test/store/mem-set-unit.test.ts (2)
4-28: ⚖️ Poor tradeoffConsider adding test coverage for YorkieSlidesStore.setUnit.
The test suite only covers
MemSlidesStore, butYorkieSlidesStoreis the production implementation used in the collaborative editor. Test coverage for both implementations helps ensure consistency and catch implementation-specific bugs. As per coding guidelines, critical business logic should have test coverage.🤖 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/mem-set-unit.test.ts` around lines 4 - 28, Add equivalent tests for YorkieSlidesStore.setUnit alongside the existing MemSlidesStore cases: create a YorkieSlidesStore instance and mirror the four tests (default meta.unit undefined, setUnit('cm') writes 'cm', setUnit('in') writes 'in', and setUnit rejects invalid values) in the same test file (or a new parallel test file) to ensure parity; reference the YorkieSlidesStore class and its setUnit method, use its batch API like the MemSlidesStore tests, and assert via yorkieStore.read().meta.unit and expect(...).toThrow(/invalid unit/i) for the invalid case.
4-28: ⚡ Quick winAdd test coverage for batch requirement enforcement.
The test suite should verify that calling
setUnitoutside of abatch()throws an error, as this is a critical contract of the store interface. As per coding guidelines, tests should cover critical business logic and edge cases.🧪 Suggested test case
it('setUnit throws when called outside batch', () => { const store = new MemSlidesStore(); expect(() => store.setUnit('cm')).toThrow(/batch/i); });🤖 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/mem-set-unit.test.ts` around lines 4 - 28, Add a test to enforce the batch requirement for MemSlidesStore.setUnit: in the MemSlidesStore.setUnit test suite, add a test that instantiates MemSlidesStore and asserts that calling store.setUnit('cm') outside of store.batch(...) throws an error matching /batch/i; reference the MemSlidesStore class and setUnit method to locate where to add the new test.packages/frontend/tests/app/slides/format-panel/image-adjustments-section.test.tsx (1)
50-62: ⚡ Quick winAdd coverage for the keyboard commit path.
This suite only verifies
pointerUp. Once the keyboard commit gap inimage-adjustments-section.tsxis addressed, add a case that fireskeyUpafter a value change and assertsonCommitis invoked, to lock in the accessibility fix.🤖 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/frontend/tests/app/slides/format-panel/image-adjustments-section.test.tsx` around lines 50 - 62, Add a new test in image-adjustments-section.test.tsx that mirrors the existing pointerUp case but exercises the keyboard commit path: render ImageAdjustmentsSection with elements like img('a', 1) and img('b', 1) and a mock onCommit, change the transparency slider (screen.getByLabelText(/transparency/i)) to a new value, then fire a keyUp event on the slider and assert onCommit was called with ['a','b'] and the expected opacity (e.g., 0.6); this ensures ImageAdjustmentsSection's keyboard commit path invokes the same onCommit handler as pointerUp.packages/frontend/src/app/slides/format-panel/size-position-section.tsx (1)
69-73: ⚡ Quick winLock reset depends on
elementsarray identity, which may be fragile.The effect resets
lockedwhenever theelementsreference changes. If the parent (FormatPanel) deriveselementsby recreating the array each render (e.g.selection.map(...)/filter(...)), any unrelated re-render that preserves the same selection will produce a new array reference and silently fliplockedback tofalsemid-edit. Keying on a stable selection identity avoids this.♻️ Key the reset on selection ids
const [locked, setLocked] = useState(false); // Reset lock state when the selection changes. - useEffect(() => { - setLocked(false); - }, [elements]); + const selectionKey = ids.join('\u0000'); + useEffect(() => { + setLocked(false); + }, [selectionKey]);Confirm whether
FormatPanelpasses a referentially stableelementsarray:#!/bin/bash # Inspect how `elements` is built/passed to SizePositionSection in the panel shell. fd -e tsx -e ts . --full-path 'format-panel/index' --exec cat {} rg -nP -C4 '<SizePositionSection' --type=tsx🤖 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/frontend/src/app/slides/format-panel/size-position-section.tsx` around lines 69 - 73, The effect resetting locked currently depends on the referential identity of elements (useEffect(() => setLocked(false), [elements])), which is fragile; change it to depend on a stable selection identity such as the set of element ids instead: derive a stable array or string of ids (e.g. selectionIds = elements.map(e => e.id).join(','), or useMemo(() => elements.map(e => e.id), [elements]) and then useEffect(() => setLocked(false), [selectionIds]); alternatively, make FormatPanel pass a referentially stable elements prop to SizePositionSection; update the dependency array and the derivation of selectionIds near the locked/setLocked and useEffect code accordingly (referencing locked, setLocked, useEffect, elements, SizePositionSection, FormatPanel).
🤖 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/format-panel/image-adjustments-section.tsx`:
- Around line 46-56: The range input currently only calls onCommit inside the
onPointerUp handler so keyboard users never trigger commits; add a shared commit
function (e.g., commit) inside the component that computes opacity as 1 - draft
/ 100 and calls onCommit(elements.map(el => el.id), opacity), then invoke that
commit from both onPointerUp and from a new onKeyUp (and/or onBlur) handler;
keep existing onChange to update draft via setDraft so keyboard adjustments
update state and are committed via the new handlers.
In `@packages/frontend/src/app/slides/yorkie-slides-store.ts`:
- Around line 785-793: Move the validation inside setUnit to after the call to
requireBatch so it matches other mutators: call this.requireBatch() first, then
validate the unit and throw if invalid, and finally call this.doc.update((r) =>
{ r.meta.unit = unit; }); reference functions: setUnit, requireBatch, and
this.doc.update / r.meta.unit to locate and update the code.
---
Nitpick comments:
In `@packages/frontend/src/app/slides/format-panel/size-position-section.tsx`:
- Around line 69-73: The effect resetting locked currently depends on the
referential identity of elements (useEffect(() => setLocked(false),
[elements])), which is fragile; change it to depend on a stable selection
identity such as the set of element ids instead: derive a stable array or string
of ids (e.g. selectionIds = elements.map(e => e.id).join(','), or useMemo(() =>
elements.map(e => e.id), [elements]) and then useEffect(() => setLocked(false),
[selectionIds]); alternatively, make FormatPanel pass a referentially stable
elements prop to SizePositionSection; update the dependency array and the
derivation of selectionIds near the locked/setLocked and useEffect code
accordingly (referencing locked, setLocked, useEffect, elements,
SizePositionSection, FormatPanel).
In
`@packages/frontend/tests/app/slides/format-panel/image-adjustments-section.test.tsx`:
- Around line 50-62: Add a new test in image-adjustments-section.test.tsx that
mirrors the existing pointerUp case but exercises the keyboard commit path:
render ImageAdjustmentsSection with elements like img('a', 1) and img('b', 1)
and a mock onCommit, change the transparency slider
(screen.getByLabelText(/transparency/i)) to a new value, then fire a keyUp event
on the slider and assert onCommit was called with ['a','b'] and the expected
opacity (e.g., 0.6); this ensures ImageAdjustmentsSection's keyboard commit path
invokes the same onCommit handler as pointerUp.
In `@packages/slides/src/model/migrate.ts`:
- Around line 18-21: The comment above the unit-preservation branch is too
terse; update it to explain why explicit preservation is required by noting that
migrateDocument reconstructs meta from scratch (see migrateDocument) so any
optional fields like meta.unit would be dropped unless explicitly carried over;
modify the comment near the if (raw?.meta?.unit === 'in' || raw?.meta?.unit ===
'cm') block to state that this preserves optional Meta fields across migration
because migrateDocument rebuilds meta and would otherwise omit newly added
optional properties.
In `@packages/slides/test/store/mem-set-unit.test.ts`:
- Around line 4-28: Add equivalent tests for YorkieSlidesStore.setUnit alongside
the existing MemSlidesStore cases: create a YorkieSlidesStore instance and
mirror the four tests (default meta.unit undefined, setUnit('cm') writes 'cm',
setUnit('in') writes 'in', and setUnit rejects invalid values) in the same test
file (or a new parallel test file) to ensure parity; reference the
YorkieSlidesStore class and its setUnit method, use its batch API like the
MemSlidesStore tests, and assert via yorkieStore.read().meta.unit and
expect(...).toThrow(/invalid unit/i) for the invalid case.
- Around line 4-28: Add a test to enforce the batch requirement for
MemSlidesStore.setUnit: in the MemSlidesStore.setUnit test suite, add a test
that instantiates MemSlidesStore and asserts that calling store.setUnit('cm')
outside of store.batch(...) throws an error matching /batch/i; reference the
MemSlidesStore class and setUnit method to locate where to add the new test.
🪄 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: 3002d7eb-ae09-4d92-bf6d-c7dfb777c1bb
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (36)
docs/design/README.mddocs/design/slides/slides-format-options-panel.mddocs/tasks/README.mddocs/tasks/archive/2026/05/20260529-slides-format-options-panel-lessons.mddocs/tasks/archive/2026/05/20260529-slides-format-options-panel-todo.mddocs/tasks/archive/README.mdpackages/frontend/package.jsonpackages/frontend/src/app/slides/format-panel/alt-text-section.tsxpackages/frontend/src/app/slides/format-panel/image-adjustments-section.tsxpackages/frontend/src/app/slides/format-panel/index.tsxpackages/frontend/src/app/slides/format-panel/pick-sections.tspackages/frontend/src/app/slides/format-panel/size-position-section.tsxpackages/frontend/src/app/slides/format-panel/text-fitting-section.tsxpackages/frontend/src/app/slides/format-panel/units.tspackages/frontend/src/app/slides/slides-detail.tsxpackages/frontend/src/app/slides/toolbar/global-controls.tsxpackages/frontend/src/app/slides/toolbar/image-controls.tsxpackages/frontend/src/app/slides/toolbar/index.tsxpackages/frontend/src/app/slides/yorkie-slides-store.tspackages/frontend/tests/app/slides/format-panel/alt-text-section.test.tsxpackages/frontend/tests/app/slides/format-panel/image-adjustments-section.test.tsxpackages/frontend/tests/app/slides/format-panel/pick-sections.test.tspackages/frontend/tests/app/slides/format-panel/size-position-section.test.tsxpackages/frontend/tests/app/slides/format-panel/text-fitting-section.test.tsxpackages/frontend/tests/app/slides/format-panel/units.test.tspackages/frontend/tests/setup.tspackages/frontend/vite.config.tspackages/slides/src/model/migrate.tspackages/slides/src/model/presentation.tspackages/slides/src/store/memory.tspackages/slides/src/store/store.tspackages/slides/src/view/editor/overlay.tspackages/slides/src/view/editor/text-box-editor.tspackages/slides/test/store/mem-set-unit.test.tspackages/slides/test/view/editor/autofit-toggle.test.tspackages/slides/test/view/text-box-autofit-gating.test.ts
Slides v1 deferred the right-side Format options panel — users have nowhere to enter precise numeric sizes, positions, or rotations and can only reach autofit and image alt text through ad-hoc spots in the toolbar. The contextual toolbar is the wrong place for precise inputs; a Google-Slides-style right panel is the established solution. v1 of the panel covers only properties already in the data model (Size & Position, Text fitting, Image opacity, Alt text), so the change is a single optional `Meta.unit` field and additive UI. Drop shadow, reflection, recolor, brightness/contrast, text padding, and numeric shape adjustments stay deferred — each needs new model fields and renderer/PPTX work that does not belong with the panel shell. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
14-task plan covering Meta.unit field + setUnit on both stores, pure helpers (units.ts, pick-sections.ts), four section components with TDD, FormatPanel shell, toolbar Format button, rightPanel union in slides-detail, browser smoke, and final verification.
The right-side Format options panel needs a persisted in/cm preference so each peer sees the same setting and so the ruler (separate spec) can pick up the same field later. Adding it as optional on Meta keeps existing serialized decks unchanged. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Mirrors MemSlidesStore.setUnit so the Format panel writes the in/cm preference through Yorkie. Peers receive the change via the normal store onChange subscription.
Pure helpers backing the Format options panel — px↔inch/cm at 192 px/in (canvas-derived), rad↔deg, and a multi-select common- value reducer. Kept in their own module so they unit-test without React mounting. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Pure mapping from PanelSelection to the ordered list of sections the shell should render. Idle returns empty so the shell renders the empty-state hint. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Textarea bound to image.alt with draft state, onBlur commit, and mixed-value placeholder. Also installs @testing-library/react, adds tests/setup.ts with afterEach cleanup, and extends the vitest include pattern to cover .tsx test files. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Maps the existing image.opacity field to a 0..100% transparency slider. pointerUp commit so a drag produces one undo entry per adjustment session. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Direct 3-radio control for none/shrink/grow. Same autofit field the existing in-canvas toggle writes — both UI surfaces stay in sync via the store onChange path. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Numeric inputs with onBlur/Enter commit, Escape revert, mixed- value blank placeholder, in/cm radio, 90 deg rotation buttons, and a local-state lock-aspect toggle that hands off per-element proportional resize to the parent. Connector W/H/rotation hidden, X/Y disabled when an endpoint is attached. text-element + autofit= grow disables H with tooltip. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Derives PanelSelection from editor + store, routes to sections via pickSections, owns the commit callbacks that wrap store.batch. Subscribes to both selection and store changes so the panel follows remote edits and selection swaps.
The Format panel's Alt text section is the single source. Removing the toolbar dropdown avoids two-surface drift.
Right global zone gains a Format button next to Theme. Wired via two optional props so mobile and read-only call sites can keep ignoring them.
themePanelOpen boolean is generalized to a single rightPanel union so Theme and Format are mutually exclusive in the slot. Opening one closes the other. Mobile and the read-only viewer are untouched — they never pass the format toggle props, so the toolbar button stays hidden there.
Match the existing Theme toggle pattern so both right-zone buttons share visual treatment, focus ring, and pressed state styling.
Plan and lessons for the 13-task implementation. T13 browser smoke deferred — slides has no interaction harness today; coverage gap closed by 45+ unit tests across T3–T8.
The panel is w-72 (288px). Rotation label + input + ° unit takes ~216px on its own; adding two 90° buttons inline overflows. Splitting the row keeps the input visible at full width and lets the buttons sit right-aligned underneath.
W/H/X/Y/Rotation labels share an 80px left column; inputs and the unit suffix share the right column. Lock-aspect, rotate-90 buttons, and the Units radio row now indent under the input column instead of floating left or right-aligned, producing one consistent grid across the section.
W/H/X/Y/Rotation now use shadcn <Input> (h-7 compact); lock and rotate buttons use shadcn <Button> with Tabler icons (IconLock/ IconLockOpen, IconRotate/IconRotateClockwise) replacing emoji and unicode arrows. Alt-text textarea inherits the same border and focus-ring styling. Text-fitting radios share the text-xs type scale with the rest of the panel.
The bottom-left in-context toggle on a selected text box now advances none → shrink → grow → none, matching the order the Format panel's Text fitting section lists them in. Previously the inline icon was a 2-state shrink/grow toggle and 'do not autofit' was reachable only via the Format panel or API. A new ICON_NONE (rectangle with X) renders for the 'none' state.
Editing a text box in 'do not autofit' mode clipped any text past the saved box height — the canvas was sized to frame.h with no growth hook, so what the user typed disappeared until commit (the slide renderer then drew the overflow below the frame, so the text reappeared on exit). The fix wires onContentHeightChange for 'none' the same way 'grow' does so the editing canvas tracks content height. The outer callback that writes back to frame.h still only fires for 'grow' — 'none' keeps the saved box, so post-commit rendering matches the pre-edit frame and the overflow paints below as before.
Two findings from the pre-PR review: - MemSlidesStore.setUnit was the only mutator in the file that skipped requireBatch(). Calling it outside a batch would corrupt undo state silently rather than throwing like every other writer. - ImageAdjustmentsSection captured the transparency draft only at mount, so swapping to a different image (or receiving a remote opacity change) would leave the slider on the stale value and the next pointerUp would commit it back.
Two follow-ups from the bot review: - ImageAdjustmentsSection committed only on pointerUp, so keyboard adjustments (Arrow / Home / End / PgUp / PgDn) updated the slider thumb but never reached the store. Add an onKeyUp commit using the same handler. - YorkieSlidesStore.setUnit ran the unit validation before requireBatch(), so callers outside a batch with an invalid value saw 'invalid unit' instead of the 'Mutations must be wrapped in batch()' error every other mutator throws. Reorder to match.
84ba917 to
bfda4cd
Compare
PRs #257/#301/#303/#304/#305/#306/#307/#309/#315/#316/#317/#321/#322 shipped between v0.4.2 and the v0.4.3 cut, leaving their paired todo files in active with the process checkboxes still open. Flip the remaining boxes for the work that landed, then move the paired todo/lessons into docs/tasks/archive/2026/05/ via pnpm tasks:archive. Active count drops from 17 to 6; archive count grows by 21 files. Patch release covering 29 commits since v0.4.2. Headline theme is Slides editing & UX polish toward Google Slides / PowerPoint parity: smart guides (equal-spacing / distance / size), Shift drag modifiers, text inside shapes, a Format options right panel, tier-1 universal toolbar controls, text autofit (shrink + grow), group-selection visuals, and several smaller text-box / selection fixes. PPTX import gained paragraph-level bullet styles, body anchor (vertical alignment), and stretched blipFill cover-crop. Docs added a pending-inline-style at collapsed caret, a caret that tracks the resolved text color, bullet indent on Tab / Shift+Tab, font / size / line-spacing / clear formatting toolbar controls, an inline-style-attribute clear that actually lands in the CRDT, and a polished toolbar trigger / color swatch pass shared across docs / sheets / slides. Sheets gained .xlsx import. Infrastructure: Docker images now build on native arm64 runners, fixing the release hang seen on v0.4.2. No Prisma migration, no new backend env vars — the devops bump is a routine image-tag change. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Summary
rightPanel: 'theme' | 'format' | nullunion.none → shrink → grow → none) matching the Format panel order, with a newICON_NONEfor the off state.do not autofitmode so text typed past the saved box height stays visible while editing; the outerframe.hwrite is still gated togrowonly, so the post-commit render matches the pre-edit frame.Data model change is one optional
Meta.unit?: 'in' | 'cm'field plusSlidesStore.setUnit. All other work is additive UI.Design: docs/design/slides/slides-format-options-panel.md
Plan: docs/tasks/archive/2026/05/20260529-slides-format-options-panel-todo.md
Test plan
pnpm verify:fast— 803 unit tests green, lint clean (--max-warnings 0).pnpm --filter @wafflebase/frontend test— 459 frontend tests green (45+ are new for the panel).MemSlidesStore.setUnitnow callsthis.requireBatch()like every other mutator in the file.ImageAdjustmentsSectionre-syncsdraftviauseEffectso selection swaps don't commit stale transparency.pnpm dev:growdisables H input with tooltip.none → shrink → grow → none;nonestate shows new X-in-box icon.nonemode — text stays visible while editing; on commit, frame keeps its saved height and overflow renders below as before.🤖 Generated with Claude Code