Editor
Build Handlebars pages and spreadsheet reports with tokens, helpers, and live previews.
Handlebars Editor
Write a template. Explore your data. See the result as you type.
The Handlebars variant combines an HTML source editor, an expandable token browser, and a live preview. Choose a template and data source using the searchable Tom Select controls, then drag a token into the template or click it to insert at the cursor. Objects and arrays expand to reveal their full paths and sample values.
Switch between Project card and Project summary in the Handlebars header. Each template retains its edits and undo history. Try expanding person and inserting lastname. Change the Tokens dropdown to see the same template with different data. The vertical caret shows where a dragged token will land. Scroll down in the source to edit the included <style> block and see the card change. The heading badge and task count use helpers defined in the demo script. A malformed block shows an error and keeps the last valid preview until you fix it.
Setup
Install handlebars, prismjs, and tom-select alongside kmaterialize. Editor loads the compiler and Prism only when initialized. Prism colors HTML, embedded CSS, and Handlebars expressions using the editor theme. The full Handlebars build is required; its runtime-only build cannot compile source text.
npm install kmaterialize handlebars prismjs tom-select<div id="template-editor"></div>import { Editor } from 'kmaterialize';
import 'tom-select/dist/css/tom-select.default.css';
import 'kmaterialize/dist/css/materialize.css';
const editor = Editor.init(document.querySelector('#template-editor'), {
variant: 'handlebars',
template: '<h1>Hello, {{person.firstname}}.</h1>',
data: {
person: { firstname: 'Alex', lastname: 'Morgan' },
items: ['Item A', 'Item B', 'Item C']
},
onChange: template => { /* Save the edited source. */ }
});
await editor.ready;Selectors load Tom Select only when shown. Include its CSS before Materialize CSS. For a plain script page, load tom-select.complete.min.js, the full handlebars.js and a Prism build containing markup, CSS, markup-templating, and Handlebars (with data-manual) before materialize.js, then call M.Editor.init(host, options). Editor uses explicit initialization because its template and data belong to the application.
Data sources and tokens
The data selector appears when you supply sources or loadSources. Set sourceSelect: false to hide it. Pass Tom Select settings such as placeholder, searchField, loadThrottle, or render through sourceSelect; use templateSelect for the template selector. Editor owns the single-selection mode and id/label mapping.
Pass data for one object, or sources for the dropdown. Each source supplies the root object used by both the token browser and preview. Switching sources preserves the template.
const editor = Editor.init(host, {
template: '<p>{{person.firstname}}</p>',
sourceId: 'person-a',
sources: [
{ id: 'person-a', label: 'Person A', data: { person: { firstname: 'Alex' } } },
{ id: 'person-b', label: 'Person B', data: { person: { firstname: 'Sam' } } }
]
});
await editor.setSource('person-b');
editor.setData({ person: { firstname: 'Jamie' } }); // Update the active source.
editor.insertToken('person.firstname'); // Insert at the current selection.
editor.insertToken(['items', '0', 'name']); // An array element.
editor.insertToken(['person', 'display.name']); // A literal dotted key.Tokens insert root paths such as {{person.firstname}}. Inside an each or with block, use {{@root.person.firstname}} to refer back to root data, or {{this}} for the current item. See Handlebars expressions and context.
<ul>
{{#each items}}
<li>{{this}}</li>
{{/each}}
</ul>Use JSON-like data. Array items have extra indentation beneath their array token. Search matches field paths and sample values. The browser shows at most 1,000 entries and 13 levels; circular references are labeled and cannot be inserted. Empty sources show an empty state.
Templates and backend loading
Provide templates to show the template selector. Each entry has an id, label, and optional template string. Use templateId for the initial selection. Without a template list, the existing template option works as before. Set templateSelect: false to hide the selector while keeping programmatic switching.
const editor = Editor.init(host, {
templates: [
{ id: 'card', label: 'Project card', template: '<h1>{{project.name}}</h1>' },
{ id: 'email', label: 'Email', template: '<p>Hello {{person.firstname}}</p>' }
],
templateId: 'card',
data: { project: { name: 'My project' }, person: { firstname: 'Alex' } },
templateSelect: { placeholder: 'Choose a template' },
onTemplateChange: id => console.log('Selected template', id)
});
await editor.ready;
await editor.selectTemplate('email');
editor.getTemplateId(); // 'email'
editor.setTemplate('<p>Updated email</p>'); // Edit the active template.
await editor.selectTemplate('card'); // The email draft is retained.Switching keeps each template's draft and undo/redo history in this Editor instance, and places the cursor at the start with the editor scrolled to the top. It does not save to your server; use onChange or editorchange and getTemplateId() to save your active draft. Template switching emits a source change too.
Use loadTemplates(query, signal) and loadSources(query, signal) to supply searchable lists. Entries can include content directly or omit it for loadTemplate(id, signal) and loadData(id, signal) to fetch on selection. The callbacks let your application control URLs, authentication, and response mapping.
// Example API contract: list endpoints return [{ id, label }].
// Template detail returns { template: "..." }; data detail returns a root object.
async function getJson(url: string, signal: AbortSignal) {
const response = await fetch(url, { signal });
if (!response.ok) throw new Error(`Request failed: ${response.status}`);
return response.json();
}
const editor = Editor.init(host, {
templates: [{ id: 'welcome', label: 'Welcome email' }],
sources: [{ id: 'project-42', label: 'Project 42' }],
templateSelect: { placeholder: 'Find a template', loadThrottle: 300 },
sourceSelect: { placeholder: 'Find project data', loadThrottle: 300 },
loadTemplates: (query, signal) =>
getJson(`/api/templates?query=${encodeURIComponent(query)}`, signal),
loadSources: (query, signal) =>
getJson(`/api/projects?query=${encodeURIComponent(query)}`, signal),
loadTemplate: async (id, signal) => {
const result = await getJson(`/api/templates/${encodeURIComponent(id)}`, signal);
return result.template;
},
loadData: (id, signal) =>
getJson(`/api/projects/${encodeURIComponent(id)}/data`, signal),
onError: error => console.error(error)
});
await editor.ready; // Includes loading the initial template and data.List loading preloads by default; set preload: false in the relevant selector settings to wait for a search. Selected content is cached for this instance, preserving local edits when revisited. Use setTemplate() or setData() to apply fresh content yourself. Switching again or destroying the editor aborts pending requests; stale results are ignored even if a loader ignores its signal. Load failures keep the current selection and preview and call onError. Programmatic selection failures reject their returned promise.
Custom helpers
Define synchronous Handlebars helpers directly in Editor.init. Configured helpers appear in a Functions section below the data tokens. Drag or click a function to insert its call; the cursor lands in its argument slot. Then insert a data token to build a call such as {{capitalize person.firstname}}. Dropping a data token inside an existing expression inserts just its path, replacing an argument under the pointer when appropriate. Helpers are local to that editor and can override helpers from its engine without changing the global Handlebars registry.
const editor = Editor.init(host, {
template: '<h1>{{uppercase person.firstname}}</h1><p>{{taskCount items}}</p>',
data: { person: { firstname: 'Alex' }, items: ['Design', 'Review'] },
helpers: {
uppercase: (value: unknown) => String(value ?? '').toUpperCase(),
taskCount: (items: unknown) => {
const count = Array.isArray(items) ? items.length : 0;
return `${count} ${count === 1 ? 'task' : 'tasks'}`;
}
}
});
// Replace this editor's helper map and refresh its preview.
editor.setHelpers({
uppercase: (value: unknown) => String(value ?? '').toUpperCase(),
taskCount: (items: unknown) => `${Array.isArray(items) ? items.length : 0} remaining`
});
await editor.render();
editor.insertHelper('uppercase'); // Inserts a call with the cursor inside.
editor.insertToken('person.firstname'); // Completes {{uppercase person.firstname}}.Regular helper return values are HTML-escaped. Block helpers receive Handlebars' normal options argument; use a regular function when you need the current this context. Helpers execute in your application script, while their rendered output appears in the preview. See Handlebars runtime helpers.
Options and methods
Press Ctrl+Z (Windows/Linux) or Cmd+Z (Mac) in the source editor to undo. Use Ctrl/Cmd+Shift+Z or Ctrl+Y to redo. The same actions are available through editor.undo() and editor.redo(). History retains the last 100 source edits, including typing, token/helper insertions, and setTemplate(). Continuous typing is grouped; undo restores the previous selection and updates the preview. A new source edit clears redo history. Data-source and helper-definition changes are outside source history. Both methods return false when no edit is available or the editor is read-only or destroyed.
| Option | Default | Purpose |
|---|---|---|
variant | 'handlebars' | HTML template editing; use spreadsheet for row mappings. |
template | Empty string | Initial HTML / Handlebars source. |
data | {} | Template context when sources are omitted. |
sources / sourceId | First source | Named data objects and initial selection. |
templates / templateId | Single template | Named templates and initial selection; each keeps its own draft and undo history. |
templateSelect / sourceSelect | Tom Select defaults | Selector settings, or false to hide one. |
loadTemplates / loadSources | None | Async searchable list callbacks receiving query and AbortSignal. |
loadTemplate / loadData | None | Async selected-content callbacks receiving ID and AbortSignal. |
onTemplateChange / onSourceChange | None | Callbacks receiving the selected ID after a successful switch. |
debounce | 180 | Delay in milliseconds after typing. |
readOnly | false | Disable typing and token insertion; data selection and programmatic updates remain available. |
highlight | true | Prism syntax highlighting; set false for plain text without Prism. |
helpers | {} | Named helper functions scoped to this editor. |
engine | Loaded Handlebars | A Handlebars environment with application-specific helpers or partials. |
onChange / onRender / onError | None | Callbacks receiving source text, rendered HTML, or an Error respectively. |
await editor.ready;
editor.getTemplate(); // Current source, including expressions.
editor.setTemplate(nextTemplate);
editor.undo(); // Undo the last source edit; returns true if applied.
editor.redo(); // Restore an undone edit; returns true if applied.
await editor.render(); // Render immediately; returns last valid HTML on a template error.
editor.getHtml(); // Last successful HTML output.
editor.getSource(); // Active source ID.
editor.getTemplateId(); // Active template ID.
await editor.selectTemplate('email');
await editor.setSource('person-b');
editor.setHelpers(nextHelpers); // Replace local helpers and refresh.
Editor.getInstance(host);
editor.destroy(); // Remove listeners and restore original host content.The host also emits a bubbling editorchange event with detail.template. getHtml() returns the compiled HTML, not sanitized output; use your application's HTML policy when displaying it elsewhere. Compiler loading failures reject ready and appear in the workspace.
Layout and preview
The editor follows the current Materialize theme. Set --editor-height on the host to change the workspace height. On screens below 700px, the preview moves below the source and tokens.
#template-editor {
--editor-height: 720px;
}To fill a parent div or a popup tab panel, add editor-fill to the editor host. The parent must have a defined height or an allocated flex height. The source, tokens, and preview share that space and scroll internally, including when the preview stacks below the input pane on small screens.
<div style="height: 600px">
<div id="template-editor" class="editor-fill"></div>
</div>The preview is a separate white HTML page and honors template styles. Scripts, form submissions, and remote resources are blocked in the sandbox; inline CSS and data-URL images are supported. The preview cannot alter your application styles or DOM.
Spreadsheet Editor
Define a row once. Generate a spreadsheet for every item.
The spreadsheet variant keeps the same layout and token browser. The top-left table has a Header row for column names and a Value row for expressions. Drag fields or functions into a Value cell, or click a cell and then a token. The live kspreadsheet preview on the right generates one row for each object in your data array.
Rename a header, edit a Value expression, or add a column and drop artist.email into it. To combine a function and token, clear a Value cell, insert uppercase(), then insert department. This creates {{uppercase department}} for every row. Switch the report template or data source to see the output change. Scroll the mapping table horizontally to reach more columns.
Setup and row mappings
Install kmaterialize, handlebars, kspreadsheet, jsuites, and tom-select if you use selectors. This variant loads kspreadsheet on demand and does not require Prism. For plain scripts, load jSuites and the kspreadsheet browser build exposing jspreadsheet before initializing.
import { Editor } from 'kmaterialize';
import 'jsuites/dist/jsuites.css';
import 'kspreadsheet/dist/jspreadsheet.css';
import 'kspreadsheet/dist/jspreadsheet.themes.css';
import 'tom-select/dist/css/tom-select.default.css';
import 'kmaterialize/dist/css/materialize.css';
const editor = Editor.init(host, {
variant: 'spreadsheet',
columns: [
{ header: 'Shot', value: '{{uppercase shot}}', width: 150 },
{ header: 'Artist', value: '{{artist.firstname}}', width: 150 },
{ header: 'Days', value: '{{days}}', type: 'numeric', width: 90 }
],
data: [
{ shot: 'sq010_sh010', artist: { firstname: 'Alex' }, days: 4.5 },
{ shot: 'sq010_sh020', artist: { firstname: 'Sam' }, days: 3 }
],
helpers: { uppercase: value => typeof value === 'string' ? value.toUpperCase() : '' },
onColumnsChange: columns => { /* Save the mapping definition. */ },
onSpreadsheetRender: ({ headers, rows }) => { /* Use the generated result. */ }
});
await editor.ready;
editor.getColumns(); // Current mapping definition.
editor.setColumns([
{ header: 'Artist', value: '{{artist.firstname}}' },
{ header: 'Estimate', value: '{{days}}', type: 'numeric' }
]);
await editor.render();
editor.getSpreadsheetData(); // { headers: ['Artist', 'Estimate'], rows: [['Alex', 4.5], ['Sam', 3]] }
editor.getWorksheet(); // The live kspreadsheet worksheet instance.
await editor.download('csv', 'shot-report'); // Downloads shot-report.csv with headers.
await editor.download('xlsx', 'shot-report'); // Downloads shot-report.xlsx with headers.
editor.undo(); // Undo a header, expression, or column change.Each column defines header (literal title), value (Handlebars expression or text), optional width, and optional type: text by default, numeric, or checkbox. Numeric columns produce numbers; checkbox columns accept true/false or 1/0. Missing fields produce empty text. Helpers and expressions are evaluated independently for every item without HTML escaping, because spreadsheet values are plain text.
The token browser uses the first item as a sample, so {{artist.firstname}} refers to the current row's artist without an array index. Pass an array of objects with similar fields. Empty data produces zero generated rows; kspreadsheet keeps one blank display row. Invalid expressions or incompatible typed values show an error and keep the last valid result.
download(format, filename?) returns a promise that resolves when the file is generated and the browser download is triggered. The format defaults to csv; the filename defaults to the selected template's label. Pass a name with or without its extension. Downloads include headers and generated values, refresh pending edits first, and reject when the current mapping is invalid or the editor is destroyed. CSV uses commas, quoted fields where needed, and UTF-8 encoding. XLSX preserves numeric and boolean cell values. The method is available only in the spreadsheet variant.
The generated preview uses kspreadsheet’s original styling and is read-only. Edit the mapping to change it. HTML and formula-looking values are displayed as text. getSpreadsheetData() returns headers separately from generated data rows. getTemplate() and onChange use serialized column JSON in this variant; use getColumns() and onColumnsChange for the structured definition. getHtml() and onRender belong to the Handlebars variant.
Templates and backend data
The Tom Select options, searchable list callbacks, cached drafts, cancellation, and selectTemplate()/setSource() methods work in both variants. Spreadsheet template entries use columns; their loadTemplate callback can return a column array or serialized column JSON. Data sources and loadData return arrays of row objects.
const editor = Editor.init(host, {
variant: 'spreadsheet',
templates: [
{ id: 'shots', label: 'Shot report', columns: [
{ header: 'Shot', value: '{{shot}}' },
{ header: 'Days', value: '{{days}}', type: 'numeric' }
] },
{ id: 'artists', label: 'Artist report' } // Load its columns when selected.
],
sources: [{ id: 'project-42', label: 'Project 42' }],
loadTemplate: async (id, signal) => {
const response = await fetch(`/api/report-templates/${encodeURIComponent(id)}`, { signal });
if (!response.ok) throw new Error('Could not load the report template.');
return response.json(); // [{ header: 'Artist', value: '{{artist.firstname}}' }]
},
loadData: async (id, signal) => {
const response = await fetch(`/api/projects/${encodeURIComponent(id)}/shots`, { signal });
if (!response.ok) throw new Error('Could not load the report data.');
return response.json(); // Array of similarly structured objects.
}
});
await editor.ready;
await editor.selectTemplate('artists');