From bd61d3ddeb390ca5aba350390c7f5c53e00ea008 Mon Sep 17 00:00:00 2001 From: pallaoro Date: Thu, 3 Sep 2026 10:06:14 +0200 Subject: [PATCH 1/3] feat(fields): name objects so designs can be filled from data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A design becomes a template the moment an object carries a `fieldName`. Two routes make that usable without the caller ever parsing canvas JSON: GET /api/designs/:id/fields — the fill schema (name, type, value, pages) POST /api/designs/:id/fill — substitute values; `save` writes a variant Naming happens in the editor: select a text or image object and fill in "Field name" in the properties panel. A name may repeat across objects and pages, which is how one logo or headline fills a whole carousel at once. Fabric v6 drops unknown properties on serialize and `canvas.toJSON()` no longer accepts a property list, so `fieldName` is registered once via `FabricObject.customProperties` and every existing toJSON call site is left alone. Also fixes the properties panel losing track of what is selected. `updateSelectedObject` forced a re-render by replacing the selected object with a spread copy, which strips the prototype — so after a single edit a textbox reported no `type`, failed every `instanceof`, rendered shape controls, and threw on the next edit because `.set()` had gone with it. A version counter re-renders instead, keeping the live Fabric instance. --- .gitignore | 1 + package.json | 3 + pnpm-lock.yaml | 320 ++++++++++++++++++++++-- src/client/components/right-sidebar.tsx | 55 ++++ src/client/hooks/use-canvas.ts | 10 +- src/client/main.tsx | 6 + src/server/fields.test.ts | 110 ++++++++ src/server/fields.ts | 153 +++++++++++ src/server/index.ts | 143 +++++++++++ 9 files changed, 776 insertions(+), 25 deletions(-) create mode 100644 src/server/fields.test.ts create mode 100644 src/server/fields.ts diff --git a/.gitignore b/.gitignore index e1ac1a95..17ba30c6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ data.db-wal data.db-shm uploads/ .env +.test-build/ diff --git a/package.json b/package.json index c35deb04..83be9d0e 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "wrangler d1 execute open-design-db --local --file=src/server/schema.sql && concurrently -n ui,api -c cyan,green \"vite\" \"wrangler dev --port 8787\"", + "test": "esbuild src/server/fields.test.ts --bundle --platform=node --format=esm --outfile=.test-build/fields.test.mjs --log-level=warning && node --test .test-build/fields.test.mjs", "build": "vite build" }, "dependencies": { @@ -22,8 +23,10 @@ }, "devDependencies": { "@preact/preset-vite": "^2.9.0", + "@types/node": "^26.4.1", "@types/webfontloader": "^1.6.38", "concurrently": "^9.0.0", + "esbuild": "^0.28.2", "typescript": "^5.7.0", "vite": "^6.0.0", "wrangler": "^4.0.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bfe201b4..2d9491b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,7 +19,7 @@ importers: version: 0.18.4(hono@4.12.10)(zod@3.25.76) '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@6.4.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.2.2(vite@6.4.1(@types/node@26.4.1)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) fabric: specifier: ^6.0.0 version: 6.9.1 @@ -44,19 +44,25 @@ importers: devDependencies: '@preact/preset-vite': specifier: ^2.9.0 - version: 2.10.5(@babel/core@7.29.0)(preact@10.29.1)(rollup@4.60.1)(vite@6.4.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 2.10.5(@babel/core@7.29.0)(preact@10.29.1)(rollup@4.60.1)(vite@6.4.1(@types/node@26.4.1)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) + '@types/node': + specifier: ^26.4.1 + version: 26.4.1 '@types/webfontloader': specifier: ^1.6.38 version: 1.6.38 concurrently: specifier: ^9.0.0 version: 9.2.1 + esbuild: + specifier: ^0.28.2 + version: 0.28.2 typescript: specifier: ^5.7.0 version: 5.9.3 vite: specifier: ^6.0.0 - version: 6.4.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) + version: 6.4.1(@types/node@26.4.1)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) wrangler: specifier: ^4.0.0 version: 4.93.1 @@ -244,6 +250,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.25.12': resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} @@ -262,6 +274,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.25.12': resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} @@ -280,6 +298,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.25.12': resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} @@ -298,6 +322,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.25.12': resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} @@ -316,6 +346,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.25.12': resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} @@ -334,6 +370,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.25.12': resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} @@ -352,6 +394,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} @@ -370,6 +418,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.25.12': resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} @@ -388,6 +442,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.25.12': resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} @@ -406,6 +466,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.25.12': resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} @@ -424,6 +490,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.25.12': resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} @@ -442,6 +514,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.25.12': resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} @@ -460,6 +538,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.25.12': resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} @@ -478,6 +562,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.25.12': resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} @@ -496,6 +586,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.25.12': resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} @@ -514,6 +610,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.25.12': resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} @@ -532,6 +634,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.12': resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} @@ -550,6 +658,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} @@ -568,6 +682,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} @@ -586,6 +706,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} @@ -604,6 +730,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} @@ -622,6 +754,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} @@ -640,6 +778,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.25.12': resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} @@ -658,6 +802,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.25.12': resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} @@ -676,6 +826,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.25.12': resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} @@ -694,6 +850,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@hono/zod-openapi@0.18.4': resolution: {integrity: sha512-6NHMHU96Hh32B1yDhb94Z4Z5/POsmEu2AXpWLWcBq9arskRnOMt2752yEoXoADV8WUAc7H1IkNaQHGj1ytXbYw==} engines: {node: '>=16.0.0'} @@ -1174,8 +1336,8 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - '@types/node@25.5.2': - resolution: {integrity: sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==} + '@types/node@26.4.1': + resolution: {integrity: sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==} '@types/webfontloader@1.6.38': resolution: {integrity: sha512-kUaF72Fv202suFx6yBrwXqeVRMx7hGtJTesyESZgn9sEPCUeDXm2p0SiyS1MTqW74nQP4p7JyrOCwZ7pNFns4w==} @@ -1519,6 +1681,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -2078,8 +2245,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - undici-types@7.18.2: - resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} undici@7.24.8: resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==} @@ -2523,6 +2690,9 @@ snapshots: '@esbuild/aix-ppc64@0.27.7': optional: true + '@esbuild/aix-ppc64@0.28.2': + optional: true + '@esbuild/android-arm64@0.25.12': optional: true @@ -2532,6 +2702,9 @@ snapshots: '@esbuild/android-arm64@0.27.7': optional: true + '@esbuild/android-arm64@0.28.2': + optional: true + '@esbuild/android-arm@0.25.12': optional: true @@ -2541,6 +2714,9 @@ snapshots: '@esbuild/android-arm@0.27.7': optional: true + '@esbuild/android-arm@0.28.2': + optional: true + '@esbuild/android-x64@0.25.12': optional: true @@ -2550,6 +2726,9 @@ snapshots: '@esbuild/android-x64@0.27.7': optional: true + '@esbuild/android-x64@0.28.2': + optional: true + '@esbuild/darwin-arm64@0.25.12': optional: true @@ -2559,6 +2738,9 @@ snapshots: '@esbuild/darwin-arm64@0.27.7': optional: true + '@esbuild/darwin-arm64@0.28.2': + optional: true + '@esbuild/darwin-x64@0.25.12': optional: true @@ -2568,6 +2750,9 @@ snapshots: '@esbuild/darwin-x64@0.27.7': optional: true + '@esbuild/darwin-x64@0.28.2': + optional: true + '@esbuild/freebsd-arm64@0.25.12': optional: true @@ -2577,6 +2762,9 @@ snapshots: '@esbuild/freebsd-arm64@0.27.7': optional: true + '@esbuild/freebsd-arm64@0.28.2': + optional: true + '@esbuild/freebsd-x64@0.25.12': optional: true @@ -2586,6 +2774,9 @@ snapshots: '@esbuild/freebsd-x64@0.27.7': optional: true + '@esbuild/freebsd-x64@0.28.2': + optional: true + '@esbuild/linux-arm64@0.25.12': optional: true @@ -2595,6 +2786,9 @@ snapshots: '@esbuild/linux-arm64@0.27.7': optional: true + '@esbuild/linux-arm64@0.28.2': + optional: true + '@esbuild/linux-arm@0.25.12': optional: true @@ -2604,6 +2798,9 @@ snapshots: '@esbuild/linux-arm@0.27.7': optional: true + '@esbuild/linux-arm@0.28.2': + optional: true + '@esbuild/linux-ia32@0.25.12': optional: true @@ -2613,6 +2810,9 @@ snapshots: '@esbuild/linux-ia32@0.27.7': optional: true + '@esbuild/linux-ia32@0.28.2': + optional: true + '@esbuild/linux-loong64@0.25.12': optional: true @@ -2622,6 +2822,9 @@ snapshots: '@esbuild/linux-loong64@0.27.7': optional: true + '@esbuild/linux-loong64@0.28.2': + optional: true + '@esbuild/linux-mips64el@0.25.12': optional: true @@ -2631,6 +2834,9 @@ snapshots: '@esbuild/linux-mips64el@0.27.7': optional: true + '@esbuild/linux-mips64el@0.28.2': + optional: true + '@esbuild/linux-ppc64@0.25.12': optional: true @@ -2640,6 +2846,9 @@ snapshots: '@esbuild/linux-ppc64@0.27.7': optional: true + '@esbuild/linux-ppc64@0.28.2': + optional: true + '@esbuild/linux-riscv64@0.25.12': optional: true @@ -2649,6 +2858,9 @@ snapshots: '@esbuild/linux-riscv64@0.27.7': optional: true + '@esbuild/linux-riscv64@0.28.2': + optional: true + '@esbuild/linux-s390x@0.25.12': optional: true @@ -2658,6 +2870,9 @@ snapshots: '@esbuild/linux-s390x@0.27.7': optional: true + '@esbuild/linux-s390x@0.28.2': + optional: true + '@esbuild/linux-x64@0.25.12': optional: true @@ -2667,6 +2882,9 @@ snapshots: '@esbuild/linux-x64@0.27.7': optional: true + '@esbuild/linux-x64@0.28.2': + optional: true + '@esbuild/netbsd-arm64@0.25.12': optional: true @@ -2676,6 +2894,9 @@ snapshots: '@esbuild/netbsd-arm64@0.27.7': optional: true + '@esbuild/netbsd-arm64@0.28.2': + optional: true + '@esbuild/netbsd-x64@0.25.12': optional: true @@ -2685,6 +2906,9 @@ snapshots: '@esbuild/netbsd-x64@0.27.7': optional: true + '@esbuild/netbsd-x64@0.28.2': + optional: true + '@esbuild/openbsd-arm64@0.25.12': optional: true @@ -2694,6 +2918,9 @@ snapshots: '@esbuild/openbsd-arm64@0.27.7': optional: true + '@esbuild/openbsd-arm64@0.28.2': + optional: true + '@esbuild/openbsd-x64@0.25.12': optional: true @@ -2703,6 +2930,9 @@ snapshots: '@esbuild/openbsd-x64@0.27.7': optional: true + '@esbuild/openbsd-x64@0.28.2': + optional: true + '@esbuild/openharmony-arm64@0.25.12': optional: true @@ -2712,6 +2942,9 @@ snapshots: '@esbuild/openharmony-arm64@0.27.7': optional: true + '@esbuild/openharmony-arm64@0.28.2': + optional: true + '@esbuild/sunos-x64@0.25.12': optional: true @@ -2721,6 +2954,9 @@ snapshots: '@esbuild/sunos-x64@0.27.7': optional: true + '@esbuild/sunos-x64@0.28.2': + optional: true + '@esbuild/win32-arm64@0.25.12': optional: true @@ -2730,6 +2966,9 @@ snapshots: '@esbuild/win32-arm64@0.27.7': optional: true + '@esbuild/win32-arm64@0.28.2': + optional: true + '@esbuild/win32-ia32@0.25.12': optional: true @@ -2739,6 +2978,9 @@ snapshots: '@esbuild/win32-ia32@0.27.7': optional: true + '@esbuild/win32-ia32@0.28.2': + optional: true + '@esbuild/win32-x64@0.25.12': optional: true @@ -2748,6 +2990,9 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true + '@esbuild/win32-x64@0.28.2': + optional: true + '@hono/zod-openapi@0.18.4(hono@4.12.10)(zod@3.25.76)': dependencies: '@asteasolutions/zod-to-openapi': 7.3.4(zod@3.25.76) @@ -2908,19 +3153,19 @@ snapshots: '@poppinss/exception@1.2.3': {} - '@preact/preset-vite@2.10.5(@babel/core@7.29.0)(preact@10.29.1)(rollup@4.60.1)(vite@6.4.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))': + '@preact/preset-vite@2.10.5(@babel/core@7.29.0)(preact@10.29.1)(rollup@4.60.1)(vite@6.4.1(@types/node@26.4.1)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0) '@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.29.0) - '@prefresh/vite': 2.4.12(preact@10.29.1)(vite@6.4.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) + '@prefresh/vite': 2.4.12(preact@10.29.1)(vite@6.4.1(@types/node@26.4.1)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) '@rollup/pluginutils': 5.3.0(rollup@4.60.1) babel-plugin-transform-hook-names: 1.0.2(@babel/core@7.29.0) debug: 4.4.3 magic-string: 0.30.21 picocolors: 1.1.1 - vite: 6.4.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) - vite-prerender-plugin: 0.5.13(vite@6.4.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) + vite: 6.4.1(@types/node@26.4.1)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) + vite-prerender-plugin: 0.5.13(vite@6.4.1(@types/node@26.4.1)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) zimmerframe: 1.1.4 transitivePeerDependencies: - preact @@ -2935,7 +3180,7 @@ snapshots: '@prefresh/utils@1.2.1': {} - '@prefresh/vite@2.4.12(preact@10.29.1)(vite@6.4.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))': + '@prefresh/vite@2.4.12(preact@10.29.1)(vite@6.4.1(@types/node@26.4.1)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@babel/core': 7.29.0 '@prefresh/babel-plugin': 0.5.3 @@ -2943,7 +3188,7 @@ snapshots: '@prefresh/utils': 1.2.1 '@rollup/pluginutils': 4.2.1 preact: 10.29.1 - vite: 6.4.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) + vite: 6.4.1(@types/node@26.4.1)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - supports-color @@ -3100,22 +3345,21 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 - '@tailwindcss/vite@4.2.2(vite@6.4.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))': + '@tailwindcss/vite@4.2.2(vite@6.4.1(@types/node@26.4.1)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@tailwindcss/node': 4.2.2 '@tailwindcss/oxide': 4.2.2 tailwindcss: 4.2.2 - vite: 6.4.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) + vite: 6.4.1(@types/node@26.4.1)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) '@tootallnate/once@2.0.0': optional: true '@types/estree@1.0.8': {} - '@types/node@25.5.2': + '@types/node@26.4.1': dependencies: - undici-types: 7.18.2 - optional: true + undici-types: 8.3.0 '@types/webfontloader@1.6.38': {} @@ -3459,6 +3703,35 @@ snapshots: '@esbuild/win32-x64': 0.27.7 optional: true + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + escalade@3.2.0: {} escodegen@2.1.0: @@ -4097,8 +4370,7 @@ snapshots: typescript@5.9.3: {} - undici-types@7.18.2: - optional: true + undici-types@8.3.0: {} undici@7.24.8: {} @@ -4124,7 +4396,7 @@ snapshots: util-deprecate@1.0.2: optional: true - vite-prerender-plugin@0.5.13(vite@6.4.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)): + vite-prerender-plugin@0.5.13(vite@6.4.1(@types/node@26.4.1)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)): dependencies: kolorist: 1.8.0 magic-string: 0.30.21 @@ -4132,9 +4404,9 @@ snapshots: simple-code-frame: 1.3.0 source-map: 0.7.6 stack-trace: 1.0.0-pre2 - vite: 6.4.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) + vite: 6.4.1(@types/node@26.4.1)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) - vite@6.4.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3): + vite@6.4.1(@types/node@26.4.1)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) @@ -4143,7 +4415,7 @@ snapshots: rollup: 4.60.1 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 25.5.2 + '@types/node': 26.4.1 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.32.0 diff --git a/src/client/components/right-sidebar.tsx b/src/client/components/right-sidebar.tsx index 3a52b0ac..586d0488 100644 --- a/src/client/components/right-sidebar.tsx +++ b/src/client/components/right-sidebar.tsx @@ -27,6 +27,53 @@ const FONT_FAMILIES = [ "Merriweather", ]; +/** + * Names an object so `/api/designs/:id/fields` publishes it and + * `/api/designs/:id/fill` can write to it. Committed on blur rather than per + * keystroke, so naming a field costs one undo step instead of one per letter. + */ +function FieldNameInput({ + object, + onCommit, +}: { + object: fabric.FabricObject; + onCommit: (name: string) => void; +}) { + const saved = ((object as any).fieldName as string) || ""; + const [draft, setDraft] = useState(saved); + + // Re-seed when the selection moves to a different object. + useEffect(() => setDraft(saved), [object]); + + const commit = () => { + const next = draft.trim(); + if (next !== saved) onCommit(next); + setDraft(next); + }; + + return ( +
+ + setDraft((e.target as HTMLInputElement).value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === "Enter") (e.target as HTMLInputElement).blur(); + }} + /> +

