@zuilib/text-editor 0.11.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +174 -18
  2. package/DRAWING_FORMAT.md +23 -21
  3. package/README.md +485 -67
  4. package/dist/chunk-32RX4GZF.js +2 -0
  5. package/dist/chunk-3TZVOXWX.js +16 -0
  6. package/dist/chunk-5VRVC56Y.js +2 -0
  7. package/dist/chunk-7Z3CW6Q3.js +2 -0
  8. package/dist/chunk-A7MCBSAZ.js +2 -0
  9. package/dist/chunk-AGTPMKLK.js +2 -0
  10. package/dist/chunk-C7VEINKX.js +2 -0
  11. package/dist/chunk-LDVPFZCN.js +2 -0
  12. package/dist/chunk-N5JWZ2VW.js +9 -0
  13. package/dist/chunk-QKHMJQO3.js +2 -0
  14. package/dist/chunk-R5PL4Q7X.js +5 -0
  15. package/dist/chunk-SDICQI2B.js +2 -0
  16. package/dist/chunk-TGVEPLDM.js +1 -0
  17. package/dist/chunk-XP3MZRCW.js +2 -0
  18. package/dist/chunk-XRJNLGI4.js +2 -0
  19. package/dist/chunk-YFFSTMIR.js +2 -0
  20. package/dist/chunk-ZXZMYJ7F.js +2 -0
  21. package/dist/comment-markers-BrrrAdSL.d.ts +27 -0
  22. package/dist/comments.d.ts +68 -0
  23. package/dist/comments.js +2 -0
  24. package/dist/drawing-data-EWzEQ1_i.d.ts +111 -0
  25. package/dist/drawing-preset-Ag7a7F9h.d.ts +69 -0
  26. package/dist/drawing.d.ts +7 -0
  27. package/dist/drawing.js +2 -0
  28. package/dist/image-node-kR1Zf5kz.d.ts +70 -0
  29. package/dist/images.d.ts +66 -0
  30. package/dist/images.js +2 -0
  31. package/dist/index-A4S7bRwQ.d.ts +425 -0
  32. package/dist/index.d.ts +36 -1394
  33. package/dist/index.js +1 -7449
  34. package/dist/lexical.d.ts +117 -0
  35. package/dist/lexical.js +2 -0
  36. package/dist/markdown.d.ts +29 -0
  37. package/dist/markdown.js +2 -0
  38. package/dist/mention-node-eSsjUpaE.d.ts +44 -0
  39. package/dist/mentions-plugin-gio3_XKd.d.ts +74 -0
  40. package/dist/mentions.d.ts +5 -0
  41. package/dist/mentions.js +2 -0
  42. package/dist/mermaid-BwGHjwpk.d.ts +553 -0
  43. package/dist/paste.d.ts +45 -0
  44. package/dist/paste.js +2 -0
  45. package/dist/presets-BtNPH0pa.d.ts +78 -0
  46. package/dist/styles.css +402 -95
  47. package/dist/table-grid-D70tNhp2.d.ts +459 -0
  48. package/package.json +52 -7
  49. package/dist/index.css +0 -79
package/README.md CHANGED
@@ -7,15 +7,16 @@ document outline, and collapsible sections.
7
7
 
8
8
  **Features**
9
9
 
10
- - **Three modes**: rich WYSIWYG (`edit-md`), raw markdown (`edit-raw`),
10
+ - **Three modes**: rich WYSIWYG (`edit-rich`), raw markdown (`edit-raw`),
11
11
  read-only render (`view`) — all driven by one controlled `value` string
12
12
  - **Markdown shortcuts** while typing: headings, lists, checklists,
13
- blockquotes, links, fenced code (with syntax highlighting), tables
13
+ blockquotes, links, fenced code (highlighted by the package's own lexer,
14
+ 16 grammars), tables
14
15
  - **Tables**: GFM pipe tables, edited in place (tab between cells, ranges,
15
16
  add / remove rows and columns from hover rails or the keyboard,
16
17
  inline formatting), styled like Claude artifacts
17
18
  - **Text measure and block width**: an opt-in readable text column
18
- (`measure="48rem"`); each table and diagram picks *full* / *text* /
19
+ (`maxTextWidth="48rem"`); each table and diagram picks *full* / *text* /
19
20
  *content* width, Slab-style; row density per table
20
21
  - **Diagrams**: a drawing canvas embedded in the document. Eight box
21
22
  shapes (rectangle, ellipse, diamond, note, database, cloud, queue, actor)
@@ -26,7 +27,11 @@ document outline, and collapsible sections.
26
27
  - **Outline**: optional table-of-contents sidebar with click-to-scroll and
27
28
  current-section highlight
28
29
  - **Section folding**: collapse everything under a heading, view-layer only
29
- - **Toolbar**: inline formatting plus insert-table / insert-drawing
30
+ - **Toolbar**: inline formatting and links, block types (headings, lists,
31
+ quote), insert-table / insert-drawing
32
+ - **Extension platform**: register your own Lexical nodes, markdown
33
+ transformers and plugins through props; `./markdown` entry without the
34
+ drawing canvas; every chrome string translatable through `labels`
30
35
  - YAML **frontmatter** block support (`---` at the top)
31
36
 
32
37
  Everything round-trips through the markdown string: tables as GFM,
@@ -36,13 +41,26 @@ frontmatter as `---` blocks. Feature history: [CHANGELOG](./CHANGELOG.md).
36
41
  ## Installation
37
42
 
38
43
  ```bash
39
- pnpm add @zuilib/text-editor @zuilib/tokens \
44
+ pnpm add @zuilib/text-editor @zuilib/tokens tailwindcss \
40
45
  lexical @lexical/react @lexical/markdown @lexical/rich-text \
41
46
  @lexical/code @lexical/list @lexical/link @lexical/table @lexical/utils
42
47
  ```
43
48
 
44
49
  All `lexical`/`@lexical/*` packages are peer dependencies at `^0.35.0`;
