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/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..09879e94 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); @@ -60,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) { @@ -113,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) => { @@ -338,22 +360,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 +492,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 +512,7 @@ export function useCanvasState() { }); } }, - [getActiveCanvas, updateUndoRedoState] + [applyDimensions, getActiveCanvas, updateUndoRedoState] ); // ── Keyboard shortcuts ────────────────────────────────────────────── @@ -497,6 +571,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(); +}