Skip to content

Resize: refit a design when the canvas size changes - #8

Open
pallaoro wants to merge 2 commits into
mainfrom
feat/magic-resize
Open

Resize: refit a design when the canvas size changes#8
pallaoro wants to merge 2 commits into
mainfrom
feat/magic-resize

Conversation

@pallaoro

@pallaoro pallaoro commented Sep 3, 2026

Copy link
Copy Markdown
Member

The problem

Changing the canvas size resized the frame and left every object exactly where it was. A square design retargeted to a story sat crammed against the top edge; a landscape one was simply cut off. On top of that the new size was never saved — saveDesign didn't send width/height, and the effect that adopts a design's stored frame was keyed on the design object, which save replaces. So saving after a resize snapped the canvas straight back.

The size picker was, in practice, a way to break a design.

What this does

Resizing now refits the whole design (src/client/resize.ts):

  • Artwork scales by the smaller of the two ratios and is anchored to the centre — nothing distorts, and nothing that fitted before lands outside the new frame.
  • The backdrop a design is built on (a template's full-bleed rectangle, or an image set through the background picker) stretches to the new frame instead, so a taller ratio no longer leaves a band of bare canvas. Only the bottom-most, unrotated, frame-covering object qualifies, so a large foreground shape is never mistaken for one.
  • Text scales its font size and box width rather than its transform, so it re-wraps at the new width and the font-size control keeps reporting the real number.

Plus the two persistence fixes above, Instagram Portrait / Pinterest / YouTube / 16:9 presets, a menu grouped by platform, and a bounded custom width/height field.

Undo

A resize spans every page at once, so it can't be expressed in the per-page undo stacks. And because the fit is lossy, resizing back is not an undo either — 1080² → 1200×627 scales by 0.58, and coming back scales by 0.9, not 1/0.58.

So a resize keeps one snapshot of its own, restored atomically by an Undo resize control that appears next to the size picker. Each page's history is reset to a baseline at that point, so you can never land in a state where a page's artwork is sized for a frame it no longer has.

Verification

Run against real Fabric 6.9.1 in real Chrome (headless), not just typechecked.

Reflow geometry — 14/14, using the actual Quote Card seed template: backdrop detection (and non-detection), backdrop fills the new frame at both taller and shorter ratios, uniform font scaling, scaleX left at 1, everything inside the new frame, stacking order preserved, no-op resize is a no-op, lone-text canvas with no backdrop, resize-is-lossy, and snapshot-restore-is-exact.

End-to-end in the running app — grouped menu and custom section render; canvas retargets to 1080×1920; undo-resize control appears; custom size applies; out-of-range input is clamped rather than crashing the tab; undo restores the previous frame; and the resized frame survives save + reload (the bug above).

Rendered pixels sampled from the canvas at 1%/25%/50%/75%/99% height confirm the backdrop fills top to bottom at 1080×1080, 1080×1920 and 1200×627.

tsc --noEmit adds no new errors (the 6 reported are pre-existing on main: canvas-area.tsx, and uploads.ts missing @types/node).

Notes

  • Client-only; no schema change. designs.width/height and the PUT /api/designs/{id} body already carried the frame — the client just never sent it.
  • Unrelated main defect met while testing: the Worker can't boot because uploads.ts calls mkdirSync at module scope. fix(uploads): store images in R2 so the Worker can boot #4 fixes it; I applied that branch locally to run the app and reverted it before committing.

Changing the canvas size resized the frame and left every object exactly
where it was, so a square design retargeted to a story sat crammed against
the top edge and a landscape one was simply cut off. The size picker was
effectively a way to break a design.

Resizing now refits the whole design:

- Artwork scales by the smaller of the two ratios and is anchored to the
  centre, so nothing distorts and nothing that fitted before lands outside
  the new frame.
- The backdrop a design is built on -- a template's full-bleed rectangle,
  or an image set through the background picker -- stretches to the new
  frame instead, so a taller ratio no longer leaves a band of bare canvas.
- Text scales its font size and box width rather than its transform, so it
  re-wraps at the new width and the font-size control keeps reporting the
  real number.

Two things had to be fixed for the frame to survive at all:

- saveDesign never sent width/height, so a resized design reopened at its
  old dimensions with artwork scaled for the new ones.
- The effect that adopts a design's stored frame was keyed on the design
  object, which save replaces. Saving after a resize snapped the canvas
  straight back. It is keyed on the design id now, and adopting a stored
  frame no longer reflows -- the stored artwork already fits it.

A resize spans every page at once, so it cannot be expressed in the
per-page undo stacks; and because the fit is lossy, resizing back is not an
undo either (1080 square to 1200x627 scales by 0.58, coming back by 0.9).
It gets one snapshot of its own instead, restored atomically by an "Undo
resize" control that appears next to the size picker.

Also adds Instagram Portrait, Pinterest, YouTube and 16:9 presets, groups
the menu by platform, and adds a bounded custom width/height field.
…ored

The snapshot behind "Undo resize" lived until the next resize, which made it
outlive the state it was captured from in two ways.

An edit made after a resize was silently destroyed: restoring the snapshot
reverts every page to the moment before the resize, and the per-page
histories are reset at that point too, so Ctrl+Z could not recover it
either. The resize is no longer undoable once an edit lands on top of it.

The snapshot also survived leaving the design. It is keyed by page id, so on
another design no entry matched and nothing was restored -- but the frame
was still applied, resizing an unrelated design to the previous one's
dimensions with no reflow, and saving persisted that. A snapshot is only
valid while the canvases it captured are mounted, so unregistering any page
it holds retires it. That covers page deletion, switching designs, and
leaving the editor and coming back.

Both paths are regression tested in a real browser.
@pallaoro

pallaoro commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

Pressure-tested the snapshot's lifecycle and found two defects the original tests were blind to — both were on paths my checks never walked (I resized and undid without editing in between, and never left the design). Fixed in 24f555f:

An edit after a resize was silently destroyed. undoResize reverts every page to the moment before the resize, and setCanvasSize resets each page's history to a single baseline at that point — so work done after the resize was unrecoverable by Ctrl+Z too. Nothing retired the snapshot on edit, so "Undo resize" stayed live indefinitely. It is now retired the moment an edit lands on top of the resize (cleared in saveHistory, the single funnel every edit passes through).

The snapshot leaked across designs. It was cleared only by undoResize and loadTemplate — never by loadDesign. useCanvasState lives in App and never unmounts, so after resizing design A and navigating to design B the button was still offered; clicking it matched no page ids (so restored no artwork) but still applied applyDimensions(oldWidth, oldHeight), resizing B to A's frame with no reflow, which a save then persisted. The snapshot is now retired in unregisterCanvas when a page it captured unmounts — the honest invariant is that it is only valid while the canvases it captured are mounted, which also covers page deletion and leaving/re-entering the same design.

5 new regression checks in a real browser, plus the existing 14 geometry and 8 end-to-end checks still pass.

One related pre-existing issue, not touched here: _isBgImage is set as a plain property and FabricObject.customProperties is never registered, so the flag does not survive toJSON(). That already breaks setBackground's replace-the-old-background lookup after a reload. isBackdrop degrades gracefully because setBackground calls sendObjectToBack, so the bottom-most + covers-the-frame test still catches it — but the flag is not load-bearing anywhere it can be trusted.

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