Fix copy/paste transform consistency — group-uniform scale + translate

Before: pasteLayersFromClipboard clamped each item's w / h individually
to the target bounds, then re-centred any item whose box landed
out-of-bounds. With multiple copied items, this:

- Collapsed every out-of-bounds item onto the same centred position
  (lost their relative positions).
- Gave each item a different effective scale (lost their relative
  sizes).

So a "Copy All" → cross-mode paste turned a tidy 4-layer composition
into a pile of overlapping clamped boxes.

Now: compute the bounding box once across every clipboard item, pick
a single scale (only shrinks — never enlarges — so a paste into a
larger target keeps items at their original size), and apply one
shared translation that centres the (possibly scaled) bbox in the
target. Both axes use the same scale, preserving the group's aspect
ratio. Items missing w / h fall back to 70%-of-target uniformly, so
the default-size path can't desync either.

Result:
- Two items copied with their original gap and a 2:1 size ratio
  retain that gap and ratio after paste, regardless of target size.
- A wide cover → narrow disc paste shrinks everything proportionally
  rather than collapsing edges onto the centre.
- Same-mode paste with everything already in-bounds is unchanged
  (scale stays at 1; translation is identity).

TODO.md: move the resolved colour-picker work to "Shipped (recent)".

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.7 committed 2026-05-26 13:46:33 -04:00
1 parent d57e6026da
commit c82267c6a1
2 files changed
+40 -11

No files matched your search

+7 -4
View File
@@ -7,10 +7,6 @@ Running list of deferred work. Detailed specs for the larger items live in
- **Slide deck PDF export.** Add multi-slide PDF export for slide
presentations, producing one page per slide.
- **Color picker fixes (vector tools).** The in-app HSV picker now works in
the pixel editor; verify the OS-native `<input type="color">` flow in the
vector tool panels (Pen / Shape / Text fill + stroke). If the same Brave
R=NaN bug bites there, port the in-app HSV picker over.
- **Copy / paste transform consistency.** Audit Copy, Paste, Copy All, and
cross-target paste so copied objects keep consistent position and scale
relative to the other copied objects, without unexpected misalignment or
@@ -81,6 +77,13 @@ Each has a full spec in `docs/`.
## Shipped (recent)
- **Pixel-editor colour picker** — ported the OS native `<input type="color">`
over from the vector tools (same swatch styling; opens the OS picker with
its built-in eyedropper on click). Removed the Screen-Capture-API screen-
eyedropper workaround (~170 lines of dead code). Palette swatches and
the in-canvas eyedropper tool now sync the picker too, so grabbing a
palette colour as the starting point for a tweaked variant just works.
- **Pixel Art** — `docs/PIXEL_ART_FEATURE.md`. Phases A–D all shipped:
embedded `pixelart` layer + standalone `casewrap-pixelart` design,
PNG / sprite-sheet / GIF export, drawing tools (line / rect / ellipse,
+33 -7
View File
@@ -7873,16 +7873,42 @@
pushHistory(`paste ${items.length} layer${items.length === 1 ? '' : 's'}`);
const target = currentPasteTarget();
const bounds = targetBounds(target);
// Compute the bounding box over all clipboard items in source coords.
// Each item missing w/h falls back to a 70%-of-target default — same
// as the previous behaviour, applied uniformly so it can't desync
// items within the same paste.
const defW = bounds.w * 0.7, defH = bounds.h * 0.7;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const it of items) {
const w = typeof it.w === 'number' && it.w > 0 ? it.w : defW;
const h = typeof it.h === 'number' && it.h > 0 ? it.h : defH;
const x = typeof it.x === 'number' ? it.x : 0;
const y = typeof it.y === 'number' ? it.y : 0;
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x + w > maxX) maxX = x + w;
if (y + h > maxY) maxY = y + h;
}
const srcW = Math.max(0.01, maxX - minX);
const srcH = Math.max(0.01, maxY - minY);
// Single scale factor — only shrinks (never enlarges) so a paste into
// a larger target keeps the items at their original size. Both axes
// share the same scale so the group's aspect ratio is preserved.
const scale = Math.min(1, bounds.w / srcW, bounds.h / srcH);
// Centre the (possibly scaled) bbox within the target.
const tx = bounds.x + (bounds.w - srcW * scale) / 2 - minX * scale;
const ty = bounds.y + (bounds.h - srcH * scale) / 2 - minY * scale;
let newLast = null;
const newIds = [];
for (const item of items) {
const w = Math.max(0.1, Math.min(item.w || bounds.w * 0.7, bounds.w));
const h = Math.max(0.1, Math.min(item.h || bounds.h * 0.7, bounds.h));
let x = typeof item.x === 'number' ? item.x : bounds.x + (bounds.w - w) / 2;
let y = typeof item.y === 'number' ? item.y : bounds.y + (bounds.h - h) / 2;
// Clamp inside the target bounds so cross-sub-mode pastes don't land off-canvas.
if (x < bounds.x || x + w > bounds.x + bounds.w) x = bounds.x + (bounds.w - w) / 2;
if (y < bounds.y || y + h > bounds.y + bounds.h) y = bounds.y + (bounds.h - h) / 2;
const w0 = typeof item.w === 'number' && item.w > 0 ? item.w : defW;
const h0 = typeof item.h === 'number' && item.h > 0 ? item.h : defH;
const x0 = typeof item.x === 'number' ? item.x : 0;
const y0 = typeof item.y === 'number' ? item.y : 0;
const w = Math.max(0.01, w0 * scale);
const h = Math.max(0.01, h0 * scale);
const x = x0 * scale + tx;
const y = y0 * scale + ty;
const layer = { ...item, id: makeLayerId(), target, x, y, w, h, z: nextLayerZ() };
state.layers.push(layer);
newLast = layer;