Images & data
Where files live, and how a reusable template gets filled with one customer's real records.
Paths, not URLs
The document stores an opaque path for every image —
'logos/acme-2024.png'. Never a URL, never base64.
The path means whatever you want it to mean — an S3 key, a database id, a filename. The
package never interprets it. It hands it to resolveImageUrl when something needs
displaying, and that is the whole contract.
Uploading
file | The browser File. |
ctx.storagePath | Whatever you passed as imageStoragePath. |
ctx.blockId | Which block is uploading, if you want per-block foldering. |
| returns | { path, width?, height? } — dimensions let the canvas reserve the right space before the image loads. |
Resolving
Called every time an image renders — canvas, preview and PDF capture. Keep it cheap and synchronous.
Deleting is optional, and only called when the user removes an image:
URL strategies
resolveImageUrl is one function, and it is the whole of your storage policy. Here
is each realistic arrangement, written out.
Files served from your own domain
The simplest case, and the right default while you are getting started.
A CDN or object store
Per-tenant or per-customer folders
imageStoragePath is a hint you send in and get back on upload. Combine it with the
same prefix on the way out.
resolveImageUrl instead, and the same document can be
rendered for a different tenant. Both are valid; mixing them produces broken images that are
very hard to trace.
Private files with signed URLs
An image proxy, for size or format
Absolute URLs, when you need them
/uploads/… resolves against whatever page it lands on, which is not always yours.
Handling a missing file
startsWith('http') line is worth keeping.
If you are migrating from a system that stored URLs, it lets old and new documents coexist
while you backfill — without it, every historical document loses its images on the day you
switch.
An image library
Let users pick from images they already uploaded rather than uploading the same logo forty times.
Placeholder tokens
This is a design tool, so block defaults never contain invented facts. They contain tokens — and the canvas shows readable sample text in their place, so a designer can judge the layout without anyone's real data.
Replacing a placeholder with real data
This is the question every integration reaches. Your user designed a template with
{{ customer.name }} in it. Now you have a real customer. How do
their details get onto the page?
There are two ways, and which one you want depends on who is looking.
| A — bind in the builder | B — substitute at render time | |
|---|---|---|
| How | The variables prop | interpolateContent() |
| Where it runs | In the browser, while editing | Anywhere, including your server |
| User sees | The finished document as they edit | Nothing — there is no user |
| Document keeps | The tokens (still reusable) | The tokens (still reusable) |
| Use for | "Preview and tweak this one invoice" | "Generate 5,000 invoices tonight" |
Method A — bind in the builder
Pass the real values as sample. The canvas draws them immediately, so the user is
editing the actual document rather than a mock-up of one.
{{ customer.name }}. Only what the canvas draws
in its place changed — which is why the same template serves every customer.
Generating the list from a record is usually cleaner than writing it out:
| Field | Meaning |
|---|---|
key | The token name without the braces — 'customer.name', not '{{ customer.name }}'. |
label | What the user sees when picking a token. |
sample | What the canvas draws in its place. Put your real value here. |
group | Groups related tokens in the picker. |
variables entry with an existing key overrides the built-in sample.
Nothing else is needed — no registration step, no opt-in. If the key matches, your value wins,
everywhere that token appears.
Method B — substitute at render time
Keep the tokens in the document and replace them on the way out. This is how you produce thousands of documents from one template with no browser and no user.
interpolateContent takes a Map, not an object.
A plain {} silently matches nothing, and every token falls back to its humanised
name — so the page renders "Name" instead of the customer's name, with no error to explain it.
It recurses into nested objects and arrays, so list fields — address rows, key/value pairs, table columns — are covered too. And it returns the original object when nothing changed, so renderers keep referential equality.
For a single string, use interpolateText:
What happens to a token you did not supply
{{ order.trackingCode }} with no value becomes
"Tracking code". So a missing binding produces a plausible-looking label
rather than an obvious error — check your document before shipping it, and supply
'' deliberately when you want a slot to print blank.
Renaming a placeholder — overriding its label
The key is the identity and the label is what humans read. Change the label and every built-in block still fills correctly, because nothing matches on the label.
customer.name so every block keeps working — the picker just stops saying
"Customer" at people who have never used that word.
label alone to rename it and keep the built-in sample. Supply
sample alone to bind data and keep the built-in name. Supply both when you want
both. Anything you omit falls back to the built-in.
{ key: 'clientName', label: 'Client name' } creates a new token that no
built-in block references — so the blocks still show the old one, unfilled, and you have two
placeholders where you wanted one.
Changing the wording a block prints
Two different things get called "the label", and it is worth separating them clearly:
| You want to change… | Use |
|---|---|
| The name in the token picker | variables[].label |
| The caption printed on the page next to a value | The Content tab — a labelled field's wording and its show/hide switch |
| The field's name in the inspector | fields={{ 'documentMeta.content.number': { label: 'Ref no.' } }} |
| Any other interface string | labels, or a locale pack |
Seeing the whole vocabulary
customer.name fills
every block that mentions it — the address block, the greeting, the footer — without you
touching any of them.
The complete override map
Every kind of thing you might want to change, and the one prop that changes it.
| To override… | Use |
|---|---|
| A placeholder's value | variables (in the builder) or interpolateContent (at render time) |
| What a new block starts with | registry.extend(type, { defaultContent }) |
| What a block looks like by default | registry.extend(type, { defaultStyle }) |
| An existing block's content, in code | updateBlockContent(doc, id, patch) |
| An existing block's style, in code | updateBlockOverrides(doc, id, patch) |
| The document's colours, fonts, spacing | tokens on the document |
| Defaults for every new document | themes[].tokens |
| The editor's own appearance | theme / --sb-* properties |
| Which typefaces users may pick | fonts (replaces the list) |
| A visible string | labels, or a locale pack |
| One field's label, or hide/disable it | fields |
| Part of the interface | ui.hide / show / disable |
| A block's designs | registry.addLayout, or extend(type, { layouts }) |
| The template list | templates + builtinTemplates={false} |
| How money is written | tokens.currency*, or formatCurrency |
| How the PDF is produced | onRenderPdf |
| Where images live | resolveImageUrl + onUploadImage |
style to change how something
looks, there is almost always a design or a token that does it in one place instead.
Why tokens are read-only
A field whose value is entirely a token is a data binding, not authored copy. The inspector shows it, explains what will appear there, and does not let anyone type over it.
Prose that merely contains a token stays fully editable —
"Thank you, {{ customer.name }}" is something you wrote, so you
can keep writing it. Only a value that is nothing but a token is locked.