+ {saved + ? "Fillable through the design's /fill endpoint." + : "Name it to fill it from data or an agent."} +

+
+ ); +} + export function RightSidebar() { const { selectedObject, updateSelectedObject, deleteSelected, canvas, setBackground, canvasWidth, canvasHeight } = useEditor(); @@ -89,6 +136,14 @@ export function RightSidebar() {
+ {/* ── Template field ────────────────────────────────────────── */} + {(isText || isImage) && ( + updateSelectedObject({ fieldName })} + /> + )} + {/* ── Text properties ───────────────────────────────────────── */} {isText && ( <> diff --git a/src/client/hooks/use-canvas.ts b/src/client/hooks/use-canvas.ts index 3c2a4bdc..cf48581a 100644 --- a/src/client/hooks/use-canvas.ts +++ b/src/client/hooks/use-canvas.ts @@ -276,6 +276,14 @@ export function useCanvasState() { // ── Object manipulation ───────────────────────────────────────────── + // Editing a property mutates the live Fabric object, which React can't see. + // This counter is what re-renders the sidebar. Spreading the object into a + // new one would also re-render, but it strips the prototype — and in Fabric + // v6 that takes `.set()` and the `type` getter with it, so the panel would + // mistake a textbox for a shape and the next edit would throw. + const [, setSelectionVersion] = useState(0); + const bumpSelection = () => setSelectionVersion((v) => v + 1); + const updateSelectedObject = useCallback( (props: Record) => { const canvas = getActiveCanvas(); @@ -284,7 +292,7 @@ export function useCanvasState() { selectedObject.set(props as Partial); canvas.requestRenderAll(); saveHistory(pageId); - setSelectedObject({ ...selectedObject } as fabric.FabricObject); + bumpSelection(); }, [getActiveCanvas, selectedObject, saveHistory] ); diff --git a/src/client/main.tsx b/src/client/main.tsx index 29c5f7fe..6a5e3394 100644 --- a/src/client/main.tsx +++ b/src/client/main.tsx @@ -1,5 +1,11 @@ import { render } from "preact"; +import * as fabric from "fabric"; import { App } from "./app"; import "./styles.css"; +// Fabric v6 drops unknown properties on serialize, and `canvas.toJSON()` takes +// no argument list any more. Registering `fieldName` here is what makes it +// survive every save and reload, without touching the toJSON call sites. +fabric.FabricObject.customProperties = ["fieldName"]; + render(, document.getElementById("app")!); diff --git a/src/server/fields.test.ts b/src/server/fields.test.ts new file mode 100644 index 00000000..2fb00884 --- /dev/null +++ b/src/server/fields.test.ts @@ -0,0 +1,110 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { collectFields, fillPages, type PageJSON } from "./fields.js"; + +function page(id: string, objects: unknown[]): PageJSON { + return { id, canvas_json: JSON.stringify({ version: "6.0.0", objects }) }; +} + +const HEADLINE = { type: "textbox", text: "Your inspiring quote goes here", fieldName: "headline" }; +const LOGO = { type: "image", src: "https://example.com/old.png", fieldName: "logo" }; +const UNTAGGED = { type: "textbox", text: "not a field" }; + +test("collectFields reports name, type and current value", () => { + const fields = collectFields([page("p1", [HEADLINE, LOGO, UNTAGGED])]); + + assert.deepEqual(fields, [ + { name: "headline", type: "text", value: "Your inspiring quote goes here", page_ids: ["p1"] }, + { name: "logo", type: "image", value: "https://example.com/old.png", page_ids: ["p1"] }, + ]); +}); + +test("a name reused across pages is one field listing every page", () => { + const fields = collectFields([page("p1", [LOGO]), page("p2", [LOGO])]); + + assert.equal(fields.length, 1); + assert.deepEqual(fields[0].page_ids, ["p1", "p2"]); +}); + +test("fields inside groups are found", () => { + const fields = collectFields([page("p1", [{ type: "group", objects: [HEADLINE] }])]); + + assert.deepEqual(fields.map((f) => f.name), ["headline"]); +}); + +test("a tag on an object that is neither text nor image is ignored", () => { + assert.deepEqual(collectFields([page("p1", [{ type: "rect", fieldName: "box" }])]), []); +}); + +test("blank and non-string field names are ignored", () => { + const objects = [ + { type: "textbox", text: "a", fieldName: " " }, + { type: "textbox", text: "b", fieldName: 42 }, + ]; + assert.deepEqual(collectFields([page("p1", objects)]), []); +}); + +test("empty and corrupt canvases are skipped, not thrown on", () => { + const pages = [ + { id: "p1", canvas_json: "{}" }, + { id: "p2", canvas_json: "not json" }, + page("p3", [HEADLINE]), + ]; + assert.deepEqual(collectFields(pages).map((f) => f.name), ["headline"]); +}); + +test("fill writes text and image by the object's own type", () => { + const result = fillPages([page("p1", [HEADLINE, LOGO])], { + headline: "Q3 revenue up 40%", + logo: "https://example.com/new.png", + }); + + const objects = JSON.parse(result.pages[0].canvas_json).objects; + assert.equal(objects[0].text, "Q3 revenue up 40%"); + assert.equal(objects[1].src, "https://example.com/new.png"); + assert.deepEqual(result.filled.sort(), ["headline", "logo"]); + assert.deepEqual(result.unmatched, []); +}); + +test("one name on two pages fills both", () => { + const result = fillPages([page("p1", [LOGO]), page("p2", [LOGO])], { + logo: "https://example.com/new.png", + }); + + for (const filledPage of result.pages) { + assert.equal(JSON.parse(filledPage.canvas_json).objects[0].src, "https://example.com/new.png"); + } +}); + +test("an unknown name is reported, not fatal", () => { + const result = fillPages([page("p1", [LOGO])], { + logo: "https://example.com/new.png", + nope: "y", + }); + + assert.deepEqual(result.filled, ["logo"]); + assert.deepEqual(result.unmatched, ["nope"]); +}); + +test("fill does not mutate the pages it was given", () => { + const original = page("p1", [HEADLINE]); + const before = original.canvas_json; + + fillPages([original], { headline: "changed" }); + + assert.equal(original.canvas_json, before); +}); + +test("untouched pages are returned unchanged", () => { + const pages = [page("p1", [HEADLINE]), page("p2", [UNTAGGED])]; + const result = fillPages(pages, { headline: "changed" }); + + assert.equal(result.pages[1].canvas_json, pages[1].canvas_json); +}); + +test("filling with an empty string clears the text rather than being skipped", () => { + const result = fillPages([page("p1", [HEADLINE])], { headline: "" }); + + assert.equal(JSON.parse(result.pages[0].canvas_json).objects[0].text, ""); + assert.deepEqual(result.filled, ["headline"]); +}); diff --git a/src/server/fields.ts b/src/server/fields.ts new file mode 100644 index 00000000..729caa48 --- /dev/null +++ b/src/server/fields.ts @@ -0,0 +1,153 @@ +/** + * Template fields — named, typed slots on a design. + * + * Any Fabric object carrying a `fieldName` becomes a fill point: text objects + * take a string, image objects take a URL. The name is deliberately not unique. + * The same name on several objects, or on several pages, fills all of them — + * that is how a logo or a campaign headline repeats across a carousel. + * + * Everything here is plain JSON walking with no Fabric dependency, so it runs + * on the server (Workers have no canvas) as well as in the browser. + */ + +export type FieldType = "text" | "image"; + +export interface Field { + name: string; + type: FieldType; + /** Current value in the design — the default a caller gets if it fills nothing. */ + value: string; + /** Every page carrying this field, in page order. */ + page_ids: string[]; +} + +export interface PageJSON { + id: string; + canvas_json: string; +} + +export interface FillResult

{ + /** The input pages with values substituted — every other column preserved. */ + pages: P[]; + /** Field names that matched at least one object. */ + filled: string[]; + /** Supplied names that matched nothing — a typo, not an error. */ + unmatched: string[]; +} + +/** Fabric serializes text as one of three type names depending on the class. */ +const TEXT_TYPES = new Set(["text", "i-text", "itext", "textbox"]); + +type FabricNode = Record; + +function fieldTypeOf(obj: FabricNode): FieldType | null { + const type = String(obj.type ?? "").toLowerCase(); + if (TEXT_TYPES.has(type)) return "text"; + if (type === "image") return "image"; + return null; +} + +function fieldNameOf(obj: FabricNode): string | null { + const raw = obj.fieldName; + if (typeof raw !== "string") return null; + const name = raw.trim(); + return name.length > 0 ? name : null; +} + +/** + * Visit every object in a parsed canvas, descending into groups. + * + * Breadth-first over a growing array rather than recursion, so a deeply nested + * group can't blow the stack. + */ +function eachObject(canvas: FabricNode, visit: (obj: FabricNode) => void): void { + const queue: FabricNode[] = Array.isArray(canvas.objects) ? [...(canvas.objects as FabricNode[])] : []; + for (let i = 0; i < queue.length; i++) { + const obj = queue[i]; + if (!obj || typeof obj !== "object") continue; + visit(obj); + if (Array.isArray(obj.objects)) queue.push(...(obj.objects as FabricNode[])); + } +} + +/** Parse a stored canvas blob. Returns null for the empty/corrupt cases. */ +function parseCanvas(canvasJson: string): FabricNode | null { + try { + const parsed = JSON.parse(canvasJson || "{}"); + return parsed && typeof parsed === "object" ? (parsed as FabricNode) : null; + } catch { + return null; + } +} + +/** + * The fill schema for a design: one entry per distinct field name. + * + * First occurrence wins for `type` and `value`, so a name reused across pages + * reports the value the author most likely thinks of as canonical. + */ +export function collectFields(pages: PageJSON[]): Field[] { + const byName = new Map(); + + for (const page of pages) { + const canvas = parseCanvas(page.canvas_json); + if (!canvas) continue; + + eachObject(canvas, (obj) => { + const name = fieldNameOf(obj); + const type = name ? fieldTypeOf(obj) : null; + if (!name || !type) return; + + const existing = byName.get(name); + if (existing) { + if (!existing.page_ids.includes(page.id)) existing.page_ids.push(page.id); + return; + } + + const value = type === "text" ? String(obj.text ?? "") : String(obj.src ?? ""); + byName.set(name, { name, type, value, page_ids: [page.id] }); + }); + } + + return [...byName.values()]; +} + +/** + * Substitute values into a design's pages without touching the stored copy. + * + * The object's own type decides what gets written, not the schema — so a name + * accidentally shared by a textbox and an image still does the right thing on + * each. Swapping an image only replaces `src`; the object keeps its stored box, + * so a replacement of a different aspect ratio will be stretched to fit. + */ +export function fillPages

(pages: P[], values: Record): FillResult

{ + const filled = new Set(); + + const out = pages.map((page) => { + const canvas = parseCanvas(page.canvas_json); + if (!canvas) return page; + + let touched = false; + eachObject(canvas, (obj) => { + const name = fieldNameOf(obj); + if (!name || !Object.prototype.hasOwnProperty.call(values, name)) return; + + const type = fieldTypeOf(obj); + if (!type) return; + + if (type === "text") obj.text = values[name]; + else obj.src = values[name]; + + filled.add(name); + touched = true; + }); + + return touched ? { ...page, canvas_json: JSON.stringify(canvas) } : page; + }); + + return { + pages: out, + filled: [...filled], + unmatched: Object.keys(values).filter((name) => !filled.has(name)), + }; +} diff --git a/src/server/index.ts b/src/server/index.ts index 030b34af..48c9f27e 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,6 +1,7 @@ import { createApp, createRoute, z } from "@clawnify/app"; import { query, get, run } from "./db.js"; import { putUpload, getUpload } from "./uploads.js"; +import { collectFields, fillPages } from "./fields.js"; type Env = { Bindings: { DB: D1Database } }; @@ -177,6 +178,148 @@ app.openapi(deleteDesign, async (c) => { return c.json({ ok: true }, 200); }); +// ── Template fields ───────────────────────────────────────────────── +// +// A design becomes a template the moment any object carries a `fieldName`. +// `/fields` publishes the schema so a caller knows what it can fill; `/fill` +// substitutes values without touching the stored design, or writes the result +// as a new design when `save` is set. Together they cover "generate N variants +// from a row of data" without the caller ever parsing canvas JSON. + +const FieldSchema = z.object({ + name: z.string(), + type: z.enum(["text", "image"]), + value: z.string(), + page_ids: z.array(z.string()), +}); + +const listFields = createRoute({ + method: "get", + path: "/api/designs/{id}/fields", + request: { params: z.object({ id: z.string() }) }, + responses: { + 200: { content: { "application/json": { schema: z.array(FieldSchema) } }, description: "OK" }, + 404: { content: { "application/json": { schema: ErrorSchema } }, description: "Not found" }, + }, +}); + +app.openapi(listFields, async (c) => { + const { id } = c.req.valid("param"); + const design = await get<{ id: string }>("SELECT id FROM designs WHERE id = ?", [id]); + if (!design) return c.json({ error: "Not found" }, 404); + const pages = await query>( + "SELECT * FROM pages WHERE design_id = ? ORDER BY sort_order", + [id] + ); + return c.json(collectFields(pages), 200); +}); + +// ── Fill a design ─────────────────────────────────────────────────── + +const FilledPageSchema = z.object({ + id: z.string(), + title: z.string(), + sort_order: z.number(), + canvas_json: z.string(), +}); + +const FillResponseSchema = z.object({ + design: DesignSchema, + pages: z.array(FilledPageSchema), + filled: z.array(z.string()), + unmatched: z.array(z.string()), +}); + +const fillDesign = createRoute({ + method: "post", + path: "/api/designs/{id}/fill", + request: { + params: z.object({ id: z.string() }), + body: { + content: { + "application/json": { + schema: z.object({ + values: z.record(z.union([z.string(), z.number()])), + // Persist the result as a new design instead of only returning it. + save: z.boolean().optional(), + name: z.string().optional(), + }), + }, + }, + }, + }, + responses: { + 200: { content: { "application/json": { schema: FillResponseSchema } }, description: "OK" }, + 404: { content: { "application/json": { schema: ErrorSchema } }, description: "Not found" }, + }, +}); + +app.openapi(fillDesign, async (c) => { + const { id } = c.req.valid("param"); + const { values, save, name } = c.req.valid("json"); + + const design = await get>("SELECT * FROM designs WHERE id = ?", [id]); + if (!design) return c.json({ error: "Not found" }, 404); + + const pages = await query>( + "SELECT * FROM pages WHERE design_id = ? ORDER BY sort_order", + [id] + ); + + // Numbers are the common case for stat cards, so accept them and stringify + // rather than making every caller do it. + const strings = Object.fromEntries(Object.entries(values).map(([k, v]) => [k, String(v)])); + const { pages: filledPages, filled, unmatched } = fillPages(pages, strings); + + const body = filledPages.map((p) => ({ + id: p.id, + title: p.title, + sort_order: p.sort_order, + canvas_json: p.canvas_json, + })); + + if (!save) return c.json({ design, pages: body, filled, unmatched }, 200); + + // Ids are generated here rather than by the column default so the new rows + // can be linked without a racy "most recently created" lookup. + const newDesignId = crypto.randomUUID(); + await run( + "INSERT INTO designs (id, name, canvas_json, width, height) VALUES (?, ?, ?, ?, ?)", + [ + newDesignId, + name || `${design.name} (filled)`, + // designs.canvas_json mirrors page 1, matching what the editor writes. + filledPages[0]?.canvas_json ?? design.canvas_json, + design.width, + design.height, + ] + ); + + const createdPages = filledPages.map((p) => ({ ...p, id: crypto.randomUUID() })); + for (const p of createdPages) { + await run( + "INSERT INTO pages (id, design_id, title, canvas_json, sort_order) VALUES (?, ?, ?, ?, ?)", + [p.id, newDesignId, p.title, p.canvas_json, p.sort_order] + ); + } + + const created = await get>("SELECT * FROM designs WHERE id = ?", [newDesignId]); + return c.json( + { + design: created!, + pages: createdPages.map((p) => ({ + id: p.id, + title: p.title, + sort_order: p.sort_order, + canvas_json: p.canvas_json, + })), + filled, + unmatched, + }, + 200 + ); +}); + // ── Add page ─────────────────────────────────────────────────────── const addPage = createRoute({ From b935cc2d1b91254519b281843fe70ec9315f8c6d Mon Sep 17 00:00:00 2001 From: pallaoro Date: Thu, 3 Sep 2026 10:23:32 +0200 Subject: [PATCH 2/3] docs(fields): document the fill API in README and agent.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also pins the serialised type names in a test. Fabric v6 writes "Textbox", "Image" and "Rect" once a canvas has been through the editor, while the seed templates use the lowercase spellings — field detection has to accept both. --- README.md | 52 ++++++++++++++++++++++++++++++++++++++- agent.md | 17 +++++++++++++ clawnify.json | 2 +- src/server/fields.test.ts | 18 ++++++++++++++ 4 files changed, 87 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ef3a147a..a119f3e5 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Unlike Canva or Adobe Express, this runs entirely on your own infrastructure. No ## Features +- **Template fields** — name any text or image object, then fill it from data or an agent over the API; one name can drive several objects and pages at once - **Fabric.js canvas** — full object manipulation with retina/HiDPI rendering (2x device pixel ratio) - **Pre-built templates** — LinkedIn-optimized: Quote Card, Stats Highlight, Announcement, Tips List, Profile Card, Minimal Text - **10 Google Fonts** — Inter, Playfair Display, Montserrat, Poppins, Roboto, Open Sans, Lora, Raleway, Source Sans Pro, Merriweather @@ -98,7 +99,8 @@ src/ server/ schema.sql — SQLite schema (designs, templates) + template seeds db.ts — SQLite wrapper (query, get, run, transaction) - index.ts — Hono REST API (designs CRUD, templates, uploads) + index.ts — Hono REST API (designs CRUD, templates, fields, uploads) + fields.ts — Template fields: read the fill schema, substitute values uploads.ts — Local file upload management dev.ts — Dev server with static file serving client/ @@ -133,11 +135,59 @@ templates (id, name, category, canvas_json, width, height, thumbnail_url, sort_o | GET | `/api/designs/:id` | Get a design | | PUT | `/api/designs/:id` | Update a design | | DELETE | `/api/designs/:id` | Delete a design | +| GET | `/api/designs/:id/fields` | List the design's fillable fields | +| POST | `/api/designs/:id/fill` | Fill those fields with values | | GET | `/api/templates` | List all templates | | GET | `/api/templates/:id` | Get a template | | POST | `/api/uploads` | Upload an image file | | GET | `/api/uploads/:filename` | Serve an uploaded image | +## Template Fields + +Design something once, then produce as many variants of it as you have rows of +data — without anything on the outside having to understand canvas JSON. + +Select a text or image object in the editor and give it a **Field name** in the +properties panel. That object is now a fill slot: + +```bash +curl localhost:8787/api/designs/$ID/fields +``` + +```json +[ + { "name": "headline", "type": "text", "value": "Your inspiring quote goes here", "page_ids": ["p1"] }, + { "name": "logo", "type": "image", "value": "https://.../old.png", "page_ids": ["p1", "p2"] } +] +``` + +Fill it. Text fields take a string (numbers are accepted and stringified), +image fields take a URL: + +```bash +curl -X POST localhost:8787/api/designs/$ID/fill \ + -H 'content-type: application/json' \ + -d '{"values": {"headline": "Q3 revenue up 40%", "logo": "https://.../new.png"}}' +``` + +The response carries the filled pages plus `filled` and `unmatched`, so a name +that matches nothing is reported rather than failing the request. The stored +design is left alone — add `"save": true` (and optionally `"name"`) to persist +the result as a new design instead, which is how you loop a spreadsheet into a +few hundred finished graphics. + +Worth knowing: + +- A field name may repeat. One `logo` across five pages fills all five, which is + what you want for a carousel. +- What gets written depends on the object, not the declared type: text objects + take `text`, image objects take `src`. +- A replacement image keeps the original object's box, so a different aspect + ratio will be stretched. Size the slot for the images you intend to feed it. +- Only text and image objects can be fields. Naming a shape does nothing. +- Rendering to PNG still happens in the browser — there is no server-side + rasterizer, because Workers have no canvas. + ## Community & Contributions This project is part of the [OpenClaw](https://github.com/openclaw/openclaw) ecosystem. Contributions are welcome — open an issue or submit a PR. diff --git a/agent.md b/agent.md index a635d6f5..53c6d184 100644 --- a/agent.md +++ b/agent.md @@ -9,6 +9,22 @@ A design editor for creating professional social media graphics, especially Link - Image uploads and placement - Multiple canvas sizes (1080x1080 square, 1200x627 landscape) - Save and manage multiple designs +- Template fields — named slots you can fill from data over the API + +## Filling a design from data +Objects in a design can be given a field name in the editor, which turns the +design into a template you can drive without touching canvas JSON: + +- `GET /api/designs/{id}/fields` — what this design can be filled with. Returns + `name`, `type` (`text` or `image`), the current `value`, and the pages the + field appears on. +- `POST /api/designs/{id}/fill` — `{"values": {"headline": "..."}}`. Returns the + filled pages plus `filled` and `unmatched`; the stored design is unchanged. + Add `"save": true` to write the result as a new design, which is how you turn + a list of rows into a batch of finished graphics. + +Prefer these over editing `canvas_json` by hand. Text fields take a string, +image fields take a URL, and one name may cover several objects or pages. ## When to use this template Use this template when the user wants to: @@ -16,3 +32,4 @@ Use this template when the user wants to: - Design LinkedIn posts, quote cards, or announcement banners - Build a simple graphic design tool - Create branded visual content +- Generate many on-brand variants of one design from a list or a spreadsheet diff --git a/clawnify.json b/clawnify.json index 308dd912..62a671e2 100644 --- a/clawnify.json +++ b/clawnify.json @@ -2,7 +2,7 @@ "$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, and visual content with a canvas-based UI, templates, multi-page support, image uploads, and text/shape tools. Named template fields let designs be filled from data or an agent over the API, so one design can produce many on-brand variants. Like Canva. Best for graphic design tools, social media post creators, banner makers, bulk visual generation, 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"], diff --git a/src/server/fields.test.ts b/src/server/fields.test.ts index 2fb00884..57da5648 100644 --- a/src/server/fields.test.ts +++ b/src/server/fields.test.ts @@ -32,6 +32,24 @@ test("fields inside groups are found", () => { assert.deepEqual(fields.map((f) => f.name), ["headline"]); }); +test("Fabric v6's capitalised type names are recognised", () => { + // A canvas that has been through the editor serialises as "Textbox"/"Image"/ + // "Rect", not the lowercase names used in the seed templates. Both must work. + const objects = [ + { type: "Textbox", text: "hi", fieldName: "headline" }, + { type: "Image", src: "https://example.com/a.png", fieldName: "logo" }, + { type: "Rect", fill: "#000", fieldName: "backdrop" }, + ]; + + assert.deepEqual( + collectFields([page("p1", objects)]).map((f) => [f.name, f.type]), + [ + ["headline", "text"], + ["logo", "image"], + ] + ); +}); + test("a tag on an object that is neither text nor image is ignored", () => { assert.deepEqual(collectFields([page("p1", [{ type: "rect", fieldName: "box" }])]), []); }); From bf1b172943bc3ad28adc2fd58ae25a2970f55412 Mon Sep 17 00:00:00 2001 From: pallaoro Date: Thu, 3 Sep 2026 10:29:01 +0200 Subject: [PATCH 3/3] docs(fields): scope the save:true claim to the size it actually holds at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `save: true` writes an editable design, not a rendered image, and every one is a row that GET /api/designs returns in full with no pagination — a three-object card is ~2.5KB serialised, a branded one with an image 10-40KB. So a few hundred saved variants are a few hundred gallery entries, not a few hundred finished graphics, and the docs should not have said otherwise. Real bulk output wants the fill loop plus browser-side rasterising, which is the follow-up. Until then this is a small-N convenience, and agent.md now says so where an agent will read it before looping. --- README.md | 22 ++++++++++++++++++++-- agent.md | 11 ++++++++--- clawnify.json | 2 +- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a119f3e5..ff8bd126 100644 --- a/README.md +++ b/README.md @@ -173,8 +173,26 @@ curl -X POST localhost:8787/api/designs/$ID/fill \ The response carries the filled pages plus `filled` and `unmatched`, so a name that matches nothing is reported rather than failing the request. The stored design is left alone — add `"save": true` (and optionally `"name"`) to persist -the result as a new design instead, which is how you loop a spreadsheet into a -few hundred finished graphics. +the result as a new design instead. + +### How far this scales today + +`save: true` writes a **new editable design**, not a rendered image, because +there is no server-side rasterizer. That makes it the right tool for a handful +of variants — an agent fills a card, opens it, exports it — and the wrong one +for a spreadsheet: + +- Each saved variant is a row in `designs`, and `GET /api/designs` returns every + row with its full `canvas_json` and no pagination. A three-object card is + ~2.5 KB serialised; a branded design with an image is 10-40 KB. A few hundred + variants turn the gallery response into megabytes. +- So a few hundred saved variants give you a few hundred gallery entries, not a + few hundred finished graphics. + +For real bulk output, drive `fill` without `save` and rasterize in the browser — +the canvas is already there, and that loop is the missing piece rather than a +server-side renderer. Until it exists, treat `save: true` as a small-N +convenience. Worth knowing: diff --git a/agent.md b/agent.md index 53c6d184..45251fc0 100644 --- a/agent.md +++ b/agent.md @@ -20,16 +20,21 @@ design into a template you can drive without touching canvas JSON: field appears on. - `POST /api/designs/{id}/fill` — `{"values": {"headline": "..."}}`. Returns the filled pages plus `filled` and `unmatched`; the stored design is unchanged. - Add `"save": true` to write the result as a new design, which is how you turn - a list of rows into a batch of finished graphics. + Add `"save": true` to write the result as a new design you can then open and + export. Prefer these over editing `canvas_json` by hand. Text fields take a string, image fields take a URL, and one name may cover several objects or pages. +`save: true` produces an editable design, not a rendered image — there is no +server-side renderer. Use it for a handful of variants. Do not loop it over a +large list: every variant becomes a row that `GET /api/designs` returns in full, +and the user ends up with gallery entries rather than finished graphics. + ## 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 simple graphic design tool - Create branded visual content -- Generate many on-brand variants of one design from a list or a spreadsheet +- Generate on-brand variants of one design by filling it with data diff --git a/clawnify.json b/clawnify.json index 62a671e2..c2d4cb6c 100644 --- a/clawnify.json +++ b/clawnify.json @@ -2,7 +2,7 @@ "$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. Named template fields let designs be filled from data or an agent over the API, so one design can produce many on-brand variants. Like Canva. Best for graphic design tools, social media post creators, banner makers, bulk visual generation, or any visual content editor.", + "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. Named template fields let designs be filled from data or an agent over the API, so one design can produce on-brand variants. Like Canva. Best for graphic design tools, social media post creators, 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"],