From c81cf1529197fce6204aa61fb9a99152935dfe91 Mon Sep 17 00:00:00 2001 From: pallaoro Date: Thu, 3 Sep 2026 09:10:43 +0200 Subject: [PATCH 1/3] feat(export): carousel PDF, per-page PNGs, and a display-independent export size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A swipeable LinkedIn carousel can only be published as a multi-page document post, i.e. a PDF. The editor already had multi-page designs, but Export only ever emitted the active page — so a finished carousel had no way out. Export is now a menu: - Carousel PDF — every page, in canvas order, one file - This page — PNG - All pages — one PNG per page Exports are named after the design instead of always "design.png", and the PDF is assembled in the browser, so no page content leaves the machine. Also fixes the export resolution. Each canvas is backed at the display's device pixel ratio, and that ratio is already folded into the dimensions toDataURL scales, so a fixed 2x multiplier produced a 4320px file on a retina laptop and 2160px on a 1x monitor from the same design. Dividing the multiplier by the canvas zoom cancels the ratio out and pins every export to 2x the design's own size. Adds the 1080x1350 (4:5) carousel canvas size, and skips jsPDF's optional extras — canvg pulls in a native node-canvas build that would make `pnpm install` require cairo. --- README.md | 16 ++++++- agent.md | 5 +- clawnify.json | 4 +- package.json | 1 + pnpm-lock.yaml | 53 +++++++++++++++++++++ pnpm-workspace.yaml | 8 ++++ src/client/components/toolbar.tsx | 77 +++++++++++++++++++++++++++---- src/client/context.tsx | 7 ++- src/client/hooks/use-canvas.ts | 67 +++++++++++++++++++-------- src/client/lib/export.ts | 73 +++++++++++++++++++++++++++++ src/client/lib/jspdf-optional.ts | 5 ++ vite.config.ts | 4 ++ 12 files changed, 286 insertions(+), 34 deletions(-) create mode 100644 src/client/lib/export.ts create mode 100644 src/client/lib/jspdf-optional.ts diff --git a/README.md b/README.md index ef3a147a..6ee7b354 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,24 @@ Unlike Canva or Adobe Express, this runs entirely on your own infrastructure. No - **Shapes** — rectangles, circles, triangles, lines with fill, stroke, border radius - **Image uploads** — drag-and-drop or click to upload, place on canvas - **Backgrounds** — solid colors, gradients, uploaded images -- **Canvas sizes** — LinkedIn Square (1080x1080), LinkedIn Landscape (1200x627), LinkedIn Portrait (1200x1500), Instagram Story (1080x1920) +- **Canvas sizes** — LinkedIn Square (1080x1080), LinkedIn Carousel (1080x1350), LinkedIn Landscape (1200x627), LinkedIn Portrait (1200x1500), Instagram Story (1080x1920) - **Undo/Redo** — full history with keyboard shortcuts (Cmd+Z / Cmd+Shift+Z) -- **2x PNG export** — crisp high-resolution output for social media +- **Carousel PDF export** — every page in one multi-page PDF, the only format LinkedIn accepts for a swipeable carousel +- **2x PNG export** — this page or every page, at exactly 2x the design size on any display - **Auto-save** — designs persist to SQLite with debounced saves - **Dual-mode UI** — human-optimized + AI-agent-optimized (`?agent=true`) +## Carousels + +LinkedIn retired native multi-image carousels in 2023 — a swipeable carousel is now +published as a multi-page **document post**, which means a PDF. OpenDesign builds one +end to end: add a page per slide, then **Export → Carousel PDF** writes them all into a +single file, in canvas order, at the slide's own aspect ratio. Upload that PDF to +LinkedIn as a document. + +Use the **LinkedIn Carousel** canvas size (1080x1350, 4:5) — it takes up the most room +on a phone screen. Exports are assembled in the browser, so nothing leaves the machine. + ## Quickstart ```bash diff --git a/agent.md b/agent.md index a635d6f5..db8ec03e 100644 --- a/agent.md +++ b/agent.md @@ -7,12 +7,15 @@ A design editor for creating professional social media graphics, especially Link - Pre-built LinkedIn post templates (Quote Card, Stats Highlight, Announcement, Tips List, Profile Card, Minimal Text) - Text editing with Google Fonts (Inter, Montserrat, Playfair Display) - Image uploads and placement -- Multiple canvas sizes (1080x1080 square, 1200x627 landscape) +- Multiple canvas sizes (1080x1080 square, 1080x1350 carousel, 1200x627 landscape) +- Multi-page designs exported as one PDF — how a LinkedIn carousel is published +- 2x PNG export, one page or all of them - Save and manage multiple designs ## When to use this template Use this template when the user wants to: - Create social media post images or graphics - Design LinkedIn posts, quote cards, or announcement banners +- Build a LinkedIn or Instagram carousel and export it as a PDF - Build a simple graphic design tool - Create branded visual content diff --git a/clawnify.json b/clawnify.json index 308dd912..3755920d 100644 --- a/clawnify.json +++ b/clawnify.json @@ -2,10 +2,10 @@ "$schema": "https://app.clawnify.com/schema/v1/clawnify.json", "version": 1, "name": "OpenDesign", - "description": "Design editor for creating professional social media graphics, LinkedIn posts, and visual content with a canvas-based UI, templates, multi-page support, image uploads, and text/shape tools. Like Canva. Best for graphic design tools, social media post creators, banner makers, or any visual content editor.", + "description": "Design editor for creating professional social media graphics, LinkedIn posts, carousels, and visual content with a canvas-based UI, templates, multi-page support, image uploads, and text/shape tools. Exports multi-page designs as a LinkedIn-ready carousel PDF, or as 2x PNGs. Like Canva. Best for graphic design tools, social media post creators, carousel makers, banner makers, or any visual content editor.", "icon": "icon.svg", "screenshot": "https://github.com/user-attachments/assets/55fcc0aa-4606-4f80-b56a-7734d7673914", - "tags": ["design", "graphics", "social-media", "canvas"], + "tags": ["design", "graphics", "social-media", "canvas", "carousel", "linkedin"], "app": { "framework": "preact+hono", diff --git a/package.json b/package.json index c35deb04..297d558e 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@tailwindcss/vite": "^4.2.2", "fabric": "^6.0.0", "hono": "^4.6.0", + "jspdf": "^4.2.1", "lucide-preact": "^0.575.0", "preact": "^10.25.0", "tailwindcss": "^4.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bfe201b4..d5f5fb30 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: hono: specifier: ^4.6.0 version: 4.12.10 + jspdf: + specifier: ^4.2.1 + version: 4.2.1 lucide-preact: specifier: ^0.575.0 version: 0.575.0(preact@10.29.1) @@ -149,6 +152,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -1177,6 +1184,9 @@ packages: '@types/node@25.5.2': resolution: {integrity: sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==} + '@types/pako@2.0.4': + resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==} + '@types/webfontloader@1.6.38': resolution: {integrity: sha512-kUaF72Fv202suFx6yBrwXqeVRMx7hGtJTesyESZgn9sEPCUeDXm2p0SiyS1MTqW74nQP4p7JyrOCwZ7pNFns4w==} @@ -1548,6 +1558,9 @@ packages: resolution: {integrity: sha512-TqG08Xbt4rtlPsXgCjSUcZz/RsyEP57Qo21nCVRkw7zz9nR0co4SLkL9Q/zQh3tC1Yxap6M5jKFHUKV6SgPovg==} engines: {node: '>=16.20.0'} + fast-png@6.4.0: + resolution: {integrity: sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1557,6 +1570,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + form-data@4.0.5: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} @@ -1661,6 +1677,9 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + iobuffer@5.4.0: + resolution: {integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==} + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} @@ -1694,6 +1713,9 @@ packages: engines: {node: '>=6'} hasBin: true + jspdf@4.2.1: + resolution: {integrity: sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==} + kleur@4.1.5: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} @@ -1882,6 +1904,9 @@ packages: openapi3-ts@4.5.0: resolution: {integrity: sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ==} + pako@2.2.0: + resolution: {integrity: sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==} + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} @@ -2270,6 +2295,12 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} +ignoredOptionalDependencies: + - canvg + - core-js + - dompurify + - html2canvas + snapshots: '@asteasolutions/zod-to-openapi@7.3.4(zod@3.25.76)': @@ -2383,6 +2414,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/runtime@7.29.7': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -3117,6 +3150,8 @@ snapshots: undici-types: 7.18.2 optional: true + '@types/pako@2.0.4': {} + '@types/webfontloader@1.6.38': {} abab@2.0.6: @@ -3491,10 +3526,18 @@ snapshots: - supports-color - utf-8-validate + fast-png@6.4.0: + dependencies: + '@types/pako': 2.0.4 + iobuffer: 5.4.0 + pako: 2.2.0 + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 + fflate@0.8.3: {} + form-data@4.0.5: dependencies: asynckit: 0.4.0 @@ -3633,6 +3676,8 @@ snapshots: inherits@2.0.4: optional: true + iobuffer@5.4.0: {} + is-fullwidth-code-point@3.0.0: {} is-potential-custom-element-name@1.0.1: @@ -3682,6 +3727,12 @@ snapshots: json5@2.2.3: {} + jspdf@4.2.1: + dependencies: + '@babel/runtime': 7.29.7 + fast-png: 6.4.0 + fflate: 0.8.3 + kleur@4.1.5: {} kolorist@1.8.0: {} @@ -3851,6 +3902,8 @@ snapshots: dependencies: yaml: 2.8.3 + pako@2.2.0: {} + parse5@7.3.0: dependencies: entities: 6.0.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 239ace72..abec7196 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,11 @@ +# jsPDF's optional extras (.svg()/.html() support) are unused here, and canvg +# drags in node-canvas — a native build that would make `pnpm install` need +# cairo on every machine that clones this template. +ignoredOptionalDependencies: + - canvg + - core-js + - dompurify + - html2canvas allowBuilds: canvas: set this to true or false esbuild: set this to true or false diff --git a/src/client/components/toolbar.tsx b/src/client/components/toolbar.tsx index 6ae09359..70eb2cf2 100644 --- a/src/client/components/toolbar.tsx +++ b/src/client/components/toolbar.tsx @@ -9,6 +9,9 @@ import { Save, ChevronDown, Home, + FileText, + Image as ImageIcon, + Images, } from "lucide-preact"; import { useEditor, CANVAS_SIZES } from "../context"; @@ -27,6 +30,10 @@ export function Toolbar() { zoomIn, zoomOut, exportPNG, + exportAllPNG, + exportPDF, + exporting, + pages, saveDesign, saving, activeDesign, @@ -35,6 +42,7 @@ export function Toolbar() { } = useEditor(); const [showSizeDropdown, setShowSizeDropdown] = useState(false); + const [showExportMenu, setShowExportMenu] = useState(false); const [editingName, setEditingName] = useState(false); const [nameValue, setNameValue] = useState(""); @@ -56,6 +64,14 @@ export function Toolbar() { setEditingName(false); }; + const designName = activeDesign?.name ?? "design"; + const pageIds = pages.map((p) => p.id); + + const runExport = (fn: () => void) => { + setShowExportMenu(false); + fn(); + }; + return (
{/* Left: Home + Design name + Canvas size */} @@ -176,14 +192,59 @@ export function Toolbar() {
- +
+ + {showExportMenu && ( + <> +
setShowExportMenu(false)} /> +
+ + + {pages.length > 1 && ( + + )} +
+ + )} +
diff --git a/src/client/context.tsx b/src/client/context.tsx index bd88546f..b30aadd9 100644 --- a/src/client/context.tsx +++ b/src/client/context.tsx @@ -11,7 +11,7 @@ export interface CanvasSize { export const CANVAS_SIZES: CanvasSize[] = [ { label: "LinkedIn Square", width: 1080, height: 1080 }, - // LinkedIn's recommended carousel slide — 4:5 fills the most phone screen. + // LinkedIn's recommended carousel slide. 4:5 fills the most phone screen. { label: "LinkedIn Carousel", width: 1080, height: 1350 }, { label: "LinkedIn Landscape", width: 1200, height: 627 }, { label: "LinkedIn Portrait", width: 1200, height: 1500 }, diff --git a/src/client/hooks/use-canvas.ts b/src/client/hooks/use-canvas.ts index d3a87257..b5dadb0d 100644 --- a/src/client/hooks/use-canvas.ts +++ b/src/client/hooks/use-canvas.ts @@ -372,7 +372,7 @@ export function useCanvasState() { // ── Export ────────────────────────────────────────────────────────── - // Page ids come from the design, which owns their order — a Map has none. + // Page ids come from the design, which owns their order. A Map has none. const canvasesForPages = useCallback( (pageIds: string[]) => pageIds @@ -394,7 +394,7 @@ export function useCanvasState() { (pageIds: string[], designName: string) => { const canvases = canvasesForPages(pageIds); const slug = slugify(designName); - // Ceiling: one download per page, staggered — browsers drop downloads fired + // Ceiling: one download per page, staggered. Browsers drop downloads fired // in the same tick, and prompt once for a burst. Fine for the 5-20 slides a // social carousel runs to. If designs ever get long enough for that to be a // nuisance, zip them (fflate is already in the tree under jspdf) rather than diff --git a/src/client/lib/export.ts b/src/client/lib/export.ts index 5b0fac5f..af1670c9 100644 --- a/src/client/lib/export.ts +++ b/src/client/lib/export.ts @@ -1,6 +1,6 @@ import type * as fabric from "fabric"; -/** Every export renders at 2x the design size — crisp in a feed, still light enough to upload. */ +/** Every export renders at 2x the design size: crisp in a feed, still light enough to upload. */ export const EXPORT_SCALE = 2; /** @@ -9,7 +9,7 @@ export const EXPORT_SCALE = 2; * PageCanvas sizes each canvas backstore by the device pixel ratio, so fabric's * own `width` already carries that ratio and `getZoom()` is the very same factor. * `toDataURL` scales `width` by the multiplier, so a fixed multiplier makes the - * export grow with whatever display the editor happens to be open on — 4320px on + * export grow with whatever display the editor happens to be open on: 4320px on * a retina laptop, 2160px on an external 1x monitor, from the same design. * Dividing by the zoom cancels the ratio back out and pins the output size. * @@ -38,7 +38,7 @@ export function slugify(name: string, fallback = "design"): string { } /** - * Assemble the pages into one PDF — the only format LinkedIn accepts for a + * Assemble the pages into one PDF, the only format LinkedIn accepts for a * swipeable carousel since it retired native multi-image carousels in 2023. * * jsPDF swaps the page box to match `orientation`, so the orientation has to diff --git a/src/client/lib/jspdf-optional.ts b/src/client/lib/jspdf-optional.ts index a90828fd..254fe693 100644 --- a/src/client/lib/jspdf-optional.ts +++ b/src/client/lib/jspdf-optional.ts @@ -1,5 +1,5 @@ // jsPDF dynamically imports canvg / html2canvas / dompurify from `doc.svg()` and -// `doc.html()`. This app only ever calls `addImage`, so those paths never run — +// `doc.html()`. This app only ever calls `addImage`, so those paths never run, // but the bundler still has to resolve the specifiers. Aliasing them here keeps // html2canvas (and canvg's native node-canvas build) out of the install. export default {};