Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .wrangler/cache/cf.json
Original file line number Diff line number Diff line change
@@ -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}}
19 changes: 11 additions & 8 deletions src/client/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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<string | null>(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]);

Expand Down
132 changes: 111 additions & 21 deletions src/client/components/toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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("");

Expand All @@ -43,6 +53,33 @@ export function Toolbar() {
);
const sizeLabel = currentSize ? currentSize.label : `${canvasWidth} x ${canvasHeight}`;

const groups = CANVAS_SIZES.reduce<Record<string, typeof CANVAS_SIZES>>((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);
Expand Down Expand Up @@ -93,38 +130,91 @@ export function Toolbar() {
<div class="relative">
<button
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-[11px] font-medium text-zinc-400 bg-zinc-100 border border-zinc-300 cursor-pointer hover:text-zinc-900 hover:border-zinc-500 transition-all"
onClick={() => setShowSizeDropdown(!showSizeDropdown)}
onClick={() => (showSizeDropdown ? setShowSizeDropdown(false) : openSizeMenu())}
>
{sizeLabel}
<ChevronDown size={12} />
</button>
{showSizeDropdown && (
<>
<div class="fixed inset-0 z-10" onClick={() => setShowSizeDropdown(false)} />
<div class="absolute top-full left-0 mt-1 bg-white border border-zinc-300 rounded-lg shadow-xl z-20 min-w-[200px] py-1">
{CANVAS_SIZES.map((s) => (
<button
key={s.label}
class={`w-full text-left px-3 py-1.5 text-xs cursor-pointer border-none transition-colors ${
s.width === canvasWidth && s.height === canvasHeight
? "bg-accent/20 text-accent"
: "text-zinc-600 bg-transparent hover:bg-zinc-100"
}`}
onClick={() => {
setCanvasSize(s.width, s.height);
setShowSizeDropdown(false);
}}
>
<span class="font-medium">{s.label}</span>
<span class="text-zinc-400 ml-2">
{s.width} x {s.height}
</span>
</button>
<div class="absolute top-full left-0 mt-1 bg-white border border-zinc-300 rounded-lg shadow-xl z-20 min-w-[240px] py-1">
{Object.entries(groups).map(([group, sizes]) => (
<div key={group}>
<div class="px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-zinc-400">
{group}
</div>
{sizes.map((s) => (
<button
key={s.label}
class={`w-full text-left px-3 py-1.5 text-xs cursor-pointer border-none transition-colors ${
s.width === canvasWidth && s.height === canvasHeight
? "bg-accent/20 text-accent"
: "text-zinc-600 bg-transparent hover:bg-zinc-100"
}`}
onClick={() => {
setCanvasSize(s.width, s.height);
setShowSizeDropdown(false);
}}
>
<span class="font-medium">{s.label}</span>
<span class="text-zinc-400 ml-2">
{s.width} x {s.height}
</span>
</button>
))}
</div>
))}

<div class="mt-1 pt-2 border-t border-zinc-200 px-3 pb-1">
<div class="text-[10px] font-semibold uppercase tracking-wider text-zinc-400 mb-1.5">
Custom
</div>
<div class="flex items-center gap-1.5">
<input
type="number"
aria-label="Custom width in pixels"
class="w-[68px] bg-zinc-100 border border-zinc-300 rounded px-2 py-1 text-xs text-zinc-900 outline-none focus:border-accent"
value={customWidth}
min={MIN_CANVAS_SIDE}
max={MAX_CANVAS_SIDE}
onInput={(e) => setCustomWidth((e.target as HTMLInputElement).value)}
onKeyDown={(e) => e.key === "Enter" && applyCustomSize()}
/>
<span class="text-zinc-400 text-xs">x</span>
<input
type="number"
aria-label="Custom height in pixels"
class="w-[68px] bg-zinc-100 border border-zinc-300 rounded px-2 py-1 text-xs text-zinc-900 outline-none focus:border-accent"
value={customHeight}
min={MIN_CANVAS_SIDE}
max={MAX_CANVAS_SIDE}
onInput={(e) => setCustomHeight((e.target as HTMLInputElement).value)}
onKeyDown={(e) => e.key === "Enter" && applyCustomSize()}
/>
<button
class="px-2.5 py-1 rounded text-xs font-medium text-white bg-accent border-none cursor-pointer hover:opacity-90 transition-opacity"
onClick={applyCustomSize}
>
Resize
</button>
</div>
</div>
</div>
</>
)}
</div>

{canUndoResize && (
<button
class="inline-flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-medium text-zinc-500 bg-transparent border border-zinc-300 cursor-pointer hover:text-zinc-900 hover:border-zinc-500 transition-all"
onClick={undoResize}
title="Restore the previous size and layout"
>
<RotateCcw size={11} />
Undo resize
</button>
)}
</div>

{/* Center: Undo / Redo */}
Expand Down
24 changes: 19 additions & 5 deletions src/client/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
Loading