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