Components

HTML Template Editor

WYSIWYG document editor for authoring Handlebars-aware HTML templates (quotes, invoices, reports). Word-style A4 page surface, four tabs (Visual / Code / Data / Preview), variable & helper chips, slash-style triggers, and a data-driven Table dialog where users discover JSON paths, configure columns, and write free-form sum / aggregate expressions per row.

Client Only

Installation

pnpm dlx @docyrus/cli add @docyrus/ui-html-template-editor
Required Packages(4 packages)
pnpm add platejs handlebars @uiw/react-codemirror @uiw/codemirror-extensions-langs
UI Primitives(14 components)
npx shadcn@latest add button checkbox command dialog dropdown-menu input label popover scroll-area select separator tabs textarea tooltip
Plate Editor(17 components)
npx shadcn@latest add @plate/align-kit @plate/basic-blocks-kit @plate/basic-marks-kit @plate/callout-kit @plate/column-kit @plate/editor @plate/fixed-toolbar @plate/font-kit @plate/history-toolbar-button @plate/link-kit @plate/link-toolbar-button @plate/list-kit @plate/list-toolbar-button @plate/mark-toolbar-button @plate/table-kit @plate/toolbar @plate/turn-into-toolbar-button

Usage

The editor opens to a Word-style A4 page surface with a sticky toolbar above it. Four tabs across the top let users move between Visual (WYSIWYG), Code (raw Handlebars HTML), Data (JSON sample data) and Preview (compiled output).

import { useState } from 'react';
import {
  DEFAULT_HELPERS,
  HtmlTemplateEditor,
  type HandlebarsVariable
} from '@docyrus/ui/components/html-template-editor';

const variables: HandlebarsVariable[] = [
  { name: 'customer.name', label: 'Customer Name', category: 'Customer' },
  { name: 'order.total', label: 'Order Total', category: 'Order' }
];

export function MyTemplateEditor() {
  const [html, setHtml] = useState('');
  const [data, setData] = useState('{}');

  return (
    <HtmlTemplateEditor
      value={html}
      onChange={setHtml}
      data={data}
      onDataChange={setData}
      variables={variables}
      helpers={DEFAULT_HELPERS}
    />
  );
}

AI Assistant slot

Pass a renderAiAssistant callback to mount any agent body inside a left-side drawer. The editor adds a Bot toggle button to the tabs row that opens/closes the drawer; the slot receives live editor state (html, data) so the agent can read the user's current draft and inject schema-aware context.

The preferred wiring uses useApplyHtmlTemplate which owns the controlled state AND ships a fully-formed applyHtmlTemplate tool — the tool not only writes the new HTML into the editor but also runs Handlebars.compile against the current Data tab JSON and returns the compiled output preview, so the LLM can iterate to a working template without further user input.

import { EditorAgent } from '@docyrus/ui/components/editor-agent';
import {
  HtmlTemplateEditor,
  useApplyHtmlTemplate
} from '@docyrus/ui/components/html-template-editor';

export function TemplateEditorWithAi({ client, user, agentId, dataSourceId, schema }) {
  const htmlTemplate = useApplyHtmlTemplate();
  const [aiOpen, setAiOpen] = useState(false);

  return (
    <HtmlTemplateEditor
      value={htmlTemplate.html}
      onChange={htmlTemplate.setHtml}
      data={htmlTemplate.data}
      onDataChange={htmlTemplate.setData}
      aiAssistantOpen={aiOpen}
      onAiAssistantOpenChange={setAiOpen}
      renderAiAssistant={({ open, onClose }) => (
        <EditorAgent
          agentId={agentId}
          dataSourceId={dataSourceId}
          client={client}
          user={user}
          open={open}
          onClose={onClose}
          editorContext={() => buildPromptContext({ schema, data: htmlTemplate.data, html: htmlTemplate.html })}
          clientTools={htmlTemplate.tools}
        />
      )}
    />
  );
}

The hook owns the html/data state, so the agent's applyHtmlTemplate tool pushes new content into the editor while the user's manual edits still flow through onChange / onDataChange. editorContext is invoked on every send — return a string that snapshots schema + current input + current template so the agent always sees fresh state. The matching backend agent must register a tool named applyHtmlTemplate whose input schema mirrors { html: string (required), data?: object, explanation?: string }.

