Skip to content

Navigation Menu

Sign in
Sign up

Template fields: fill a design from data instead of hand-editing canvas JSON - #6

Open
pallaoro wants to merge 3 commits into
main from
feat/template-fields
Open

Template fields: fill a design from data instead of hand-editing canvas JSON #6
pallaoro wants to merge 3 commits into
main from
feat/template-fields

Conversation

@pallaoro

@pallaoro pallaoro commented Sep 3, 2026
edited
Loading

Copy link
Copy Markdown
Member

Why

A design in OpenDesign can only be changed by replacing its whole canvas_json
blob. To swap one headline, a caller has to fetch the design, parse Fabric JSON,
guess which objects[] entry is the right textbox, mutate .text, and PUT the
lot back. That rules out the thing this category is actually bought for:
producing many on-brand variants of one design from a list of data.

Everything else needed for that is already here — self-hosted, embeddable,
white-label, MIT, no per-seat pricing. The missing piece was a schema.

What this adds

Give any text or image object a Field name in the properties panel. The
design is now a template:

Method Endpoint Description
GET /api/designs/:id/fields The fill schema: name, type, current value, page_ids
POST /api/designs/:id/fill Substitute values; save: true writes the result as a new design
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"}}'

Behaviour worth calling out:

  • Names may repeat. One logo across five pages fills all five — carousels.
  • The object decides what is written, not the declared type: text objects
    take text, images take src. A name shared by both still does the right
    thing on each.
  • A name that matches nothing is reported, not fatal — it comes back in
    unmatched with a 200.
  • fill does not mutate the stored design unless you ask it to.
  • Only text and image objects can be fields; naming a shape does nothing.
  • A replacement image keeps the original object's box, so a different aspect
    ratio is stretched. Documented in the README.

Two Fabric v6 details that shaped the implementation

  1. canvas.toJSON() takes no arguments in v6 — the JSDoc shipped in the
    package still shows the v5 toJSON([...]) form, which is a trap. The v6
    mechanism is the static FabricObject.customProperties
    (fabric/dist/index.mjs:8111). Registering fieldName there once in
    main.tsx makes it survive every save and reload, so none of the six
    existing toJSON() call sites had to change.
  2. A canvas that has been through the editor serialises type as "Textbox" /
    "Image" / "Rect", while the seed templates use lowercase. Detection
    case-folds, and there is a test pinning both spellings.

Bug fixed along the way

updateSelectedObject forced a re-render by replacing the selected object with
a spread copy:

setSelectedObject({ ...selectedObject } as fabric.FabricObject);

Spreading a class instance drops the prototype, and in Fabric v6 type is a
prototype getter and set() is a prototype method. Verified in the browser:

original .type : "textbox"
spread .type : undefined
spread instanceof : false

So after a single property edit the panel lost the object's type — a textbox
rendered shape controls — and the next edit on the same selection would throw
because .set() had gone with the prototype. Replaced with a version counter
that re-renders while keeping the live Fabric instance.

Verification

The API was exercised end to end against a running Worker (wrangler dev with
PR #4 merged in locally, since the worker does not boot on main without it):

  • /fields returns both fields, with logo correctly listing two page_ids
  • /fill fills text and image, fills the same name on both pages, leaves an
    untagged object alone, returns unmatched: ["nope"], and leaves the stored
    design byte-identical
  • save: true creates a distinct design with fresh page ids, preserving page
    titles and sort_order; a numeric value is stringified
  • 404 on both routes for a missing design; 400 for a non-scalar value
  • Round-trip: a field name set on a design survives the editor's own Save —
    the objects come back Fabric-serialised ("Textbox", with fontFamily) and
    fieldName intact, which is the property most at risk

13 unit tests cover the pure fill logic including groups, corrupt canvases,
blank/non-string names, empty-string fills, and non-mutation.

In the editor, the control renders in the Text panel and reads back an existing
name (observed live in the DOM as header=TEXT | fieldInputValue="author").
Typing a new name and the two-consecutive-edits regression were not driven
interactively — the browser harness kept clearing the canvas selection between
calls. The commit path is updateSelectedObject({ fieldName }), the same call
every other control in that panel uses.

Notes for the reviewer

  • Adds esbuild + @types/node as devDependencies and a test script. Run it
    with pnpm test.
  • pnpm install, pnpm dev and pnpm test all fail on a fresh clone, for
    a reason that predates this branch: pnpm-workspace.yaml still has pnpm's
    placeholder allowBuilds values (canvas: set this to true or false, and the
    same for esbuild, sharp, workerd). Left alone here because picking those
    values is a supply-chain call and PR Carousel PDF export, per-page PNGs, and a display-independent export size #5 already edits that file — but it needs
    deciding. esbuild and workerd have to be true for anything to run;
    canvas and sharp look like they should be false, which matches PR Carousel PDF export, per-page PNGs, and a display-independent export size #5 's
    stated reason for dropping node-canvas.
  • Two pre-existing tsc errors remain untouched: canvas-area.tsx:179 calls
    addPage(page.id) against a zero-arg signature, and D1Database is unresolved
    because @cloudflare/workers-types is not installed.
  • No server-side rasterizer: Workers have no canvas, so PNG export stays in the
    browser. Bulk render is a caller loop over fill plus the existing export.

Pressure-test follow-up (added after review of my own claim)

save: true writes an editable design, not a rendered image — there is no
server-side rasterizer. Measured from the local D1 after an editor round-trip, a
three-object quote card is 2,470 bytes of canvas_json; a branded design
with an image is 10-40 KB. GET /api/designs (src/server/index.ts:58) is
unpaginated and returns every row's full canvas_json, and the gallery renders
one card per design (home.tsx:110).

So a few hundred saved variants are a few hundred gallery entries and megabytes
of list payload — not a few hundred finished graphics. An earlier revision of
this PR's README said otherwise; that claim is now scoped down in README.md,
agent.md (where an agent reads it before looping) and clawnify.json.

The API itself is unchanged and I'd defend it: at N=1-10 — an agent fills a card,
opens it, exports — this is exactly the right primitive. Real bulk wants the
fill loop plus browser-side rasterizing, since the canvas is already there;
that is the follow-up, not a server-side renderer.

Two other things checked while reviewing:

  • @clawnify/db exposes no raw-SQL batch or transaction (initDB/query/get/run/ json/assertBindable only, per its dist/index.d.ts); batching exists solely
    via getDB(...).batch(), D1-only, with that file's own docs noting the Facet
    path runs statements individually. So the N+1 inserts in save: true are not
    a missed primitive — but a partial variant is possible on failure, which is a
    known constraint of this path rather than something introduced here.
  • Both comparable products keep one template and emit renders rather than N
    editable documents. That difference is the rasterizer, and it is why the
    scoping above matters.

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.
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.
`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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Reviewers

No reviews

Assignees

No one assigned

Labels

None yet

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

1 participant

AltStyle によって変換されたページ (->オリジナル) /