45
- `react`/`react-dom` at `^18 || ^19`.
50
+ `react`/`react-dom` at `^18 || ^19`. Document typography (headings, lists,
51
+ inline code) is Tailwind v4 utility classes, so the host needs `tailwindcss`
52
+ and must scan this package from its CSS entry:
53
+
54
+ ```css
55
+ @import "tailwindcss";
56
+ @import "@zuilib/tokens/styles.css";
57
+ @source "../node_modules/@zuilib/text-editor/dist";
58
+ @import "@zuilib/text-editor/styles.css";
59
+ ```
60
+
61
+ Without Tailwind, load `@zuilib/tokens/tokens.css` instead and style the
62
+ document block classes yourself; the class list is on the
63
+ [Theming](https://zuilib.com/docs/text-editor/theming#block-classes) page.
46
64
 
47
65
  ## Quick start
48
66
 
@@ -54,40 +72,58 @@ import { MarkdownEditor } from '@zuilib/text-editor'
54
72
 
55
73
  function Notes() {
56
74
  const [value, setValue] = useState('# Hello\n\n- [ ] Try checklists')
57
- return <MarkdownEditor value={value} onChange={setValue} outline />
75
+ return <MarkdownEditor value={value} onValueChange={setValue} outline />
58
76
  }
59
77
  ```
60
78
 
61
- The component is controlled: pass `value` and persist what `onChange`
62
- emits. The emitted string is always plain markdown.
79
+ The component is controlled: `value` is the document. `onValueChange` fires on
80
+ every keystroke with the full markdown string; passing a different `value`
81
+ back in (including `''`) replaces the document, and passing back the string
82
+ just emitted is a no-op. Persisting on every call is usually too often, so
83
+ debounce the write to your store; keep the React state update synchronous.
63
84
 
64
85
  ## Props (`MarkdownEditorProps`)
65
86
 
66
87
  | Prop | Type | Default | Description |
67
88
  |------|------|---------|-------------|
68
- | `value` | `string` | `''` | Markdown source (controlled) |
69
- | `onChange` | `(value: string) => void` | | Called on every edit |
70
- | `mode` | `'edit-md' \| 'edit-raw' \| 'view'` | `'edit-md'` | Editing surface |
71
- | `placeholder` | `string` | `'Start writing...'` | Empty-state hint |
89
+ | `value` | `string` | | Markdown source (controlled). Omit for an uncontrolled editor |
90
+ | `defaultValue` | `string` | `''` | Initial markdown of an uncontrolled editor |
91
+ | `onValueChange` | `(value: string) => void` | | Called on every edit (see `debounceMs`) |
92
+ | `debounceMs` | `number` | `0` | Milliseconds to wait after the last edit before `onValueChange` fires; the document itself is never delayed, and a pending call is flushed when the editor unmounts |
93
+ | `mode` | `'edit-rich' \| 'edit-raw' \| 'view'` | `'edit-rich'` | Editing surface |
94
+ | `placeholder` | `string` | `labels.placeholder` | Empty-state hint |
72
95
  | `readOnly` | `boolean` | `false` | Disables editing in Lexical modes |
73
96
  | `autoFocus` | `boolean` | `false` | Focus on mount |
74
97
  | `className` | `string` | — | Root wrapper class |
75
- | `toolbar` | `boolean \| (items) => ReactNode` | `true` | Formatting/insert toolbar (`edit-md`); function form customises it |
98
+ | `toolbar` | `boolean \| (items) => ReactNode` | `true` | Formatting/insert toolbar (`edit-rich`); function form customises it |
76
99
  | `outline` | `boolean` | `false` | Table-of-contents sidebar |
77
- | `foldable` | `boolean` | `true` | Collapse sections under headings |
78
- | `measure` | `string` | — | Max width of the text column, a CSS length (`'48rem'` recommended). Sugar for `--zui-text-editor-measure` |
79
- | `defaultBlockWidth` | `{ table?, drawing? }` | — | `BlockWidth` written when the toolbar inserts a table / drawing |
100
+ | `collapsible` | `boolean` | `true` | Collapse sections under headings |
101
+ | `maxTextWidth` | `string` | — | Max width of the text column, a CSS length (`'48rem'` recommended). Sugar for `--zui-text-editor-measure` |
102
+ | `newBlockWidth` | `{ table?, drawing? }` | — | `BlockWidth` written when the toolbar inserts a table / drawing |
103
+ | `drawingStyle` | `'clean' \| 'ink'` | `'clean'` | Diagram rendering style; `ink` draws shapes as seeded pen strokes |
104
+ | `onError` | `(error: Error) => void` | `console.error` | Called when Lexical throws inside an update |
105
+ | `nodes` | `Klass<LexicalNode>[]` | — | Extra node classes, appended to the built-ins. Mount-time only |
106
+ | `transformers` | `Transformer[] \| (defaults) => Transformer[]` | — | Extra markdown transformers ([Extending](#extending)). Mount-time only |
107
+ | `nodeClassNames` | `EditorThemeClasses` | — | Class names for the rendered nodes, merged over the built-ins one level deep. Mount-time only |
108
+ | `onEditorReady` | `(editor: LexicalEditor) => void` | — | Imperative access to the Lexical instance |
109
+ | `labels` | `DeepPartial<EditorLabels>` | English | Every chrome string ([Localisation](#localisation)) |
110
+ | `maxLength` | `number` | — | Plain-text limit; input past it is rejected at the caret. A controlled `value` is never trimmed |
111
+ | `onCharacterCountChange` | `(length: number) => void` | — | Plain-text length after every change and once on mount |
112
+ | `showCharacterCount` | `boolean` | `false` | Character counter under the document (`length / maxLength`) |
113
+ | `id`, `name`, `aria-label`, `aria-labelledby`, `aria-describedby`, `aria-invalid`, `aria-required` | | — | Forwarded to the editable surface ([Form integration](#form-integration)) |
114
+ | `children` | `ReactNode` | — | Extra plugins or UI rendered under the root |
80
115
 
81
116
  ### Modes
82
117
 
83
118
  | `mode` | UI | Notes |
84
119
  |--------|-----|-------|
85
- | `'edit-md'` | Lexical rich editor | Shortcuts, toolbar, tables, canvas |
120
+ | `'edit-rich'` | Lexical rich editor | Shortcuts, toolbar, tables, canvas |
86
121
  | `'edit-raw'` | Plain `<textarea>` | Direct markdown source editing |
87
122
  | `'view'` | Read-only render | Outline/folding still work |
88
123
 
89
- Switching `edit-raw` → `edit-md` re-mounts Lexical with the latest text
90
- (cursor-safe).
124
+ Switching `edit-raw` → `edit-rich` re-imports the latest text into Lexical.
125
+ In `edit-raw` the textarea keeps its own keystrokes whether or not `value`
126
+ is given.
91
127
 
92
128
  ## Tables
93
129
 
@@ -169,7 +205,7 @@ frontmatter are capped at that width and centred in the pane, while each
169
205
  table and drawing chooses its own width.
170
206
 
171
207
  ```tsx
172
- <MarkdownEditor value={value} onChange={setValue} measure="48rem" />
208
+ <MarkdownEditor value={value} onValueChange={setValue} maxTextWidth="48rem" />
173
209
  ```
174
210
 
175
211
  The prop is sugar for the CSS custom property `--zui-text-editor-measure`,
@@ -180,7 +216,7 @@ which is the actual contract — set it in a stylesheet on `.zui-text-editor`
180
216
  .zui-text-editor { --zui-text-editor-measure: 48rem; }
181
217
  ```
182
218
 
183
- **Adopting:** set `measure` (or the variable) and delete any external
219
+ **Adopting:** set `maxTextWidth` (or the variable) and delete any external
184
220
  `max-width` / `margin-inline` overrides on `.zui-text-editor-content > *`;
185
221
  the library now owns that layout.
186
222
 
@@ -193,13 +229,13 @@ toolbar or the right end of the drawing toolbar:
193
229
  | `text` | The text column: edges align with the paragraphs. Identical to `full` until a measure is set | `<!-- width: text -->` / `"width":"text"` |
194
230
  | `content` | Shrinks to its columns / shapes, left-aligned with the text, never wider than the column | `<!-- width: content -->` / `"width":"content"` |
195
231
 
196
- Absence of a marker always means `full`, in every app. `defaultBlockWidth`
232
+ Absence of a marker always means `full`, in every app. `newBlockWidth`
197
233
  only changes what the toolbar / `insertTable` / `insertDrawing` write into
198
234
  *new* blocks — and that value is written explicitly, even when it is
199
235
  `full`, so the document reads the same elsewhere:
200
236
 
201
237
  ```tsx
202
- <MarkdownEditor measure="48rem" defaultBlockWidth={{ table: 'text', drawing: 'text' }} … />
238
+ <MarkdownEditor maxTextWidth="48rem" newBlockWidth={{ table: 'text', drawing: 'text' }} … />
203
239
  ```
204
240
 
205
241
  Headless: `useMarkdownEditor().blockWidth` / `setBlockWidth` act on the
@@ -222,9 +258,19 @@ keeps every table inside the column. Block classes: `.zui-paragraph`,
222
258
 
223
259
  ## Toolbar & custom toolbars
224
260
 
225
- In `edit-md` mode a toolbar offers inline formatting (bold, italic,
226
- strikethrough, inline code) plus **Insert table** and **Insert drawing**.
227
- Hide it with `toolbar={false}`.
261
+ In `edit-rich` mode the toolbar has three groups: **format** (bold, italic,
262
+ strikethrough, inline code, link), **block** (heading 1 to 3, bullet,
263
+ numbered and check list, quote; pressing the active one returns to a
264
+ paragraph) and **insert** (table, drawing). Hide it with `toolbar={false}`.
265
+
266
+ The link button needs a non-empty selection (it is disabled for a bare
267
+ caret) and opens a small bubble under it with a URL input, apply and
268
+ remove; nothing is written to the document until a URL is applied, so
269
+ Escape, moving the caret or leaving the bubble abandons the link without
270
+ an `onValueChange`. The bubble also opens when the caret enters an existing
271
+ link. URLs are validated: `javascript:`, `data:` and other script-capable
272
+ schemes are refused (`isSafeUrl`, `SAFE_LINK_PROTOCOLS`), both here and
273
+ for pasted links (an HTML paste keeps only the text of an unsafe `href`).
228
274
 
229
275
  ### Extending the toolbar
230
276
 
@@ -236,11 +282,13 @@ clicked.
236
282
  ```tsx
237
283
  <MarkdownEditor
238
284
  value={value}
239
- onChange={setValue}
285
+ onValueChange={setValue}
240
286
  toolbar={(items) => (
241
287
  <MarkdownEditor.Toolbar>
242
288
  {items.format}
243
289
  <MarkdownEditor.ToolbarDivider />
290
+ {items.block}
291
+ <MarkdownEditor.ToolbarDivider />
244
292
  {items.insert}
245
293
  <MarkdownEditor.ToolbarDivider />
246
294
  {items.history}
@@ -253,6 +301,10 @@ clicked.
253
301
  ```
254
302
 
255
303
  `MarkdownEditor.Toolbar` hides itself in `view` / `edit-raw` / `readOnly`.
304
+ `items` has `format`, `block`, `insert` and `history` (undo / redo, only
305
+ in this function form). Buttons made with `ToolbarButton` take part in the
306
+ toolbar's arrow-key navigation (`data-toolbar-item`); other elements you
307
+ put in the toolbar keep their own focus handling.
256
308
 
257
309
  ### Placing your own toolbar (compound components)
258
310
 
@@ -262,7 +314,7 @@ panel header), compose the editor from its parts. Everything under
262
314
  anywhere in that subtree.