Features

  • A4 page surface — Visual + Preview tabs render the document inside a 794×1123 px sheet (210×297 mm at 96 DPI) with proper margins, so the editor matches the final PDF 1:1.
  • Sticky Word-style toolbar — pinned above the page; follows the user when scrolling long documents.
  • Variable & helper chips — {{customer.name}} renders as a colored inline badge (color is derived from category). Block helpers like {{#if}}, {{#each}}, {{#with}}, {{#unless}} get their own chips; {{/helper}} and {{else}} complete the set.
  • {{-trigger autocomplete — type {{ anywhere to open a filtered picker. ↑↓ navigates, Enter/Tab inserts. Each variable's category becomes a section heading.
  • Auto-convert — typing a complete {{var}}, {{#helper expr}}, {{/helper}}, or {{else}} and closing with }} converts the text to the corresponding chip automatically.
  • Click-to-edit chips — clicking any inserted chip opens an edit popover (above the chip via Radix collision detection). Variable chips swap their name from the same variables list. Block-helper chips edit their expression; {{#each}} and {{#with}} get a path picker built live from the Data tab JSON (array vs. object scan, suffix-match highlights the current selection). Marks (bold/italic/etc.) on the chip survive the swap.
  • Robust table round-trip — <table><tbody>{{#each}}<tr>…</tr>{{/each}}</tbody></table> survives any chip edit. Block markers transport via HTML comments to bypass HTML foster-parenting, get hoisted out of table sections (slate can't hold inline-void as direct child of <table>), <thead>/<tbody>/<tfoot> are unwrapped and colSizes is pre-seeded so the column count doesn't collapse to one. The serializer pattern-detects [chip, table, chip] adjacency and re-injects the chips around the body rows only (first <tr> is treated as <thead>), so {{#each}} never wraps the header.
  • Four tabs: Visual (WYSIWYG), Code (raw HTML in CodeMirror), Data (JSON sample data in CodeMirror), Preview (Handlebars-compiled output rendered in an A4-sized iframe).
  • Data-driven Table dialog — the toolbar Table button scans the Data tab JSON for array-of-object paths and walks the user through column selection, per-cell styling, per-column aggregate pills (Sum / Avg / Min / Max / Count) and free-form formula expressions like qty * unitPrice * (1 - discountPct/100). Inserted tables stay live: serialization emits {{#each <path>}} so the Preview tab iterates over real data.
  • Safe expression evaluator — sumLineExpr evaluates user-typed math via a small recursive-descent parser (no eval / new Function) so templates can be safely shared between users.
  • Precision-safe currency math — every financial helper rounds with EPSILON correction (Math.round((n + Number.EPSILON) * 100) / 100) so cumulative IEEE-754 drift can't move displayed totals by 0.01.
  • Default Handlebars helpers — formatCurrency, formatNumber, formatPercent, formatDate, multiply, add, subtract, divide, sumProperty, avgProperty, minProperty, maxProperty, countItems, lineNet, lineTotal, sumLineNets, sumLineTaxes, sumGrandTotal, sumLineExpr, eq, gt, lt are registered at module load.
  • extraHelpers prop — register additional helpers (locale packs, domain formatters) without touching the package.
  • Locale pack: numberToWordsTR — opt-in Turkish number-to-words helper (Sekiz Yüz Altmış Dokuz Bin Altı Yüz Kırk Türk Lirası). Pass via extraHelpers only when needed.
  • Built-in Plate kits — Basic blocks, marks, lists, links, alignment, font color & size, columns, native tables, callouts.
  • Read-only mode — pass readOnly to render a non-editable view with chips visible but the toolbar hidden.

Data-driven tables

Click the toolbar Table button to open a configuration dialog backed by the Data tab JSON. The dialog walks the user through four steps; everything is configured by typing or clicking — no schema code required.

1. Pick a data path

The dialog enumerates every array-of-objects path found in the Data tab JSON (up to 6 levels deep) and lists them as a tree. For example with this JSON:

{
  "customer": {
    "contacts": [
      { "id": "c1", "name": "Aytekin", "phones": [{ "type": "Work", "number": "..." }] }
    ]
  },
  "items": [{ "qty": 1, "unitPrice": 100, "discountPct": 5 }]
}

…the picker surfaces items, customer.contacts, and customer.contacts.0.phones as selectable sources.

2. Configure columns

Once a path is selected, the dialog auto-detects fields from the first row and lists them as toggleable rows. Each field carries:

  • Visibility — id-shaped fields default to hidden; the user opts them back in.
  • Format — text / number / currency / percent / date / computed. Smart inference picks percent for *_pct / *_rate keys, currency for price, cost, total-style keys, date for *_at / *date* keys.
  • Alignment, weight, size, text & background color — per-cell styling that flows into the serialized HTML.

A live preview chip on each row shows what the cell will look like with the first sample row's data.

3. Toggle per-column aggregates

Each column row carries pill toggles for the standard aggregates — Toplam (Sum), Ort. (Avg), Min, Max, Adet (Count). Active pills add a row to the table's <tfoot> at serialize time. Currency / number columns get all five pills; text / date columns only get Count.

4. Write free-form total formulas

Below the field list is a "Smart total" section where users type compound expressions like qty * unitPrice * (1 - discountPct/100) * (1 + taxPct/100) — anything the expression parser understands. Each saved total has a wide editable label textarea (anything — ×, −, multi-line — goes) and a separate formula textarea. The serializer emits these as a right-aligned 2-column block below the main table, with the canonical net → tax → grand-total order if those shapes are detected.

Editing inserted tables

Each ad-hoc table renders an Edit button in its header. Clicking it re-opens the same dialog with the existing config (path, columns, formulas) pre-filled. Legacy schema-driven tables loaded from older templates also round-trip cleanly.

Expression syntax

User-typed formulas are evaluated by a small recursive-descent parser that supports:

ConstructExample
Identifier (column key)qty, unitPrice, discountPct
Numeric literal100, 0.5, .25
Binary operators+ - * / %
Parentheses(1 - discountPct/100)
Unary minus-amount

The evaluator deliberately avoids eval / new Function — templates can be persisted and shared across users without becoming a code-injection vector. Anything that fails to parse evaluates to 0, so a typo in the textarea doesn't blow up the preview.

{{formatCurrency (sumLineExpr items "qty * unitPrice * (1 - discountPct/100) * (1 + taxPct/100)") "USD"}}

Bring your own helpers

The editor only registers a small generic helper set by default. Locale-specific or domain-specific helpers should be passed via extraHelpers:

import {
  HtmlTemplateEditor,
  numberToWordsTR
} from '@docyrus/ui/components/html-template-editor';

<HtmlTemplateEditor
  extraHelpers={{ numberToWordsTR }}
  ... />

Helpers are registered on first mount and become available globally via the singleton Handlebars import. From the template body:

<p>Total: {{formatCurrency (sumLineExpr items "qty * unitPrice * (1 + taxPct/100)") order.currency}}</p>
<p>In words: {{numberToWordsTR (sumLineExpr items "qty * unitPrice * (1 + taxPct/100)")}}</p>

Compile the template at runtime

The component itself does NOT compile templates — it produces the template HTML. To render it with live data in your app (preview pane, PDF generation, server-side render):

import Handlebars from 'handlebars';

const output = Handlebars.compile(html)(data);

The Preview tab inside the editor uses this exact pattern against the data JSON the user provides.

Advanced: schema-driven tables (legacy)

For consumer apps that need fully pre-defined tables (e.g. fixed invoice template, hard-coded column compute functions), the editor still accepts a tableSchemas prop. Schema-driven <computed_table> blocks already present in loaded HTML continue to render and edit; new schemas can be passed without affecting the data-driven Table dialog.

import {
  type ComputedRow,
  type ComputedTableSchema
} from '@docyrus/ui/components/html-template-editor';

function netOf(row: ComputedRow): number {
  return (Number(row.qty) || 0) * (Number(row.unitPrice) || 0) * (1 - (Number(row.discountPct) || 0) / 100);
}

const QUOTE_SCHEMA: ComputedTableSchema = {
  id: 'quote-line-items',
  label: 'Line Items',
  defaultCurrency: 'USD',
  columns: [
    { key: 'name', label: 'Description', type: 'text' },
    { key: 'qty', label: 'Qty', type: 'number', defaultValue: 1 },
    { key: 'unitPrice', label: 'Unit Price', type: 'currency', defaultValue: 0 },
    { key: 'discountPct', label: 'Discount', type: 'percent', defaultValue: 0 },
    { key: 'lineTotal', label: 'Total', type: 'computed', compute: netOf }
  ],
  footer: [
    { key: 'subtotal', label: 'Subtotal', compute: rows => rows.reduce((a, r) => a + netOf(r), 0) }
  ]
};

<HtmlTemplateEditor tableSchemas={[QUOTE_SCHEMA]} ... />

Inside the template HTML, instances are stored as <div data-computed-table="1" data-config="<urlencoded JSON>"></div> — the editor reconstructs the table from schemaId + rows on mount.

Advanced: HandlebarsKit

HandlebarsKit is a flat array of the five HBS-related Plate plugins (variable / block-open / block-close / else / normalizer). Use it when you want HBS chip behavior embedded inside a custom Plate editor:

import { HandlebarsKit } from '@docyrus/ui/components/html-template-editor';

const editor = usePlateEditor({ plugins: [...myPlugins, ...HandlebarsKit] });

API Reference

PropTypeDefaultDescription
valuestring''Initial HBS HTML string
onChange(value: string) => void—Called with serialized HBS HTML on change (debounced 300 ms). After initial mount the editor re-pushes a serialized version so <div data-computed-table> shells receive their inner static table HTML.
datastring'{\\n \\n}'Initial JSON sample data shown in the Data tab and used by the Preview tab's Handlebars compile. Also scanned by the Table dialog to surface array paths.
onDataChange(data: string) => void—Called when the user edits the JSON data.
variablesHandlebarsVariable[][]Variables shown in the side picker and the {{-trigger combobox.
helpersHandlebarsBlockHelper[]DEFAULT_HELPERSBlock helpers shown in the picker and toolbar popover.
tableSchemasComputedTableSchema[][]Legacy schema-driven table definitions (see Advanced: schema-driven tables).
extraHelpersRecord<string, (...args: unknown[]) => unknown>—Extra Handlebars helpers to register on mount (e.g. numberToWordsTR).
defaultTab'visual' | 'code' | 'data' | 'preview''visual'Tab shown on first render.
readOnlybooleanfalseDisables editing; hides toolbar.
classNamestring—Extra class on the root container.
placeholderstring'Write your template…'Placeholder shown in the empty editor.
minHeightstring'240px'CSS min-height of the editor / code view area.
aiAssistantOpenboolean—Controlled open state for the AI Assistant drawer. When provided the editor stops managing the open state internally — pair with onAiAssistantOpenChange.
onAiAssistantOpenChange(open: boolean) => void—Fired when the AI Assistant toolbar button toggles the drawer.
renderAiAssistant(ctx: IHtmlTemplateAiAssistantRenderContext) => ReactNode—Mounts a custom AI Assistant drawer body on the left of the editor. When set, the toolbar shows a Bot toggle that opens/closes the drawer; this render fn supplies the body (typically a <DocyrusAgent> or <EditorAgent> wrapper). Without it the AI button is hidden.
aiAssistantWidthnumber380Width in pixels the drawer animates to when open.

Type Exports

TypeDescription
HtmlTemplateEditorPropsProps for HtmlTemplateEditor
HandlebarsVariableVariable definition passed to variables
HandlebarsBlockHelperHelper definition passed to helpers
ComputedTableSchemaFull schema describing a legacy schema-driven table
ComputedColumnOne column inside a schema
ComputedColumnType'text' | 'number' | 'currency' | 'percent' | 'computed'
ComputedColumnContext{ currency, locale, rows, index } passed to column.compute / column.format
ComputedFooterFooter aggregate row inside a schema
ComputedFooterContext{ currency, locale, rows } passed to footer.compute
ComputedRowOpen dict Record<string, unknown> & { id: string }
ComputedTableLabelsi18n labels (title / addRow / emptyState / currencyLabel)
ComputedCurrencyOption{ code, label, locale? } entry for schema.currencyOptions
ComputedColumnConfigAd-hoc column definition stored on the Plate node (key + label + format + per-cell styling)
ComputedColumnFormat'text' | 'number' | 'currency' | 'percent' | 'date' | 'computed'
ComputedFooterConfigAd-hoc footer entry: per-column aggregate or free-form formula
ComputedAggregate'sum' | 'average' | 'count' | 'min' | 'max'
ComputedFontWeight'normal' | 'bold'
ComputedFontSize'xs' | 'sm' | 'base' | 'lg' | 'xl'
FormulaTermGeneric term-chain model { op, key } used by legacy formula DSL
FormulaTermOp'multiply' | 'divide' | 'multiply_complement' | 'multiply_premium' | 'multiply_pct'
TComputedTableElementPlate element node — extended with dataPath, label, columns, footer for ad-hoc mode
ExtraHandlebarsHelperSignature for entries in extraHelpers
IHtmlTemplateAiAssistantRenderContextArgument passed to renderAiAssistant — { open, width, onClose, html, data }
IUseApplyHtmlTemplateResultReturn shape of useApplyHtmlTemplate — { html, setHtml, data, setData, tools }

Type Reference

HandlebarsVariable

FieldTypeDescription
namestringHandlebars expression body (e.g. customer.name, formatCurrency total order.currency)
labelstring?Human-readable label shown in the picker
descriptionstring?Short description shown below the label
categorystring?Groups variables in the picker popover; also drives chip color

HandlebarsBlockHelper

FieldTypeDescription
namestringHelper name (if, each, …)
labelstring?Human-readable label
descriptionstring?Short description
defaultExpressionstring?Pre-filled expression when inserting via the toolbar popover

ComputedColumnConfig

FieldTypeDescription
keystringField name on each row in the bound array
labelstringHeader cell text
formatComputedColumnFormatCell rendering / formatting type
visibleboolean?Initial visibility (default true; identifier-shaped keys default to false)
align'left' | 'right' | 'center'?Cell alignment
widthstring?CSS width hint
fontWeight'normal' | 'bold'?Text weight
fontSize'xs' | 'sm' | 'base' | 'lg' | 'xl'?Tailwind text-size token
textColorstring?Tailwind class, hex, or CSS color string
backgroundColorstring?Tailwind class, hex, or CSS color string
formatPatternstring?Optional override format string (e.g. 'DD/MM/YYYY')

ComputedFooterConfig

FieldTypeDescription
keystringColumn key the entry sits under (drives cell placement)
labelstringFooter row label cell text
aggregateComputedAggregateStandard aggregate when formula is not set
formulastring?Raw Handlebars sub-expression (sumLineExpr items "qty * unitPrice") — overrides aggregate
formulaFormatComputedColumnFormat?Format wrapper hint for the formula output (defaults to the target column's format)
textColorstring?Optional row text color
backgroundColorstring?Optional row background color

ComputedColumn (legacy schema)

FieldTypeDescription
keystringField name on each row dict (qty, unitPrice, …)
labelstringHeader cell text
typeComputedColumnTypeOne of text / number / currency / percent / computed
defaultValueunknown?Seeded into new rows
widthstring?CSS width hint ('72px', '20%')
align'left' | 'right' | 'center'?Cell alignment
stepnumber?step attr for numeric inputs
minnumber?min attr for numeric inputs
maxnumber?max attr for numeric inputs
compute(row, ctx) => number | string?For computed columns: derive the value
format(value, row, ctx) => string?Override the default formatter
toggleableboolean?Show in the column-toggle dropdown
defaultVisibleboolean?Initial visibility (default true)

ComputedFooter (legacy schema)

FieldTypeDescription
keystringStable id (subtotal, tax, grandTotal)
labelstringFooter label cell text
compute(rows, ctx) => number | stringAggregator over all rows
format(value, ctx) => string?Display format override (default: currency)
emphasis'normal' | 'strong'?Visual weight (strong = bordered grand-total row)

IHtmlTemplateAiAssistantRenderContext

FieldTypeDescription
openbooleanWhether the drawer is currently open
widthnumberWidth in pixels the drawer animates to when open (mirrors aiAssistantWidth)
onClose() => voidCall from inside the slot to close the drawer
htmlstringCurrent template HTML in the editor
datastringCurrent JSON input string in the Data tab

ComputedTableSchema (legacy schema)

FieldTypeDescription
idstringStable id stored in node JSON to look up the schema at render time
labelstringShort label shown in the insert dropdown
columnsComputedColumn[]Column definitions
footerComputedFooter[]?Aggregate footer rows
defaultCurrencystring?Default currency for new instances
defaultLocalestring?Default locale for formatting
defaultRowsComputedRow[]?Initial rows on first insert (else a single empty row)
labelsComputedTableLabels?i18n strings (title / addRow / emptyState / currencyLabel)
currencyOptionsComputedCurrencyOption[]?Entries for the in-table currency picker (omit to hide the picker)

Components

ComponentDescription
HtmlTemplateEditorMain editor component
HandlebarsKitArray of all HBS Plate plugins for embedding in a custom editor
DEFAULT_HELPERSDefault if / unless / each / with helper definitions
numberToWordsTROptional Turkish number-to-words Handlebars helper (pass via extraHelpers)
useApplyHtmlTemplateHook that owns html + data state and exposes the applyHtmlTemplate client-side tool for <EditorAgent> — the tool writes the agent's HTML AND compiles it against the current Data tab JSON, returning a compiled-output preview or a categorized error (data-empty / invalid-json / Handlebars compile failure). See the AI Assistant slot section above.

On this page