@icure/editor
v0.5.3
Published
A paginated, template-aware document editor for React, built on ProseMirror: fields, repeating blocks, nested templates and images resolved through a host-supplied data provider, with HTML and PDF output.
Keywords
Readme
@icure/editor
A paginated, template-aware document editor for React, built on ProseMirror.
A template is a document with references that a host-supplied data provider resolves when the document is shown or printed:
- fields: a name (
recipient.lastName) resolved to text, optionally qualified by a label (preference.value:Newsletter); - repeating blocks: a header, a body and a footer; the body is drawn once per item of a source (a line per item, or a table that keeps its header row once and repeats its body rows), and items may hold lists of their own;
- embedded templates: another template inlined by id, recursively, with cycle detection and a depth limit;
- images: a reference to the host's image store, or the image itself embedded in the document
(a raster
data:URI), with alt text and a width.
References the provider can't fill are never left silently blank: they show as their expression
({recipient.city}) and are listed behind an "unresolved references" chip. The output (HTML and
PDF) is one-to-one with what the editor shows.
The repository is a Bun workspaces monorepo:
packages/editor the library (@icure/editor)
apps/demo a test app: sample templates, a fake in-memory provider, language and theme switchInstall
bun add @icure/editor # peer dependencies: react, react-dom ^19
bun add pdfmake # optional, only for PDF outputUsage
import { DocumentEditor, type EditorDataProvider } from '@icure/editor'
import '@icure/editor/styles.css'
const provider: EditorDataProvider = {
listFields: () => [{ key: 'recipient.lastName', label: 'Last name', group: 'Recipient' }],
resolveField: (key) => ({ 'recipient.lastName': 'Martin' })[key],
listRepeatSources: () => [{ key: 'orders', label: 'Orders', layout: 'table', itemFields: [{ key: 'order.ref', label: 'Reference' }] }],
resolveRepeat: async (source) => (source === 'orders' ? { items: [{ 'order.ref': 'A-1' }, { 'order.ref': 'A-2' }] } : null),
listTemplates: () => [{ id: 'signature', name: 'Signature' }],
loadTemplate: async (id) => (id === 'signature' ? { id, name: 'Signature', content: signatureJson } : null),
images: {
resolve: (id) => ({ src: myImageStore.dataUri(id) }),
upload: async (file) => ({ id: await myImageStore.put(file) }),
},
}
export function Letter({ template }: { template: unknown }) {
return (
<DocumentEditor
initial={template} // ProseMirror JSON of the editor schema; read once per mount
provider={provider}
documentId="letter" // so the document can't embed itself
labels={{ bold: 'Gras' }} // any subset; English defaults fill the rest
onEdit={(doc) => saveDraft(doc.toJSON())}
/>
)
}Output:
import { A4_PAGE, resolveDocument, toHtmlDocument, toMarkdown, pdfPageOf, toPdfContent, toPdfDefinition, preparePdfmake, renderPdf, withPdfImages } from '@icure/editor'
const resolution = await resolveDocument(doc, provider, { rootId: 'letter' })
// One sheet per page, sized to the page format (the editor's `page`, A4 by default) on screen and on paper.
const html = toHtmlDocument(doc, resolution, { title: 'Letter', lang: 'en', page: A4_PAGE })
// CommonMark with GFM tables: headings, paragraphs, bold, italic, tables, images; pages split by `---`.
const markdown = toMarkdown(doc, resolution)
const pdfmake = preparePdfmake(pdfmakeInstance, { files: robotoVfs, families: { Roboto: { normal: 'Roboto-Regular.ttf', bold: 'Roboto-Medium.ttf', italics: 'Roboto-Italic.ttf', bolditalics: 'Roboto-MediumItalic.ttf' } } })
const content = toPdfContent(doc, await withPdfImages(resolution), { title: 'Letter', createdAt: new Date(2026, 0, 1) })
// The sheet: `page` is 'A4' (default), 'A5' or 'Letter', or a PageFormat in mm (any size, landscape
// included; out of bounds falls back to A4); the editor's own format: `{ ..., ...pdfPageOf(format) }`.
const bytes = await renderPdf(pdfmake, toPdfDefinition(content))The same document, resolution and createdAt give byte-identical PDFs.
Tables: a table's layout is grid (default: every cell ruled) or layout (no rules: it only lays
blocks out; the editor outlines it, the outputs never do). A cell's borders names the sides it
draws, a subset of tblr ('' none); the default is all four in a grid table and none in a layout
table. A table nested in a layout table keeps its own rules. The Table menu sets both, its entries
showing the current choice (menuitemradio); "all borders" is unavailable in a layout table.
Delete or Backspace with every cell of a table selected removes the table; with only some cells
selected, it empties them.
Customising the toolbar
customizeToolbar receives the built-in items, in order, and returns the items to show. With it the
host can remove, reorder, wrap or add items. Each item is { id, render }. The built-in ids are
TOOLBAR_ITEM_IDS: undo, redo, blockStyle, font, size, bold, italic, underline,
colour, alignment, insertTable, tableMenu, insertField, insertBlock, insertTemplate,
insertImage. A control with nothing to offer is left out (for example, the field menu without
fields, or the image button without an image provider). The context gives the active view's state,
which is the innermost block open in place, along with run(command), which acts on that view, and
the merged labels.
import { ToolButton, type CustomizeToolbar } from '@icure/editor'
const customizeToolbar: CustomizeToolbar = (items, { state, run }) => [
...items.filter((item) => item.id !== 'font' && item.id !== 'colour'),
{ id: 'stamp', render: () => <ToolButton label="Insert stamp" glyph={<StampIcon />} onPress={() => run(insertStamp)} /> },
]
<DocumentEditor initial={template} provider={provider} customizeToolbar={customizeToolbar} />Ids must be unique. ToolButton gives a host's button the toolbar's look, tooltip, aria-label
and aria-pressed (active), and it keeps the focus in the document. A host's control that doesn't
use ToolButton must still have an accessible name. toolbarExtras still appends controls after
the items.
The data provider
interface EditorDataProvider {
listFields?(): MaybePromise<readonly FieldDescriptor[]> // "Insert field" menu
resolveField(key: string): MaybePromise<string | null | undefined> // blank = unresolved
listRepeatSources?(): MaybePromise<readonly RepeatSourceDescriptor[]> // "Insert repeating block"
resolveRepeat?(source: string): MaybePromise<RepeatData | null | undefined>
listTemplates?(): MaybePromise<readonly TemplateSummary[]> // "Insert template"
loadTemplate?(id: string): MaybePromise<TemplateContent | null | undefined>
saveTemplate?(id: string, content: unknown): MaybePromise<void> // "Update the shared template"
readonly images?: ImageProvider
}
interface ImageProvider {
resolve(id: string): MaybePromise<ResolvedImage | null | undefined> // { src, alt?, width? }
pick?(): MaybePromise<PickedImage | null> // the host's own picker
upload?(file: File): MaybePromise<PickedImage> // after the editor's checks
}- Every method may answer synchronously or with a promise; a method that throws leaves its key unresolved (the editor never breaks on the host's errors).
- Answers are cached per editor (
createResolver);editorRef.current.refresh()forgets them. - Labels of fields, sources and templates come from the provider, already in the user's language.
Repeating blocks
A repeating block has three zones. The header and footer are drawn once, and still shown
when the list is empty. The body is drawn once per item. A block whose source the provider
can't resolve prints nothing (it is listed as unresolved); in the editor, a resolved empty list
shows a "nothing to list" line between body and footer, never printed (for a body made of a single
table, the line comes after the table, as its footer rows live inside it). When the body is a single
table, its rows carry a zone (table_row attribute zone: header | body | footer): header
rows are drawn once before, footer rows once after, and body rows once per item.
Scopes. The body is filled in the item's scope, the header and footer in the enclosing scope.
A field is read from the innermost scope whose source declares it (itemFields); if that item has
no value for it, it prints the missing-item value (blank in the output, "—" in the editor) and adds
no unresolved entry, with no fallback to an outer scope. A field no scope declares
is a document field, asked of the provider.
Nested lists. An item value is a text or a list (ItemValue = string | RepeatData). A source
declares the lists its items hold in itemRepeats (nested descriptors), and its data names them
in RepeatData.itemRepeats. A repeating block inside a body, whose source an enclosing item
declares, reads that item's list: an absent list is an empty list, and the provider is never asked
for it. Nesting is bounded by MAX_REPEAT_NESTING (8); deeper data is dropped.
const provider: EditorDataProvider = {
resolveField: () => undefined,
listRepeatSources: () => [{
key: 'days', label: 'Days', layout: 'lines',
itemFields: [{ key: 'day.name', label: 'Day' }],
itemRepeats: [{ key: 'sessions', label: 'Sessions', itemFields: [{ key: 'session.title', label: 'Title' }] }],
}],
resolveRepeat: (source) => source === 'days'
? { items: [
{ 'day.name': 'Monday', sessions: { items: [{ 'session.title': 'Opening' }, { 'session.title': 'Workshop' }] } },
{ 'day.name': 'Tuesday', sessions: { items: [] } }, // an empty nested list; a missing key means the same
] }
: null,
}A sessions block placed in the body of a days block lists the sessions of each day.
Editing blocks in place
A repeating block or an embedded template is drawn from data, as one unit. To change it, open it in
place: double-click it, or select it (the arrows select it whole) and press Enter. It turns
into an editing area showing its template, fields as placeholders ({event.title}), under a bar:
- Done (✓, top right; or Ctrl+Enter / ⌘+Enter) applies the changes; Cancel (×; or Escape) discards them, without a question. Clicking elsewhere doesn't leave: the rest of the document waits (it is not editable, and pages are not re-paginated, until the area closes).
- The toolbar acts on the editing area. Undo and redo work inside it; after Done, the whole change
is one undo step in the document.
getDocument(),onChangeandonEditonly see the change on Done;editorRef.current.activeViewis the view the toolbar acts on. - A repeating block shows its three zones, captioned "Header", "Repeated for each item" and "Footer"; a missing header or footer is offered empty ("Click to add a header"), and dropped again if still empty on Done. "Insert field" offers the item fields of the block (and of the blocks around it, innermost first) before the document's fields. In a body table, header and footer rows are shown distinctly, and the Table menu can make the current row a header row, a footer row or a repeated row. Done writes the block back into the document.
- An embedded template: Done asks where the changes go. Update the shared template calls the
provider's
saveTemplate(id, content)with ProseMirror JSON of the editor schema (never HTML), then resolves the document again (every document embedding it changes); while it saves, the area can't be edited or left; if it fails, the area stays open with an alert. Make a copy for this document replaces the reference with the edited blocks (fields stay fields). Keep editing goes back. WithoutsaveTemplate, only the copy is offered. A template that didn't change closes without asking. - A block inside an editing area opens the same way, one level at a time. Opening another block cancels the open one. A read-only editor opens nothing. An embedded template inserted inside an editing area is resolved on Done (until then it is drawn as not found and can't be opened).
Labels and styling
- No user-facing string is hard-coded: pass
labels(PartialLabels) to the editor; English defaults (DEFAULT_LABELS) fill the rest. The demo ships English, French and Dutch label sets. The editing area's strings are underinlineEditing(bar title, Done, Cancel, the template question, zone captions and placeholders, the save error, the hint), and the Table menu's row actions underrowZone. - The stylesheet uses
--ie-*custom properties only (colours, spacing, radii, control size, page format); set any of them on.ie-editoror an ancestor. Dark mode followsprefers-color-scheme, overridable withdata-theme="light|dark"on an ancestor. - Controls are 44 px by default and 32 px with a fine pointer that can hover
(
(hover: hover) and (pointer: fine)).
Accessibility
- The toolbar is a
role="toolbar"with a label; every icon button has anaria-labelandaria-pressedfor marks and alignment; alignment buttons form a labelledrole="group". Its name also shows in a tooltip on hover or keyboard focus, which stays while pointed at and closes with Escape. - Menus follow the WAI-ARIA menu-button pattern (arrows, Home/End, Escape returns focus, Tab
leaves); grouped fields are
role="group"sections. - The canvas is a labelled
role="textbox"witharia-multiline. Mod-B / I / U, Mod-Z / Y, Tab / Shift-Tab between table cells. A repeating block or an embedded template is one unit: the arrows select it whole, and stop between two such blocks (a gap cursor) where typing or Enter starts a new line. - An editing area is a
role="group"named after its bar title ("Repeating block: Sessions"); its editor is arole="textbox"with the same name, described by the bar's hint (how to leave). Its zones are labelled groups. The bar's buttons are named by their text. Double-click has Enter as its keyboard equivalent; Escape cancels from the editor or the bar. - The raw expression of a tapped reference is announced under the toolbar (
aria-live): the touch alternative to the hover tooltip. Images are resized by buttons and a number field, never by dragging; their description is asked as soon as they are inserted.
Security
- The schema is the only parser of pasted or loaded HTML. There is no link, script or raw-HTML
node; every style value (colour, size, font, alignment, a cell's
borders, a table'slayout) is checked against a closed pattern; in pasted HTML, reference names (fields, sources, template ids, image ids) must match a bounded identifier pattern: letters, digits and_ . : @ / - [ ] = ' "— enough for a host to name a selection (person.contacts[kind==home].phone), never<,>,&, a space or a control character. The library does not read a name; the host's provider does. - Nothing is evaluated. A field is looked up by key; its value is inserted as a text node, never as HTML, in the editor and in both outputs.
- Images are references or embedded, never URLs. An image node holds an id the host's provider
maps to a source — a raster
data:URI (PNG, JPEG, GIF, WebP, within the size limit), ablob:URL or anhttps:URL without credentials; SVG and every other scheme are refused. Or it EMBEDS the image in itssrcattribute: then only a rasterdata:URI whose bytes match its declared type, within the size limit (safeEmbeddedImageSrc), checked wherever it is drawn or printed (imageOf), since a document's JSON is not sanitised when loaded; the provider is not asked. An<img src="https://…">in pasted HTML is dropped; a pasted embedded image keeps itsdata:source only under the same rule. A host embeds an image it picked or received by returning itssrcwith thePickedImage. Uploaded files are checked for declared type, size (2 MB by default) and actual content (magic numbers) before the host sees them. - Editing in place uses the same schema (pasted HTML goes through the same parser); a template
saved with
saveTemplateis ProseMirror JSON of that schema, never HTML. The demo's provider refuses content that doesn't fit the schema (isEditorDocument); a host should check it too. - Nested templates are inlined with cycle detection (a template met again on its own path, the edited document included) and a depth limit (5 by default).
- PDF: the pdfmake instance is locked down (no URL, no local file); images are embedded from
data:URIs only. - Standalone HTML carries a CSP (
default-src 'none', no script). The demo shows it in an empty-sandboxframe. - The document lives in memory; persisting it (and where) is the host's decision.
Develop
bun install
bun run build # the library: ESM bundle, declarations, styles.css
bun run typecheck
bun run test # unit tests (vitest + jsdom)
bun run dev # the demo on http://localhost:5179
bun run test:e2e # Playwright on the demo (Chromium)