From 4a3aad0d2de6db324dcdd6fa4eb5dd72e71c5e83 Mon Sep 17 00:00:00 2001 From: pallaoro Date: Thu, 3 Sep 2026 20:36:32 +0200 Subject: [PATCH 1/2] feat(resize): refit a design when the canvas size changes 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. --- src/client/app.tsx | 19 +++-- src/client/components/toolbar.tsx | 132 +++++++++++++++++++++++++----- src/client/context.tsx | 24 ++++-- src/client/hooks/use-canvas.ts | 112 +++++++++++++++++++------ src/client/hooks/use-designs.ts | 13 ++- src/client/resize.ts | 108 ++++++++++++++++++++++++ 6 files changed, 348 insertions(+), 60 deletions(-) create mode 100644 src/client/resize.ts diff --git a/src/client/app.tsx b/src/client/app.tsx index 485e1a09..af2a76a3 100644 --- a/src/client/app.tsx +++ b/src/client/app.tsx @@ -5,12 +5,12 @@ import { useRouter } from "./hooks/use-router"; import { Editor } from "./components/editor"; import { Home } from "./components/home"; import WebFont from "webfontloader"; -import { useEffect } from "preact/hooks"; +import { useEffect, useRef } from "preact/hooks"; export function App() { const { path, navigate, designId } = useRouter(); const canvasState = useCanvasState(); - const designState = useDesigns(canvasState.getCanvasJSONForPage); + const designState = useDesigns(canvasState.getCanvasJSONForPage, canvasState.getCanvasSize); // Load Google Fonts useEffect(() => { @@ -41,13 +41,16 @@ export function App() { } }, [designId, designState.loading]); - // Sync canvas size to the loaded design's dimensions + // Adopt a design's own frame when it is opened. Keyed on the design id, not + // the object: saving replaces activeDesign, and re-running then would snap + // the canvas back to the stored size mid-edit, undoing a resize. + const framedDesignIdRef = useRef(null); useEffect(() => { - if (designState.activeDesign) { - const { width, height } = designState.activeDesign; - if (width && height && (width !== canvasState.canvasWidth || height !== canvasState.canvasHeight)) { - canvasState.setCanvasSize(width, height); - } + const design = designState.activeDesign; + if (!design || framedDesignIdRef.current === design.id) return; + framedDesignIdRef.current = design.id; + if (design.width && design.height) { + canvasState.setCanvasSize(design.width, design.height, { reflow: false }); } }, [designState.activeDesign]); diff --git a/src/client/components/toolbar.tsx b/src/client/components/toolbar.tsx index 6ae09359..794e02e6 100644 --- a/src/client/components/toolbar.tsx +++ b/src/client/components/toolbar.tsx @@ -9,14 +9,22 @@ import { Save, ChevronDown, Home, + RotateCcw, } from "lucide-preact"; -import { useEditor, CANVAS_SIZES } from "../context"; +import { + useEditor, + CANVAS_SIZES, + MIN_CANVAS_SIDE, + MAX_CANVAS_SIDE, +} from "../context"; export function Toolbar() { const { canvasWidth, canvasHeight, setCanvasSize, + undoResize, + canUndoResize, undo, redo, canUndo, @@ -35,6 +43,8 @@ export function Toolbar() { } = useEditor(); const [showSizeDropdown, setShowSizeDropdown] = useState(false); + const [customWidth, setCustomWidth] = useState(""); + const [customHeight, setCustomHeight] = useState(""); const [editingName, setEditingName] = useState(false); const [nameValue, setNameValue] = useState(""); @@ -43,6 +53,33 @@ export function Toolbar() { ); const sizeLabel = currentSize ? currentSize.label : `${canvasWidth} x ${canvasHeight}`; + const groups = CANVAS_SIZES.reduce>((acc, size) => { + (acc[size.group] ||= []).push(size); + return acc; + }, {}); + + const clampSide = (raw: string) => { + const n = Math.round(Number(raw)); + if (!Number.isFinite(n) || n <= 0) return null; + return Math.min(Math.max(n, MIN_CANVAS_SIDE), MAX_CANVAS_SIDE); + }; + + const openSizeMenu = () => { + setCustomWidth(String(canvasWidth)); + setCustomHeight(String(canvasHeight)); + setShowSizeDropdown(true); + }; + + const applyCustomSize = () => { + const w = clampSide(customWidth); + const h = clampSide(customHeight); + if (w === null || h === null) return; + setCustomWidth(String(w)); + setCustomHeight(String(h)); + setCanvasSize(w, h); + setShowSizeDropdown(false); + }; + const startRename = () => { if (!activeDesign) return; setNameValue(activeDesign.name); @@ -93,7 +130,7 @@ export function Toolbar() {
+
+ {Object.entries(groups).map(([group, sizes]) => ( +
+
+ {group} +
+ {sizes.map((s) => ( + + ))} +
))} + +
+
+ Custom +
+
+ setCustomWidth((e.target as HTMLInputElement).value)} + onKeyDown={(e) => e.key === "Enter" && applyCustomSize()} + /> + x + setCustomHeight((e.target as HTMLInputElement).value)} + onKeyDown={(e) => e.key === "Enter" && applyCustomSize()} + /> + +
+
)}
+ + {canUndoResize && ( + + )} {/* Center: Undo / Redo */} diff --git a/src/client/context.tsx b/src/client/context.tsx index e6abf124..a1d746ec 100644 --- a/src/client/context.tsx +++ b/src/client/context.tsx @@ -2,20 +2,31 @@ import { createContext } from "preact"; import { useContext } from "preact/hooks"; import type { Design, Template, Page } from "./types"; import type * as fabric from "fabric"; +import type { Dimensions } from "./resize"; export interface CanvasSize { + group: string; label: string; width: number; height: number; } export const CANVAS_SIZES: CanvasSize[] = [ - { label: "LinkedIn Square", width: 1080, height: 1080 }, - { label: "LinkedIn Landscape", width: 1200, height: 627 }, - { label: "LinkedIn Portrait", width: 1200, height: 1500 }, - { label: "Instagram Story", width: 1080, height: 1920 }, + { group: "LinkedIn", label: "LinkedIn Square", width: 1080, height: 1080 }, + { group: "LinkedIn", label: "LinkedIn Landscape", width: 1200, height: 627 }, + { group: "LinkedIn", label: "LinkedIn Portrait", width: 1200, height: 1500 }, + { group: "Instagram", label: "Instagram Portrait", width: 1080, height: 1350 }, + { group: "Instagram", label: "Instagram Story", width: 1080, height: 1920 }, + { group: "More", label: "Pinterest Pin", width: 1000, height: 1500 }, + { group: "More", label: "YouTube Thumbnail", width: 1280, height: 720 }, + { group: "More", label: "Presentation 16:9", width: 1920, height: 1080 }, ]; +// Guardrails for the custom size fields. The upper bound keeps a typo from +// allocating a canvas big enough to crash the tab. +export const MIN_CANVAS_SIDE = 50; +export const MAX_CANVAS_SIDE = 8000; + export interface EditorContextValue { // Canvas (multi-canvas) registerCanvas: (pageId: string, canvas: fabric.Canvas) => void; @@ -42,7 +53,10 @@ export interface EditorContextValue { redo: () => void; canUndo: boolean; canRedo: boolean; - setCanvasSize: (width: number, height: number) => void; + setCanvasSize: (width: number, height: number, options?: { reflow?: boolean }) => void; + getCanvasSize: () => Dimensions; + undoResize: () => void; + canUndoResize: boolean; zoomToFit: () => void; zoomIn: () => void; zoomOut: () => void; diff --git a/src/client/hooks/use-canvas.ts b/src/client/hooks/use-canvas.ts index 3c2a4bdc..eb049e81 100644 --- a/src/client/hooks/use-canvas.ts +++ b/src/client/hooks/use-canvas.ts @@ -1,6 +1,7 @@ import { useState, useCallback, useRef, useEffect } from "preact/hooks"; import * as fabric from "fabric"; import type { Template } from "../types"; +import { reflowCanvas, type Dimensions } from "../resize"; const MAX_HISTORY = 50; @@ -30,6 +31,14 @@ export function useCanvasState() { const [selectedObject, setSelectedObject] = useState(null); const [canvasWidth, setCanvasWidth] = useState(1080); const [canvasHeight, setCanvasHeight] = useState(1080); + const canvasWidthRef = useRef(1080); + const canvasHeightRef = useRef(1080); + // A resize spans every page at once, so it cannot be expressed in the + // per-page undo stacks. It gets one snapshot of its own instead. + const resizeSnapshotRef = useRef< + { width: number; height: number; pages: Map } | null + >(null); + const [canUndoResize, setCanUndoResize] = useState(false); const [zoom, setZoom] = useState(0.58); const [fitScale, setFitScale] = useState(0.58); const [canUndo, setCanUndo] = useState(false); @@ -338,22 +347,82 @@ export function useCanvasState() { // ── Canvas size ───────────────────────────────────────────────────── + // Retarget every page's canvas to a new frame. Moves no artwork. + const applyDimensions = useCallback((width: number, height: number) => { + setCanvasWidth(width); + setCanvasHeight(height); + canvasWidthRef.current = width; + canvasHeightRef.current = height; + const dpr = window.devicePixelRatio || 1; + for (const canvas of canvasMapRef.current.values()) { + canvas.setDimensions({ width: width * dpr, height: height * dpr }, { cssOnly: false }); + canvas.setDimensions({ width, height }, { cssOnly: true }); + canvas.setViewportTransform([dpr, 0, 0, dpr, 0, 0]); + canvas.requestRenderAll(); + } + }, []); + + const getCanvasSize = useCallback( + (): Dimensions => ({ width: canvasWidthRef.current, height: canvasHeightRef.current }), + [] + ); + const setCanvasSize = useCallback( - (width: number, height: number) => { - setCanvasWidth(width); - setCanvasHeight(height); - // Resize all canvases - const dpr = window.devicePixelRatio || 1; - for (const canvas of canvasMapRef.current.values()) { - canvas.setDimensions({ width: width * dpr, height: height * dpr }, { cssOnly: false }); - canvas.setDimensions({ width, height }, { cssOnly: true }); - canvas.setViewportTransform([dpr, 0, 0, dpr, 0, 0]); - canvas.requestRenderAll(); + (width: number, height: number, options?: { reflow?: boolean }) => { + const from = getCanvasSize(); + const to = { width, height }; + + // Adopting a saved design's own frame: the stored artwork already fits it. + if (options?.reflow === false) { + applyDimensions(width, height); + return; + } + if (from.width === to.width && from.height === to.height) return; + + const pages = new Map(); + for (const [pageId, canvas] of canvasMapRef.current) { + pages.set(pageId, JSON.stringify(canvas.toJSON())); + } + resizeSnapshotRef.current = { ...from, pages }; + setCanUndoResize(true); + + applyDimensions(width, height); + for (const [pageId, canvas] of canvasMapRef.current) { + isRestoringRef.current.add(pageId); + reflowCanvas(canvas, from, to); + isRestoringRef.current.delete(pageId); + // A resize is a commit point: undoing across it page by page would + // leave that page's artwork sized for the frame it no longer has. + historyMapRef.current.set(pageId, { + entries: [JSON.stringify(canvas.toJSON())], + index: 0, + }); + updateUndoRedoState(pageId); } }, - [] + [applyDimensions, getCanvasSize, updateUndoRedoState] ); + // Restores the frame and every page's artwork together. + const undoResize = useCallback(() => { + const snapshot = resizeSnapshotRef.current; + if (!snapshot) return; + resizeSnapshotRef.current = null; + setCanUndoResize(false); + applyDimensions(snapshot.width, snapshot.height); + for (const [pageId, canvas] of canvasMapRef.current) { + const json = snapshot.pages.get(pageId); + if (!json) continue; + isRestoringRef.current.add(pageId); + canvas.loadFromJSON(JSON.parse(json)).then(() => { + canvas.requestRenderAll(); + isRestoringRef.current.delete(pageId); + historyMapRef.current.set(pageId, { entries: [json], index: 0 }); + updateUndoRedoState(pageId); + }); + } + }, [applyDimensions, updateUndoRedoState]); + // ── Zoom ──────────────────────────────────────────────────────────── const zoomToFit = useCallback(() => { @@ -410,18 +479,10 @@ export function useCanvasState() { const loadTemplate = useCallback( (template: Template) => { - setCanvasWidth(template.width); - setCanvasHeight(template.height); - // Template loading — resize all canvases to new dimensions - const dpr = window.devicePixelRatio || 1; - for (const canvas of canvasMapRef.current.values()) { - canvas.setDimensions( - { width: template.width * dpr, height: template.height * dpr }, - { cssOnly: false } - ); - canvas.setDimensions({ width: template.width, height: template.height }, { cssOnly: true }); - canvas.setViewportTransform([dpr, 0, 0, dpr, 0, 0]); - } + // The template brings its own frame and artwork, so nothing is reflowed. + applyDimensions(template.width, template.height); + resizeSnapshotRef.current = null; + setCanUndoResize(false); // Load template JSON onto active canvas const canvas = getActiveCanvas(); const pageId = activeCanvasIdRef.current; @@ -438,7 +499,7 @@ export function useCanvasState() { }); } }, - [getActiveCanvas, updateUndoRedoState] + [applyDimensions, getActiveCanvas, updateUndoRedoState] ); // ── Keyboard shortcuts ────────────────────────────────────────────── @@ -497,6 +558,9 @@ export function useCanvasState() { canUndo, canRedo, setCanvasSize, + getCanvasSize, + undoResize, + canUndoResize, zoomToFit, zoomIn, zoomOut, diff --git a/src/client/hooks/use-designs.ts b/src/client/hooks/use-designs.ts index dd07366e..145791dd 100644 --- a/src/client/hooks/use-designs.ts +++ b/src/client/hooks/use-designs.ts @@ -1,8 +1,12 @@ import { useState, useCallback, useRef, useEffect } from "preact/hooks"; import type { Design, DesignWithPages, Template, Page } from "../types"; +import type { Dimensions } from "../resize"; import { api } from "../api"; -export function useDesigns(getCanvasJSONForPage: (pageId: string) => string) { +export function useDesigns( + getCanvasJSONForPage: (pageId: string) => string, + getCanvasSize: () => Dimensions +) { const [designs, setDesigns] = useState([]); const [templates, setTemplates] = useState([]); const [activeDesign, setActiveDesign] = useState(null); @@ -54,8 +58,13 @@ export function useDesigns(getCanvasJSONForPage: (pageId: string) => string) { } // Also update design's canvas_json with first page for backwards compat const firstPageJson = currentPages.length > 0 ? getCanvasJSONForPage(currentPages[0].id) : "{}"; + // The frame is part of the design: without it a resized design reopens + // at its old dimensions with artwork scaled for the new ones. + const { width, height } = getCanvasSize(); const updated = await api("PUT", `/api/designs/${activeIdRef.current}`, { canvas_json: firstPageJson, + width, + height, }); setDesigns((prev) => prev.map((d) => (d.id === updated.id ? updated : d))); setActiveDesign(updated); @@ -64,7 +73,7 @@ export function useDesigns(getCanvasJSONForPage: (pageId: string) => string) { } finally { setSaving(false); } - }, [getCanvasJSONForPage, pages]); + }, [getCanvasJSONForPage, getCanvasSize, pages]); const createDesign = useCallback(async (): Promise => { try { diff --git a/src/client/resize.ts b/src/client/resize.ts new file mode 100644 index 00000000..aa5d2668 --- /dev/null +++ b/src/client/resize.ts @@ -0,0 +1,108 @@ +import * as fabric from "fabric"; + +export interface Dimensions { + width: number; + height: number; +} + +// A backdrop has to sit within this fraction of the frame's edge, and reach +// this close to its far edge, to count as covering the canvas. +const COVER_TOLERANCE = 0.02; + +function isTextbox(obj: fabric.FabricObject): obj is fabric.Textbox { + // Fabric capitalises `type` once a canvas has round-tripped through JSON, + // while hand-written seed templates use the lowercase spelling. + return obj instanceof fabric.Textbox || String(obj.type).toLowerCase() === "textbox"; +} + +/** + * A backdrop is the object a design is built on top of — a template's + * full-bleed rectangle, or an image added through the background picker. + * It has to stretch to the new frame rather than scale with the artwork, + * otherwise resizing to a taller ratio leaves a band of bare canvas. + */ +export function isBackdrop( + obj: fabric.FabricObject, + index: number, + from: Dimensions +): boolean { + if ((obj as { _isBgImage?: boolean })._isBgImage) return true; + // Only the bottom-most object can be a backdrop, so a large foreground + // shape that happens to cover the frame is never mistaken for one. + if (index !== 0) return false; + if (obj.angle) return false; + obj.setCoords(); + const r = obj.getBoundingRect(); + return ( + r.left <= from.width * COVER_TOLERANCE && + r.top <= from.height * COVER_TOLERANCE && + r.width >= from.width * (1 - COVER_TOLERANCE) && + r.height >= from.height * (1 - COVER_TOLERANCE) + ); +} + +function stretchToFrame(obj: fabric.FabricObject, to: Dimensions): void { + obj.set({ + left: 0, + top: 0, + scaleX: to.width / (obj.width || 1), + scaleY: to.height / (obj.height || 1), + }); + obj.setCoords(); +} + +function scaleAndReposition( + obj: fabric.FabricObject, + from: Dimensions, + to: Dimensions, + scale: number +): void { + const centre = obj.getCenterPoint(); + const next = new fabric.Point( + to.width / 2 + (centre.x - from.width / 2) * scale, + to.height / 2 + (centre.y - from.height / 2) * scale + ); + + if (isTextbox(obj)) { + // Scale the type rather than the transform: the font-size control reads + // `fontSize` straight off the object, and a wider box has to re-wrap. + const text = obj as fabric.Textbox; + text.set({ + fontSize: (text.fontSize || 1) * scale, + width: (text.width || 1) * scale, + }); + } else { + obj.set({ + scaleX: (obj.scaleX || 1) * scale, + scaleY: (obj.scaleY || 1) * scale, + }); + } + + obj.setXY(next, "center", "center"); + obj.setCoords(); +} + +/** + * Refit every object on a canvas from one frame to another. Artwork scales + * uniformly by the smaller of the two ratios, so nothing distorts and nothing + * that fitted before is pushed outside the frame; positions are anchored to + * the centre so the composition survives a change of aspect ratio. + */ +export function reflowCanvas( + canvas: fabric.Canvas, + from: Dimensions, + to: Dimensions +): void { + if (from.width === to.width && from.height === to.height) return; + if (from.width <= 0 || from.height <= 0) return; + + const scale = Math.min(to.width / from.width, to.height / from.height); + canvas.getObjects().forEach((obj, index) => { + if (isBackdrop(obj, index, from)) { + stretchToFrame(obj, to); + } else { + scaleAndReposition(obj, from, to, scale); + } + }); + canvas.requestRenderAll(); +} From 24f555f2be39a8a97c92142a6a55438587afc5cb Mon Sep 17 00:00:00 2001 From: pallaoro Date: Thu, 3 Sep 2026 20:43:20 +0200 Subject: [PATCH 2/2] fix(resize): retire the resize snapshot once it can no longer be restored 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. --- .wrangler/cache/cf.json | 1 + src/client/hooks/use-canvas.ts | 13 +++++++++++++ 2 files changed, 14 insertions(+) create mode 100644 .wrangler/cache/cf.json diff --git a/.wrangler/cache/cf.json b/.wrangler/cache/cf.json new file mode 100644 index 00000000..e57d0751 --- /dev/null +++ b/.wrangler/cache/cf.json @@ -0,0 +1 @@ +{"httpProtocol":"HTTP/1.1","clientAcceptEncoding":"gzip, deflate, br","requestPriority":"","edgeRequestKeepAliveStatus":1,"requestHeaderNames":{},"clientTcpRtt":22,"clientQuicRtt":0,"colo":"AMS","asn":1136,"asOrganization":"KPN CM","country":"NL","isEUCountry":"1","city":"Amsterdam","continent":"EU","region":"North Holland","regionCode":"NH","timezone":"Europe/Amsterdam","longitude":"4.88969","latitude":"52.37403","postalCode":"1012","tlsVersion":"TLSv1.3","tlsCipher":"AEAD-AES256-GCM-SHA384","tlsClientRandom":"gN6dj2TNzYkiAUVOwYDyJ+PifWCzvLLn6rHpGo7LQtc=","tlsClientCiphersSha1":"kXrN3VEKDdzz2cPKTQaKzpxVTxQ=","tlsClientExtensionsSha1":"1eY97BUYYO8vDaTfHQywB1pcNdM=","tlsClientExtensionsSha1Le":"u4wtEMFQBY18l3BzHAvORm+KGRw=","tlsExportedAuthenticator":{"clientHandshake":"0cd6a0f380a4a3cb48a9d19bb92169a052bca3a14801284f917067a5e30e8e15ac6ced4aef24da528e0de89eac046612","serverHandshake":"608304cd76337efc34f945dfd8ce78b0c69c744e6fa98a8b349975343acc37db7a23971317a527d766abbcd428c81271","clientFinished":"cb9ca785683383b1f9c95aa51047d081dfcc789f45c1355604bb5e725003cc49d042ed279ee2cfaf58bea38f04a8fd22","serverFinished":"819271a0ad87c7ea881619716db36addb1d57a646f0ec243a3ce2885070efd11428b7b7ca9a077fcfb9b7710ab5a374f"},"tlsClientHelloLength":"1605","tlsClientAuth":{"certPresented":"0","certVerified":"NONE","certRevoked":"0","certIssuerDN":"","certSubjectDN":"","certIssuerDNRFC2253":"","certSubjectDNRFC2253":"","certIssuerDNLegacy":"","certSubjectDNLegacy":"","certSerial":"","certIssuerSerial":"","certSKI":"","certIssuerSKI":"","certFingerprintSHA1":"","certFingerprintSHA256":"","certNotBefore":"","certNotAfter":"","certRFC9440":"","certRFC9440TooLarge":false,"certChainRFC9440":"","certChainRFC9440TooLarge":false},"verifiedBotCategory":"","edgeL4":{"deliveryRate":251978},"botManagement":{"corporateProxy":false,"verifiedBot":false,"jsDetection":{"passed":false},"staticResource":false,"detectionIds":{},"score":99}} \ No newline at end of file diff --git a/src/client/hooks/use-canvas.ts b/src/client/hooks/use-canvas.ts index eb049e81..09879e94 100644 --- a/src/client/hooks/use-canvas.ts +++ b/src/client/hooks/use-canvas.ts @@ -69,6 +69,12 @@ export function useCanvasState() { if (isRestoringRef.current.has(pageId)) return; const canvas = canvasMapRef.current.get(pageId); if (!canvas) return; + // An edit lands on top of the resize. Restoring the snapshot would now + // throw this work away too, so the resize stops being undoable here. + if (resizeSnapshotRef.current) { + resizeSnapshotRef.current = null; + setCanUndoResize(false); + } const json = JSON.stringify(canvas.toJSON()); let hist = historyMapRef.current.get(pageId); if (!hist) { @@ -122,6 +128,13 @@ export function useCanvasState() { const unregisterCanvas = useCallback((pageId: string) => { canvasMapRef.current.delete(pageId); historyMapRef.current.delete(pageId); + // The snapshot holds JSON keyed by page id. Once a page it captured is + // gone -- page deleted, or the editor left for another design -- it can no + // longer be restored onto the canvases that are actually mounted. + if (resizeSnapshotRef.current?.pages.has(pageId)) { + resizeSnapshotRef.current = null; + setCanUndoResize(false); + } }, []); const setActiveCanvas = useCallback((pageId: string) => {