263
315
 
264
316
  ```tsx
265
- <MarkdownEditor.Root value={value} onChange={setValue}>
317
+ <MarkdownEditor.Root value={value} onValueChange={setValue}>
266
318
  <header className="app-bar">
267
319
  <MarkdownEditor.Toolbar>
268
320
  <MarkdownEditor.FormatButtons />
@@ -279,12 +331,13 @@ anywhere in that subtree.
279
331
 
280
332
  | Part | Role |
281
333
  |------|------|
282
- | `Root` | Lexical composer + plugins. Takes `value`, `onChange`, `mode`, `readOnly`, `autoFocus`, `className` |
283
- | `Content` | The editable surface. Takes `placeholder`, `foldable`; children are docked sidebars |
334
+ | `Root` | Lexical composer + plugins. Takes every prop of `MarkdownEditor` except `placeholder`, `toolbar`, `outline`, `collapsible`, `showCharacterCount` |
335
+ | `Content` | The editable surface. Takes `placeholder`, `collapsible`, `showCharacterCount`; children are docked sidebars |
284
336
  | `Toolbar` | Container; renders the default groups when empty |
285
- | `FormatButtons`, `InsertButtons`, `HistoryButtons` | Built-in groups |
337
+ | `FormatButtons`, `BlockButtons`, `InsertButtons`, `HistoryButtons` | Built-in groups (`InsertButtons drawing={false}` hides the drawing button) |
286
338
  | `ToolbarButton`, `ToolbarDivider` | Primitives for your own items |
287
339
  | `Outline` | Table-of-contents sidebar |
340
+ | `CharacterCount` | Character counter, for placing it yourself |
288
341
 
289
342
  ### Headless: `useMarkdownEditor()`
290
343
 
@@ -303,7 +356,10 @@ function BoldButton() {
303
356
  ```
304
357
 
305
358
  It returns `editor` (the Lexical instance), `activeFormats`,
306
- `toggleFormat`, `insertTable`, `insertDrawing`, `blockWidth` /
359
+ `toggleFormat`, `blockType` / `setBlockType` (`paragraph`, `h1`..`h6`,
360
+ `quote`, `bullet`, `number`, `check`), `link` / `setLink(url | null)` /
361
+ `hasSelection` (`setLink` with a URL is a no-op for a collapsed caret
362
+ outside a link), `insertTable`, `insertDrawing`, `blockWidth` /
307
363
  `setBlockWidth` (width of the table or drawing containing the selection,
308
364
  `null` outside both), `tableDensity` / `setTableDensity` (`null` outside
309
365
  tables), `tableCell` / `insertTableRow` / `deleteTableRow` / `insertTableColumn` /
@@ -313,7 +369,7 @@ tables), `tableCell` / `insertTableRow` / `deleteTableRow` / `insertTableColumn`
313
369
  ## Diagrams (drawing canvas)
314
370
 
315
371
  The toolbar's insert-drawing button embeds a canvas; the drawing persists
316
- in the markdown as a ` ```drawing ` fenced JSON block (format version 2),
372
+ in the markdown as a ` ```drawing ` fenced JSON block (format version 3),
317
373
  fully specified in **[DRAWING_FORMAT.md](./DRAWING_FORMAT.md)**.
318
374
 
319
375
  - **Shapes**: rectangle, ellipse, diamond, note (sticky note), database
@@ -355,10 +411,10 @@ A stored drawing looks like this:
355
411
 
356
412
  ````md
357
413
  ```drawing
358
- {"version":2,"canvasHeight":260,"shapes":[
359
- {"id":"web","type":"rect","x":40,"y":70,"w":170,"h":100,"stroke":"#1971c2","fill":"#a5d8ff","strokeWidth":2,"label":"CLIENT","text":"Web App"},
360
- {"id":"api","type":"rect","x":330,"y":70,"w":170,"h":100,"stroke":"#2f9e44","fill":"#b2f2bb","strokeWidth":2,"label":"SERVICE","text":"API"},
361
- {"id":"e1","type":"arrow","x":216,"y":120,"w":108,"h":0,"stroke":"#1e1e1e","fill":"transparent","strokeWidth":2,"startBinding":{"id":"web"},"endBinding":{"id":"api"},"text":"REST"}
414
+ {"version":3,"canvasHeight":260,"shapes":[
415
+ {"id":"web","type":"rect","x":40,"y":70,"width":170,"height":100,"stroke":"#1971c2","fill":"#a5d8ff","strokeWidth":2,"label":"CLIENT","text":"Web App"},
416
+ {"id":"api","type":"rect","x":330,"y":70,"width":170,"height":100,"stroke":"#2f9e44","fill":"#b2f2bb","strokeWidth":2,"label":"SERVICE","text":"API"},
417
+ {"id":"e1","type":"arrow","x":216,"y":120,"width":108,"height":0,"stroke":"#1e1e1e","fill":"transparent","strokeWidth":2,"startBinding":{"id":"web"},"endBinding":{"id":"api"},"text":"REST"}
362
418
  ]}
363
419
  ```
