<bmx-document-editor>
A word processor in the page: documents laid out on real pages (A4, Letter and the rest, portrait or landscape, with their margins) and edited where they are drawn, the way Word and Google Docs work - and Word documents (.docx) opened and saved in the browser.
15 properties · 5 events · 22 methods · 10 parts
Example
Try it with your own files in confidence: they are opened by your browser, on your device. Nothing is uploaded, no copy reaches us or anyone else, and nothing is kept once you close this tab.
A proposal from a fictional consultancy, laid out on A4 with a table of contents, its header, footnotes and page numbers - and a reviewer's comments and tracked changes to accept or reject. Type anywhere (changes are tracked when Track changes is on), format with the toolbar or Ctrl+B, I and U, find and replace with Ctrl+F, double-click the header to edit it, and save it as a Word document or a PDF - or open a .docx of your own.
Show markup
<bmx-document-editor id="ex-de" label="Proposal for Northwind Rail" file-name="northwind-proposal" locale="en-GB" author="Sam Carter" show-review></bmx-document-editor>
<p class="note" id="ex-de-out" role="status">A proposal from a fictional consultancy, laid out on A4 with a table of contents, its header, footnotes and page numbers - and a reviewer's comments and tracked changes to accept or reject. Type anywhere (changes are tracked when Track changes is on), format with the toolbar or Ctrl+B, I and U, find and replace with Ctrl+F, double-click the header to edit it, and save it as a Word document or a PDF - or open a .docx of your own.</p>
<div class="row" style="gap: 0.75rem; flex-wrap: wrap; align-items: center; margin-block: 0.5rem 1.5rem">
<button type="button" id="ex-de-letter">Switch to US Letter</button>
<button type="button" id="ex-de-readonly">Read only</button>
<button type="button" id="ex-de-json">Show the document as JSON</button>
<button type="button" id="ex-de-letter-merge">Try mail merge (a letter to three clients)</button>
</div>
<pre id="ex-de-json-out" hidden style="max-block-size: 18rem; overflow: auto; font-size: 12px"></pre>
<script type="module">
await customElements.whenDefined('bmx-document-editor');
const el = document.getElementById('ex-de');
const out = document.getElementById('ex-de-out');
const p = (text, style, props) => ({ type: 'paragraph', style, props, inlines: Array.isArray(text) ? text : [text] });
const b = text => ({ type: 'text', text, style: { bold: true } });
const cell = (text, shading) => ({ blocks: [p(text)], shading });
const bullet = text => p(text, undefined, { list: { id: 'bullet', level: 0 } });
const reviewer = { author: 'Ruth Okafor', date: '2026-10-16T15:20:00Z' };
const step = (text, level = 0) => p(text, undefined, { list: { id: 'number', level } });
el.content = {
title: 'Timetable data platform - proposal',
author: 'Fernhill Consulting',
header: [p([{ type: 'text', text: 'Fernhill Consulting', style: { bold: true, color: '#1f3864' } }, { type: 'tab' }, { type: 'tab' }, { type: 'tab' }, { type: 'tab' }, { type: 'tab' }, { type: 'tab' }, { type: 'tab' }, { type: 'text', text: 'Commercial in confidence', style: { color: '#595959', size: 9 } }], 'NoSpacing')],
footer: [p(['Page ', { type: 'field', field: 'page' }, ' of ', { type: 'field', field: 'pages' }], 'NoSpacing', { align: 'center' })],
blocks: [
p('Timetable data platform', 'Title'),
p('A proposal for Northwind Rail, October 2026', 'Subtitle'),
p('Contents', 'TOCHeading', { toc: true }),
p('1. Summary', 'Heading1'),
p([
'Northwind Rail publishes timetables from four systems that were never meant to talk to each other.',
{ type: 'note', id: 'fn-systems' },
' This proposal sets out how one data platform would take their place over ',
{ type: 'text', text: 'nine', style: { bold: true, del: reviewer } },
{ type: 'text', text: 'ten', style: { bold: true, ins: reviewer } },
b(' months'),
', in three releases, without a day of service lost to ',
{ type: 'text', text: 'the change', style: { comments: ['c-change'] } },
'.',
], undefined, { align: 'justify' }),
p('What Northwind gets:'),
bullet('One source of truth for every timetable, from long-term plans to the day of running.'),
bullet('Changes published to passengers, staff and partners within two minutes.'),
bullet(['A full record of who changed what and when, kept for ', b('seven years'), '.']),
p('2. Approach', 'Heading1'),
p('The work runs in three releases. Each one goes live on its own and is used in service before the next begins.', undefined, { align: 'justify' }),
step('Discovery and design (weeks 1 to 6)'),
step('Map every feed and its owner', 1),
step('Agree the shared timetable model', 1),
step('Build and migrate (weeks 7 to 30)'),
step('Run both systems side by side, then switch (weeks 31 to 39)'),
p('3. Plan and cost', 'Heading1'),
{
type: 'table',
columns: [190, 110, 80, 71],
rows: [
{ header: true, cells: [cell('Release', '#dde4ee'), cell('Weeks', '#dde4ee'), cell('People', '#dde4ee'), cell('Cost', '#dde4ee')] },
{ cells: [cell('Discovery and design'), cell('1 to 6'), cell('3'), cell('£84,000')] },
{ cells: [cell('Build and migrate'), cell('7 to 30'), cell('6'), cell('£512,000')] },
{ cells: [cell('Parallel running and switch'), cell('31 to 39'), cell('4'), cell('£141,000')] },
{ cells: [{ blocks: [p([b('Total')])] }, cell('39 weeks'), cell(''), { blocks: [p([b('£737,000')])] }] },
],
borders: true,
},
p('Figures exclude VAT. Travel within the UK is included.', 'Caption'),
p('4. Risks', 'Heading1', { pageBreakBefore: true }),
p([
'The largest risk is the quality of the freight feed, which has no single owner today. We would ask for a named owner before release two begins. Our full risk log is at ',
{ type: 'text', text: 'example.com/northwind/risks', style: { link: 'https://example.com/northwind/risks' } },
'.',
], undefined, { align: 'justify' }),
p('5. Next steps', 'Heading1'),
p('If this proposal suits, we can begin discovery on 2 November. A one-hour call to walk through it is the best place to start.', undefined, { align: 'justify' }),
p(['Priya Shah', { type: 'break', kind: 'line' }, 'Principal, Fernhill Consulting'], undefined, { spaceBefore: 18 }),
],
notes: {
'fn-systems': [p('Long-term planning, short-term planning, the day-of-running system and the passenger information feed.')],
},
comments: [
{
id: 'c-change',
author: 'Ruth Okafor',
date: '2026-10-16T15:24:00Z',
text: 'Can we say how the switch-over weekend is covered?',
replies: [{ author: 'Priya Shah', date: '2026-10-16T16:02:00Z', text: 'Yes - added to section 2.' }],
},
],
};
// Fill in the table of contents from the headings.
await el.execute('toc');
await el.selectRange({ story: 'body', path: [0], offset: 0 });
el.addEventListener('bmxChange', async event => {
const stats = await el.getStats();
out.textContent = `Edited (${event.detail.reason}): ${stats.words} words on ${stats.pages} pages.`;
});
el.addEventListener('bmxSave', event => {
out.textContent = `Saved ${event.detail.fileName} (${Math.round(event.detail.bytes.length / 1024)} KB).`;
});
el.addEventListener('bmxOpen', event => {
out.textContent = `Opened ${event.detail.fileName}${event.detail.warnings.length ? ' - some parts were simplified' : ''}.`;
});
document.getElementById('ex-de-letter').addEventListener('click', async e => {
const doc = await el.getContent();
const letter = doc.page.width > 600;
el.content = { ...doc, page: { ...doc.page, width: letter ? 595.3 : 612, height: letter ? 841.9 : 792 } };
e.target.textContent = letter ? 'Switch to US Letter' : 'Switch to A4';
});
document.getElementById('ex-de-readonly').addEventListener('click', e => {
el.readonly = !el.readonly;
e.target.textContent = el.readonly ? 'Allow editing' : 'Read only';
});
document.getElementById('ex-de-letter-merge').addEventListener('click', () => {
el.mergeData = [
{ FirstName: 'Amira', Company: 'Harbour Freight', City: 'Hull' },
{ FirstName: 'Tom', Company: 'Pennine Buses', City: 'Leeds' },
{ FirstName: 'Grace', Company: 'Severn Ferries', City: 'Bristol' },
];
el.content = {
title: 'Renewal letter',
blocks: [
p('Fernhill Consulting', 'Title'),
p(['Dear ', { type: 'field', field: 'merge', name: 'FirstName' }, ',']),
p(['Thank you for another year working with ', { type: 'field', field: 'merge', name: 'Company' }, '. We look forward to seeing the ', { type: 'field', field: 'merge', name: 'City' }, ' team at the November review.'], undefined, { align: 'justify' }),
p(['Kind regards,', { type: 'break', kind: 'line' }, 'Priya Shah']),
],
};
out.textContent = 'A letter with three merge fields: choose Preview results to see each client\'s copy, or Merge to one Word document.';
});
document.getElementById('ex-de-json').addEventListener('click', async e => {
const pre = document.getElementById('ex-de-json-out');
pre.hidden = !pre.hidden;
if (!pre.hidden) pre.textContent = JSON.stringify(await el.getContent(), null, 2).slice(0, 20000);
e.target.textContent = pre.hidden ? 'Show the document as JSON' : 'Hide the JSON';
});
</script>
WHAT IT DOES
- Paragraph styles (Title, Headings 1 to 6, Quote and the document's own, based on one another), fonts, sizes, bold, italic, underline, strike, superscript and subscript, colour and highlight; alignment, justified text, indents, line spacing and the space around paragraphs.
- Bulleted and numbered lists nested nine deep (1., a., i.), numbered automatically; typing "- " or "1. " starts one.
- Tables: rows and columns added and removed, merged cells, shading, borders, heading rows repeated on every page a table runs onto; Tab moves from cell to cell.
- Pictures, links, tabs, line and page breaks; page number fields; the document's header and footer on every page.
- Pages break as the text grows, keeping headings with what follows them.
- Opens and saves .docx: styles, lists, tables, pictures, header and footer, page setup, fields, tracked changes, comments and footnotes come through both ways. Saves a PDF and prints.
- Review: track changes (insertions and deletions marked with who and when, accepted or rejected one by one or all at once), comments on any text with replies and resolving, in a pane beside the pages.
- Footnotes at the foot of their page; a table of contents with page numbers behind dot leaders, updated on request; tab stops of a paragraph's own; find and replace (case, whole words, patterns).
- Mail merge: merge fields filled from records, previewed record by record, merged into one document or one per record.
- The header and footer edited in place (double-click), pictures resized by their corners or by size, table columns by dragging their borders.
- Copy and paste with formatting, to and from Word, mail and the web; undo and redo; a headings pane to move around; zoom; page and word count.
Everything happens in the page: files are opened and saved in the browser
and nothing is sent anywhere. The document is plain JSON (content,
bmxChange) for the page to store wherever it likes.
Properties
| Property | Attribute | Type | Default | Description |
|---|---|---|---|---|
author |
author |
string |
— | The name tracked changes and comments made here are recorded under. |
content |
content |
Partial<BmxDocument> | string |
— | The document (an object, or its JSON); see BmxDocument. Kept up to date as it is edited. |
fileName |
file-name |
string |
'document' |
The name a saved file gets, without .docx. Opening a file takes its name. |
fonts |
fonts |
string[] | string |
— | Fonts offered in the font list (a list, or its JSON). The document's own fonts are added. |
label |
label |
string |
— | The element's accessible name. |
locale |
locale |
string |
— | The locale for dates and numbers. Unset: the page's lang. |
mergeData |
merge-data |
Record<string, unknown>[] | string |
— | Records for mail merge (a list of objects, or its JSON): merge fields show a record's values in preview and fill every copy when merged. |
mergeFields |
merge-fields |
string[] | string |
— | The merge fields offered (a list, or its JSON). Unset: the keys of the first record of mergeData. |
readonly |
readonly |
boolean |
false |
Look and select, but do not change. |
showHeadings |
show-headings |
boolean |
false |
Show the headings pane (it can also be opened from the toolbar). |
showReview |
show-review |
boolean |
false |
Show the pane of comments and tracked changes beside the pages. |
showToolbar |
show-toolbar |
boolean |
true |
Show the formatting tools. |
strings |
strings |
Partial<BmxDocumentEditorStrings> | string |
— | Replacements for any of the wording (an object, or its JSON). |
trackChanges |
track-changes |
boolean |
false |
Track changes: what is typed is marked inserted and what is deleted stays, struck through, until accepted or rejected. |
zoom |
zoom |
'auto' | 'fit' | number |
'auto' |
Zoom: auto (100%, or the page's width when it does not fit), fit (the page's width) or a percentage. |
Events
| Event | Detail | Description |
|---|---|---|
bmxChange |
BmxDocumentEditorChangeDetail |
Fired when the document changes. |
bmxLinkOpen |
BmxDocumentEditorLinkDetail |
Fired before a link is opened (Ctrl+click). Cancelable. |
bmxOpen |
BmxDocumentEditorOpenDetail |
Fired when a .docx file has been read, before it replaces the document. Cancelable. |
bmxSave |
BmxDocumentEditorSaveDetail |
Fired with the .docx bytes before the browser downloads them. Cancelable: cancel it to store them yourself. |
bmxSelectionChange |
BmxDocumentEditorSelectionDetail |
Fired when the caret moves or the selection changes. |
Methods
| Method | Signature | Description |
|---|---|---|
execute |
execute(command: BmxDocumentEditorCommand, value?: string | number | boolean) => Promise<boolean> |
Runs a toolbar command (bold, align with center, style with Heading1, size with 14 and so on). False when it does not apply. |
focusEditor |
focusEditor() => Promise<void> |
Puts the keyboard in the document. |
getChanges |
getChanges() => Promise<BmxDocChange[]> |
The tracked changes, in reading order. |
getComments |
getComments() => Promise<(BmxDocComment & { readonly from?: BmxDocPosition; readonly to?: BmxDocPosition; readonly quote?: string; })[]> |
The comments, with where their text is. |
getContent |
getContent() => Promise<BmxDocument> |
The document as it is now. |
getSelectedText |
getSelectedText() => Promise<string> |
The selected text. |
getStats |
getStats() => Promise<{ words: number; characters: number; paragraphs: number; pages: number; }> |
Words, characters, paragraphs and pages. |
getText |
getText() => Promise<string> |
The body's text: paragraphs on lines, table cells separated by tabs. |
insertTable |
insertTable(rows?: number, columns?: number) => Promise<void> |
Inserts a table at the caret. |
insertText |
insertText(text: string) => Promise<void> |
Types text at the caret (replacing the selection), as if typed. |
mergeToDocuments |
mergeToDocuments(records?: Record<string, unknown>[]) => Promise<BmxDocument[]> |
One document per mail-merge record (mergeData, or the records given). |
mergeToDocx |
mergeToDocx(options?: { records?: Record<string, unknown>[]; separate?: boolean; }) => Promise<Uint8Array | Uint8Array[]> |
The mail merge as .docx: one file with every record's copy on new pages, or with separate, one file per record. |
openDocx |
openDocx(file: Blob | ArrayBuffer | Uint8Array, fileName?: string) => Promise<string[]> |
Opens a .docx file (a File, Blob or its bytes). Resolves with what the file had that the editor does not keep; rejects when it is not a Word document. |
print |
print() => Promise<void> |
Prints the document's pages (not the rest of the page), from a frame of its own. |
redo |
redo() => Promise<void> |
Redoes what was undone. |
saveDocx |
saveDocx() => Promise<void> |
Saves the document as a .docx file: bmxSave first, then a download unless the event is cancelled. |
savePdf |
savePdf() => Promise<void> |
Saves the document as a PDF: bmxSave first, then a download unless the event is cancelled. |
selectRange |
selectRange(anchor: BmxDocPosition, focus?: BmxDocPosition) => Promise<BmxDocSelection> |
The selection, or moves it (positions are clamped to the document). |
setContent |
setContent(content: Partial<BmxDocument> | string, options?: { keepHistory?: boolean; }) => Promise<void> |
Replaces the document. Undo goes back to what was there, unless keepHistory is false. |
toDocx |
toDocx() => Promise<Uint8Array> |
The document as .docx bytes. |
toPdf |
toPdf(options?: { markup?: boolean; }) => Promise<Uint8Array> |
The document as PDF bytes: as the pages show it, tracked changes marked unless markup is false. |
undo |
undo() => Promise<void> |
Undoes the last change. |
CSS shadow parts
| Part | Description |
|---|---|
headings |
The headings pane. |
merge-bar |
The mail merge preview and merge tools. |
notice |
What opening a file left out. |
page |
Each page. |
pages |
The scrolling area holding the pages. |
panel |
The link, table and page setup forms. |
review |
The comments and tracked changes pane. |
status |
The page and word count and the zoom. |
table-tools |
The tools shown while the caret is in a table. |
toolbar |
The formatting tools. |
CSS custom properties
| Property | Description |
|---|---|
--bmx-document-editor-caret |
Colour of the caret. |
--bmx-document-editor-comment |
Background of text with a comment. |
--bmx-document-editor-comment-active |
Background of the text of the comment in hand. |
--bmx-document-editor-desk |
Background behind the pages. |
--bmx-document-editor-headings-width |
Width of the headings pane. |
--bmx-document-editor-height |
Height of the editor. |
--bmx-document-editor-match |
Background of find's matches. |
--bmx-document-editor-match-current |
Background of the match in hand. |
--bmx-document-editor-paper |
Colour of the pages. |
--bmx-document-editor-radius |
Corner radius of the frame. |
--bmx-document-editor-review-width |
Width of the comments and changes pane. |
--bmx-document-editor-selection |
Colour of selected text. |
--bmx-document-editor-selection-blurred |
Colour of selected text while the editor does not have the keyboard. |