Skip to content

Angular integration

@broadpaper/angular provides standalone components that mount the same editor React uses. The editor runs outside Angular's zone, so canvas interactions never trigger host change detection.

bash
pnpm add @broadpaper/core @broadpaper/blocks @broadpaper/renderer @broadpaper/editor @broadpaper/angular

The designer

Every field the template binds to is declared on the class — nothing here refers to a value that does not exist:

ts
import { Component } from "@angular/core";
import { ReportDesignerComponent } from "@broadpaper/angular";
import type { DataSource, Issue, ReportTemplate, Theme } from "@broadpaper/core";

const brand: Theme = {
  id: "meridian",
  name: "Meridian Wealth",
  typography: { headingFont: "Inter, sans-serif", bodyFont: "Inter, sans-serif" },
  colors: {
    primary: "#1F3A5F",
    secondary: "#4A6FA5",
    accent: "#E0B04B",
    text: "#1A1D21",
    mutedText: "#6B7280",
    surface: "#FFFFFF",
    background: "#F5F6F8",
    border: "#E2E5EA"
  }
};

const whiteLabel: Theme = { ...brand, id: "hartwell", name: "Hartwell & Kane", colors: { ...brand.colors, primary: "#4A3728" } };

@Component({
  selector: "app-template-editor",
  standalone: true,
  imports: [ReportDesignerComponent],
  template: `
    <bp-report-designer
      [template]="template"
      [dataSources]="dataSources"
      [sampleData]="sampleData"
      [theme]="theme"
      [themes]="themes"
      [onSave]="save"
      (templateChange)="onChange($event)"
      (validate)="issues = $event"
      style="height: 100vh"
    />
  `
})
export class TemplateEditorComponent {
  /** The saved document, or undefined for a new one. */
  template?: ReportTemplate;
  /** The current, unsaved document. */
  draft?: ReportTemplate;
  issues: Issue[] = [];

  theme = brand;
  themes = [brand, whiteLabel];

  dataSources: DataSource[] = [
    {
      id: "client",
      label: "Client",
      schema: {
        firstName: { type: "string", label: "First name" },
        surname: { type: "string", label: "Surname" },
        isRetired: { type: "boolean", label: "Retired" }
      }
    }
  ];

  sampleData = [
    {
      id: "example",
      label: "Alice Chen",
      data: { client: { firstName: "Alice", surname: "Chen", isRetired: false } }
    }
  ];

  onChange(t: ReportTemplate): void {
    this.draft = t;
  }

  // An arrow property, not a method: the component passes it straight through
  // to the editor, so it must not depend on how it is called.
  save = async (t: ReportTemplate): Promise<void> => {
    const res = await fetch("/api/templates/investment-review", {
      method: "PUT",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(t)
    });
    if (!res.ok) throw new Error(`Could not save (${res.status})`);
  };
}

Inputs mirror ReportDesignerOptions. Outputs are templateChange, selectionChange, validate, modeChange, themeChange and ready — the last emits the imperative API, which is how you drive the editor from a toolbar of your own:

ts
import { Component } from "@angular/core";
import { ReportDesignerComponent } from "@broadpaper/angular";
import type { ReportDesignerApi } from "@broadpaper/editor";

@Component({
  selector: "app-designer-with-toolbar",
  standalone: true,
  imports: [ReportDesignerComponent],
  template: `
    <button type="button" (click)="api?.setMode('preview')">Preview</button>
    <button type="button" (click)="api?.undo()">Undo</button>
    <bp-report-designer (ready)="api = $event" style="height: 90vh" />
  `
})
export class DesignerWithToolbarComponent {
  api?: ReportDesignerApi;
}

Preview

template is required; everything else is optional. The layout output fires after each pagination, which is where a page count comes from:

ts
import { Component, Input } from "@angular/core";
import { ReportPreviewComponent } from "@broadpaper/angular";
import type { LayoutResult } from "@broadpaper/renderer";
import type { DataSource, ReportData, ReportTemplate, Theme } from "@broadpaper/core";

@Component({
  selector: "app-report-preview",
  standalone: true,
  imports: [ReportPreviewComponent],
  template: `
    <p>{{ pages }} page(s)</p>
    <bp-report-preview
      [template]="template"
      [data]="data"
      [theme]="theme"
      [dataSources]="dataSources"
      (layout)="onLayout($event)"
    />
  `
})
export class AppReportPreviewComponent {
  @Input({ required: true }) template!: ReportTemplate;
  @Input() data?: ReportData;
  @Input() theme?: Theme;
  @Input() dataSources?: DataSource[];
  pages = 0;

  onLayout(result: LayoutResult): void {
    this.pages = result.paged.totalPages;
  }
}

Custom inspector UIs

A block can hand one of its inspector fields to an Angular component. angularInspector(component, viewContainerRef) returns the InspectorComponent that defineBlock expects; your component receives the field's InspectorApi on an api input and calls api.setValue(...) to write back.

ts
import { Component, Input, ViewContainerRef, inject } from "@angular/core";
import { angularInspector } from "@broadpaper/angular";
import type { InspectorApi, InspectorComponent } from "@broadpaper/core";

@Component({
  selector: "app-colour-scale",
  standalone: true,
  template: `<input type="color" [value]="value" (input)="write($any($event.target).value)" />`
})
export class ColourScaleComponent {
  @Input() api?: InspectorApi;

  get value(): string {
    return typeof this.api?.value === "string" ? this.api.value : "#000000";
  }

  write(next: string): void {
    this.api?.setValue(next);
  }
}

/** Call this from a component or service that has a ViewContainerRef. */
export function colourScaleInspector(): InspectorComponent {
  return angularInspector(ColourScaleComponent, inject(ViewContainerRef));
}

A note on bundling

The editor is built once, in React, behind a framework-neutral mount API. Angular hosts therefore include React inside the editor bundle (about 45 KB gzipped). Nothing React-specific leaks into your application code, templates or the saved JSON. The rendering engine, blocks and PDF output contain no framework at all.

For AOT production builds, compile the package with ng-packagr or consume the shipped ESM (JIT-decorated) build; both are supported.