Events & API
createReportDesigner(element, options)
function createReportDesigner(element: HTMLElement, options?: ReportDesignerOptions): ReportDesignerInstance;Mounts the designer into element and returns the imperative API. It mounts into a child of the element it is given, so the element may hold other content. Styles are injected once per Document. The React and Angular packages wrap this and pass the same options.
The instance keeps running until you call destroy().
Options
Every option is optional. Defaults are what you get when you omit it.
| Option | Type | Default | Notes |
|---|---|---|---|
template | ReportTemplate | AnyVersionTemplate | a new blank report | Migrated on load, so an older saved version is fine. |
dataSources | DataSource[] | [] | Schemas shown in the Data panel, used for autocomplete and validation. |
sampleData | ReportData | SampleDataSet[] | [] | Data for design mode and preview. Passing several sets shows a switcher. |
theme | Theme | the built-in default theme | The active theme. |
themes | Theme[] | [theme] | The switcher list. The active theme is added automatically. |
blocks | BlockDefinition[] | [] | Custom blocks, on top of the built-ins. |
library | LibraryItem[] | [] | Host-provided reusable sections. |
features | Partial<FeatureFlags> | all on except whiteLabel | See White labelling. |
enabledTiers | ("community" | "pro" | "enterprise")[] | all | Blocks above the enabled tier are badged in the palette. |
locale | string | the template's settings.locale, else "en-GB" | Number and date formatting. |
currency | string | the template's settings.currency, else "GBP" | Default currency for the currency filter. |
timeZone | string | the host's | Date formatting. |
appearance | "light" | "dark" | "system" | "light" | Editor chrome only; the page always draws light. |
zoom | number | "fit" | fits the page width | 1 is 100 %. |
interactiveCharts | "preview" | "always" | "off" | "preview" | Hover, focus and tooltips on chart marks. See Charts. |
className | string | — | Extra class on the editor's root element. |
pdfService | { url: string; token?: string } | — | Enables direct PDF download from Export. Without it, and without onExportPdf, Export opens the print dialog. |
now | Date | the real clock | Fixes {{ now }} and the Date block, for screenshots and tests. |
saveDescription | string | — | One sentence saying where saving goes, shown beside the save indicator. |
createMeasurer | (ctx) => EditorMeasurer | the browser | See measuring with the engine. |
onUploadImage | `(file: File) => string | null | Promise<...>` |
Callbacks
onChange(template, info)
onChange?(template: ReportTemplate, info: { reason: string }): void;Fires after every document change, once per undo step — typing is coalesced, so you get one call per edit rather than one per keystroke. info.reason is the label of the command that ran, the same string the undo tooltip shows ("Add Callout", "Edit text"). It is not called for selection, zoom or mode changes, which do not alter the document. The template handed over is a fresh object; it is safe to keep.
onSave(template)
onSave?(template: ReportTemplate): void | Promise<void>;Called by the Save button and ⌘/Ctrl S. Saving is entirely yours — BroadPaper has no persistence and will not write anywhere.
Error behaviour. Return a promise and the indicator shows "Saving…" while it is pending. Resolve and it shows "Saved" with the time, and the document is marked clean. Reject and it shows "Save failed" with your error's message, which stays on screen — and clickable, to retry — until the next attempt. Anything you want the user to read belongs in the Error you throw.
Without an onSave handler at all, the indicator says "Not saved anywhere" rather than implying a persistence layer that does not exist. saveDescription is how you say what actually happens.
import { createReportDesigner } from "@broadpaper/editor";
const designer = createReportDesigner(document.getElementById("designer")!, {
saveDescription: "Saved to your workspace. Colleagues see it after they refresh.",
onSave: async (template) => {
const res = await fetch("/api/templates/investment-review", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(template)
});
// The message is what the user reads next to the failed save.
if (!res.ok) throw new Error(res.status === 409 ? "Someone else saved a newer version." : `Save failed (${res.status}).`);
}
});
export { designer };onUploadImage(file)
onUploadImage?(file: File): string | null | Promise<string | null>;Every image field in the inspector has an upload button beside its address box. Without this option the designer reads the file in the browser and stores it as a data: URL — no server, nothing uploaded anywhere, which is the right answer for a logo or a signature.
It is the wrong answer for anything large. A data: URL is not a reference to the bytes, it is the bytes, so they are copied into every save, every export and every render request for the life of the template. The built-in path therefore refuses files over 1 MB rather than quietly producing documents that are expensive to move around.
Supply this and you decide where the file goes. Return an address the renderer can fetch and the designer stores that instead, with no size limit of its own:
onUploadImage: async (file) => {
const body = new FormData();
body.append("file", file);
const res = await fetch("/api/assets", { method: "POST", body });
if (!res.ok) throw new Error("That image could not be saved. Try again.");
return (await res.json()).url;
};Return null to cancel silently. Throw to cancel with a reason — the message is shown under the field, so write it for the person who will read it.
Whichever route a value arrives by, it is still just a URL in the template: nothing in the renderers, the validator or the PDF backends knows that uploading exists. Uploaded files are checked with the same rule as any other address (see Security), so an SVG containing a script is refused at the point of upload rather than silently dropped at render.
onExportPdf(ctx)
onExportPdf?(ctx: ExportContext): void | boolean | Promise<void | boolean>;
interface ExportContext {
template: ReportTemplate;
theme: Theme; // the theme currently selected in the toolbar
data: ReportData; // the sample data set currently selected
dataSources: DataSource[];
layout: LayoutResult | null; // null before the first layout completes
print(): void; // the built-in browser print fallback
}Called when Export is clicked. Return false — and only false — to fall back to the built-in behaviour (the PDF service if pdfService is set, otherwise the print dialog). undefined, true and a resolved promise all mean "handled, do nothing else".
Errors are yours to catch: a rejected promise is not surfaced to the user, so report failure in your own UI.
import { renderPdfPaginated } from "@broadpaper/forme";
import { createRegistry } from "@broadpaper/blocks";
import type { ExportContext } from "@broadpaper/editor";
export async function exportPdf(ctx: ExportContext): Promise<void | boolean> {
// Nothing laid out yet: let the editor deal with it.
if (!ctx.layout) return false;
const { pdf } = await renderPdfPaginated({
template: ctx.template,
registry: createRegistry(),
data: ctx.data,
theme: ctx.theme,
dataSources: ctx.dataSources,
metadata: { title: ctx.template.name }
});
const url = URL.createObjectURL(new Blob([pdf.slice()], { type: "application/pdf" }));
const a = document.createElement("a");
a.href = url;
a.download = `${ctx.template.name || "report"}.pdf`;
a.click();
URL.revokeObjectURL(url);
}onValidate(issues)
onValidate?(issues: Issue[]): void;
interface Issue {
severity: "error" | "warning" | "info";
code: string; // e.g. "empty-value", "unknown-field"
message: string; // written for a designer, not a developer
source?: "template" | "output";
nodeId?: string; // select(nodeId) to take the user to it
prop?: string;
region?: "body" | "header" | "footer";
}Fires after validation, debounced ~250 ms behind document and sample-data changes. source separates the two questions the panel answers: "template" is whether the design is sound (a binding to a field that does not exist), "output" is what the current sample data does to it (a field that comes out blank). Both are always present in the array. See Validation.
The rest
| Callback | Signature | When |
|---|---|---|
onSelectionChange | (ids: string[]) => void | Selection changed. Empty array when nothing is selected. |
onModeChange | (mode: "design" | "preview") => void | Mode switched, by button or by API. |
onThemeChange | (theme: Theme) => void | Theme switched. The theme is the merged one, with defaults filled in. |
onReady | (api: ReportDesignerApi) => void | Once, after mount, on a microtask. |
createMeasurer | (ctx: { document: Document; container: HTMLElement }) => EditorMeasurer | Once per mount. |
The instance
interface ReportDesignerApi {
getTemplate(): ReportTemplate; // the live document
setTemplate(t: ReportTemplate | AnyVersionTemplate): void; // loads it; clears undo history
getStore(): EditorStore; // commands, subscriptions, undo/redo
setTheme(theme: Theme): void; // also adds it to the switcher
setThemes(themes: Theme[]): void;
setSampleData(data: ReportData | SampleDataSet[]): void;
setDataSources(dataSources: DataSource[]): void;
registerBlock(def: BlockDefinition): void; // replaces one of the same type
validate(): Issue[]; // synchronous; also fires onValidate
undo(): void;
redo(): void;
setMode(mode: "design" | "preview"): void;
getMode(): "design" | "preview";
select(ids: string[]): void; // [] clears the selection
exportPdf(): Promise<void>; // the same path as the Export button
print(): void;
getLayout(): LayoutResult | null; // null until the first layout lands
update(options: Partial<ReportDesignerOptions>): void;
destroy(): void; // unmounts and releases everything
}ReportDesignerInstance is that, plus element — the host element you passed in.
destroy() is safe to call from a framework cleanup hook: the unmount is deferred to a task, because React forbids unmounting a root synchronously from an effect cleanup. Calling anything else afterwards is undefined.
The store
Everything the editor does goes through commands on EditorStore, which you can drive yourself. A transaction groups commands into one undo step:
import { createReportDesigner } from "@broadpaper/editor";
import { createRegistry } from "@broadpaper/blocks";
import { createNode, richTextFromTemplate } from "@broadpaper/core";
const designer = createReportDesigner(document.getElementById("designer")!, {});
const store = designer.getStore();
const registry = createRegistry();
const disclaimer = createNode(registry, "text", {
props: { content: richTextFromTemplate("Past performance is not a reliable indicator of future results.") }
});
store.transaction("Add disclaimer", () => {
store.dispatch({ type: "insertNode", node: disclaimer, at: { region: "body", path: [0, 0] } });
});
// One call to undo() removes the whole transaction.
store.undo();
// `prev` is the state before the change, so you can react to one field.
const stop = store.subscribe((state, prev) => {
if (state.template !== prev.template) console.log("document changed");
});
export { stop };at.path is an index path into the region: [2, 0] is "the first child of the third section". region is "body", "header", "header:first", "footer" or "footer:first".
Commands: insertNode, insertNodes, removeNodes, moveNode, duplicateNode, setProps, setProp, setStyle, setFlow, setVisibleWhen, setName, setLocked, setRowLayout, wrapNodes, replaceNode, setPage, setDocumentStyles, setTextStyle, setTemplateName, setSettings, setRegionFirstPage, addLibraryItem, removeLibraryItem, replaceTemplate.
Rendering without the editor
import { layoutDocument, documentToHtml, DomMeasurer } from "@broadpaper/renderer";
import { createRegistry } from "@broadpaper/blocks";
import { createTemplate, createSection, createNode } from "@broadpaper/core";
const registry = createRegistry();
const template = createTemplate({ name: "Minimal", withEmptySection: false });
template.body = [createSection({ children: [createNode(registry, "heading", { props: { text: "Hello", level: "h1" } })] })];
// The DOM measurer needs somewhere to measure into; it never becomes visible.
const host = document.createElement("div");
document.body.appendChild(host);
const result = layoutDocument({ template, registry, mode: "preview" }, new DomMeasurer(document, host));
console.log(result.paged.totalPages, result.warnings);
// A standalone HTML page — what the Chromium PDF service prints.
export const html = documentToHtml(result, { title: template.name });createReportPreview(element, options) mounts a live paginated preview that re-lays-out when you call update(). layoutDocument(input, measurer) is the one-shot version and returns the pages, the geometry and any layout warnings.
Keyboard shortcuts
⌘/Ctrl Z undo · ⇧⌘Z / Ctrl Y redo · ⌘C/X/V copy, cut, paste · ⌘D duplicate · ⌘S save · Delete/Backspace remove · Enter edit text · Esc deselect or finish editing · ↑/↓ previous/next block · Alt ↑/↓ move block · ← parent · → first child · ⌘+/− zoom · ⌘0 100 %.