Slides: variable pasteboard so off-slide shapes stay selectable#353
Conversation
Today the slide canvas DOM is sized exactly to the slide rect, so any
shape that lands outside [0..SLIDE_WIDTH] x [0..SLIDE_HEIGHT] is both
clipped from rendering AND unreachable for pointer events. Dragging a
shape off the slide effectively loses it: nothing paints into the
pasteboard band and no click can reach the geometry that's already
outside the canvas DOM box.
Treat the empty area inside scrollHost that surrounds the slide rect
as a pasteboard. The slide keeps its original Fit-zoom size; the
canvas / canvasWrap grow to max(slide, scrollHost), and the slide is
centered inside. Off-slide elements now paint onto the same canvas
the slide does and pointer events on the surrounding band reach
hit-test, so the user can grab them again.
- SlideRenderer.drawSlide takes slideOffsetLogicalX/Y; when non-zero
it translates world (0, 0) into the bigger bitmap and paints the
slide background + drop shadow only in the slide rect.
- Editor gets a setSlideOffset(x, y) entry point next to
setHostSize; clientToLogical subtracts both offsets.
- slides-view.tsx's refitCanvas reads scrollHost dimensions to
derive canvas/offset on every tick.
- canvasWrap background is transparent so the band blends into
the surrounding workspace; the slide's drop shadow carries the
visual edge.
Known v1 limit: at zoom > Fit the slide overflows scrollHost, canvas
equals slide, and there's no surrounding pasteboard. User drops to
Fit to recover off-slide shapes. Documented in the design doc.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Self-review of the pasteboard commit surfaced four issues:
1. The slide drop shadow only painted inside drawSlide's
`hasPasteboard` branch, so any no-pasteboard call (zoom > Fit,
mobile, presenter, or the first frame before refit) showed no
shadow at all. The old CSS box-shadow on the canvas was already
deleted, so the regression compounded.
2. The 1-px theme-aware hairline (`color-mix(in srgb, --foreground 25%,
transparent)`) was dropped entirely. The original comment marked
it as load-bearing in dark mode + Simple Dark, where the dark
slide on a near-black workspace had no other visible edge.
3. `(canvasFullW - hostW) / 2` could land on 0.5 CSS px when the
parity of canvas-fit and slide-fit differed. The fractional
value sub-pixel-positioned the overlay (AA-blurring handles) and
risked 1-px drift between canvas paint and absolute children.
4. Two test mocks at packages/frontend/tests/app/slides/toolbar/
cast their fake editor as `as unknown as SlidesEditor`, hiding
the missing `setSlideOffset` method — a latent runtime crash if
any code path eventually calls it on the mock.
Fixes:
- Add a transparent `slideElevation` absolute div pinned to the
slide rect with the original CSS box-shadow (hairline + drop
shadow). The shadow extends into the pasteboard band; the canvas
above is opaque inside the slide rect and transparent outside, so
the ring renders exactly at the slide edge. Elevation now lives
in CSS — present in every paint mode, theme-reactive, and
constant in CSS-px size regardless of zoom.
- Remove the canvas-painted shadow from drawSlide.
- Math.floor the slide offset so overlay + elevation div sit on
integer CSS pixels.
- Add `setSlideOffset() {}` to the two toolbar test mocks.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
|
Warning Review limit reached
More reviews will be available in 36 minutes and 8 seconds. Learn how PR review limits work. Your organization has run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the 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 include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (10)
📝 WalkthroughWalkthroughThis PR implements Slides Pasteboard v1, enabling shapes outside the slide canvas to remain visible and selectable. The feature expands the canvas beyond the slide rect, introduces offset tracking through the renderer and editor, and moves slide styling (hairline, shadow) to a separate DOM element while making canvas background transparent for pasteboard visibility. ChangesSlides Pasteboard v1
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes 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 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 264.4s
Verification: verify:integrationResult: ✅ PASS |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
- Bump `target-version` of docs/design/slides/slides-pasteboard.md from 0.2.0 to 0.4.5 to match the shipping release. - Mark the task todo as `status: done`, record the smoke + review loop, link PR #353. - Add the paired `*-lessons.md` capturing what the three iterations taught (separate reachability from rendering; keep theme-reactive styling in CSS; snap centering offsets to integer CSS px). - pnpm tasks:archive — moves the pasteboard pair into docs/tasks/archive/2026/06/ and regenerates the task index. The archive run also picked up an unrelated completed task (20260608-slides-dark-theme-on-create) that had been left in active; the rename is included so the index stays consistent. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (3)
docs/design/slides/slides-pasteboard.md (2)
26-26: 💤 Low valueMinor style: "empty space" is redundant.
The phrase "empty space" can be simplified to "space" without loss of meaning.
📝 Optional simplification
-to fill `scrollHost`. Whatever empty space sits between the slide +to fill `scrollHost`. Whatever space sits between the slide🤖 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/design/slides/slides-pasteboard.md` at line 26, Replace the phrase "empty space" with "space" in the sentence containing "to fill `scrollHost`. Whatever empty space sits between the slide" inside slides-pasteboard.md; update that fragment so it reads "...to fill `scrollHost`. Whatever space sits between the slide" to remove the redundancy.
67-67: 💤 Low valueAdd language specifiers to fenced code blocks.
The markdown linter recommends adding language specifiers to fenced code blocks for better rendering and syntax highlighting. Consider adding
textorplaintextfor the diagram blocks.Also applies to: 133-133
🤖 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/design/slides/slides-pasteboard.md` at line 67, Replace the bare fenced code blocks (the triple-backtick blocks used for diagrams) with language-specified fences so the linter can apply proper rendering; for each diagram block that currently uses ``` change it to a fenced block with a language specifier such as ```text or ```plaintext so the diagram content is annotated and will get syntax highlighting and satisfy the markdown linter.packages/slides/src/view/editor/editor.ts (1)
1248-1260: ⚡ Quick winAdd a regression test for the slide-offset coordinate contract.
setSlideOffset(...)andclientToLogical(...)are now the core mapping that keeps off-slide hit-testing aligned with the translated renderer. A small editor test that exercises a non-zero offset would protect selection/drag flows from subtle math regressions. As per coding guidelines, "Write tests for critical business logic and edge cases".Also applies to: 4553-4565
🤖 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 1248 - 1260, Add a regression test that verifies the slide-offset coordinate contract by exercising a non-zero offset and ensuring mappings and hit-testing remain aligned: in the editor unit tests create an Editor instance (or the test harness used by existing editor tests), call setSlideOffset(x, y) with non-zero values, then invoke clientToLogical(clientX, clientY) and assert the returned logical coordinates equal the expected values given the offset; also exercise an off-slide hit-test or a simple selection/drag scenario (e.g., hitTest or the selection logic used in tests) to confirm hit results respect the offset. Reference the setSlideOffset and clientToLogical methods (and any off-slide hit-testing helper like hitTest or selection/drag handlers) so the test guards against regressions in the mapping math.Source: Coding guidelines
🤖 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/design/README.md`:
- Line 76: The README entry for slides-pasteboard.md incorrectly calls the
design "fixed margin"; update that table row to describe the implemented
"variable pasteboard" approach (reference slides-pasteboard.md) by replacing
"fixed margin around the slide" with wording like "variable pasteboard — sized
from the surrounding empty area so off-slide shapes remain visible,
hit-testable, selectable; renderer translates origin, overlay stays at slide
rect, ruler/thumb unchanged" and ensure the wording aligns with the task doc
20260610-slides-pasteboard-todo.md which documents the move away from
fixed-margin behavior.
In `@docs/design/slides/slides-pasteboard.md`:
- Around line 156-157: The sentence claiming "the shadow is painted into the
canvas" is incorrect; update the sentence to state that the shadow is not
painted into the canvas but is applied as a CSS box-shadow on the slideElevation
element. Change the text referencing canvas.style.boxShadow to instead reference
slideElevation's CSS box-shadow behavior and, if helpful, cite the related
implementation in slides-view.tsx (see drawSlide and the comment "Painting the
elevation in CSS — rather than as canvas paint inside `drawSlide`") to ensure
consistency with lines 113–115 and the slideElevation usage.
In `@docs/tasks/active/20260610-slides-pasteboard-todo.md`:
- Around line 78-80: The risk statement is incorrect: the drop shadow was not
moved into the canvas but remains as a CSS box-shadow applied to the
slideElevation element; update the doc text to state that the drop shadow and
hairline are NOT painted into the canvas and instead live as a CSS box-shadow on
slideElevation (see slides-view.tsx and the slideElevation element) and adjust
the sentence that currently reads "Drop shadow now painted into canvas (was CSS
`box-shadow`)" to reflect this correct behavior.
In `@packages/frontend/src/app/slides/slides-view.tsx`:
- Around line 707-710: The early return when sameSlide and sameCanvas skips
updating the ruler origin; before returning, explicitly resync the ruler scroll
using the existing routine that updates ruler origin based on
scrollLeft/scrollTop (i.e., call the function you already use to update
rulers/scroll origin) so the ruler state is corrected even when hostW/hostH and
canvasFullW/canvasFullH are unchanged; apply the same change to the other
identical check later (the block around the second sameSlide/sameCanvas check).
- Around line 697-706: The pasteboard growth logic currently expands
nextCanvasW/nextCanvasH whenever either axis is smaller than the viewport, which
allows one-axis-only pasteboard at absolute zoom; change the calculation so you
only grow the canvas to the scrollRect size when the view is in Fit zoom (e.g.
guard the Math.max calls by the Fit mode check — use your app's Fit detector
such as isFitZoom() or zoomMode === 'Fit'); compute nextCanvasW = isFit ?
Math.max(nextW, Math.floor(scrollRect.width)) : nextW and likewise for
nextCanvasH, and keep nextOffsetX/nextOffsetY derived from those guarded canvas
sizes so offsets remain correct.
---
Nitpick comments:
In `@docs/design/slides/slides-pasteboard.md`:
- Line 26: Replace the phrase "empty space" with "space" in the sentence
containing "to fill `scrollHost`. Whatever empty space sits between the slide"
inside slides-pasteboard.md; update that fragment so it reads "...to fill
`scrollHost`. Whatever space sits between the slide" to remove the redundancy.
- Line 67: Replace the bare fenced code blocks (the triple-backtick blocks used
for diagrams) with language-specified fences so the linter can apply proper
rendering; for each diagram block that currently uses ``` change it to a fenced
block with a language specifier such as ```text or ```plaintext so the diagram
content is annotated and will get syntax highlighting and satisfy the markdown
linter.
In `@packages/slides/src/view/editor/editor.ts`:
- Around line 1248-1260: Add a regression test that verifies the slide-offset
coordinate contract by exercising a non-zero offset and ensuring mappings and
hit-testing remain aligned: in the editor unit tests create an Editor instance
(or the test harness used by existing editor tests), call setSlideOffset(x, y)
with non-zero values, then invoke clientToLogical(clientX, clientY) and assert
the returned logical coordinates equal the expected values given the offset;
also exercise an off-slide hit-test or a simple selection/drag scenario (e.g.,
hitTest or the selection logic used in tests) to confirm hit results respect the
offset. Reference the setSlideOffset and clientToLogical methods (and any
off-slide hit-testing helper like hitTest or selection/drag handlers) so the
test guards against regressions in the mapping math.
🪄 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: 337220ef-094a-4a33-9988-9516159da746
📒 Files selected for processing (8)
docs/design/README.mddocs/design/slides/slides-pasteboard.mddocs/tasks/active/20260610-slides-pasteboard-todo.mdpackages/frontend/src/app/slides/slides-view.tsxpackages/frontend/tests/app/slides/toolbar/state.test.tspackages/frontend/tests/app/slides/toolbar/text-edit-section.test.tspackages/slides/src/view/canvas/slide-renderer.tspackages/slides/src/view/editor/editor.ts
CodeRabbit review surfaced four real defects and asked for a focused regression test on the new coord contract. - docs/design/README.md described the design as a "fixed margin" approach. That was the rejected first iteration; replace with the shipped variable-pasteboard wording. - docs/design/slides/slides-pasteboard.md and the archived task todo both claimed the drop shadow moved into the canvas. It did briefly, but the review-fix commit had already migrated it back to a CSS box-shadow on the slideElevation div. Realign the doc text. - The contract clause "no pasteboard above Fit" was over-restrictive; the implementation gives an asymmetric band whenever the slide fits inside scrollHost on a given axis. Restate the non-goal to match. - refitCanvas ran editor.setRulerScroll only after the early-out, so a resize that did not change canvas/slide dims (devtools toggle, sidebar collapse, notes-pane drag) could leave the ruler origin stale: the browser silently clamps scrollLeft / scrollTop on layout changes without dispatching a scroll event. Move the resync ahead of the early return. Add two regression tests in editor.test.ts: - slideOffsetLogical shifts clientToLogical so off-slide shapes are hit-testable — initializes the editor with a non-zero offset, drops an element at negative logical x/y, asserts a pasteboard-band click selects it. - setSlideOffset is live — switches the offset post-mount and verifies clientToLogical follows along. Two CodeRabbit nitpicks (style "empty space" → "space", code-fence language specifier) are intentionally skipped: low value, doc-only. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Three slides-editor bugs surfaced while testing groups; they travel together because the same `/shared/<id>` deck exercises them in sequence. 1. Lines drift after grouping their endpoints (the reported bug). `MemSlidesStore.group()` keeps a connector as a group child when both endpoints reference candidates, but `drawElement`'s connector branch skipped the per-element frame transform — `drawConnector` expects world-coord endpoints and `buildElementWorldLookup` lifts grouped targets to world, so the group transform compounded on top of already-world endpoints and the line jumped by the group's offset. `drawElement` now threads a cumulative `parentTransform`; on a connector child it inverts the parent transform onto the ctx and hands `drawConnector` the lookup's view of the connector — free endpoints are world-coord via `walkWorld`, attached endpoints already resolve through the lookup, so both kinds agree. The "v1 group() never includes connectors" NOTE in `element-renderer.ts` was stale since #263; #320 then bolted the world-coord lookup on top, which is exactly what broke this. 2. Rotate angle tooltip drifts by the pasteboard offset. `acquireRotateTooltip` appends to `overlay.parentElement` so `renderOverlay`'s innerHTML reset can't wipe it mid-drag, but `showTooltip` was computing coords against `overlay`'s rect. These containers shared an origin until #353 gave the overlay a non-zero `slideOffsetCssX/Y` inside `canvasWrap`. Measure against the tooltip's actual parent rect instead. 3. Rotate tooltip flickers at the previous drag's last position on re-acquire. `acquireRotateTooltip` flipped `display: block` immediately while `transform` still held the previous drag's terminal coords. Acquire now returns the cached element hidden; `startRotate` calls `showTooltip(clientX, clientY, 0)` once immediately so `transform` and `display: block` land in the same paint frame. Bonus: a 0° readout pops at the click position with no input latency. Pre-merge code review surfaced one blocking concern and seven non-blocking follow-ups (tracked in the task doc). Fixed before merge: `invertGroupTransform` now returns `null` instead of throwing on a singular matrix, and the connector branch skips the paint. A degenerate group (frame.w = 0 with refSize > 0, reachable via PPTX import) used to throw, escape `drawElement`'s try/finally, and blank every later element on the slide. Deferred follow-ups: flip blindness in `groupToTransform`; reference-equality identity gate; ghost connector inside a group ghost; `tooltipContainer` cached once at `startRotate`; `overlay.parentElement` null fallback inside `acquireRotateTooltip`; three copies of the 6-coef affine inverse math (`element-renderer.ts` + `model/group.ts`); acquire-then-show invariant naming. Regression coverage: - `slide-renderer.test.ts` — attached connector: `ctx.transform` runs before `moveTo` with the expected inverse coefficients; free connector: pre/post-group `moveTo`/`lineTo` args match (the lookup swap); singular parent transform: skips that connector but keeps painting the rest of the slide. - `editor.test.ts` — pasteboard layout: tooltip transform encodes parent-frame coords, not overlay-relative; two-cycle drag: second pointerdown's transform reflects the new click position, not the previous drag's last move.
Summary
[0..SLIDE_WIDTH] × [0..SLIDE_HEIGHT]was both clipped from rendering AND outside the canvas's pointer-event box. Dragging a shape off the slide effectively lost it.canvasWraptomax(slide, scrollHost)so the empty area insidescrollHostbecomes a pasteboard. The slide keeps its original Fit-zoom size; off-slide shapes now paint onto the same canvas the slide does and pointer events on the surrounding band reach hit-test.slideElevationabsolute div pinned to the slide rect — survives every paint mode (no-pasteboard, mobile, presenter, pre-first-refit) and stays theme-reactive via--foreground.Design
docs/design/slides/slides-pasteboard.md— DOM shape, renderer / editor / view contract, known v1 limit (no pasteboard at zoom > Fit).Test plan
pnpm verify:fastgreen (904 passed)🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Documentation