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.
Installation
pnpm dlx @docyrus/cli add @docyrus/ui-html-template-editorpnpm add platejs handlebars @uiw/react-codemirror @uiw/codemirror-extensions-langsnpx shadcn@latest add button checkbox command dialog dropdown-menu input label popover scroll-area select separator tabs textarea tooltipnpx 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-buttonUsage
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 fromcategory). 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/Tabinserts. Each variable'scategorybecomes 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
namefrom 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 andcolSizesis 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
Tablebutton 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 likeqty * unitPrice * (1 - discountPct/100). Inserted tables stay live: serialization emits{{#each <path>}}so the Preview tab iterates over real data. - Safe expression evaluator —
sumLineExprevaluates user-typed math via a small recursive-descent parser (noeval/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 by0.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,ltare registered at module load. extraHelpersprop — 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 viaextraHelpersonly when needed. - Built-in Plate kits — Basic blocks, marks, lists, links, alignment, font color & size, columns, native tables, callouts.
- Read-only mode — pass
readOnlyto 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 pickspercentfor*_pct/*_ratekeys,currencyforprice,cost,total-style keys,datefor*_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:
| Construct | Example |
|---|---|
| Identifier (column key) | qty, unitPrice, discountPct |
| Numeric literal | 100, 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
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | '' | 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. |
data | string | '{\\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. |
variables | HandlebarsVariable[] | [] | Variables shown in the side picker and the {{-trigger combobox. |
helpers | HandlebarsBlockHelper[] | DEFAULT_HELPERS | Block helpers shown in the picker and toolbar popover. |
tableSchemas | ComputedTableSchema[] | [] | Legacy schema-driven table definitions (see Advanced: schema-driven tables). |
extraHelpers | Record<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. |
readOnly | boolean | false | Disables editing; hides toolbar. |
className | string | — | Extra class on the root container. |
placeholder | string | 'Write your template…' | Placeholder shown in the empty editor. |
minHeight | string | '240px' | CSS min-height of the editor / code view area. |
aiAssistantOpen | boolean | — | 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. |
aiAssistantWidth | number | 380 | Width in pixels the drawer animates to when open. |
Type Exports
| Type | Description |
|---|---|
HtmlTemplateEditorProps | Props for HtmlTemplateEditor |
HandlebarsVariable | Variable definition passed to variables |
HandlebarsBlockHelper | Helper definition passed to helpers |
ComputedTableSchema | Full schema describing a legacy schema-driven table |
ComputedColumn | One column inside a schema |
ComputedColumnType | 'text' | 'number' | 'currency' | 'percent' | 'computed' |
ComputedColumnContext | { currency, locale, rows, index } passed to column.compute / column.format |
ComputedFooter | Footer aggregate row inside a schema |
ComputedFooterContext | { currency, locale, rows } passed to footer.compute |
ComputedRow | Open dict Record<string, unknown> & { id: string } |
ComputedTableLabels | i18n labels (title / addRow / emptyState / currencyLabel) |
ComputedCurrencyOption | { code, label, locale? } entry for schema.currencyOptions |
ComputedColumnConfig | Ad-hoc column definition stored on the Plate node (key + label + format + per-cell styling) |
ComputedColumnFormat | 'text' | 'number' | 'currency' | 'percent' | 'date' | 'computed' |
ComputedFooterConfig | Ad-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' |
FormulaTerm | Generic term-chain model { op, key } used by legacy formula DSL |
FormulaTermOp | 'multiply' | 'divide' | 'multiply_complement' | 'multiply_premium' | 'multiply_pct' |
TComputedTableElement | Plate element node — extended with dataPath, label, columns, footer for ad-hoc mode |
ExtraHandlebarsHelper | Signature for entries in extraHelpers |
IHtmlTemplateAiAssistantRenderContext | Argument passed to renderAiAssistant — { open, width, onClose, html, data } |
IUseApplyHtmlTemplateResult | Return shape of useApplyHtmlTemplate — { html, setHtml, data, setData, tools } |
Type Reference
HandlebarsVariable
| Field | Type | Description |
|---|---|---|
name | string | Handlebars expression body (e.g. customer.name, formatCurrency total order.currency) |
label | string? | Human-readable label shown in the picker |
description | string? | Short description shown below the label |
category | string? | Groups variables in the picker popover; also drives chip color |
HandlebarsBlockHelper
| Field | Type | Description |
|---|---|---|
name | string | Helper name (if, each, …) |
label | string? | Human-readable label |
description | string? | Short description |
defaultExpression | string? | Pre-filled expression when inserting via the toolbar popover |
ComputedColumnConfig
| Field | Type | Description |
|---|---|---|
key | string | Field name on each row in the bound array |
label | string | Header cell text |
format | ComputedColumnFormat | Cell rendering / formatting type |
visible | boolean? | Initial visibility (default true; identifier-shaped keys default to false) |
align | 'left' | 'right' | 'center'? | Cell alignment |
width | string? | CSS width hint |
fontWeight | 'normal' | 'bold'? | Text weight |
fontSize | 'xs' | 'sm' | 'base' | 'lg' | 'xl'? | Tailwind text-size token |
textColor | string? | Tailwind class, hex, or CSS color string |
backgroundColor | string? | Tailwind class, hex, or CSS color string |
formatPattern | string? | Optional override format string (e.g. 'DD/MM/YYYY') |
ComputedFooterConfig
| Field | Type | Description |
|---|---|---|
key | string | Column key the entry sits under (drives cell placement) |
label | string | Footer row label cell text |
aggregate | ComputedAggregate | Standard aggregate when formula is not set |
formula | string? | Raw Handlebars sub-expression (sumLineExpr items "qty * unitPrice") — overrides aggregate |
formulaFormat | ComputedColumnFormat? | Format wrapper hint for the formula output (defaults to the target column's format) |
textColor | string? | Optional row text color |
backgroundColor | string? | Optional row background color |
ComputedColumn (legacy schema)
| Field | Type | Description |
|---|---|---|
key | string | Field name on each row dict (qty, unitPrice, …) |
label | string | Header cell text |
type | ComputedColumnType | One of text / number / currency / percent / computed |
defaultValue | unknown? | Seeded into new rows |
width | string? | CSS width hint ('72px', '20%') |
align | 'left' | 'right' | 'center'? | Cell alignment |
step | number? | step attr for numeric inputs |
min | number? | min attr for numeric inputs |
max | number? | 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 |
toggleable | boolean? | Show in the column-toggle dropdown |
defaultVisible | boolean? | Initial visibility (default true) |
ComputedFooter (legacy schema)
| Field | Type | Description |
|---|---|---|
key | string | Stable id (subtotal, tax, grandTotal) |
label | string | Footer label cell text |
compute | (rows, ctx) => number | string | Aggregator over all rows |
format | (value, ctx) => string? | Display format override (default: currency) |
emphasis | 'normal' | 'strong'? | Visual weight (strong = bordered grand-total row) |
IHtmlTemplateAiAssistantRenderContext
| Field | Type | Description |
|---|---|---|
open | boolean | Whether the drawer is currently open |
width | number | Width in pixels the drawer animates to when open (mirrors aiAssistantWidth) |
onClose | () => void | Call from inside the slot to close the drawer |
html | string | Current template HTML in the editor |
data | string | Current JSON input string in the Data tab |
ComputedTableSchema (legacy schema)
| Field | Type | Description |
|---|---|---|
id | string | Stable id stored in node JSON to look up the schema at render time |
label | string | Short label shown in the insert dropdown |
columns | ComputedColumn[] | Column definitions |
footer | ComputedFooter[]? | Aggregate footer rows |
defaultCurrency | string? | Default currency for new instances |
defaultLocale | string? | Default locale for formatting |
defaultRows | ComputedRow[]? | Initial rows on first insert (else a single empty row) |
labels | ComputedTableLabels? | i18n strings (title / addRow / emptyState / currencyLabel) |
currencyOptions | ComputedCurrencyOption[]? | Entries for the in-table currency picker (omit to hide the picker) |
Components
| Component | Description |
|---|---|
HtmlTemplateEditor | Main editor component |
HandlebarsKit | Array of all HBS Plate plugins for embedding in a custom editor |
DEFAULT_HELPERS | Default if / unless / each / with helper definitions |
numberToWordsTR | Optional Turkish number-to-words Handlebars helper (pass via extraHelpers) |
useApplyHtmlTemplate | Hook 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. |
Handlebars Editor
A three-pane workbench for writing and rendering Handlebars templates — a JSON input pane, a template editor with IntelliSense, and a live output pane with HTML preview.
Image Editor
An image editor with crop, brightness, saturation, contrast, and hue adjustments, supporting rectangle and circle stencils with zoom controls.