Skip to content

Events & API

createReportDesigner(element, options)

ts
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.

OptionTypeDefaultNotes
templateReportTemplate | AnyVersionTemplatea new blank reportMigrated on load, so an older saved version is fine.
dataSourcesDataSource[][]Schemas shown in the Data panel, used for autocomplete and validation.
sampleDataReportData | SampleDataSet[][]Data for design mode and preview. Passing several sets shows a switcher.
themeThemethe built-in default themeThe active theme.
themesTheme[][theme]The switcher list. The active theme is added automatically.
blocksBlockDefinition[][]Custom blocks, on top of the built-ins.
libraryLibraryItem[][]Host-provided reusable sections.
featuresPartial<FeatureFlags>all on except whiteLabelSee White labelling.
enabledTiers("community" | "pro" | "enterprise")[]allBlocks above the enabled tier are badged in the palette.
localestringthe template's settings.locale, else "en-GB"Number and date formatting.
currencystringthe template's settings.currency, else "GBP"Default currency for the currency filter.
timeZonestringthe host'sDate formatting.
appearance"light" | "dark" | "system""light"Editor chrome only; the page always draws light.
zoomnumber | "fit"fits the page width1 is 100 %.
interactiveCharts"preview" | "always" | "off""preview"Hover, focus and tooltips on chart marks. See Charts.
classNamestringExtra 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.
nowDatethe real clockFixes {{ now }} and the Date block, for screenshots and tests.
saveDescriptionstringOne sentence saying where saving goes, shown beside the save indicator.
createMeasurer(ctx) => EditorMeasurerthe browserSee measuring with the engine.
onUploadImage`(file: File) => stringnullPromise<...>`

Callbacks

onChange(template, info)

ts
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)

ts
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.

ts
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)

ts
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:

ts
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)

ts
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.

ts
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)

ts
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

CallbackSignatureWhen
onSelectionChange(ids: string[]) => voidSelection changed. Empty array when nothing is selected.
onModeChange(mode: "design" | "preview") => voidMode switched, by button or by API.
onThemeChange(theme: Theme) => voidTheme switched. The theme is the merged one, with defaults filled in.
onReady(api: ReportDesignerApi) => voidOnce, after mount, on a microtask.
createMeasurer(ctx: { document: Document; container: HTMLElement }) => EditorMeasurerOnce per mount.

The instance

ts
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:

ts
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

ts
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 %.