Blocks

The 44 built-in blocks, how a user works with them on the canvas, and how to add actions of your own.

@storvexa/pdf-builder Chapter 5 of 14
Documentation

The catalogue

44 blocks in nine categories, carrying 433 designs between them. Every one is real — there is no "coming soon" entry in the palette.

Category#Blocks (designs each)
Brand3companyBlock (11), documentTitle (10), brandBanner (8)
Text11introText, salesText, closingText, thankYouText, infoText, documentTerms, salesTerms, paymentTerms, shippingTerms, legalDisclaimer, notes — 14 designs each
Basic1richText (14)
Parties6billingAddress, shippingAddress, supplierAddress, customerAddress, contactPerson, customParty — 10 each
Business4documentMeta (10), keyValueDetails (8), statusBadge (8), paymentDetails (8)
Products6lineItems (12), productCard (10), productImage (8), productGallery (7), specTable (7), offerPanel (8)
Media1image (10)
Proof6signatureArea (9), attachmentImage (6), attachmentsList (6), companyStamp (6), qrCode (6), barcode (6)
Layout6sectionHeading (8), divider (8), spacer (5), pageBreak (4), footer (6), coverPage (10)

Read the catalogue at runtime rather than copying this table — it reflects your registry, including blocks you added and built-ins you removed.

import { createBuiltinRegistry } from '@storvexa/pdf-builder' createBuiltinRegistry().list().forEach((d) => console.log(d.type, d.category, d.layouts?.length))

Adding a block

Three ways, and they all end in the same place — a block inside a column.

  1. Drag from the palette onto the page. A ghost slot shows exactly where it will land.
  2. Drop onto a row rather than between rows, and it becomes a new column in that row — which is what the ghost promised before you released.
  3. Drop into the header, footer or cover band. Those take up to three columns too, exactly like the body.
A drop that would do nothing does not highlight. If nothing lights up, the drop is being rejected — you are not missing a keyboard modifier.

Moving and reordering

Rows and columns sort optimistically: elements physically move as you drag, so releasing confirms what is already on screen rather than changing it. You can drag a column out of one row and into another.

A whole drag is one undo step — not one per pointer movement. Drag a block across three rows, press Ctrl+Z once, and it is back where it started.

Drags start from the block's handle, not its body — otherwise selecting text inside a block would start dragging it.

The inspector: three tabs

Select a block and the right-hand panel shows three tabs. The split is strict, and knowing it saves a lot of hunting.

TabEditsWritten to
ContentWhat the block says — words, values, image paths, which rows showblock.content
StyleManual appearance changes for this one blockblock.overrides
DesignsChoosing a professional presetblock.layoutId
The internal id for the Designs tab is template. The visible label is "Designs", but the id stayed template for backward compatibility — and it is the id you use in fields keys. See Customising.

Advanced controls are collapsed by default, a modified value shows a reset affordance, and every field visibly affects the selected block. If a control appears to do nothing, that is a bug — please report it.

Labelled fields

Some fields are a single control carrying four things at once: the field's name, a switch for whether it appears at all, an input for the label's wording, and a switch for whether that label prints.

One idea, one control. There is no "label style: none" option anywhere, because "no labels" is not a setting — it is what you see when the label switches are off. If a switch is present, it is the only thing deciding whether that label prints.

Block actions

Selecting a block shows an action bar. The built-ins:

DuplicateA copy directly beneath, with fresh ids and identical content and style.
DeleteRemoves it. Undoable.
MoveThe drag handle.
Reset styleClears overrides, so the block falls back to its design and the document tokens.

The action bar is positioned so it never covers the block's own content.

Adding your own actions

The actions prop adds entries to that bar. This is the hook for product-specific behaviour — "fill from CRM", "insert last month's totals", "send for review".

<PdfBuilder actions={[ { id: 'fill-from-crm', label: 'Fill from CRM', icon: <MyIcon />, run: (blockId, doc) => { // `doc` is the document as it stands; `blockId` is the selected block. openCrmPickerFor(blockId) }, }, ]} />
FieldNotes
idUnique string. Yours to choose.
labelShown in the action bar and read by screen readers.
iconOptional React node.
run(blockId, doc)Called on activation with the selected block's id and the current document.
run does not mutate the document for you. Treat doc as read-only. To change the document, go through your own state or ref.loadDocument() — that way the change is validated and enters history as one undoable step.

An action that actually changes the block

The package exports the same pure operations its own UI uses, so you build the next document and hand it back. This one fills a block from your CRM:

import { useRef } from 'react' import { PdfBuilder, findBlock, updateBlockContent } from '@storvexa/pdf-builder' function Editor() { const builder = useRef(null) const fillFromCrm = { id: 'fill-from-crm', label: 'Fill from CRM', run: async (blockId, doc) => { const block = findBlock(doc, blockId) if (!block || block.type !== 'billingAddress') return const customer = await (await fetch(`/api/crm/customer/${currentOrder.customerId}`)).json() const next = updateBlockContent(doc, blockId, { company: customer.company, line1: customer.street, city: customer.city, postcode: customer.postcode, country: customer.country, }) builder.current.loadDocument(next) // validated, and ONE undo step }, } return <PdfBuilder ref={builder} actions={[fillFromCrm]} /> }

The operations you can build with

All pure: they take a document and return a new one, never mutating the original.

findBlock(doc, id)Locate a block.
updateBlockContent(doc, id, patch)Change what it says.
updateBlockOverrides(doc, id, patch)Change its manual style.
setBlockLayout(doc, id, layoutId)Apply a design.
clearBlockOverrides(doc, id)Reset its style.
duplicateBlock(doc, id) / removeBlock(doc, id)Copy or delete.
createBlockInstance(def)A fresh block from a definition.
appendBlockRow(doc, block)
insertBlockRowAfter(doc, rowId, block)
Add a new row.
Show your action only where it makes sense. There is no per-block filter on the action list — every action appears on every block. Check block.type at the top of run and return early, as above, so "Fill from CRM" on a divider does nothing rather than something wrong.
Do not call loadDocument on every keystroke of a long operation. Each call is one history entry. Build the whole next document, then hand it over once.

Why so many text blocks?

Eleven text entries in the palette — intro, sales, closing, thank-you, terms, disclaimer, notes and the rest — share one renderer, one schema and one set of designs. They are not eleven implementations; they are one engine with eleven semantic presets.

Why bother, if they render the same?

  • A user looking for "payment terms" finds it by that name, instead of adding a generic text block and remembering what it was for.
  • Each preset drops in with sensible default wording rather than empty.
  • An AI service — and your own code — can tell what a paragraph is, which makes "put the terms at the bottom" something a machine can act on.

The same is true of the six party blocks: one address engine, six role presets. When you write your own blocks, copy this pattern rather than duplicating a renderer.