364
420
  ````
@@ -386,7 +442,7 @@ expansion).
386
442
 
387
443
  The full skeleton spec (attach sides, explicit positions, free texts) is
388
444
  in [DRAWING_FORMAT.md](./DRAWING_FORMAT.md). Programmatic access:
389
- `parseDrawingSkeleton`, `expandSkeleton`, `DRAWING_SKELETON_JSON_SCHEMA`.
445
+ `parseDrawingSkeleton`, `expandDrawingSkeleton`, `DRAWING_SKELETON_JSON_SCHEMA`.
390
446
 
391
447
  ### Theming the canvas
392
448
 
@@ -427,13 +483,279 @@ host theme in light and dark mode. Override any of them on
427
483
 
428
484
  `outline` docks a collapsible table-of-contents sidebar: live heading list
429
485
  indented by level, click to scroll, current section highlighted. Not part
430
- of the document — pure UI. Available in `edit-md` and `view`.
486
+ of the document — pure UI. Available in `edit-rich` and `view`.
431
487
 
432
- `foldable` (default on) shows a chevron in the gutter of each heading on
488
+ `collapsible` (default on) shows a chevron in the gutter of each heading on
433
489
  hover; clicking collapses the section (until the next heading of the same
434
490
  or higher level). Folding never changes the markdown; fold state resets on
435
491
  remount; a folded section auto-expands if the cursor enters it.
436
492
 
