v3.0.0

<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

PropertyAttributeTypeDefaultDescription
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

EventDetailDescription
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

MethodSignatureDescription
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

PartDescription
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

PropertyDescription
--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.