npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@zuilib/text-editor

v0.14.0

Published

ZUI — Markdown editor with tables, drawings, code blocks, comments and mentions

Readme

@zuilib/text-editor

A markdown editor for ZUI built on Lexical. Plain markdown in, plain markdown out — with an artifact-style editing experience on top: rich tables, an embedded Excalidraw-style diagram canvas, a document outline, and collapsible sections.

Features

  • Three modes: rich WYSIWYG (edit-rich), raw markdown (edit-raw), read-only render (view) — all driven by one controlled value string
  • Markdown shortcuts while typing: headings, lists, checklists, blockquotes, links, fenced code (highlighted by the package's own lexer, 16 grammars), tables
  • Tables: GFM pipe tables, edited in place (tab between cells, ranges, add / remove rows and columns from hover rails or the keyboard, inline formatting), styled like Claude artifacts
  • Text measure and block width: an opt-in readable text column (maxTextWidth="48rem"); each table and diagram picks full / text / content width, Slab-style; row density per table
  • Diagrams: a drawing canvas embedded in the document. Eight box shapes (rectangle, ellipse, diamond, note, database, cloud, queue, actor) with attached text slots, bound arrows that follow their boxes and attach to a chosen side, auto-routed elbows that avoid other boxes, multi-select, color palettes, dark mode, Mermaid export. LLMs author diagrams as a coordinate-free ```diagram skeleton that the editor lays out
  • Outline: optional table-of-contents sidebar with click-to-scroll and current-section highlight
  • Section folding: collapse everything under a heading, view-layer only
  • Toolbar: inline formatting and links, block types (headings, lists, quote), insert table / drawing / horizontal rule / footnote
  • Horizontal rules (---) and GFM footnotes: [^id] cues numbered in reference order, notes gathered in a section at the end, cue ↔ note navigation
  • Extension platform: register your own Lexical nodes, markdown transformers and plugins through props; ./markdown entry without the drawing canvas; every chrome string translatable through labels
  • YAML frontmatter block support (--- at the top)

Everything round-trips through the markdown string: tables as GFM, diagrams as ```drawing fenced JSON (format spec), frontmatter as --- blocks. Feature history: CHANGELOG.

Installation

pnpm add @zuilib/text-editor

Lexical and ZUI tokens are regular dependencies. React and React DOM remain peers so the editor shares the application's React runtime. Tailwind v4 is optional; when the app uses it, the package-owned entry registers the editor's document typography classes:

@import "tailwindcss";
@import "@zuilib/text-editor/tailwind.css";

Without Tailwind, import @zuilib/text-editor/styles.css; it includes the tokens and editor chrome. Style the document block classes yourself; the list is on the Theming page.

Quick start

import { useState } from 'react'
import '@zuilib/text-editor/styles.css'
import { MarkdownEditor } from '@zuilib/text-editor'

function Notes() {
  const [value, setValue] = useState('# Hello\n\n- [ ] Try checklists')
  return <MarkdownEditor value={value} onValueChange={setValue} outline />
}

The component is controlled: value is the document. onValueChange fires on every keystroke with the full markdown string; passing a different value back in (including '') replaces the document, and passing back the string just emitted is a no-op. Persisting on every call is usually too often, so debounce the write to your store; keep the React state update synchronous.

Props (MarkdownEditorProps)

| Prop | Type | Default | Description | |------|------|---------|-------------| | value | string | — | Markdown source (controlled). Omit for an uncontrolled editor | | defaultValue | string | '' | Initial markdown of an uncontrolled editor | | onValueChange | (value: string) => void | — | Called on every edit (see debounceMs) | | 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 | | mode | 'edit-rich' \| 'edit-raw' \| 'view' | 'edit-rich' | Editing surface | | placeholder | string | labels.placeholder | Empty-state hint | | readOnly | boolean | false | Disables editing in Lexical modes | | autoFocus | boolean | false | Focus on mount | | className | string | — | Root wrapper class | | toolbar | boolean \| (items) => ReactNode | true | Formatting/insert toolbar (edit-rich); function form customises it | | outline | boolean | false | Table-of-contents sidebar | | collapsible | boolean | true | Collapse sections under headings | | maxTextWidth | string | — | Max width of the text column, a CSS length ('48rem' recommended). Sugar for --zui-text-editor-measure | | newBlockWidth | { table?, drawing? } | — | BlockWidth written when the toolbar inserts a table / drawing | | drawingStyle | 'clean' \| 'ink' | 'clean' | Diagram rendering style; ink draws shapes as seeded pen strokes | | onError | (error: Error) => void | console.error | Called when Lexical throws inside an update | | nodes | Klass<LexicalNode>[] | — | Extra node classes, appended to the built-ins. Mount-time only | | transformers | Transformer[] \| (defaults) => Transformer[] | — | Extra markdown transformers (Extending). Mount-time only | | nodeClassNames | EditorThemeClasses | — | Class names for the rendered nodes, merged over the built-ins one level deep. Mount-time only | | onEditorReady | (editor: LexicalEditor) => void | — | Imperative access to the Lexical instance | | labels | DeepPartial<EditorLabels> | English | Every chrome string (Localisation) | | maxLength | number | — | Plain-text limit; input past it is rejected at the caret. A controlled value is never trimmed | | onCharacterCountChange | (length: number) => void | — | Plain-text length after every change and once on mount | | showCharacterCount | boolean | false | Character counter under the document (length / maxLength) | | id, name, aria-label, aria-labelledby, aria-describedby, aria-invalid, aria-required | | — | Forwarded to the editable surface (Form integration) | | children | ReactNode | — | Extra plugins or UI rendered under the root |

Modes

| mode | UI | Notes | |--------|-----|-------| | 'edit-rich' | Lexical rich editor | Shortcuts, toolbar, tables, canvas | | 'edit-raw' | Plain <textarea> | Direct markdown source editing | | 'view' | Read-only render | Outline/folding still work |

Switching edit-rawedit-rich re-imports the latest text into Lexical. In edit-raw the textarea keeps its own keystrokes whether or not value is given.

Tables

Type a | a | b | row or use the toolbar's insert-table button. GFM round-trip:

| Metric | Q1 | Q2 |
| --- | --- | --- |
| Revenue | $1.2M | $1.8M |

Rendering is artifact-style (rounded outer border, shaded header row). Editing is in place: Tab/arrows between cells, cell range selection, inline formatting inside cells. Cell content is single-line in markdown; newlines are escaped as \n.

Rows and columns

Click into a table and two slim rails appear: one along its left edge for rows, one along its top edge for columns. A bar on each marks the row and column the caret is in, and a + handle rests after the last row / column: click it to append one. Move the pointer over a rail and the handle follows it — near a boundary it is a + that inserts there (hovering it previews the insertion as a line across the table), over the middle of a row or column it becomes a that removes it (hovering tints what will go). The caret moves into whatever was inserted.

Keyboard, inside a table:

| Shortcut | Action | | --- | --- | | ⌘↩ / Ctrl+Enter | Insert row below | | ⌘⇧↩ / Ctrl+Shift+Enter | Insert row above | | ⌘⇧⌫ / Ctrl+Shift+Backspace | Delete row | | Tab in the last cell | Append a row |

GFM tables have exactly one header row, the first, so nothing is inserted above it (above there inserts right below the header), deleting the header row makes the next row the header, and a new column gets a header cell. The last remaining row or column can't be deleted.

Programmatic access: useMarkdownEditor().tableCell ({ row, column, rows, columns, hasHeader } or null), insertTableRow('above' | 'below'), deleteTableRow(), insertTableColumn('before' | 'after') and deleteTableColumn(); or, inside editor.update, $insertTableRowAt(table, index), $deleteTableRowAt(table, index), $insertTableColumnAt(table, index), $deleteTableColumnAt(table, index) and their *Near / $deleteSelected* selection-relative forms.

Table density

Place the caret in a table and a small toolbar floats above its top-right corner with the table's width (see Block width and text measure) and its densitycompact, comfortable (default), or spacious cell padding and font size.

Non-default settings are persisted as one HTML comment on the line directly above the table — other markdown renderers hide it:

<!-- width: content; density: compact -->
| Key | Value |
| --- | --- |
| Region | eu-west-1 |

Programmatic access: useMarkdownEditor().tableDensity / setTableDensity (for custom toolbars), or $getTableSettings(node) / $setTableSettings(node, { width, density }) inside editor.update. parseTableSettingsMarker / formatTableSettingsMarker convert between the comment line and a TableSettings object.

Block width and text measure

By default every block spans the content pane. Set a measure to get a readable text column instead: paragraphs, headings, lists, quotes, code, frontmatter are capped at that width and centred in the pane, while each table and drawing chooses its own width.

<MarkdownEditor value={value} onValueChange={setValue} maxTextWidth="48rem" />

The prop is sugar for the CSS custom property --zui-text-editor-measure, which is the actual contract — set it in a stylesheet on .zui-text-editor (or any ancestor) and never touch the prop:

.zui-text-editor { --zui-text-editor-measure: 48rem; }

Adopting: set maxTextWidth (or the variable) and delete any external max-width / margin-inline overrides on .zui-text-editor-content > *; the library now owns that layout.

Every table and drawing has a BlockWidth, picked from the floating table toolbar or the right end of the drawing toolbar:

| BlockWidth | Layout | Markdown | |--------------|--------|----------| | full | The content pane, edge to edge (never under the outline sidebar). The default | Omitted | | text | The text column: edges align with the paragraphs. Identical to full until a measure is set | <!-- width: text --> / "width":"text" | | content | Shrinks to its columns / shapes, left-aligned with the text, never wider than the column | <!-- width: content --> / "width":"content" |

Absence of a marker always means full, in every app. newBlockWidth only changes what the toolbar / insertTable / insertDrawing write into new blocks — and that value is written explicitly, even when it is full, so the document reads the same elsewhere:

<MarkdownEditor maxTextWidth="48rem" newBlockWidth={{ table: 'text', drawing: 'text' }} … />

Headless: useMarkdownEditor().blockWidth / setBlockWidth act on the block containing the selection — the focused drawing, or else the table the caret is in. (tableWidth / setTableWidth still work and are deprecated in favour of these.)

Styling: the layout is driven by low-specificity :where() rules and two derived custom properties on .zui-text-editor-main--zui-text-editor-gutter (the pane padding, 2rem without a measure) and --zui-text-editor-bleed (how far a full-width block extends past the column, 0 without a measure). Each top-level block reads --zui-text-editor-block-bleed, so a single class rule overrides any block kind: .zui-code { --zui-text-editor-block-bleed: var(--zui-text-editor-bleed) } makes code blocks full width; .zui-table { --zui-text-editor-block-bleed: 0px } keeps every table inside the column. Block classes: .zui-paragraph, .zui-heading (+ .zui-heading-16), .zui-list (+ -ordered / -unordered, .zui-checklist), .zui-quote, .zui-code, .zui-frontmatter, .zui-table, .zui-drawing, .zui-hr, .zui-footnotes (+ .zui-footnote items, .zui-footnote-ref cues).

Horizontal rules and footnotes

--- (or ***, ___) on a line of its own is a horizontal rule: a <hr> you can click to select and Backspace to remove. Type --- and then a space or Enter, press the toolbar button or call insertHorizontalRule(). It exports as ---; a rule that opens the document exports as *** so a later --- cannot turn the two into a frontmatter block on re-import.

Footnotes follow GFM: [^id] in the text is a cue, [^id]: text a note. Cues render as superscript numbers in first-reference order (the id is never shown, so [^note] and [^7] are equally fine); notes are gathered in a section that is always the last block of the document, in numbering order, with further lines of a note as four-space continuation lines. A note nothing cites is kept and shown unnumbered. The toolbar button, insertFootnote() or INSERT_FOOTNOTE_COMMAND insert a cue and an empty note and move the caret into the note; click a cue (or press Enter on a selected one) to go to its note, press Escape or the note's to come back after the cue. Enter inside a note adds a line; Backspace on an empty note removes it. Typing [^id] after some text, or [^id]: at the start of a paragraph, works too.

Sales grew 12%[^growth] on the back of the new tier[^tier].

[^growth]: Year over year, constant currency.
[^tier]: Launched in March.
    Includes migrated legacy accounts.

Toolbar & custom toolbars

In edit-rich mode the toolbar has three groups: format (bold, italic, strikethrough, inline code, link), block (heading 1 to 3, bullet, numbered and check list, quote; pressing the active one returns to a paragraph) and insert (table, drawing, horizontal rule, footnote). Hide it with toolbar={false}.

The link button needs a non-empty selection (it is disabled for a bare caret) and opens a small bubble under it with a URL input, apply and remove; nothing is written to the document until a URL is applied, so Escape, moving the caret or leaving the bubble abandons the link without an onValueChange. The bubble also opens when the caret enters an existing link. URLs are validated: javascript:, data: and other script-capable schemes are refused (isSafeUrl, SAFE_LINK_PROTOCOLS), both here and for pasted links (an HTML paste keeps only the text of an unsafe href).

Extending the toolbar

Pass a function as toolbar. It receives the default button groups and returns the toolbar to render. Use MarkdownEditor.ToolbarButton for your own buttons so they match the built-ins and keep the editor selection when clicked.

<MarkdownEditor
  value={value}
  onValueChange={setValue}
  toolbar={(items) => (
    <MarkdownEditor.Toolbar>
      {items.format}
      <MarkdownEditor.ToolbarDivider />
      {items.block}
      <MarkdownEditor.ToolbarDivider />
      {items.insert}
      <MarkdownEditor.ToolbarDivider />
      {items.history}
      <MarkdownEditor.ToolbarButton label="Save" onClick={save}>
        💾
      </MarkdownEditor.ToolbarButton>
    </MarkdownEditor.Toolbar>
  )}
/>

MarkdownEditor.Toolbar hides itself in view / edit-raw / readOnly. items has format, block, insert and history (undo / redo, only in this function form). Buttons made with ToolbarButton take part in the toolbar's arrow-key navigation (data-toolbar-item); other elements you put in the toolbar keep their own focus handling. In an editor narrower than 40rem the toolbar scrolls sideways instead of wrapping (see Small screens and touch).

Placing your own toolbar (compound components)

When the toolbar must live somewhere else in your layout (an app bar, a panel header), compose the editor from its parts. Everything under MarkdownEditor.Root shares one editor instance, so the toolbar can sit anywhere in that subtree.

<MarkdownEditor.Root value={value} onValueChange={setValue}>
  <header className="app-bar">
    <MarkdownEditor.Toolbar>
      <MarkdownEditor.FormatButtons />
      <MarkdownEditor.ToolbarDivider />
      <MarkdownEditor.InsertButtons />
    </MarkdownEditor.Toolbar>
    <MyAppButtons />
  </header>
  <MarkdownEditor.Content placeholder="Write…">
    <MarkdownEditor.Outline />
  </MarkdownEditor.Content>
</MarkdownEditor.Root>

| Part | Role | |------|------| | Root | Lexical composer + plugins. Takes every prop of MarkdownEditor except placeholder, toolbar, outline, collapsible, showCharacterCount | | Content | The editable surface. Takes placeholder, collapsible, showCharacterCount; children are docked sidebars | | Toolbar | Container; renders the default groups when empty | | FormatButtons, BlockButtons, InsertButtons, HistoryButtons | Built-in groups (InsertButtons drawing={false} hides the drawing button) | | ToolbarButton, ToolbarDivider | Primitives for your own items | | Outline | Table-of-contents sidebar | | CharacterCount | Character counter, for placing it yourself |

Headless: useMarkdownEditor()

For fully custom UI (e.g. buttons in your own design system), call the hook from any component rendered under MarkdownEditor.Root:

function BoldButton() {
  const { activeFormats, toggleFormat } = useMarkdownEditor()
  return (
    <MyButton pressed={activeFormats.has('bold')} onClick={() => toggleFormat('bold')}>
      B
    </MyButton>
  )
}

It returns editor (the Lexical instance), activeFormats, toggleFormat, blockType / setBlockType (paragraph, h1..h6, quote, bullet, number, check), link / setLink(url | null) / hasSelection (setLink with a URL is a no-op for a collapsed caret outside a link), insertTable, insertDrawing, insertHorizontalRule, insertFootnote (cue plus empty note, caret in the note), blockWidth / setBlockWidth (width of the table or drawing containing the selection, null outside both), tableDensity / setTableDensity (null outside tables), tableCell / insertTableRow / deleteTableRow / insertTableColumn / deleteTableColumn (see Rows and columns), canUndo, canRedo, undo, redo. tableWidth / setTableWidth remain as deprecated aliases limited to tables.

Diagrams (drawing canvas)

The toolbar's insert-drawing button embeds a canvas; the drawing persists in the markdown as a ```drawing fenced JSON block (format version 3), fully specified in DRAWING_FORMAT.md.

  • Shapes: rectangle, ellipse, diamond, note (sticky note), database (cylinder), cloud, queue, actor (stick figure), arrow, line, text. The toolbar shows the common tools and a "more shapes" popover for the rest. Draw by picking a tool and dragging.
  • Cards: every box carries three text slots that move, resize, and wrap with it: a bold label on top, content in the center, a dim footer at the bottom (actor has only the name under the figure). Click a selected box (or double-click its top/middle/bottom strip, or press Enter) to edit a slot.
  • Bound connectors: an arrow drawn from one card to another attaches to both; moving a card moves its arrows, endpoints anchored to the outline. An endpoint either auto-aims at the other end or sticks to a fixed point on the box (a side, or a ratio inside the box). Drag an endpoint off/onto a card to detach/re-attach.
  • Routing: straight (diagonal) by default; toggle elbow for an auto-routed right-angled path that leaves the box perpendicular to its attach side and avoids the other boxes, or drag the dashed "+" handles on a selected connector to add any number of waypoints (they snap to neighbors' axes for clean 90° bends). One-way / two-way arrowhead toggle; midpoint labels.
  • Multi-select: shift-click, or drag a marquee on empty canvas, then move, delete, or recolor the selection together. A contextual property bar shows the options for whatever is selected.
  • Copy as Mermaid: a canvas button copies the drawing as a Mermaid flowchart (also available as drawingToMermaid(data)).
  • Canvas: resizable height, dot grid, white surface that inverts Excalidraw-style in dark mode (.dark ancestor class). An optional canvasWidth scales the drawing to fit narrower layouts.
  • Width: the buttons at the right end of the canvas toolbar switch between full (spans the pane), text (aligns with the text column, see Block width and text measure) and content (the canvas fits the rightmost shape and grows as shapes move). Stored as "width":"text" / "width":"content" in the payload; omitted when full.

A stored drawing looks like this:

```drawing
{"version":3,"canvasHeight":260,"shapes":[
  {"id":"web","type":"rect","x":40,"y":70,"width":170,"height":100,"stroke":"#1971c2","fill":"#a5d8ff","strokeWidth":2,"label":"CLIENT","text":"Web App"},
  {"id":"api","type":"rect","x":330,"y":70,"width":170,"height":100,"stroke":"#2f9e44","fill":"#b2f2bb","strokeWidth":2,"label":"SERVICE","text":"API"},
  {"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"}
]}
```

The ```diagram skeleton

Generators (LLMs, scripts) do not need coordinates. A ```diagram block holds a skeleton: boxes, connectors, colors. On import the editor lays it out along direction (right by default, or down), sizes boxes to their text, and stores the result as a normal ```drawing block (one-way expansion).

```diagram
{"boxes":[
  {"id":"web","label":"CLIENT","text":"Web App","color":"blue"},
  {"id":"api","label":"SERVICE","text":"API","color":"green"},
  {"id":"db","type":"cylinder","text":"Postgres"}
],"connectors":[
  {"from":"web","to":"api","text":"REST"},
  {"from":"api","to":"db","routing":"elbow"}
]}
```

The full skeleton spec (attach sides, explicit positions, free texts) is in DRAWING_FORMAT.md. Programmatic access: parseDrawingSkeleton, expandDrawingSkeleton, DRAWING_SKELETON_JSON_SCHEMA.

Theming the canvas

The canvas chrome (header, property row, popover, selection handles) is styled entirely through --zui-drawing-* custom properties. They default to the ZUI core tokens (--primary, --foreground, --border, --popover, --radius, --shadow-medium, …), so the canvas follows the host theme in light and dark mode. Override any of them on .zui-drawing-canvas or an ancestor:

.my-app .zui-drawing-canvas {
  --zui-drawing-accent: #0f766e;        /* selection, active tool, handles */
  --zui-drawing-surface: #fbfbf7;       /* drawing area (pre dark-mode filter) */
  --zui-drawing-grid: rgba(0, 0, 0, 0.12);
  --zui-drawing-grid-size: 16px;
  --zui-drawing-font: 'Inter', sans-serif;
}

| Variable | Default | Used for | |----------|---------|----------| | --zui-drawing-accent | var(--primary) | Selection frame and handles, active tool, focus ring, bind highlight | | --zui-drawing-foreground | var(--foreground) | Header text and icons | | --zui-drawing-muted-foreground | var(--muted-foreground) | Swatch labels, empty-state hint | | --zui-drawing-border | var(--border) | Block border, header divider, popover border | | --zui-drawing-chrome | var(--background) | Header and property row background | | --zui-drawing-popover / -foreground | var(--popover) / var(--popover-foreground) | "More shapes" menu | | --zui-drawing-danger / --zui-drawing-success | var(--destructive) / var(--success) | Delete button, "copied" state | | --zui-drawing-radius / --zui-drawing-control-radius | var(--radius-lg) / var(--radius-sm) | Block corners / buttons and menus | | --zui-drawing-shadow | var(--shadow-medium) | Popover | | --zui-drawing-font | system sans | Shape text and the inline editor | | --zui-drawing-surface / --zui-drawing-grid / --zui-drawing-grid-size | white / 9% black / 20px | Drawing area and its dot grid, before the dark filter | | --zui-drawing-dark-filter | invert(93%) hue-rotate(180deg) | Applied to the drawing area under .dark; set to none to opt out | | --zui-drawing-hover / --zui-drawing-active | currentColor 8% / 12% | Button hover and pressed backgrounds |

Outline & section folding

outline docks a collapsible table-of-contents sidebar: live heading list indented by level, click to scroll, current section highlighted. Not part of the document — pure UI. Available in edit-rich and view.

collapsible (default on) shows a chevron in the gutter of each heading on hover (always visible on touch screens); clicking collapses the section (until the next heading of the same or higher level). Folding never changes the markdown; fold state resets on remount; a folded section auto-expands if the cursor enters it.

Small screens and touch

The editor lays out against its own width, not the viewport: .zui-text-editor and .zui-text-editor-main are CSS inline-size containers (which also makes the root a stacking context, so the sticky toolbar's z-index never escapes the editor). Nothing changes above a pane width of 40rem; below it:

  • the toolbar scrolls sideways (hidden scrollbar, scroll snapping) rather than wrapping or overflowing — button order is part of the UI;
  • the outline becomes a collapsible strip above the document instead of a 220px side column;
  • each table is its own horizontal scroll container with a 6rem column minimum, so a wide table scrolls inside the pane and never widens it;
  • headings step down (h1 2rem, h2 1.625rem, h3 1.375rem);
  • a drawing wider than the pane scales down to fit, aspect preserved (it is still edited at full resolution).

Link, mention and comment panels are clamped inside the pane; the mention list is capped at 50dvh so it clears a soft keyboard. On coarse pointers (@media (pointer: coarse)) toolbar buttons, drawing tools, outline entries and the table rail handles get 44px targets, the hover-revealed fold chevrons and rail handles are visible by default, and a tap on a table rail pins the + / handle where the finger was (a second tap acts; sliding along the rail moves it). A finger on an idle drawing scrolls the page; once the canvas is tapped (focused) touches draw and drag. Below a 40rem viewport the editable surface renders at 16px or more so iOS does not zoom on focus.

Custom toolbar content: anything that pops out of MarkdownEditor.Toolbar (a menu, a select) should render in a portal — below 40rem the toolbar is a scroll container and clips its overflow.

Extending

The editor is a Lexical composer with a fixed set of nodes and markdown transformers. Three props open it up; a first-party plugin (mentions, comments, images) is built the same way a host would build one.

import { MarkdownEditor, useMarkdownEditor, DRAWING_PRESET_TRANSFORMERS } from '@zuilib/text-editor'
import { MentionNode, MENTION } from './mention'

function MentionPlugin() {
  const { editor } = useMarkdownEditor()
  useEffect(() => editor.registerCommand(INSERT_MENTION_COMMAND, ...), [editor])
  return null
}

<MarkdownEditor
  value={value}
  onValueChange={setValue}
  nodes={[MentionNode]}
  transformers={[MENTION]}
  nodeClassNames={{ mention: 'zui-mention' }}
  onEditorReady={(editor) => (editorRef.current = editor)}
>
  <MentionPlugin />
</MarkdownEditor>

| Prop | What it does | |------|--------------| | 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 | | 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 | | children (plugins) | Anything under MarkdownEditor / MarkdownEditor.Root shares the editor: a component calling useMarkdownEditor() or useLexicalComposerContext() is a plugin | | nodeClassNames | Class names merged over editorNodeClassNames one level deep ({ heading: { h1: 'x' } } keeps h2); mount-time only | | onEditorReady | Called with the LexicalEditor once it exists |

The built-in transformer order is exported as DRAWING_PRESET_TRANSFORMERS (default entry) and MARKDOWN_TRANSFORMERS (./markdown): FRONTMATTER, DRAWING, DIAGRAM, LISTS, CHECK_LIST, TABLE, CODE_BLOCK, HORIZONTAL_RULE, the three FOOTNOTES, then Lexical's TRANSFORMERS without CODE. Use the same list in a headless editor so import and export match the component byte for byte.

Entry points

| Import | Contents | |--------|----------| | @zuilib/text-editor | Everything: the editor with the drawing canvas, all nodes, transformers and helpers | | @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 | | @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 | | @zuilib/text-editor/drawing | The drawing canvas on its own: DrawingNode, DRAWING, DIAGRAM, DrawingPlugin, DRAWING_PRESET, the payload and skeleton helpers | | @zuilib/text-editor/mentions, ./comments, ./images, ./paste | The first-party plugins (Plugins); plugin components also on the default entry, node classes and $-helpers on the plugin entry and ./lexical |

./markdown plus ./drawing reassembles the default entry:

import { MarkdownEditor } from '@zuilib/text-editor/markdown'
import { DrawingNode, DRAWING, DIAGRAM, DrawingPlugin } from '@zuilib/text-editor/drawing'

<MarkdownEditor.Root value={value} onValueChange={setValue} nodes={[DrawingNode]} transformers={[DRAWING, DIAGRAM]}>
  <MarkdownEditor.Content />
  <DrawingPlugin />
</MarkdownEditor.Root>

createMarkdownEditor(preset) (on ./lexical) builds a MarkdownEditor (with its compound parts) around an EditorPreset ({ nodes, transformers, plugins? }) for packages that ship their own bundle of extensions.

Localisation

labels takes a deep partial of EditorLabels (exported, English defaults in DEFAULT_LABELS): placeholder, toolbar.*, outline.*, collapsibleHeadings.*, table.*, layout.*, drawing.*, count and limitExceeded / limitRestored (the counter's limit announcements). Strings with a number are functions (table.deleteRow(3), drawing.deleteShapes(2)). Document content is never translated.

<MarkdownEditor
  labels={{
    placeholder: 'Commencez à écrire…',
    toolbar: { bold: 'Gras', insertTable: 'Insérer un tableau' },
    drawing: { deleteShapes: (n) => `Supprimer ${n} formes` },
  }}
/>

useLabels() returns the resolved labels inside the editor, for your own plugins.

Plugins

Four first-party plugins ship in the package. Each is opt-in: import it from its own entry point (or from the default entry), pass its node and transformer to the root and mount the plugin as a child. ./markdown does not include them. The one stylesheet covers all four. See the plugins page for live examples.

| Entry | Node / transformer | Plugin | Markdown | |-------|--------------------|--------|----------| | @zuilib/text-editor/mentions | MentionNode, MENTION | MentionsPlugin | [@name](mention:id) | | @zuilib/text-editor/comments | MarkNode (from @lexical/mark), COMMENT | CommentsPlugin | <!-- zui:comment id -->text<!-- /zui:comment --> | | @zuilib/text-editor/images | ImageNode, IMAGE | ImagesPlugin, ImageButton | ![alt](src), ![alt](src "title") | | @zuilib/text-editor/paste | none | PastePlugin | unchanged |

Mentions

import { MentionNode, MENTION, MentionsPlugin } from '@zuilib/text-editor/mentions'

<MarkdownEditor value={value} onValueChange={setValue} nodes={[MentionNode]} transformers={[MENTION]}>
  <MentionsPlugin search={(query) => api.people(query)} />
</MarkdownEditor>

Typing the trigger at the start of a word opens a role="listbox" under the caret with the search(query) results (called on every keystroke; stale results are dropped). Arrow keys move, Enter or Tab inserts the highlighted item as a MentionNode followed by a space, Escape closes until the query changes. While the list is open the surface carries aria-controls and aria-activedescendant. Enter with no results is a normal Enter. The list never opens in readOnly mode, in inline code or inside another mention; the query stops at whitespace and 40 characters.

| Prop | Description | |------|-------------| | trigger | One non-word character (default '@') | | search | (query) => Promise<MentionItem[]>; an item is { id, name, hint? } | | render | (item, { active, query, trigger }) => ReactNode for the option body; the option element, its role and selection state stay the plugin's | | triggers | [{ trigger, search, render? }] for several triggers (@ people, # tags); replaces the three props above | | maxItems | Options listed at most (default 8) | | labels | suggestions(trigger) (listbox aria-label), noResults, loading | | onSelect | (item, trigger) after an insertion |

The node is a token text node: the caret cannot enter it, Backspace removes it whole, its text is the trigger plus the name. Markdown writes [@name](mention:id), a link other renderers show as text with a mention: href; a trigger must be a single non-word character for the transformer to claim it. ] and \ in the name are backslash-escaped and %, ) and whitespace in the id percent-encoded, so any host values round-trip. INSERT_MENTION_COMMAND ({ id, name, trigger? }) inserts a mention at the selection without the menu. The theme key is mention (zui-mention by default), the element carries data-mention="<id>" and data-slot="mention".

Comments

import { MarkNode, COMMENT, CommentsPlugin } from '@zuilib/text-editor/comments'

<MarkdownEditor value={value} onValueChange={setValue} nodes={[MarkNode]} transformers={[COMMENT]}>
  <CommentsPlugin
    comments={comments}
    onAdd={(c) => setComments([...comments, c])}
    onResolve={(id, resolved) => update(id, { resolved })}
    onDelete={(id) => remove(id)}
  />
</MarkdownEditor>

The document owns the ranges, the host owns everything else. Selecting text shows a floating "Add comment" button above it; the button or Mod+Shift+M wraps the selection in a MarkNode with a fresh id and calls onAdd({ id, quote, range, resolved: false }), then puts the caret at the end of the range. With the caret inside a commented range (or right after it) a role="group" bubble under it lists the comments there with resolve / reopen and delete; Mod+Shift+M there moves focus into the bubble and Escape returns it to the surface. Delete removes the id from the document before onDelete(id); resolve only calls onResolve(id, resolved), the host decides.

| Prop | Description | |------|-------------| | comments | { id, quote, range?, resolved? }[]; a marker whose id is missing here is listed with labels.unknown(id) and only offers delete | | onAdd, onResolve, onDelete | See above | | createId | Id generator (default crypto.randomUUID) | | labels | add, comments (bubble aria-label), resolve, reopen, remove, resolved, unknown(id) | | renderComment | (comment, { resolve, remove }) => ReactNode replaces an entry's body |

range is { start, end } in $getRoot().getTextContent() at the time the comment was added; it is informational and not updated by later edits, the mark is the source of truth. Markdown writes <!-- zui:comment id -->text<!-- /zui:comment -->, which every other renderer hides, so the plain document stays readable. A range spanning several blocks is one marker pair per block with the same id; overlapping comments share a pair with the ids comma-separated (zui:comment a,b). Mark elements get zui-comment, data-comment-ids and is-resolved when every id on them is resolved; the colour reads --zui-comment-color (falls back to --warning). Helpers inside editor.update / read: $getMarkNodes(), $getCommentIds(), $removeComment(id), $plainTextOffset(node), plus formatCommentMarker, parseCommentIds, ADD_COMMENT_COMMAND and COMMENTS_THEME. @lexical/mark installs with the editor.

Images

import { ImageNode, IMAGE, ImagesPlugin, ImageButton } from '@zuilib/text-editor/images'

<MarkdownEditor
  value={value}
  onValueChange={setValue}
  nodes={[ImageNode]}
  transformers={[IMAGE]}
  toolbar={(items) => (
    <MarkdownEditor.Toolbar>
      {items.format}
      {items.insert}
      <ImageButton />
    </MarkdownEditor.Toolbar>
  )}
>
  <ImagesPlugin upload={(file) => api.upload(file)} maxSize={5 * 1024 * 1024} onError={toast} />
</MarkdownEditor>

Files pasted or dropped on the surface (Lexical's DRAG_DROP_PASTE), or chosen through ImageButton, are checked against accept (default any image/*) and maxSize (bytes), then a progress placeholder (role="progressbar") stands at the caret until upload(file) resolves with { src, alt? }. The placeholder exports as nothing, so a value read mid-upload has no half image. A rejected promise removes the placeholder. onError receives { type: 'unsupported' | 'too-large' | 'upload' | 'unsafe-src', ... }. Sources must pass isSafeUrl (http:, https:, relative paths; no data: or blob:), on import, on insertion and on upload results. Whitespace and parentheses in a source are percent-encoded on the node (markdown destinations end at them), and ] in alt text and " in titles are backslash-escaped, so any uploaded file name round-trips.

Clicking an image selects it (Backspace removes it) and shows an alt-text input under it; Enter or Escape commits and returns to the surface. The image is inline; a paragraph holding only an image renders it as a block. Markdown is ![alt](src) or ![alt](src "title"). Commands: INSERT_IMAGE_COMMAND ({ src, alt?, title? }) and UPLOAD_IMAGES_COMMAND (File[]). Labels: altText, altPlaceholder, uploading, insertImage. Theme key image (zui-image).

Paste normalisation

import { PastePlugin } from '@zuilib/text-editor/paste'

<MarkdownEditor value={value} onValueChange={setValue}>
  <PastePlugin />
</MarkdownEditor>

HTML from Word, Google Docs, Confluence and Outlook is cleaned before the built-in paste converts it: headings, paragraphs, lists (Word's MsoListParagraph runs become real ul / ol), tables, links, code, quotes, emphasis (b, i and styled spans become strong, em, s), images, br and hr stay; styles, classes, ids, spans, fonts, Office namespaces, conditional comments, scripts, styles, forms, iframes and SVG go. Plain-text pastes and pastes into an input are untouched.

| Prop | Description | |------|-------------| | when | 'rich-sources' (default) acts only on HTML with an Office / Docs / Confluence fingerprint (isRichSourceHtml); 'always' cleans every HTML paste | | tables, links, images | false flattens cells to paragraphs, keeps link text only, drops images | | transform | (html) => html runs on the cleaned HTML before conversion |

normalizePastedHtml(html, options) is the pure function behind it. @lexical/html and @lexical/clipboard install with the editor.

For AI agents / programmatic authoring

Documents are plain markdown, so LLMs can generate them — including diagrams:

  • Emit ```diagram skeleton blocks: no coordinates, the editor lays them out. Use ```drawing (format v2) only to edit an existing concrete payload.
  • DRAWING_FORMAT.md is the authoritative spec of both payloads, written to be pasted into a prompt (ships in the npm package next to this README).
  • DRAWING_SKELETON_JSON_SCHEMA and DRAWING_DATA_JSON_SCHEMA (exported) are the same contracts as JSON Schema: use them to validate generated payloads or as structured-output/tool schemas.
  • parseDrawingSkeleton(json) / expandDrawingSkeleton(skeleton) turn a skeleton into DrawingData. deserializeDrawingData(json) is the editor's own lenient parser (invalid shapes drop out; never throws); serializeDrawingData is its inverse. drawingToMermaid(data) exports a Mermaid flowchart.
  • A ready-made Claude Code skill lives in the monorepo at .claude/skills/text-editor-documents/ — copy it into consuming repos so agents there know the dialect.

Exports

The public surface is split by audience. The default entry and ./markdown carry the host-facing API — no $-prefixed helper and no Lexical type. Plugin authors composing at the Lexical level import @zuilib/text-editor/lexical.

@zuilib/text-editor (and ./markdown)

| Export | Purpose | |--------|---------| | 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 | | EditorRootProps, EditorContentProps, EditorMode, EditorFieldProps | Types of the compound parts | | useMarkdownEditor, MarkdownEditorApi, BlockType | Headless editor hook | | useEditorContext, useLabels | Editor context (mode, labels, field attributes) for plugins | | EditorLabels, EditorLabelsInput, DeepPartial, DEFAULT_LABELS, resolveLabels | Localisation | | CollapsibleHeadingsLabels, TableRowShortcutsLabels, TableColumnShortcutsLabels | Label types of the individual plugins | | Toolbar, ToolbarButton, ToolbarDivider, FormatButtons, BlockButtons, InsertButtons, HistoryButtons, CharacterCount | Toolbar primitives and the counter | | useToolbarKeyboard, TOOLBAR_ITEM_ATTRIBUTE | APG toolbar keyboard pattern for your own toolbars | | isSafeUrl, SAFE_LINK_PROTOCOLS | Link validation used by the link plugin and bubble | | EDIT_LINK_COMMAND | Opens the link bubble for a non-empty selection (what the toolbar link button dispatches) | | BlockWidth, BLOCK_WIDTHS, isBlockWidth, NewBlockWidths | Block width (full / text / content) type, values, guard, insertion defaults | | TableDensity, TableSettings, TableSettingsOptions, DEFAULT_TABLE_SETTINGS | Table setting types | | parseTableSettingsMarker, formatTableSettingsMarker | Marker comment ⇄ TableSettings (TABLE_WIDTH_MARKER is deprecated) | | TableCellPosition, insertableRowIndices, insertableColumnIndices, canDeleteRow, canDeleteColumn | Pure table guards for custom toolbars | | CodeGrammar, CodeRule, CodeToken, CodeTokenType | Code highlighting types | | registerCodeLanguage, hasCodeLanguage, resolveCodeLanguage, getCodeLanguages, tokenizeCode | Code language registry and lexer | | BUILTIN_CODE_LANGUAGES, CODE_TOKEN_TYPES, PLAIN_LANGUAGE | Code highlighting constants | | @zuilib/text-editor/styles.css | Tokens + editor chrome for hosts without Tailwind | | @zuilib/text-editor/tailwind.css | Tokens, editor chrome and package source registration for Tailwind v4 hosts |

The default entry additionally re-exports the pure drawing-data API (below) and the plugin components (MentionsPlugin, CommentsPlugin, ImagesPlugin, PastePlugin with their commands, labels and types).

Drawing data (default entry and ./drawing)

| Export | Purpose | |--------|---------| | DrawingData, DrawingShape, DrawingShapeType, NodeShapeType, ConnectorType, Binding, BindingSide, Point | Drawing payload types | | deserializeDrawingData, serializeDrawingData, normalizeDrawingData | Drawing payload (de)serialization; version 2 payloads migrate on read | | DRAWING_DATA_JSON_SCHEMA | JSON Schema of the drawing payload | | NODE_SHAPE_TYPES, CONNECTOR_TYPES, SHAPE_TYPES, SIDE_FIXED_POINTS, STROKE_COLORS, FILL_COLORS, EMPTY_DRAWING | Drawing constants | | isNodeShapeType, findNodeShapeAt, createBinding | Node-shape guard, hit test, binding creation | | NODE_SHAPE_DEFINITIONS, NodeShapeDefinition, TextField | Data-driven shape geometry and text slots | | DrawingSkeleton, SkeletonBox, SkeletonConnector, SkeletonEnd, SkeletonText, ColorName | Skeleton types | | parseDrawingSkeleton, isDrawingSkeleton, expandDrawingSkeleton, DRAWING_SKELETON_JSON_SCHEMA, COLOR_PRESETS | Skeleton parsing, expansion to DrawingData, JSON Schema, palette | | drawingToMermaid, MermaidOptions, MermaidDirection | Mermaid flowchart export | | DrawingPlugin, INSERT_DRAWING_COMMAND, DRAWING_FOCUS_COMMAND | Handles insertion (useMarkdownEditor().insertDrawing dispatches the command); the canvas reports focus through the second | | DrawingStyle | The drawingStyle prop's type |

Geometry internals (binding resolution, the elbow router, ink rendering, outline math) are implementation details and are no longer exported.

@zuilib/text-editor/lexical

| Export | Purpose | |--------|---------| | createMarkdownEditor, EditorPreset, MARKDOWN_PRESET, DRAWING_PRESET | Build the component around a preset of nodes / transformers / plugins | | MARKDOWN_NODES, MARKDOWN_TRANSFORMERS, DRAWING_PRESET_NODES, DRAWING_PRESET_TRANSFORMERS | The production node and transformer lists (./markdown / default entry) | | resolveTransformers, shortcutTransformers, TransformersInput | How the transformers prop is merged, and which of the result double as typing shortcuts | | editorNodeClassNames, mergeNodeClassNames | The built-in node class names and the merge the nodeClassNames prop uses | | $replaceMarkdown(markdown, transformers), replaceMarkdown | Replace the whole document from markdown (what the controlled value uses); keeps the caret position when it still exists | | EXTERNAL_UPDATE_TAG | Update tag on every external (host-driven) import, so a plugin's update listener can tell host imports from user edits | | 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) | | FRONTMATTER, FrontmatterNode, $createFrontmatterNode, $isFrontmatterNode, SerializedFrontmatterNode | Frontmatter node + transformer | | HORIZONTAL_RULE, HorizontalRuleNode, $createHorizontalRuleNode, $isHorizontalRuleNode | Thematic break (---) transformer over Lexical's rule node | | FootnoteRefNode, FootnoteDefinitionNode, FootnoteSectionNode (+ $create*, $is*, Serialized*), FOOTNOTES (FOOTNOTE_DEFINITION, FOOTNOTE_CONTINUATION, FOOTNOTE_REF) | Footnote cue, note and section nodes with their GFM transformers | | $insertFootnote, $normalizeFootnotes, $computeFootnoteNumbers, $collectFootnoteRefs, $getFootnoteSection, $getFootnoteDefinitions, $findFootnoteDefinition, $selectFootnoteDefinition, $selectFootnoteRef, $getSelectedFootnoteDefinition, $nextFootnoteId, FootnotesPlugin | Footnote model helpers (inside editor.update / read) and the plugin the root mounts | | $getTableSettings, $setTableSettings, $isTableWidthExplicit, $getTableWidth, $setTableWidth, $getTableDensity, $setTableDensity, $getSelectedTable | Table setting helpers (inside editor.update/read) | | $getSelectedTableCell, $getTableCellPosition | Where the selection is inside a table | | $insertTableRowAt, $deleteTableRowAt, $insertTableRowNear, $deleteTableRowNear, $insertTableColumnAt, $deleteTableColumnAt, $insertTableColumnNear, $deleteTableColumnNear | Row / column operations (*At by index, *Near relative to the selection; inside editor.update) | | codeTokenizer, registerCodeBlockHighlighting | Lexical tokenizer adapter and editor wiring | | DRAWING, DIAGRAM, DrawingNode, $createDrawingNode, $isDrawingNode, SerializedDrawingNode | Drawing node + markdown transformers (```drawing and ```diagram) | | 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) |

Form integration

The editor is a controlled input with the attributes a form field needs. id and the aria-* props land on the editable surface (the contenteditable, or the textarea in edit-raw); name renders a hidden input carrying the markdown so a native form post includes it. The surface shows a focus ring on keyboard focus (--ring, falling back to --primary) and an inset outline in --destructive while aria-invalid.

<FormField control={form.control} name="body">
  {({ field, fieldState }) => (
    <FormItem>
      <FormLabel htmlFor="body">Body</FormLabel>
      <MarkdownEditor
        id="body"
        name={field.name}
        value={field.value}
        onValueChange={field.onChange}
        aria-describedby="body-hint"
        aria-invalid={fieldState.invalid}
        aria-required
        maxLength={2000}
        showCharacterCount
      />
      <FormDescription id="body-hint">Markdown, 2000 characters max.</FormDescription>
      <FormMessage />
    </FormItem>
  )}
</FormField>

maxLength counts the document's plain text (not the markdown, except in edit-raw where the textarea's own maxLength applies); an edit that would pass it is rejected at the caret. onCharacterCountChange reports the length, showCharacterCount renders it (MarkdownEditor.CharacterCount places it elsewhere), and labels.count formats it. The visible count is not a live region; a hidden status announces labels.limitExceeded(max) / labels.limitRestored(max) only when the document crosses the limit.

Load one editor CSS entry in the app layout. styles.css includes the token values and authored editor chrome for hosts without Tailwind. A Tailwind v4 host imports Tailwind itself and then tailwind.css, which includes those same styles and registers the package source. Dark mode follows the ZUI convention: a dark class on <html>.

@import "tailwindcss";
@import "@zuilib/text-editor/tailwind.css";

Architecture notes (for extenders)

  • Source: src/editor-root.tsx (composer + plugins), src/editor-content.tsx, src/markdown-editor.tsx (default composition), src/use-markdown-editor.ts, src/components/toolbar.tsx; plugins in src/plugins/; drawing canvas in src/drawing/; custom nodes in src/nodes/; transformers in src/transformers/
  • Markdown import/export via @lexical/markdown transformers; order matters: FRONTMATTER, DRAWING and DIAGRAM claim their blocks before CODE, CHECK_LIST before UNORDERED_LIST, HORIZONTAL_RULE and the footnote transformers before Lexical's HEADING / LINK
  • src/drawing/ keeps the format (drawing-data.ts, schema.ts), shape definitions (shapes/), pure geometry (geometry.ts), the Mermaid exporter (mermaid.ts) and the React canvas (canvas/) apart, so the router, bindings and skeleton expansion run without a DOM
  • Entry points: src/index.ts (default, DRAWING_PRESET), src/markdown.ts (MARKDOWN_PRESET), src/lexical.ts (the Lexical escape hatch), src/drawing.ts; src/editor-api.ts lists the host-facing exports the first two have in common. tsup builds them with code splitting so the entries share one copy of every module
  • package.json sideEffects lists the stylesheet and src/code/suppress-global-highlighter.ts (the first import of every entry: it flags Prism as manual before @lexical/code loads it, so a host page's own highlighted blocks are never rewritten); nothing else runs code at import time (the code-language registry loads its built-in grammars on first lookup)
  • Tests: pnpm test runs every tests/*.test.mjs under node --test (a11y, block width, code highlighting, drawing, extension API, ink, lists, mermaid, round trip, skeleton, table grid, table transformer, value sync). Mounted tests use jsdom (tests/fixtures/dom.mjs) and end with an axe-core check. They import from dist/, so a pretest script runs the build before them

Related packages

  • @zuilib/tokens — design tokens and base styles
  • @zuilib/primitives — the UI primitives (Button, Input, …) and the react-hook-form wiring (form, form-field)

Build & release (maintainers)

pnpm --filter @zuilib/text-editor build   # tsup → dist/
pnpm --filter @zuilib/text-editor test    # builds first (pretest), then the node --test suites
pnpm --filter @zuilib/text-editor publish --access public