493
+ ## Extending
494
+
495
+ The editor is a Lexical composer with a fixed set of nodes and markdown
496
+ transformers. Three props open it up; a first-party plugin (mentions,
497
+ comments, images) is built the same way a host would build one.
498
+
499
+ ```tsx
500
+ import { MarkdownEditor, useMarkdownEditor, DRAWING_PRESET_TRANSFORMERS } from '@zuilib/text-editor'
501
+ import { MentionNode, MENTION } from './mention'
502
+
503
+ function MentionPlugin() {
504
+ const { editor } = useMarkdownEditor()
505
+ useEffect(() => editor.registerCommand(INSERT_MENTION_COMMAND, ...), [editor])
506
+ return null
507
+ }
508
+
509
+ <MarkdownEditor
510
+ value={value}
511
+ onValueChange={setValue}
512
+ nodes={[MentionNode]}
513
+ transformers={[MENTION]}
514
+ nodeClassNames={{ mention: 'zui-mention' }}
515
+ onEditorReady={(editor) => (editorRef.current = editor)}
516
+ >
517
+ <MentionPlugin />
518
+ </MarkdownEditor>
519
+ ```
520
+
521
+ | Prop | What it does |
522
+ |------|--------------|
523
+ | `nodes` | Appended to the built-in node list (`DRAWING_PRESET_NODES`; `MARKDOWN_NODES` on `./markdown`). Lexical registers node classes when the composer is created, so the prop is read on mount only; change the `key` to remount with a different set |
524
+ | `transformers` | An array goes **before** the built-ins, so it claims its syntax first on import and export; a function receives the built-in list and returns the whole list (`(defaults) => [MENTION, ...defaults.filter(t => t !== CODE_BLOCK)]`). Mount-time only. Everything that is not a `multiline-element` transformer (and not `CHECK_LIST`) is also offered as a typing shortcut |
525
+ | `children` (plugins) | Anything under `MarkdownEditor` / `MarkdownEditor.Root` shares the editor: a component calling `useMarkdownEditor()` or `useLexicalComposerContext()` is a plugin |
526
+ | `nodeClassNames` | Class names merged over `editorNodeClassNames` one level deep (`{ heading: { h1: 'x' } }` keeps `h2`); mount-time only |
527
+ | `onEditorReady` | Called with the `LexicalEditor` once it exists |
528
+
529
+ The built-in transformer order is exported as `DRAWING_PRESET_TRANSFORMERS` (default
530
+ entry) and `MARKDOWN_TRANSFORMERS` (`./markdown`): `FRONTMATTER`, `DRAWING`,
531
+ `DIAGRAM`, `LISTS`, `CHECK_LIST`, `TABLE`, `CODE_BLOCK`, then Lexical's
532
+ `TRANSFORMERS` without `CODE`. Use the same list in a headless editor so
533
+ import and export match the component byte for byte.
534
+
535
+ ### Entry points
536
+
537
+ | Import | Contents |
538
+ |--------|----------|
539
+ | `@zuilib/text-editor` | Everything: the editor with the drawing canvas, all nodes, transformers and helpers |
540
+ | `@zuilib/text-editor/markdown` | The editor without drawings: no `DrawingNode`, no ` ```drawing ` / ` ```diagram ` transformers, no insert-drawing button, no canvas code in the bundle. `MarkdownEditor`, `useMarkdownEditor`, the toolbar parts, tables, code highlighting, labels |
541
+ | `@zuilib/text-editor/lexical` | The Lexical-facing escape hatch for plugin authors: presets (`MARKDOWN_PRESET`, `DRAWING_PRESET`), node classes, transformers, `$`-prefixed helpers, `createMarkdownEditor`, the tokenizer plumbing |
542
+ | `@zuilib/text-editor/drawing` | The drawing canvas on its own: `DrawingNode`, `DRAWING`, `DIAGRAM`, `DrawingPlugin`, `DRAWING_PRESET`, the payload and skeleton helpers |
543
+ | `@zuilib/text-editor/mentions`, `./comments`, `./images`, `./paste` | The first-party plugins ([Plugins](#plugins)); plugin components also on the default entry, node classes and `$`-helpers on the plugin entry and `./lexical` |
544
+
545
+ `./markdown` plus `./drawing` reassembles the default entry:
546
+
547
+ ```tsx
548
+ import { MarkdownEditor } from '@zuilib/text-editor/markdown'
549
+ import { DrawingNode, DRAWING, DIAGRAM, DrawingPlugin } from '@zuilib/text-editor/drawing'
550
+
551
+ <MarkdownEditor.Root value={value} onValueChange={setValue} nodes={[DrawingNode]} transformers={[DRAWING, DIAGRAM]}>
552
+ <MarkdownEditor.Content />
553
+ <DrawingPlugin />
554
+ </MarkdownEditor.Root>
555
+ ```
556
+
557
+ `createMarkdownEditor(preset)` (on `./lexical`) builds a `MarkdownEditor`
558
+ (with its compound parts) around an `EditorPreset`
559
+ (`{ nodes, transformers, plugins? }`) for packages that ship their own
560
+ bundle of extensions.
561
+
562
+ ### Localisation
563
+
564
+ `labels` takes a deep partial of `EditorLabels` (exported, English
565
+ defaults in `DEFAULT_LABELS`): `placeholder`, `toolbar.*`, `outline.*`,
566
+ `collapsibleHeadings.*`, `table.*`, `layout.*`, `drawing.*`, `count` and
567
+ `limitExceeded` / `limitRestored` (the counter's limit announcements).
568
+ Strings with a number are functions (`table.deleteRow(3)`,
569
+ `drawing.deleteShapes(2)`).
570
+ Document content is never translated.
571
+
572
+ ```tsx
573
+ <MarkdownEditor
574
+ labels={{
575
+ placeholder: 'Commencez à écrire…',
576
+ toolbar: { bold: 'Gras', insertTable: 'Insérer un tableau' },
577
+ drawing: { deleteShapes: (n) => `Supprimer ${n} formes` },
578
+ }}
579
+ />
580
+ ```
581
+
582
+ `useLabels()` returns the resolved labels inside the editor, for your own
583
+ plugins.
584
+
585
+ ## Plugins
586
+
587
+ Four first-party plugins ship in the package. Each is opt-in: import it
588
+ from its own entry point (or from the default entry), pass its node and
589
+ transformer to the root and mount the plugin as a child. `./markdown` does not
590
+ include them. The one stylesheet covers all four. See the [plugins
591
+ page](https://zuilib.com/docs/text-editor/plugins) for live examples.
592
+
593
+ | Entry | Node / transformer | Plugin | Markdown |
594
+ |-------|--------------------|--------|----------|
595
+ | `@zuilib/text-editor/mentions` | `MentionNode`, `MENTION` | `MentionsPlugin` | `[@name](mention:id)` |
596
+ | `@zuilib/text-editor/comments` | `MarkNode` (from `@lexical/mark`), `COMMENT` | `CommentsPlugin` | `<!-- zui:comment id -->text<!-- /zui:comment -->` |
597
+ | `@zuilib/text-editor/images` | `ImageNode`, `IMAGE` | `ImagesPlugin`, `ImageButton` | `![alt](src)`, `![alt](src "title")` |
598
+ | `@zuilib/text-editor/paste` | none | `PastePlugin` | unchanged |
599
+
600
+ ### Mentions
601
+
602
+ ```tsx
603
+ import { MentionNode, MENTION, MentionsPlugin } from '@zuilib/text-editor/mentions'
604
+
605
+ <MarkdownEditor value={value} onValueChange={setValue} nodes={[MentionNode]} transformers={[MENTION]}>
606
+ <MentionsPlugin search={(query) => api.people(query)} />
607
+ </MarkdownEditor>
608
+ ```
609
+
610
+ Typing the trigger at the start of a word opens a `role="listbox"` under
611
+ the caret with the `search(query)` results (called on every keystroke;
612
+ stale results are dropped). Arrow keys move, Enter or Tab inserts the
613
+ highlighted item as a `MentionNode` followed by a space, Escape closes
614
+ until the query changes. While the list is open the surface carries
615
+ `aria-controls` and `aria-activedescendant`. Enter with no results is a
616
+ normal Enter. The list never opens in `readOnly` mode, in inline code or
617
+ inside another mention; the query stops at whitespace and 40 characters.
618
+
619
+ | Prop | Description |
620
+ |------|-------------|
621
+ | `trigger` | One non-word character (default `'@'`) |
622
+ | `search` | `(query) => Promise<MentionItem[]>`; an item is `{ id, name, hint? }` |
623
+ | `render` | `(item, { active, query, trigger }) => ReactNode` for the option body; the option element, its role and selection state stay the plugin's |
624
+ | `triggers` | `[{ trigger, search, render? }]` for several triggers (`@` people, `#` tags); replaces the three props above |
625
+ | `maxItems` | Options listed at most (default 8) |
626
+ | `labels` | `suggestions(trigger)` (listbox `aria-label`), `noResults`, `loading` |
627
+ | `onSelect` | `(item, trigger)` after an insertion |
628
+
629
+ The node is a token text node: the caret cannot enter it, Backspace
630
+ removes it whole, its text is the trigger plus the name. Markdown writes
631
+ `[@name](mention:id)`, a link other renderers show as text with a
632
+ `mention:` href; a trigger must be a single non-word character for the
633
+ transformer to claim it. `]` and `\` in the name are backslash-escaped
634
+ and `%`, `)` and whitespace in the id percent-encoded, so any host values
635
+ round-trip. `INSERT_MENTION_COMMAND` (`{ id, name, trigger? }`)
636
+ inserts a mention at the selection without the menu. The theme key is
637
+ `mention` (`zui-mention` by default), the element carries
638
+ `data-mention="<id>"` and `data-slot="mention"`.
639
+
640
+ ### Comments
641
+
642
+ ```tsx
643
+ import { MarkNode, COMMENT, CommentsPlugin } from '@zuilib/text-editor/comments'
644
+
645
+ <MarkdownEditor value={value} onValueChange={setValue} nodes={[MarkNode]} transformers={[COMMENT]}>
646
+ <CommentsPlugin
647
+ comments={comments}
648
+ onAdd={(c) => setComments([...comments, c])}
649
+ onResolve={(id, resolved) => update(id, { resolved })}
650
+ onDelete={(id) => remove(id)}
651
+ />
652
+ </MarkdownEditor>
653
+ ```
654
+
655
+ The document owns the ranges, the host owns everything else. Selecting
656
+ text shows a floating "Add comment" button above it; the button or
657
+ `Mod+Shift+M` wraps the selection in a `MarkNode` with a fresh id and
658
+ calls `onAdd({ id, quote, range, resolved: false })`, then puts the caret
659
+ at the end of the range. With the caret inside a commented range (or right
660
+ after it) a `role="group"` bubble under it lists the comments there with
661
+ resolve / reopen and delete; `Mod+Shift+M` there moves focus into the
662
+ bubble and Escape returns it to the surface. Delete removes the id from
663
+ the document before `onDelete(id)`; resolve only calls
664
+ `onResolve(id, resolved)`, the host decides.
665
+
666
+ | Prop | Description |
667
+ |------|-------------|
668
+ | `comments` | `{ id, quote, range?, resolved? }[]`; a marker whose id is missing here is listed with `labels.unknown(id)` and only offers delete |
669
+ | `onAdd`, `onResolve`, `onDelete` | See above |
670
+ | `createId` | Id generator (default `crypto.randomUUID`) |
671
+ | `labels` | `add`, `comments` (bubble `aria-label`), `resolve`, `reopen`, `remove`, `resolved`, `unknown(id)` |
672
+ | `renderComment` | `(comment, { resolve, remove }) => ReactNode` replaces an entry's body |
673
+
674
+ `range` is `{ start, end }` in `$getRoot().getTextContent()` at the time
675
+ the comment was added; it is informational and not updated by later
676
+ edits, the mark is the source of truth. Markdown writes
677
+ `<!-- zui:comment id -->text<!-- /zui:comment -->`, which every other
678
+ renderer hides, so the plain document stays readable. A range spanning
679
+ several blocks is one marker pair per block with the same id;
680
+ overlapping comments share a pair with the ids comma-separated
681
+ (`zui:comment a,b`). Mark elements get `zui-comment`, `data-comment-ids`
682
+ and `is-resolved` when every id on them is resolved; the colour reads
683
+ `--zui-comment-color` (falls back to `--warning`). Helpers inside
684
+ `editor.update` / `read`: `$getMarkNodes()`, `$getCommentIds()`,
685
+ `$removeComment(id)`, `$plainTextOffset(node)`, plus
686
+ `formatCommentMarker`, `parseCommentIds`, `ADD_COMMENT_COMMAND` and
687
+ `COMMENTS_THEME`. Peer dependency: `@lexical/mark`.
688
+
689
+ ### Images
690
+
691
+ ```tsx
692
+ import { ImageNode, IMAGE, ImagesPlugin, ImageButton } from '@zuilib/text-editor/images'
693
+
694
+ <MarkdownEditor
695
+ value={value}
696
+ onValueChange={setValue}
697
+ nodes={[ImageNode]}
698
+ transformers={[IMAGE]}
699
+ toolbar={(items) => (
700
+ <MarkdownEditor.Toolbar>
701
+ {items.format}
702
+ {items.insert}
703
+ <ImageButton />
704
+ </MarkdownEditor.Toolbar>
705
+ )}
706
+ >
707
+ <ImagesPlugin upload={(file) => api.upload(file)} maxSize={5 * 1024 * 1024} onError={toast} />
708
+ </MarkdownEditor>
709
+ ```
710
+
711
+ Files pasted or dropped on the surface (Lexical's `DRAG_DROP_PASTE`), or
712
+ chosen through `ImageButton`, are checked against `accept` (default any
713
+ `image/*`) and `maxSize` (bytes), then a progress placeholder
714
+ (`role="progressbar"`) stands at the caret until `upload(file)` resolves
715
+ with `{ src, alt? }`. The placeholder exports as nothing, so a value read
716
+ mid-upload has no half image. A rejected promise removes the placeholder.
717
+ `onError` receives `{ type: 'unsupported' | 'too-large' | 'upload' | 'unsafe-src', ... }`.
718
+ Sources must pass `isSafeUrl` (`http:`, `https:`, relative paths; no
719
+ `data:` or `blob:`), on import, on insertion and on upload results.
720
+ Whitespace and parentheses in a source are percent-encoded on the node
721
+ (markdown destinations end at them), and `]` in alt text and `"` in
722
+ titles are backslash-escaped, so any uploaded file name round-trips.
723
+
724
+ Clicking an image selects it (Backspace removes it) and shows an alt-text
725
+ input under it; Enter or Escape commits and returns to the surface. The
726
+ image is inline; a paragraph holding only an image renders it as a block.
727
+ Markdown is `![alt](src)` or `![alt](src "title")`. Commands:
728
+ `INSERT_IMAGE_COMMAND` (`{ src, alt?, title? }`) and
729
+ `UPLOAD_IMAGES_COMMAND` (`File[]`). Labels: `altText`, `altPlaceholder`,
730
+ `uploading`, `insertImage`. Theme key `image` (`zui-image`).
731
+
732
+ ### Paste normalisation
733
+
734
+ ```tsx
735
+ import { PastePlugin } from '@zuilib/text-editor/paste'
736
+
737
+ <MarkdownEditor value={value} onValueChange={setValue}>
738
+ <PastePlugin />
739
+ </MarkdownEditor>
740
+ ```
741
+
742
+ HTML from Word, Google Docs, Confluence and Outlook is cleaned before the
743
+ built-in paste converts it: headings, paragraphs, lists (Word's
744
+ `MsoListParagraph` runs become real `ul` / `ol`), tables, links, code,
745
+ quotes, emphasis (`b`, `i` and styled spans become `strong`, `em`, `s`),
746
+ images, `br` and `hr` stay; styles, classes, ids, spans, fonts, Office
747
+ namespaces, conditional comments, scripts, styles, forms, iframes and SVG
748
+ go. Plain-text pastes and pastes into an input are untouched.
749
+
750
+ | Prop | Description |
751
+ |------|-------------|
752
+ | `when` | `'rich-sources'` (default) acts only on HTML with an Office / Docs / Confluence fingerprint (`isRichSourceHtml`); `'always'` cleans every HTML paste |
753
+ | `tables`, `links`, `images` | `false` flattens cells to paragraphs, keeps link text only, drops images |
754
+ | `transform` | `(html) => html` runs on the cleaned HTML before conversion |
755
+
756
+ `normalizePastedHtml(html, options)` is the pure function behind it. Peer
757
+ dependencies: `@lexical/html`, `@lexical/clipboard`.
758
+
437
759
  ## For AI agents / programmatic authoring
438
760
 
439
761
  Documents are plain markdown, so LLMs can generate them — including
@@ -448,8 +770,8 @@ diagrams:
448
770
  - `DRAWING_SKELETON_JSON_SCHEMA` and `DRAWING_DATA_JSON_SCHEMA` (exported)
449
771
  are the same contracts as JSON Schema: use them to validate generated
450
772
  payloads or as structured-output/tool schemas.
451
- - `parseDrawingSkeleton(json)` / `expandSkeleton(skeleton)` turn a skeleton
452
- into `DrawingData`. `parseDrawingData(json)` is the editor's own lenient
773
+ - `parseDrawingSkeleton(json)` / `expandDrawingSkeleton(skeleton)` turn a skeleton
774
+ into `DrawingData`. `deserializeDrawingData(json)` is the editor's own lenient
453
775
  parser (invalid shapes drop out; never throws); `serializeDrawingData` is
454
776
  its inverse. `drawingToMermaid(data)` exports a Mermaid `flowchart`.
455
777
  - A ready-made Claude Code skill lives in the monorepo at
@@ -458,36 +780,117 @@ diagrams:
458
780
 
459
781
  ## Exports
460
782
 
783
+ The public surface is split by audience. The default entry and
784
+ `./markdown` carry the host-facing API — no `$`-prefixed helper and no
785
+ Lexical type. Plugin authors composing at the Lexical level import
786
+ `@zuilib/text-editor/lexical`.
787
+
788
+ ### `@zuilib/text-editor` (and `./markdown`)
789
+
461
790
  | Export | Purpose |
462
791
  |--------|---------|
463
- | `MarkdownEditor`, `MarkdownEditorProps` | The component; compound parts as statics (`.Root`, `.Content`, `.Toolbar`, …) |
464
- | `useMarkdownEditor`, `MarkdownEditorApi` | Headless editor hook |
465
- | `Toolbar`, `ToolbarButton`, `ToolbarDivider`, `FormatButtons`, `InsertButtons`, `HistoryButtons` | Toolbar primitives |
466
- | `@zuilib/text-editor/styles.css` | Editor chrome + theme styles (required) |
467
- | `TABLE` | GFM table markdown transformer |
468
- | `BlockWidth`, `BLOCK_WIDTHS`, `isBlockWidth`, `BlockWidthDefaults` | Block width (`full` / `text` / `content`) type, values, guard, insertion defaults |
792
+ | `MarkdownEditor`, `MarkdownEditorProps`, `ToolbarItems` | The component; compound parts as statics (`.Root`, `.Content`, `.Toolbar`, `.CharacterCount`, …); the groups (`format`, `block`, `insert`, `history`) handed to a `toolbar` render function |
793
+ | `EditorRootProps`, `EditorContentProps`, `EditorMode`, `EditorFieldProps` | Types of the compound parts |
794
+ | `useMarkdownEditor`, `MarkdownEditorApi`, `BlockType` | Headless editor hook |
795
+ | `useEditorContext`, `useLabels` | Editor context (mode, labels, field attributes) for plugins |
796
+ | `EditorLabels`, `EditorLabelsInput`, `DeepPartial`, `DEFAULT_LABELS`, `resolveLabels` | Localisation |
797
+ | `CollapsibleHeadingsLabels`, `TableRowShortcutsLabels`, `TableColumnShortcutsLabels` | Label types of the individual plugins |
798
+ | `Toolbar`, `ToolbarButton`, `ToolbarDivider`, `FormatButtons`, `BlockButtons`, `InsertButtons`, `HistoryButtons`, `CharacterCount` | Toolbar primitives and the counter |
799
+ | `useToolbarKeyboard`, `TOOLBAR_ITEM_ATTRIBUTE` | APG toolbar keyboard pattern for your own toolbars |
800
+ | `isSafeUrl`, `SAFE_LINK_PROTOCOLS` | Link validation used by the link plugin and bubble |
801
+ | `EDIT_LINK_COMMAND` | Opens the link bubble for a non-empty selection (what the toolbar link button dispatches) |
802
+ | `BlockWidth`, `BLOCK_WIDTHS`, `isBlockWidth`, `NewBlockWidths` | Block width (`full` / `text` / `content`) type, values, guard, insertion defaults |
469
803
  | `TableDensity`, `TableSettings`, `TableSettingsOptions`, `DEFAULT_TABLE_SETTINGS` | Table setting types |
470
- | `$getTableSettings`, `$setTableSettings`, `$isTableWidthExplicit`, `$getTableWidth`, `$setTableWidth`, `$getTableDensity`, `$setTableDensity`, `$getSelectedTable` | Table setting helpers (inside `editor.update`/`read`) |
471
- | `parseTableSettingsMarker`, `formatTableSettingsMarker` | Marker comment `TableSettings` |
472
- | `DRAWING`, `DIAGRAM`, `DrawingNode`, `$createDrawingNode`, `$isDrawingNode` | Drawing node + markdown transformers (` ```drawing ` and ` ```diagram `) |
473
- | `FRONTMATTER`, `FrontmatterNode`, `$createFrontmatterNode`, `$isFrontmatterNode` | Frontmatter node + transformer |
474
- | `DrawingData`, `DrawingShape`, `DrawingShapeType` | Drawing payload types |
475
- | `parseDrawingData`, `serializeDrawingData` | Drawing payload (de)serialization |
804
+ | `parseTableSettingsMarker`, `formatTableSettingsMarker` | Marker comment `TableSettings` (`TABLE_WIDTH_MARKER` is deprecated) |
805
+ | `TableCellPosition`, `insertableRowIndices`, `insertableColumnIndices`, `canDeleteRow`, `canDeleteColumn` | Pure table guards for custom toolbars |
806
+ | `CodeGrammar`, `CodeRule`, `CodeToken`, `CodeTokenType` | Code highlighting types |
807
+ | `registerCodeLanguage`, `hasCodeLanguage`, `resolveCodeLanguage`, `getCodeLanguages`, `tokenizeCode` | Code language registry and lexer |
808
+ | `BUILTIN_CODE_LANGUAGES`, `CODE_TOKEN_TYPES`, `PLAIN_LANGUAGE` | Code highlighting constants |
809
+ | `@zuilib/text-editor/styles.css` | Editor chrome + theme styles (required) |
810
+
811
+ The default entry additionally re-exports the pure drawing-data API (below)
812
+ and the plugin components (`MentionsPlugin`, `CommentsPlugin`,
813
+ `ImagesPlugin`, `PastePlugin` with their commands, labels and types).
814
+
815
+ ### Drawing data (default entry and `./drawing`)
816
+
817
+ | Export | Purpose |
818
+ |--------|---------|
819
+ | `DrawingData`, `DrawingShape`, `DrawingShapeType`, `NodeShapeType`, `ConnectorType`, `Binding`, `BindingSide`, `Point` | Drawing payload types |
820
+ | `deserializeDrawingData`, `serializeDrawingData`, `normalizeDrawingData` | Drawing payload (de)serialization; version 2 payloads migrate on read |
476
821
  | `DRAWING_DATA_JSON_SCHEMA` | JSON Schema of the drawing payload |
477
- | `DrawingSkeleton`, `SkeletonBox`, `SkeletonConnector` | Skeleton types |
478
- | `parseDrawingSkeleton`, `expandSkeleton`, `DRAWING_SKELETON_JSON_SCHEMA` | Skeleton parsing, expansion to `DrawingData`, JSON Schema |
479
- | `drawingToMermaid` | Mermaid `flowchart` export |
822
+ | `NODE_SHAPE_TYPES`, `CONNECTOR_TYPES`, `SHAPE_TYPES`, `SIDE_FIXED_POINTS`, `STROKE_COLORS`, `FILL_COLORS`, `EMPTY_DRAWING` | Drawing constants |
823
+ | `isNodeShapeType`, `findNodeShapeAt`, `createBinding` | Node-shape guard, hit test, binding creation |
824
+ | `NODE_SHAPE_DEFINITIONS`, `NodeShapeDefinition`, `TextField` | Data-driven shape geometry and text slots |
825
+ | `DrawingSkeleton`, `SkeletonBox`, `SkeletonConnector`, `SkeletonEnd`, `SkeletonText`, `ColorName` | Skeleton types |
826
+ | `parseDrawingSkeleton`, `isDrawingSkeleton`, `expandDrawingSkeleton`, `DRAWING_SKELETON_JSON_SCHEMA`, `COLOR_PRESETS` | Skeleton parsing, expansion to `DrawingData`, JSON Schema, palette |
827
+ | `drawingToMermaid`, `MermaidOptions`, `MermaidDirection` | Mermaid `flowchart` export |
828
+ | `DrawingPlugin`, `INSERT_DRAWING_COMMAND`, `DRAWING_FOCUS_COMMAND` | Handles insertion (`useMarkdownEditor().insertDrawing` dispatches the command); the canvas reports focus through the second |
829
+ | `DrawingStyle` | The `drawingStyle` prop's type |
830
+
831
+ Geometry internals (binding resolution, the elbow router, ink rendering,
832
+ outline math) are implementation details and are no longer exported.
833
+
834
+ ### `@zuilib/text-editor/lexical`
835
+
836
+ | Export | Purpose |
837
+ |--------|---------|
838
+ | `createMarkdownEditor`, `EditorPreset`, `MARKDOWN_PRESET`, `DRAWING_PRESET` | Build the component around a preset of nodes / transformers / plugins |
839
+ | `MARKDOWN_NODES`, `MARKDOWN_TRANSFORMERS`, `DRAWING_PRESET_NODES`, `DRAWING_PRESET_TRANSFORMERS` | The production node and transformer lists (`./markdown` / default entry) |
840
+ | `resolveTransformers`, `shortcutTransformers`, `TransformersInput` | How the `transformers` prop is merged, and which of the result double as typing shortcuts |
841
+ | `editorNodeClassNames`, `mergeNodeClassNames` | The built-in node class names and the merge the `nodeClassNames` prop uses |
842
+ | `$replaceMarkdown(markdown, transformers)`, `replaceMarkdown` | Replace the whole document from markdown (what the controlled `value` uses); keeps the caret position when it still exists |
843
+ | `EXTERNAL_UPDATE_TAG` | Update tag on every external (host-driven) import, so a plugin's update listener can tell host imports from user edits |
844
+ | `TABLE`, `LISTS`, `CODE_BLOCK` | GFM table transformer; nested lists (2-space nesting on export, 2 or 4 on import); fenced code (replaces Lexical's `CODE`; untagged blocks export as bare fences) |
845
+ | `FRONTMATTER`, `FrontmatterNode`, `$createFrontmatterNode`, `$isFrontmatterNode`, `SerializedFrontmatterNode` | Frontmatter node + transformer |
846
+ | `$getTableSettings`, `$setTableSettings`, `$isTableWidthExplicit`, `$getTableWidth`, `$setTableWidth`, `$getTableDensity`, `$setTableDensity`, `$getSelectedTable` | Table setting helpers (inside `editor.update`/`read`) |
847
+ | `$getSelectedTableCell`, `$getTableCellPosition` | Where the selection is inside a table |
848
+ | `$insertTableRowAt`, `$deleteTableRowAt`, `$insertTableRowNear`, `$deleteTableRowNear`, `$insertTableColumnAt`, `$deleteTableColumnAt`, `$insertTableColumnNear`, `$deleteTableColumnNear` | Row / column operations (`*At` by index, `*Near` relative to the selection; inside `editor.update`) |
849
+ | `codeTokenizer`, `registerCodeBlockHighlighting` | Lexical tokenizer adapter and editor wiring |
850
+ | `DRAWING`, `DIAGRAM`, `DrawingNode`, `$createDrawingNode`, `$isDrawingNode`, `SerializedDrawingNode` | Drawing node + markdown transformers (` ```drawing ` and ` ```diagram `) |
851
+ | `MentionNode`, `$createMentionNode`, `$isMentionNode`, `MENTION`, `ImageNode`, `$createImageNode`, `$isImageNode`, `$getSelectedImage`, `IMAGE`, `COMMENT`, `$getMarkNodes`, `$getCommentIds`, `$removeComment`, `$plainTextOffset` | The first-party plugins' nodes, transformers and `$`-helpers (also on the plugin entries) |
480
852
 
481
853
  ## Form integration
482
854
 
855
+ The editor is a controlled input with the attributes a form field needs.
856
+ `id` and the `aria-*` props land on the editable surface (the
857
+ contenteditable, or the textarea in `edit-raw`); `name` renders a hidden
858
+ input carrying the markdown so a native form post includes it. The
859
+ surface shows a focus ring on keyboard focus (`--ring`, falling back to
860
+ `--primary`) and an inset outline in `--destructive` while
861
+ `aria-invalid`.
862
+
483
863
  ```tsx
484
864
  <FormField control={form.control} name="body">
485
- {({ field }) => (
486
- <MarkdownEditor value={field.value} onChange={field.onChange} />
865
+ {({ field, fieldState }) => (
866
+ <FormItem>
867
+ <FormLabel htmlFor="body">Body</FormLabel>
868
+ <MarkdownEditor
869
+ id="body"
870
+ name={field.name}
871
+ value={field.value}
872
+ onValueChange={field.onChange}
873
+ aria-describedby="body-hint"
874
+ aria-invalid={fieldState.invalid}
875
+ aria-required
876
+ maxLength={2000}
877
+ showCharacterCount
878
+ />
879
+ <FormDescription id="body-hint">Markdown, 2000 characters max.</FormDescription>
880
+ <FormMessage />
881
+ </FormItem>
487
882
  )}
488
883
  </FormField>
489
884
  ```
490
885
 
886
+ `maxLength` counts the document's plain text (not the markdown, except in
887
+ `edit-raw` where the textarea's own `maxLength` applies); an edit that
888
+ would pass it is rejected at the caret. `onCharacterCountChange` reports the
889
+ length, `showCharacterCount` renders it (`MarkdownEditor.CharacterCount` places it
890
+ elsewhere), and `labels.count` formats it. The visible count is not a
891
+ live region; a hidden status announces `labels.limitExceeded(max)` /
892
+ `labels.limitRestored(max)` only when the document crosses the limit.
893
+
491
894
  Load both CSS entry points (`@zuilib/tokens/styles.css`,
492
895
  `@zuilib/text-editor/styles.css`) in the app layout. Dark mode follows the
493
896
  ZUI convention: a `dark` class on `<html>`.
@@ -506,20 +909,35 @@ v4 host must scan this package for them to be generated:
506
909
 
507
910
  ## Architecture notes (for extenders)
508
911
 
509
- - Source: `src/EditorRoot.tsx` (composer + plugins), `src/EditorContent.tsx`,
510
- `src/MarkdownEditor.tsx` (default composition), `src/useMarkdownEditor.ts`,
511
- `src/components/Toolbar.tsx`; plugins in `src/plugins/`; drawing
912
+ - Source: `src/editor-root.tsx` (composer + plugins), `src/editor-content.tsx`,
913
+ `src/markdown-editor.tsx` (default composition), `src/use-markdown-editor.ts`,
914
+ `src/components/toolbar.tsx`; plugins in `src/plugins/`; drawing
512
915
  canvas in `src/drawing/`; custom nodes in `src/nodes/`;
513
916
  transformers in `src/transformers/`
514
917
  - Markdown import/export via `@lexical/markdown` transformers; order
515
918
  matters: `FRONTMATTER`, `DRAWING` and `DIAGRAM` claim their blocks before
516
919
  `CODE`, `CHECK_LIST` before `UNORDERED_LIST`
517
- - `src/drawing/` keeps the format (`types.ts`, `schema.ts`), shape
920
+ - `src/drawing/` keeps the format (`drawing-data.ts`, `schema.ts`), shape
518
921
  definitions (`shapes/`), pure geometry (`geometry.ts`), the Mermaid
519
922
  exporter (`mermaid.ts`) and the React canvas (`canvas/`) apart, so the
520
923
  router, bindings and skeleton expansion run without a DOM
521
- - Tests: `pnpm test` runs a headless-Lexical markdown round-trip suite
522
- (`tests/roundtrip.mjs`)
924
+ - Entry points: `src/index.ts` (default, `DRAWING_PRESET`), `src/markdown.ts`
925
+ (`MARKDOWN_PRESET`), `src/lexical.ts` (the Lexical escape hatch),
926
+ `src/drawing.ts`; `src/editor-api.ts` lists the host-facing exports the
927
+ first two have in common. tsup builds them with code splitting so the
928
+ entries share one copy of every module
929
+ - `package.json` `sideEffects` lists the stylesheet and
930
+ `src/code/suppress-global-highlighter.ts` (the first import of every
931
+ entry: it flags Prism as manual before `@lexical/code` loads it, so a
932
+ host page's own highlighted blocks are never rewritten); nothing else
933
+ runs code at import time (the code-language registry loads its built-in
934
+ grammars on first lookup)
935
+ - Tests: `pnpm test` runs every `tests/*.test.mjs` under `node --test`
936
+ (a11y, block width, code highlighting, drawing, extension API, ink, lists,
937
+ mermaid, round trip, skeleton, table grid, table transformer, value sync).
938
+ Mounted tests use jsdom (`tests/fixtures/dom.mjs`) and end with an
939
+ axe-core check. They import from `dist/`, so a `pretest` script runs the
940
+ build before them
523
941
 
524
942
  ## Related packages
525
943
 
@@ -531,6 +949,6 @@ v4 host must scan this package for them to be generated:
531
949
 
532
950
  ```bash
533
951
  pnpm --filter @zuilib/text-editor build # tsup → dist/
534
- pnpm --filter @zuilib/text-editor test # round-trip suite
952
+ pnpm --filter @zuilib/text-editor test # builds first (pretest), then the node --test suites
535
953
  pnpm --filter @zuilib/text-editor publish --access public
536
954
  ```