@olddevs/ui 4.5.1 → 4.6.1
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 +26 -0
- package/dist/editor/RichTextEditor.d.ts +2 -2
- package/dist/editor/RichTextEditor.d.ts.map +1 -1
- package/dist/editor/RichTextEditor.js +3 -0
- package/dist/editor/RichTextEditor.js.map +1 -1
- package/dist/editor/core/blockTypes.d.ts +2 -8
- package/dist/editor/core/blockTypes.d.ts.map +1 -1
- package/dist/editor/core/blockTypes.js +11 -54
- package/dist/editor/core/blockTypes.js.map +1 -1
- package/dist/editor/core/containers.d.ts.map +1 -1
- package/dist/editor/core/containers.js +1 -0
- package/dist/editor/core/containers.js.map +1 -1
- package/dist/editor/core/customMarkdown.js +1 -1
- package/dist/editor/core/customMarkdown.js.map +1 -1
- package/dist/editor/core/customTool.d.ts +6 -6
- package/dist/editor/core/customTool.d.ts.map +1 -1
- package/dist/editor/core/customToolEngine.d.ts +2 -2
- package/dist/editor/core/extensions.d.ts +7 -2
- package/dist/editor/core/extensions.d.ts.map +1 -1
- package/dist/editor/core/extensions.js +96 -64
- package/dist/editor/core/extensions.js.map +1 -1
- package/dist/editor/core/fieldNotice.d.ts +1 -1
- package/dist/editor/core/insertItems.d.ts +5 -28
- package/dist/editor/core/insertItems.d.ts.map +1 -1
- package/dist/editor/core/insertItems.js +3 -94
- package/dist/editor/core/insertItems.js.map +1 -1
- package/dist/editor/core/insertMenuTool.d.ts +13 -0
- package/dist/editor/core/insertMenuTool.d.ts.map +1 -0
- package/dist/editor/core/insertMenuTool.js +20 -0
- package/dist/editor/core/insertMenuTool.js.map +1 -0
- package/dist/editor/core/schema.d.ts +20 -5
- package/dist/editor/core/schema.d.ts.map +1 -1
- package/dist/editor/core/schema.js +18 -3
- package/dist/editor/core/schema.js.map +1 -1
- package/dist/editor/core/shortcuts.d.ts +56 -5
- package/dist/editor/core/shortcuts.d.ts.map +1 -1
- package/dist/editor/core/shortcuts.js +91 -156
- package/dist/editor/core/shortcuts.js.map +1 -1
- package/dist/editor/core/skeleton.d.ts +1 -1
- package/dist/editor/core/skeleton.d.ts.map +1 -1
- package/dist/editor/core/skeleton.js +1 -0
- package/dist/editor/core/skeleton.js.map +1 -1
- package/dist/editor/core/toolButtons.d.ts +3 -3
- package/dist/editor/core/toolDefinition.d.ts +3 -3
- package/dist/editor/core/toolDescriptor.d.ts +49 -30
- package/dist/editor/core/toolDescriptor.d.ts.map +1 -1
- package/dist/editor/core/toolDescriptor.js +2 -5
- package/dist/editor/core/toolDescriptor.js.map +1 -1
- package/dist/editor/core/toolLoader.d.ts +1 -1
- package/dist/editor/core/toolLoader.d.ts.map +1 -1
- package/dist/editor/core/toolOptions.d.ts +19 -2
- package/dist/editor/core/toolOptions.d.ts.map +1 -1
- package/dist/editor/core/toolOptions.js.map +1 -1
- package/dist/editor/core/tools.d.ts +10 -2
- package/dist/editor/core/tools.d.ts.map +1 -1
- package/dist/editor/core/tools.js +1 -0
- package/dist/editor/core/tools.js.map +1 -1
- package/dist/editor/features/builtins.d.ts +6 -8
- package/dist/editor/features/builtins.d.ts.map +1 -1
- package/dist/editor/features/builtins.js +48 -21
- package/dist/editor/features/builtins.js.map +1 -1
- package/dist/editor/features/code/codeBlockTool.d.ts +5 -6
- package/dist/editor/features/code/codeBlockTool.d.ts.map +1 -1
- package/dist/editor/features/code/codeBlockTool.js +10 -1
- package/dist/editor/features/code/codeBlockTool.js.map +1 -1
- package/dist/editor/features/color/colorTools.d.ts +14 -0
- package/dist/editor/features/color/colorTools.d.ts.map +1 -0
- package/dist/editor/features/color/colorTools.js +39 -0
- package/dist/editor/features/color/colorTools.js.map +1 -0
- package/dist/editor/features/details/details.d.ts +68 -0
- package/dist/editor/features/details/details.d.ts.map +1 -0
- package/dist/editor/features/details/details.js +237 -0
- package/dist/editor/features/details/details.js.map +1 -0
- package/dist/editor/features/details/detailsMarkdown.d.ts +17 -0
- package/dist/editor/features/details/detailsMarkdown.d.ts.map +1 -0
- package/dist/editor/features/details/detailsMarkdown.js +84 -0
- package/dist/editor/features/details/detailsMarkdown.js.map +1 -0
- package/dist/editor/features/details/detailsTool.d.ts +16 -0
- package/dist/editor/features/details/detailsTool.d.ts.map +1 -0
- package/dist/editor/features/details/detailsTool.js +39 -0
- package/dist/editor/features/details/detailsTool.js.map +1 -0
- package/dist/editor/features/emoji/emojiTool.d.ts +1 -1
- package/dist/editor/features/heading/headingTool.d.ts +18 -0
- package/dist/editor/features/heading/headingTool.d.ts.map +1 -0
- package/dist/editor/features/heading/headingTool.js +59 -0
- package/dist/editor/features/heading/headingTool.js.map +1 -0
- package/dist/editor/features/history/historyTool.d.ts +15 -0
- package/dist/editor/features/history/historyTool.d.ts.map +1 -0
- package/dist/editor/features/history/historyTool.js +28 -0
- package/dist/editor/features/history/historyTool.js.map +1 -0
- package/dist/editor/features/image/imageTool.d.ts +19 -0
- package/dist/editor/features/image/imageTool.d.ts.map +1 -0
- package/dist/editor/features/image/imageTool.js +28 -0
- package/dist/editor/features/image/imageTool.js.map +1 -0
- package/dist/editor/features/layout/layoutTool.d.ts +11 -0
- package/dist/editor/features/layout/layoutTool.d.ts.map +1 -0
- package/dist/editor/features/layout/layoutTool.js +20 -0
- package/dist/editor/features/layout/layoutTool.js.map +1 -0
- package/dist/editor/features/lists/listTools.d.ts +15 -0
- package/dist/editor/features/lists/listTools.d.ts.map +1 -0
- package/dist/editor/features/lists/listTools.js +78 -0
- package/dist/editor/features/lists/listTools.js.map +1 -0
- package/dist/editor/features/marks/markTools.d.ts +16 -0
- package/dist/editor/features/marks/markTools.d.ts.map +1 -0
- package/dist/editor/features/marks/markTools.js +43 -0
- package/dist/editor/features/marks/markTools.js.map +1 -0
- package/dist/editor/features/search/searchTool.d.ts +1 -1
- package/dist/editor/features/table/tableTool.d.ts +3 -3
- package/dist/editor/features/wrapper/wrapperTool.d.ts +11 -0
- package/dist/editor/features/wrapper/wrapperTool.d.ts.map +1 -0
- package/dist/editor/features/wrapper/wrapperTool.js +32 -0
- package/dist/editor/features/wrapper/wrapperTool.js.map +1 -0
- package/dist/editor/index.d.ts +1 -1
- package/dist/editor/index.d.ts.map +1 -1
- package/dist/editor/index.js.map +1 -1
- package/dist/editor/schema-entry.d.ts +1 -1
- package/dist/editor/styles/prose.d.ts.map +1 -1
- package/dist/editor/styles/prose.js +26 -0
- package/dist/editor/styles/prose.js.map +1 -1
- package/dist/editor/ui/EditorToolbar.d.ts.map +1 -1
- package/dist/editor/ui/EditorToolbar.js +5 -38
- package/dist/editor/ui/EditorToolbar.js.map +1 -1
- package/dist/editor/ui/SelectionBubble.d.ts.map +1 -1
- package/dist/editor/ui/SelectionBubble.js +1 -1
- package/dist/editor/ui/SelectionBubble.js.map +1 -1
- package/dist/editor/ui/ToolButtonView.d.ts.map +1 -1
- package/dist/editor/ui/ToolButtonView.js +1 -1
- package/dist/editor/ui/ToolButtonView.js.map +1 -1
- package/dist/i18n/defaults.d.ts.map +1 -1
- package/dist/i18n/defaults.js +3 -0
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/messages.d.ts +16 -1
- package/dist/i18n/messages.d.ts.map +1 -1
- package/dist/icons.d.ts +8 -1
- package/dist/icons.d.ts.map +1 -1
- package/dist/icons.js +1 -1
- package/dist/icons.js.map +1 -1
- package/dist/locales/ru.d.ts.map +1 -1
- package/dist/locales/ru.js +3 -0
- package/dist/locales/ru.js.map +1 -1
- package/docs/api/editor-extension.en.md +68 -68
- package/docs/api/editor-extension.md +55 -55
- package/docs/api/messages.en.md +4 -1
- package/docs/api/messages.md +3 -0
- package/docs/api/migration-notes.en.md +11 -11
- package/docs/api/migration-notes.md +2 -2
- package/docs/api/utilities.en.md +6 -6
- package/docs/components/actions/split-button.en.md +9 -9
- package/docs/components/content/data-display.en.md +1 -1
- package/docs/components/content/layout-primitives.en.md +3 -3
- package/docs/components/content/rich-text-toc.en.md +1 -1
- package/docs/components/content/rich-text-view.en.md +23 -17
- package/docs/components/content/rich-text-view.md +17 -11
- package/docs/components/content/virtual-grid.en.md +2 -2
- package/docs/components/date-time/date-picker.en.md +1 -1
- package/docs/components/inputs/rich-text-editor.en.md +283 -230
- package/docs/components/inputs/rich-text-editor.md +139 -86
- package/docs/components/navigation/mega-menu.en.md +10 -10
- package/docs/components/overlays/menu.en.md +2 -2
- package/docs/components/overlays/shortcuts-help.en.md +1 -1
- package/docs/components/table/data-table.en.md +3 -3
- package/docs/components/table/filter-bar.en.md +1 -1
- package/docs/components/table/toolbar.en.md +1 -1
- package/docs/conventions.en.md +2 -2
- package/docs/conventions.md +1 -1
- package/docs/getting-started.en.md +9 -9
- package/docs/getting-started.md +8 -8
- package/docs/glossary.en.md +193 -133
- package/docs/glossary.md +191 -132
- package/docs/icons.en.md +12 -9
- package/docs/icons.md +3 -0
- package/docs/palette.en.md +3 -3
- package/docs/theming.en.md +6 -6
- package/package.json +2 -1
- package/src/editor/RichTextEditor.tsx +9 -6
- package/src/editor/core/atomText.ts +2 -2
- package/src/editor/core/blockTypes.ts +19 -83
- package/src/editor/core/containers.ts +4 -2
- package/src/editor/core/customMarkdown.ts +2 -2
- package/src/editor/core/customTool.ts +6 -6
- package/src/editor/core/customToolEngine.ts +9 -9
- package/src/editor/core/extensions.ts +165 -110
- package/src/editor/core/fieldNotice.ts +1 -1
- package/src/editor/core/handle.ts +2 -2
- package/src/editor/core/insertItems.ts +12 -158
- package/src/editor/core/insertMenuTool.ts +32 -0
- package/src/editor/core/jsonDocument.ts +1 -1
- package/src/editor/core/schema.ts +42 -7
- package/src/editor/core/shortcuts.ts +116 -177
- package/src/editor/core/skeleton.ts +4 -3
- package/src/editor/core/toolButtons.ts +3 -3
- package/src/editor/core/toolDefinition.ts +4 -4
- package/src/editor/core/toolDescriptor.ts +65 -52
- package/src/editor/core/toolLoader.ts +3 -3
- package/src/editor/core/toolOptions.ts +21 -2
- package/src/editor/core/tools.ts +10 -1
- package/src/editor/core/viewIsland.ts +1 -1
- package/src/editor/features/builtins.ts +76 -41
- package/src/editor/features/code/codeBlockTool.ts +16 -8
- package/src/editor/features/color/colorTools.ts +68 -0
- package/src/editor/features/details/details.ts +389 -0
- package/src/editor/features/details/detailsMarkdown.ts +158 -0
- package/src/editor/features/details/detailsTool.ts +69 -0
- package/src/editor/features/emoji/emojiTool.tsx +1 -1
- package/src/editor/features/heading/headingTool.ts +96 -0
- package/src/editor/features/history/historyTool.ts +42 -0
- package/src/editor/features/image/imageTool.ts +49 -0
- package/src/editor/features/layout/layoutTool.ts +40 -0
- package/src/editor/features/lists/listTools.ts +112 -0
- package/src/editor/features/marks/markTools.ts +78 -0
- package/src/editor/features/search/searchTool.ts +1 -1
- package/src/editor/features/table/tableTool.ts +3 -3
- package/src/editor/features/toc/tocTool.ts +1 -1
- package/src/editor/features/wrapper/wrapperTool.ts +52 -0
- package/src/editor/index.ts +1 -0
- package/src/editor/schema-entry.ts +1 -1
- package/src/editor/styles/prose.ts +49 -0
- package/src/editor/ui/EditorToolbar.tsx +7 -50
- package/src/editor/ui/SelectionBubble.tsx +2 -5
- package/src/editor/ui/ToolButtonView.tsx +2 -1
- package/src/i18n/defaults.ts +3 -0
- package/src/i18n/messages.ts +14 -1
- package/src/icons.ts +7 -0
- package/src/locales/ru.ts +3 -0
|
@@ -19,6 +19,7 @@ exports:
|
|
|
19
19
|
RichTextTools,
|
|
20
20
|
SearchToolOptions,
|
|
21
21
|
TocToolOptions,
|
|
22
|
+
DetailsToolOptions,
|
|
22
23
|
TocLevel,
|
|
23
24
|
SearchToolCommands,
|
|
24
25
|
TocToolCommands,
|
|
@@ -55,7 +56,7 @@ category: inputs
|
|
|
55
56
|
status: stable
|
|
56
57
|
source: src/editor/RichTextEditor.tsx
|
|
57
58
|
story: src/editor/RichTextEditor.stories.tsx
|
|
58
|
-
sourceHash:
|
|
59
|
+
sourceHash: bba3a0af1f9c
|
|
59
60
|
---
|
|
60
61
|
|
|
61
62
|
# RichTextEditor
|
|
@@ -66,22 +67,23 @@ sourceHash: b5a4f8fde844
|
|
|
66
67
|
|
|
67
68
|
- **Entry point and engine.** It lives in its own entry point, `@olddevs/ui/editor`, not in the barrel, and the consumer installs the engine: it is not in the kit's manifest.
|
|
68
69
|
- **Value and modes.** The value is an HTML string constrained by the Schema; an empty Document is `""`. The `value`/`onChange` and `defaultValue` modes are supported; `name` is for a native form; the label and error come from `FormField`.
|
|
69
|
-
- **Height.** By default the field grows with the Document. `maxHeight` (pixels or a CSS length) limits the Document area: it scrolls inside the frame while the Toolbar and the Status
|
|
70
|
-
- **Toolbar and
|
|
70
|
+
- **Height.** By default the field grows with the Document. `maxHeight` (pixels or a CSS length) limits the Document area: it scrolls inside the frame while the Toolbar and the Status bar stay in place. `resizable` adds a height handle (a grip strip plus a slider you can use from the keyboard).
|
|
71
|
+
- **Toolbar and Bubble menu.** The Toolbar (`toolbar`) is either sticky above the field, floating above the selection, or the compact Bubble menu on an inverse surface (`"bubble"`); on a touch screen it is always sticky. `Alt+F10` moves focus into it.
|
|
71
72
|
- **Block handle.** A Block with the caret gets a "⋮" handle on the left with a menu of Block actions (add below, format, move up, move down, duplicate, delete; `Ctrl/⌘+/`).
|
|
72
73
|
- **Link and image.** A link is edited in a popover on `Ctrl/⌘+K`. An image is a `<figure>` with a caption and a width of 50/75/100%, inserted by URL; with `onUploadImage(file) → Promise<url>` it can also come from the clipboard, by drag and drop, and through a file picker (base64 is never written into the Document).
|
|
73
74
|
- **Code block.** It has a header (language on the left, "Copy" on the right), a language (Python, TypeScript, JavaScript, Go, HTML, JSON, YAML, Markdown or plain text) and lowlight highlighting. Only `class="language-…"` is written into the Document, and for `RichTextView` the server helper from `@olddevs/ui/editor/highlight` highlights the Blocks.
|
|
74
|
-
- **
|
|
75
|
+
- **Source mode.** The "HTML" button of the fixed Toolbar (the `source` Tool) swaps the Document for a field with its source HTML; leaving the mode, `blur`, form submission and `getHTML()` apply the edit through the Schema, like an incoming `value`.
|
|
75
76
|
- **Feature set.** `preset="full" | "basic"` or an exact `tools` list; a disabled Tool removes both its button and its markup from the Schema.
|
|
76
|
-
- **Tool options, state and commands.** The built-in `search` and `
|
|
77
|
+
- **Tool options, state and commands.** The built-in `search`, `toc` and `details` Tools have options: a `tool("toc", { offsetVar: "--header-h", levels: [2, 3] })` entry sits in `tools` next to the names, switches the Tool on and configures it (for Search, the starting flags `matchCase`, `wholeWord` and `regex`; for the Table of contents, `offsetVar`, `levels` and `listWidth`; for the collapsible Block, `persist`). The `useRichTextToolState(ref, "search")` hook reads the state: for Search, whether the find panel is open, the query, the number of matches and the number of the current one; for the Table of contents, the headings and the current section. The hook returns `null` before mount, on the server, after the Editor unmounts and when the Tool is not in the set. Commands are called through the Handle: `ref.current?.tool("search")?.open()`, `ref.current?.tool("toc")?.jumpTo(2)`; each returns a `boolean`. Options are read once, when the engine is created. A custom Tool with the same three parts is described with `defineTool` (["Options, state and commands"](../../api/editor-extension.md#опции-состояние-и-команды)).
|
|
77
78
|
- **Handle (`ref`).** The screen drives the field through `ref`: `insertHTML`, `insertText`, `getText`, `getSelectedText`, `isEmpty`, `focus(position?)`, `blur`, `undo`, `redo`, `tool(name)`; `getJSON()`/`setJSON()` export and load the Document as a lossless JSON envelope (see the "JSON" section), and `defaultValue` accepts the envelope too. The screen builds its own toolbar with the `useRichTextEditorState(ref, selector)` hook. `openSearch` is deprecated (removed in 5.0): use `tool("search")?.open()` instead. Before Readiness (on the server, before mount, while Tool Loaders are running) the methods are silent: see the "Handle (`ref`) and a custom toolbar outside the field" section.
|
|
78
79
|
- **Events.** The screen learns about field events through the `onReady()`, `onFocus()` and `onBlur()` callbacks (see the "Events" section).
|
|
79
|
-
- **
|
|
80
|
-
- **
|
|
81
|
-
- **
|
|
80
|
+
- **Collapsible block.** The `details` Tool (in `full`) is a Block made of a title and content: it is inserted with the "Collapsible block" item of the Slash menu, its HTML is the `details` and `summary` tags, and in the View it opens the browser's native way, without the engine. The `tool("details", { persist: true })` option writes the Block's open state into the Document (`open`); by default it does not. In Markdown the Block is raw HTML `<details><summary>`.
|
|
81
|
+
- **Skeleton and Loader.** Until Readiness the field shows a Skeleton: the frame and the Document as static HTML without input, with no height jump. The Skeleton's Document is `value` cleaned by the Schema, and only in the browser; on the server the Skeleton is a frame without a Document. The Extensions of a custom Tool can be passed as a Loader (`engine: () => import(…)`); if it fails, the result is a field error with "Retry".
|
|
82
|
+
- **Status bar and Field notice.** Below the field, inside the frame, are the Status bar (`status`: the screen's React node at the start, the kit's counter at the end, no `role`) and the Field notice: the engine command `showNotice({ tone: "status" | "error", text })` / `showNotice(null)`. `status` is only announced, `error` is shown as a `role="alert"` line until the next edit; image messages go through the same channel.
|
|
83
|
+
- **Buttons of a custom Tool.** A custom Tool (`CustomTool` in `tools`) can carry buttons in the `buttons` field: a label (a string or a function that receives `{ locale }`), an icon, `can`, `isActive`, `isVisible`, `selectionMenu`, `shortcut` and exactly one of `run` and `popover`. The kit renders the button in one group before "Undo" and "Redo", binds the shortcut and shows it in the tooltip, in `aria-keyshortcuts` and in the Keyboard shortcuts popover.
|
|
82
84
|
- **Text card.** Another kind of UI contribution is a Text card (`textCards`): a range `rangeAt(state, pos)` (for a Mark, the ready-made `markRange("acme_comment")`) and a body `render({ editor, range, close })`. The kit opens it when the caret is in the range or on click, manages the frame (layer, anchor under the range, flip at the edge, Escape), does not take focus out of the Document, and moves focus into it with `Alt+F10`.
|
|
83
85
|
- **Insert items and Block actions.** Two more kinds of UI contributions are Insert items (`insertItems`: "/", "+", and "Add ›" and "Format ›" of the Block handle) and Block actions (`blockActions`: a section of the Block handle menu before "Delete"), also with exactly one of `run` and `popover`. An item's popover opens at the caret, an action's at the Block.
|
|
84
|
-
- **Paste and drop.** A custom Tool can affect paste and drop only through
|
|
86
|
+
- **Paste and drop.** A custom Tool can affect paste and drop only through Extensions.
|
|
85
87
|
- **Names and HTML convention.** The names of everything custom (the Tool, its Blocks, Marks and attributes) start with a Consumer prefix (`acme_card`). The HTML of a custom Block is a `div` or `span` with `data-block`, a Mark is a `span` with `data-mark`, and attributes are `data-acme_*` with a value checked against a rule (list, pattern, URL).
|
|
86
88
|
- **Presets.** These are the arrays `RICH_TEXT_PRESETS.full` and `.basic`, so you can append custom Tools to them.
|
|
87
89
|
- **Server and View.** The same `tools` array is accepted by server-side sanitization (`sanitizeRichText(html, DOMPurify, tools)`, `richTextSchema(tools)`) and by `RichTextView` (the Island of a custom Block is the `island` field).
|
|
@@ -98,6 +100,7 @@ import { RichTextEditor } from "@olddevs/ui/editor";
|
|
|
98
100
|
- Long formatted text: an article, a description, a policy, a proposal to a client. The author writes headings, lists, quotes and highlights words.
|
|
99
101
|
- The value is stored and served as HTML and is shown later by a page.
|
|
100
102
|
- A component "for the future" (ADR-0048): at release there was no requesting screen, so the API is modeled on comparable products — Imperavi Redactor 5, Tiptap Simple Editor, minimal-tiptap, Notion. Your screen is the first test of this API. The engine decision is in ADR-0050.
|
|
103
|
+
- The collapsible Block `details` is "for the future" too (ADR-0048): at release there was no requesting screen, and the API follows the Details extension from Tiptap; the first screen is entitled to ask for a different API.
|
|
101
104
|
|
|
102
105
|
## When not to use
|
|
103
106
|
|
|
@@ -113,7 +116,7 @@ The engine is **not in the kit's manifest** — neither in `peerDependencies` no
|
|
|
113
116
|
npm install @tiptap/core @tiptap/pm @tiptap/react @tiptap/starter-kit @tiptap/extensions \
|
|
114
117
|
@tiptap/extension-list @tiptap/extension-subscript @tiptap/extension-superscript \
|
|
115
118
|
@tiptap/extension-text-style @tiptap/extension-highlight @tiptap/extension-text-align @tiptap/extension-link \
|
|
116
|
-
@tiptap/extension-table @tiptap/extension-blockquote @tiptap/extension-horizontal-rule \
|
|
119
|
+
@tiptap/extension-table @tiptap/extension-details @tiptap/extension-blockquote @tiptap/extension-horizontal-rule \
|
|
117
120
|
@tiptap/markdown marked @tiptap/suggestion \
|
|
118
121
|
@tiptap/extension-code-block-lowlight lowlight highlight.js
|
|
119
122
|
```
|
|
@@ -143,64 +146,64 @@ import { RichTextEditor } from "@olddevs/ui/editor";
|
|
|
143
146
|
|
|
144
147
|
The fixed Toolbar sits above the field and **sticks on scroll** below the app header: the offset is the CSS variable `--topbar-h`, published by the kit's `TopBar` (ADR-0016, ADR-0019). Without a header the variable does not exist, and the Toolbar sticks to the viewport edge. The stickiness works inside the field's frame: the Toolbar leaves together with the field when the field is scrolled out entirely. By default the Editor has no scroll of its own — it grows with the Document; a height limit and a height handle are enabled by `maxHeight` and `resizable` (see the "Field height" section).
|
|
145
148
|
|
|
146
|
-
- `toolbar="fixed"` (default) — the Toolbar above the field, and above a non-empty selection also the
|
|
149
|
+
- `toolbar="fixed"` (default) — the Toolbar above the field, and above a non-empty selection also the Bubble menu, the same pill as in `toolbar="bubble"` (turn it off with `selectionMenu={false}`; it does not exist on a touch screen); `toolbar="floating"` — the Toolbar above a non-empty selection (below); `toolbar="bubble"` — the Bubble menu instead of the Toolbar, a compact pill above the selection (below); `toolbar="none"` — no Toolbar, formatting is available only from the keyboard.
|
|
147
150
|
- In `readOnly` there is no Toolbar. In `disabled` the Toolbar is visible, but all buttons are disabled and do not take focus.
|
|
148
|
-
- **
|
|
151
|
+
- **Keyboard shortcuts popover** is the last Toolbar button, with a keyboard icon, at the right edge; only the fixed Toolbar has it (there is none above the selection or in `readOnly`, and with `toolbar="none"` there is no Toolbar at all). Details are in the "Keyboard shortcuts popover" section.
|
|
149
152
|
- **Contents and order — one compact row; the kit sets the order, not `tools`**. Groups are separated by a divider:
|
|
150
153
|
|
|
151
154
|
| Group | Buttons and menus in order |
|
|
152
155
|
| ----------- | ---------------------------------------------------------------------------------- |
|
|
153
|
-
| Whole Block | "+"
|
|
156
|
+
| Whole Block | "+" Slash menu · HTML · "¶" Block type · Alignment ▾ |
|
|
154
157
|
| Marks | bold · italic · highlight · "…" (other Marks, code block, quote, clear formatting) |
|
|
155
158
|
| Lists | Lists ▾ |
|
|
156
159
|
| Insert | link · image · Table ▾ · emoji |
|
|
157
160
|
| History | undo · redo |
|
|
158
161
|
|
|
159
|
-
At the right edge are Find (the `search` Tool) and the
|
|
162
|
+
At the right edge are Find (the `search` Tool) and the Keyboard shortcuts popover. A button or menu without its Tool enabled is not rendered, and a group without buttons disappears together with its divider: in `basic` the row is bold, italic, "…" | Lists ▾ | link | undo, redo | find and the Keyboard shortcuts popover at the right edge (a "¶" with only "Text" is not rendered). The floating Toolbar (`toolbar="floating"`) has the same contents and order, only without Find and the Keyboard shortcuts popover; the Bubble menu (`toolbar="bubble"`) has its own short set — no alignment, with its own "Lists ▾" and "Color" (below).
|
|
160
163
|
|
|
161
|
-
- There are no separate buttons on the Toolbar for alignment, lists, quote, horizontal rule and code block: alignment and lists are menus, quote is a checkbox in "…", the rule is
|
|
164
|
+
- There are no separate buttons on the Toolbar for alignment, lists, quote, horizontal rule and code block: alignment and lists are menus, quote is a checkbox in "…", the rule is a Slash menu item ("+" and `/`), the code block is an item in "…" (they are used rarely). When the Toolbar is narrower than its content it wraps onto the next row instead of running past the edge — **by whole groups**: "Undo · Redo" never split across rows, the divider sits at the end of a group, and a wrapped row starts with a button. The full row in a 640px column takes two rows: search and the Keyboard shortcuts popover go to the second one, to the right edge.
|
|
162
165
|
|
|
163
166
|
### The "¶" menu — line type
|
|
164
167
|
|
|
165
168
|
The "¶" button opens the line type menu under the caret: **Text** (`Ctrl/⌘+Alt+0`), **Heading 1** (only with `heading1`, `Ctrl/⌘+Alt+1`), **Heading 2–4** (`Ctrl/⌘+Alt+2…4`). The shortcut is shown on every item: `⌘`, `⌥`, `⇧` joined with a plus on Mac and iOS (`⌘+⌥+2`), `Ctrl`, `Alt`, `Shift` elsewhere.
|
|
166
169
|
|
|
167
|
-
- **There are no lists or quote in "¶"** (the owner's decision): lists have the "Lists ▾" menu (on the Toolbar and in the
|
|
170
|
+
- **There are no lists or quote in "¶"** (the owner's decision): lists have the "Lists ▾" menu (on the Toolbar and in the Bubble menu), quote has the "Quote" checkbox in "…". They stay in the Block handle's "Format ›" and in the Slash menu ("/", "Turn into"): there a Block is turned into anything, rather than a line type being chosen.
|
|
168
171
|
- **The items come from the enabled Tools** (`heading`, `heading1`) plus the "¶" items of custom Tools; "Text" is always there as long as there is at least one other item. Without headings there is no "¶" button — that is the case in `basic`.
|
|
169
172
|
- **The line under the caret decides**: a paragraph in a list item or in a quote is "Text", a heading in a quote is its level. This type is checked, and it also appears in the button's accessible name ("Block type: Heading 2") and **on the icon**: the heading level (H1–H4), the icon of a custom Tool's item, and "¶" for text (before that the icon was always "¶", ADR-0054). In a code block the button says "Code block", the icon is "¶", and no item is checked: the code block is an item of "…".
|
|
170
|
-
- **A choice changes the line type in place** and keeps the text: a heading in a quote stays in the quote. Where the Schema does not allow this type for the line (a heading first in a list item), the
|
|
173
|
+
- **A choice changes the line type in place** and keeps the text: a heading in a quote stays in the quote. Where the Schema does not allow this type for the line (a heading first in a list item), the Slash menu's "Turn into" takes over: the lists and quotes around the line are removed, the type is set. It is one edit, and `Ctrl/⌘+Z` undoes it whole. Choosing the current type changes nothing. Focus returns to the Document.
|
|
171
174
|
|
|
172
175
|
### The "…" menu — More formatting
|
|
173
176
|
|
|
174
|
-
The "…" button (tooltip and name — "More formatting", `editor.moreFormatting`) comes right after the "Highlight" button (in the
|
|
177
|
+
The "…" button (tooltip and name — "More formatting", `editor.moreFormatting`) comes right after the "Highlight" button (in the Bubble menu, after "Color") and opens the menu of the remaining formatting: **Inline code** (`Ctrl/⌘+E`), **Underline** (`Ctrl/⌘+U`), **Strikethrough** (`Ctrl/⌘+Shift+S`), **Superscript** (`Ctrl/⌘+.`), **Subscript** (`Ctrl/⌘+,`), **Code block** (`Ctrl/⌘+Alt+C`), **Quote** (`Ctrl/⌘+Shift+B`), **Clear formatting** (`Ctrl/⌘+\`). There are no colors in "…" — they have their own buttons (below). The shortcuts shown on the items come from the same table as the Toolbar tooltips and the Keyboard shortcuts popover. There are no shortcuts borrowed from familiar editors (`⌘H`, `⌘L`, `⌘⇧M`): macOS and the browser intercept them, and the label would promise something that does not fire.
|
|
175
178
|
|
|
176
|
-
- **The items come from the enabled Tools** (`code`, `underline`, `strike`, `superscript`, `subscript`, `codeBlock`, `blockquote`, `clearFormatting`). Without any of them there is no "…" button; with one, the menu has a single item. In `basic`, "…" has inline code, strikethrough and quote. A Mark that has a button in the row of the Toolbar or the
|
|
179
|
+
- **The items come from the enabled Tools** (`code`, `underline`, `strike`, `superscript`, `subscript`, `codeBlock`, `blockquote`, `clearFormatting`). Without any of them there is no "…" button; with one, the menu has a single item. In `basic`, "…" has inline code, strikethrough and quote. A Mark that has a button in the row of the Toolbar or the Bubble menu is not repeated in that menu's "…": the Bubble menu has no underline in "…" because it is in the row.
|
|
177
180
|
- **Marks are items with a check** (`menuitemcheckbox`, `aria-checked`): the Marks of the selection or caret are checked. A choice sets or removes the Mark in one edit (`Ctrl/⌘+Z` undoes it whole), closes the menu and returns focus to the Document. An item that does not apply here (a Mark inside a code block) is unavailable.
|
|
178
181
|
- **The code block is a separate section right after the Marks** (before that it was a Toolbar button between "HTML" and "¶"). It is a Block, not a Mark, so it comes after a divider, but it is the same checkbox item with a shortcut: all the toggles of "…" are listed one after another, and "Clear formatting" comes after them.
|
|
179
182
|
- The code block item is checked when the caret is in a code block; choosing it turns the selection's Blocks into a code block or back into text — in one edit, like `Ctrl/⌘+Alt+C`. It is unavailable where the engine does not allow a code block.
|
|
180
|
-
- The same item is in the "…" menu of the
|
|
183
|
+
- The same item is in the "…" menu of the Bubble menu: the Bubble menu had no code block button, and that is where turning selected lines into code is convenient.
|
|
181
184
|
- **Quote is a checkbox next to the code block** (before that it was a "¶" item): checked when the caret is in a quote; choosing it wraps the selection's Blocks in a quote or removes it — in one edit, like `Ctrl/⌘+Shift+B`. Unavailable where the engine does not allow a quote. Present only if `blockquote` is enabled.
|
|
182
185
|
- **On a narrow screen the submenu goes under the item** (narrower than 640px): there is no room at the side on a phone, so the submenu of the Block handle "⋮" and the "Table" menu (together with the size grid) open under their item on top of the parent menu. On a wide screen they open at the side, as before.
|
|
183
|
-
- There are no separate buttons on the Toolbar for inline code, underline, strikethrough, superscript, subscript, code block and clearing: "…" replaces them. The menu is built in the shared formatting menu module (`MoreFormatMenu` in `FormatMenus.tsx`) — the same "…" appears in the
|
|
184
|
-
- The row of frequently used Marks is shared by the Toolbar and the
|
|
186
|
+
- There are no separate buttons on the Toolbar for inline code, underline, strikethrough, superscript, subscript, code block and clearing: "…" replaces them. The menu is built in the shared formatting menu module (`MoreFormatMenu` in `FormatMenus.tsx`) — the same "…" appears in the Bubble menu too.
|
|
187
|
+
- The row of frequently used Marks is shared by the Toolbar and the Bubble menu (`rowMarks.ts`): on the Toolbar it is bold and italic, in the Bubble menu also underline.
|
|
185
188
|
|
|
186
189
|
### The "Highlight" and "Color" buttons
|
|
187
190
|
|
|
188
191
|
Color is chosen by eye, from dots, in one click — as in Tiptap's Notion-like template. Before that, text color and highlight were submenus of "…", and the "Highlight" button applied yellow in one click.
|
|
189
192
|
|
|
190
|
-
**Which button goes where** (the owner's decision — no duplicates): on the Toolbar (fixed and floating) there is only "Highlight", in the
|
|
193
|
+
**Which button goes where** (the owner's decision — no duplicates): on the Toolbar (fixed and floating) there is only "Highlight", in the Bubble menu there is only "Color", which also has the highlight grid. Without the `color` Tool, the Bubble menu has "Highlight" — otherwise there would be no way to set a highlight there. **The trade-off of this decision:** text color is chosen only from the Bubble menu. Buttons cannot set it where there is no Bubble menu: with `selectionMenu={false}`, with `toolbar="floating"` and on a touch screen (there the fixed Toolbar replaces the Bubble menu). The Handle and engine commands remain.
|
|
191
194
|
|
|
192
195
|
- **"Highlight"** (the `highlight` Tool, tooltip — `editor.highlightToggle`): a click opens a row of nine highlights by hue — gray, brown, orange, yellow, green, blue, purple, pink, red — and "Remove highlight" (a crossed-out circle with a tooltip, `editor.highlightRemove`). The button's name states the current highlight ("Highlight: Pink", "Highlight: Custom #ffe066", "Highlight: None"), so it is audible without opening the popover; while the selection has a highlight, the button looks pressed. A click on the button itself no longer applies yellow: yellow is set by clicking its dot.
|
|
193
196
|
- **"Color"** (the `color` Tool, `editor.color`): the letter "A" in the current text color and an arrow. The popover has two grids: **"Text color"** (`editor.textColorSection`: six tones, a dot with the letter "A" in that color, and "Remove color" as the last cell, `editor.colorRemove`) and **"Highlight color"** (`editor.highlightSection`: the same nine highlights, and "Remove highlight" as the last cell). The highlight grid is there only if `highlight` is enabled too. Removal is a crossed-out circle with a tooltip at the end of the grid, as in "Highlight", not a white dot at the start: that one looked like one more color and was checked when there is no color. With no color, nothing in the grid is checked, and focus on open is on the first dot. Next to each grid's heading is **"Custom color…"**: it opens a `ColorPicker` popover (see the "Custom color" section below).
|
|
194
197
|
- **Roles — as in a WAI-ARIA Authoring Practices (APG) menu, not a radio group:** the popover is a `menu`, the grids are a `group` with a heading, the dots and "Custom color…" are `menuitemradio` with `aria-checked` (the current one is marked with a ring around the dot), "Remove color" and "Remove highlight" are `menuitem`. In a radio group an arrow key would choose right away and close the popover; here the arrow keys only move between dots: `←` `→` — the neighboring dot, `↑` `↓` — the same column in the row above or below (five per row), `Home` and `End` — the first and the last; Enter, Space and click apply.
|
|
195
198
|
- **Focus.** On open, it is on the checked color (or the first one). A choice applies the color in one edit (`Ctrl/⌘+Z` undoes it whole), closes the popover and returns focus to the Document, to the previous selection. Escape closes only the popover and returns focus to the button — in both `Dialog` and `Drawer` the dialog or drawer stays open (ADR-0041).
|
|
196
|
-
- **In the
|
|
199
|
+
- **In the Bubble menu** the button itself holds the popover: while it is open, the button has `aria-expanded="true"` and the Bubble menu does not hide. In Source mode and on inline code the buttons are unavailable (`aria-disabled`) and do not open the popover. In `basic` neither button exists: neither `color` nor `highlight` is part of it.
|
|
197
200
|
|
|
198
201
|
### The "Alignment" and "Lists" menus
|
|
199
202
|
|
|
200
203
|
Both are menus from the same shared module as "¶" and "…" (`AlignMenu` and `ListMenu` in `FormatMenus.tsx`): radio items (`menuitemradio`) with the current value checked and shortcut labels from the shared table. The button's icon is the icon of the current value, its name is the value in words; a choice returns focus to the Document and is undone by a single `Ctrl/⌘+Z`.
|
|
201
204
|
|
|
202
205
|
- **Alignment ▾** (the `align` Tool): **Align left** (`Ctrl/⌘+Shift+L`), **Align center** (`Ctrl/⌘+Shift+E`), **Align right** (`Ctrl/⌘+Shift+R`). The button's name is "Alignment: Align center"; for a Block without `data-align`, "Align left" is checked, and so it is for a selection with mixed alignment. Choosing the checked item changes nothing and puts no empty edit into history; choosing "Align left" restores the default. **There is no justify** — the decision is below, in "Color, highlight and alignment" (WCAG 1.4.8).
|
|
203
|
-
- **Lists ▾** (any of `bulletList`, `orderedList`, `taskList`): **Bulleted list** (`Ctrl/⌘+Shift+8`), **Numbered list** (`Ctrl/⌘+Shift+7`), **Checklist** (`Ctrl/⌘+Shift+9`) — the same kinds as in "Format ›" of the Block handle, only from the enabled Tools. The kind of the nearest list around the caret is checked (also in a quote inside an item), the button's name is "List: numbered", and outside a list it is "Lists" with the generic list icon. Choosing another kind works the same way as the
|
|
206
|
+
- **Lists ▾** (any of `bulletList`, `orderedList`, `taskList`): **Bulleted list** (`Ctrl/⌘+Shift+8`), **Numbered list** (`Ctrl/⌘+Shift+7`), **Checklist** (`Ctrl/⌘+Shift+9`) — the same kinds as in "Format ›" of the Block handle, only from the enabled Tools. The kind of the nearest list around the caret is checked (also in a quote inside an item), the button's name is "List: numbered", and outside a list it is "Lists" with the generic list icon. Choosing another kind works the same way as the Slash menu's "Turn into": the Block leaves the list and the quote and is wrapped again in one edit. **Choosing the checked kind again removes the list**, like the old toggle button. A menu with a single enabled kind stays a menu.
|
|
204
207
|
- Without the `align` Tool there is no alignment menu; without any list kind there is no "Lists" menu. In `basic` there is "Lists" with two items and no alignment.
|
|
205
208
|
|
|
206
209
|
### Floating Toolbar
|
|
@@ -211,55 +214,55 @@ Both are menus from the same shared module as "¶" and "…" (`AlignMenu` and `L
|
|
|
211
214
|
- **The Toolbar lives in the Editor's DOM**, not in a portal: it travels with the text in any scroll container (window, drawer, card). So an ancestor with `overflow: hidden` clips it at its edge — do not put the Editor in such a container right against the top, or use `toolbar="fixed"`.
|
|
212
215
|
- The layer is the `OVERLAY_LAYER.popover` step, like popovers. The Toolbar does not take part in the Escape stack: there is nothing to close in it, and Escape stays with the dialog or drawer around the Editor.
|
|
213
216
|
|
|
214
|
-
###
|
|
217
|
+
### Bubble menu: `selectionMenu` and `toolbar="bubble"`
|
|
215
218
|
|
|
216
|
-
By default the
|
|
219
|
+
By default the Bubble menu also appears on top of the fixed Toolbar (`toolbar="fixed"`, the `selectionMenu` prop, `true` by default): the Toolbar at the top is for everything; the Bubble menu keeps the mouse near the text. In that case `Alt+F10` moves focus to the Toolbar. `toolbar="bubble"` puts the **Bubble menu** in place of the Toolbar — a compact pill above the selected text, as in Notion and Medium. It sits on an **inverse surface**: dark in the light theme, light in `dark` and `dim` — so it blends neither with the text nor with the field under it.
|
|
217
220
|
|
|
218
|
-
- **The set is defined by the kit and is not configurable:** "¶" (line type, the same menu as on the Toolbar) · Lists ▾ (the same menu as on the Toolbar) · bold · italic · underline · Color ▾ (both grids — text and highlight; without `color`, "Highlight") · "…" (the other Marks and the code block — the same menu as on the Toolbar, but without underline: it is in the row here) · link (the same popover as `Ctrl/⌘+K`) · emoji (the same grid). A button of a disabled Tool is not rendered: `basic` has no emoji, and with `tools={["italic", "link"]}` two buttons remain. There is no undo, redo,
|
|
221
|
+
- **The set is defined by the kit and is not configurable:** "¶" (line type, the same menu as on the Toolbar) · Lists ▾ (the same menu as on the Toolbar) · bold · italic · underline · Color ▾ (both grids — text and highlight; without `color`, "Highlight") · "…" (the other Marks and the code block — the same menu as on the Toolbar, but without underline: it is in the row here) · link (the same popover as `Ctrl/⌘+K`) · emoji (the same grid). A button of a disabled Tool is not rendered: `basic` has no emoji, and with `tools={["italic", "link"]}` two buttons remain. There is no undo, redo, Keyboard shortcuts popover or rare Marks in the Bubble menu — it is about the selected text.
|
|
219
222
|
- **Bold, italic and underline are toggles** with `aria-pressed`; the tooltip names the shortcut from the same table as the Toolbar's, and the button announces it with `aria-keyshortcuts`.
|
|
220
|
-
- **Display and position are as in the floating Toolbar:** the
|
|
223
|
+
- **Display and position are as in the floating Toolbar:** the Bubble menu appears when the selection has settled, and hides on an empty selection, on a selected image (it has its own panel) and when focus has left the Editor. At the edge of the viewport or a scroll container it flips below the selection and shifts inward. In `readOnly` there is no Bubble menu.
|
|
221
224
|
- **A touch screen gets the fixed Toolbar**, for the same reason as with `floating`: the system selection menu of iOS and Android appears in the same place.
|
|
222
|
-
- **Menus and popovers opened from the
|
|
223
|
-
- **The
|
|
224
|
-
- The
|
|
225
|
-
- **The colors are inverse surface tokens** `--surface-inverse`, `--surface-inverse-hover`, `--surface-inverse-pressed`, `--text-on-inverse` and the focus outline `--accent-on-inverse` ([palette.md](../../palette.md#инверсная-поверхность)). On the Paper (`surface="paper"`) the
|
|
225
|
+
- **Menus and popovers opened from the Bubble menu use the regular popover surface**, not the inverse one: "¶", "…", the link field and the emoji grid look the same as when opened from the Toolbar. Escape in an open menu closes only the menu and returns focus to its button in the Bubble menu; inside `Dialog` and `Drawer` the dialog or drawer stays open.
|
|
226
|
+
- **The Bubble menu lives in the Editor's DOM**, like the floating Toolbar: it travels with the text in a scroll container, and an ancestor with `overflow: hidden` clips it at its edge. The layer is `OVERLAY_LAYER.popover`.
|
|
227
|
+
- The Bubble menu's name is `editor.selectionToolbar` ("Selection formatting" in English, "Форматирование выделения" in Russian): the screen reader tells it apart from the Toolbar.
|
|
228
|
+
- **The colors are inverse surface tokens** `--surface-inverse`, `--surface-inverse-hover`, `--surface-inverse-pressed`, `--text-on-inverse` and the focus outline `--accent-on-inverse` ([palette.md](../../palette.md#инверсная-поверхность)). On the Paper (`surface="paper"`) the Bubble menu stays in the app theme, like the Toolbar.
|
|
226
229
|
|
|
227
230
|
### Glass on popup surfaces
|
|
228
231
|
|
|
229
|
-
Everything the Editor shows above the Document sits on **Glass** — a semi-transparent background of its surface that blurs what is behind it: the floating Toolbar and the image panel, the
|
|
232
|
+
Everything the Editor shows above the Document sits on **Glass** — a semi-transparent background of its surface that blurs what is behind it: the floating Toolbar and the image panel, the Bubble menu, menu lists ("¶", "…", alignment, lists, table, Slash menu, Block handle, code block language), popovers (link, image, custom color, emoji, Keyboard shortcuts popover, popovers of the buttons, Insert items and Block actions of a custom Tool) and the Text card. The background is 82% of the surface token in `color-mix`; the blur is 14px, as in toasts (ADR-0010); tooltips keep their own 85% and 10px.
|
|
230
233
|
|
|
231
|
-
- **The color is the token of its own surface:** the Toolbar and popovers use `--bg-1`, menu lists use `--surface-elevated`, the
|
|
234
|
+
- **The color is the token of its own surface:** the Toolbar and popovers use `--bg-1`, menu lists use `--surface-elevated`, the Bubble menu uses `--surface-inverse`. The theme changes the token, and the Glass changes with it.
|
|
232
235
|
- **A solid background under `prefers-reduced-transparency: reduce` and `prefers-contrast: more`:** under a semi-transparent background, text contrast depends on what lies beneath the menu, and a user who asked the system to remove transparency or raise contrast gets the old solid surface without blur.
|
|
233
236
|
- **A solid background on iOS:** in Safari on iOS 27 the menu's Glass background was not rendered at all, and the items ended up on top of the Document text. So on iOS (the condition `@supports (-webkit-touch-callout: none)`, which only WebKit on iOS understands) the Glass is replaced with a solid surface without blur; desktop Safari, Chrome and Firefox keep the Glass.
|
|
234
237
|
- **The Editor only.** The kit's `Menu` and `Popover` outside the Editor stay solid: the Editor passes the Glass to them as a class, and the public API did not change.
|
|
235
238
|
|
|
236
239
|
### Keyboard: `Alt+F10`
|
|
237
240
|
|
|
238
|
-
`Alt+F10` (`⌥F10` on Mac) from the Document moves focus to the first button of the Toolbar — fixed, floating or the
|
|
241
|
+
`Alt+F10` (`⌥F10` on Mac) from the Document moves focus to the first button of the Toolbar — fixed, floating or the Bubble menu; the key shows the floating Toolbar and the Bubble menu at once, without waiting for the delay. The shortcut is the same as in CKEditor and TinyMCE: APG has none of its own, and one shortcut for all editors is easier to remember. Tab is not an option here — in the Document it indents a list item.
|
|
239
242
|
|
|
240
243
|
- Inside the Toolbar, the arrow keys, Home and End work as usual. A button that has fired returns focus to the Document, to the same selection; if nothing was pressed, `Shift+Tab` moves focus back: the floating Toolbar sits in the DOM right after the Document.
|
|
241
244
|
- The Toolbar does not intercept Escape: in a dialog or drawer it closes it (see "Accessibility and forms").
|
|
242
|
-
- On an empty selection of the floating Toolbar and the
|
|
245
|
+
- On an empty selection of the floating Toolbar and the Bubble menu, and with `toolbar="none"`, the key does nothing and passes on.
|
|
243
246
|
- The Toolbar announces the shortcut with `aria-keyshortcuts="Alt+F10"` — the screen reader names it on the Toolbar itself.
|
|
244
|
-
- From the
|
|
247
|
+
- From the Source mode field, `Alt+F10` moves focus to the "HTML" button: the other Toolbar buttons are `aria-disabled` there, and it is the first one that works (Enter on it switches back to visual mode, and the arrow keys move on through the Toolbar). See the "Source mode" section.
|
|
245
248
|
- **An open Text card** takes priority over the Toolbar: `Alt+F10` moves focus into it, Escape returns the caret; while the Text card is hidden or closed, the key moves focus to the Toolbar (see the "Text card" section).
|
|
246
249
|
- With the caret in a code block, `Alt+F10` moves focus first to that Block's language button, then to "Copy", then to the Toolbar (see the "Code block" section). Likewise, on a selected image the key moves focus to its panel.
|
|
247
250
|
|
|
248
|
-
##
|
|
251
|
+
## Keyboard shortcuts popover
|
|
249
252
|
|
|
250
253
|
The button with a keyboard icon ("Keyboard shortcuts") is the last one on the fixed Toolbar, at the right edge. It opens the kit's `Popover` with a "command — shortcut" list that is **read-only**: the rows are not clickable, this is not a command palette.
|
|
251
254
|
|
|
252
|
-
- **There is no button on a phone.** On a touch screen without hover (`@media (hover: none) and (pointer: coarse)` — a phone, a tablet without a trackpad) there are no shortcuts, so the
|
|
255
|
+
- **There is no button on a phone.** On a touch screen without hover (`@media (hover: none) and (pointer: coarse)` — a phone, a tablet without a trackpad) there are no shortcuts, so the Keyboard shortcuts button is hidden, and menu items ("¶", "…", table, Slash menu, Block handle) do not show shortcut labels. A tablet with a trackpad and a laptop with a touch screen keep the tooltips.
|
|
253
256
|
- **The markup is a table** with the column headers "Command" and "Shortcut"; the shortcut is the kit's `Kbd` (`<kbd>`). A screen reader reads a row as a "command — shortcut" pair.
|
|
254
|
-
- **Custom buttons.** The shortcut of a custom Tool's button (the `shortcut` field) goes into the
|
|
257
|
+
- **Custom buttons.** The shortcut of a custom Tool's button (the `shortcut` field) goes into the Keyboard shortcuts popover as a separate group without a heading after the kit's groups, in `tools` order; the label uses the `UiProvider` locale. Keyboard shortcuts bound by a custom Tool's Extensions do not appear here — see the "Custom Tool's button" section.
|
|
255
258
|
- **Group order:** selection (the first `⌘A` selects the Block's text, the second the whole Document), undo and redo, Block commands (duplicate, up, down, handle menu), clear formatting, Marks (bold, italic, strikethrough, underline, inline code, superscript and subscript), Block type (text, headings, quote, code block), lists and item indent (`⌘]` and `⌘`), alignment, link, search (`Ctrl/⌘+F`, `Ctrl/⌘+Alt+F`), moving to the Toolbar (`Alt+F10`).
|
|
256
259
|
- **The set depends on `tools`.** A row exists only for the command of an enabled Tool: `basic` has no headings, underline, superscript and subscript, alignment, checklist or code block, and the item indent is there with any of the three lists. Heading 1 only with `heading` and `heading1` together. Commands without a Tool of their own are always there: selection, duplicating and reordering a Block, the Block handle menu, `Alt+F10`.
|
|
257
260
|
- **Custom Tools.** The shortcuts of their Insert items and Block actions (the `shortcut` field) form a group without a heading after the kit's groups, one group per Tool, in `tools` order; a row's label is the UI contribution's label in the current locale.
|
|
258
261
|
- **Width follows the content:** the popover is as wide as the longest row, with no empty gap between the command and the shortcut; the screen edge limits it, and a long list scrolls.
|
|
259
262
|
- **Labels.** Keys are joined with a plus on all platforms: `⌘+⇧+Z` on Apple and `Ctrl+Shift+Z` elsewhere. The macOS run-together form (`⌘⌥⇧↑`) reads as one word in a long list. The Apple detection is the same as ProseMirror's, that is, the one the engine uses to decide which key is `mod`. `Kbd` renders the symbols `⌘ ⌥ ⇧ ⌃` in a proportional font: a monospace stack has no `⇧`, and the fallback glyph comes out thin.
|
|
260
|
-
- **One command table is the source of truth.** The tooltips and `aria-keyshortcuts` of Toolbar buttons, the labels in the
|
|
261
|
-
- **Closing.** Escape and a click outside close the popover and return focus to the button (if the click itself put focus on a field or button, it is not taken away). Inside `Dialog` and `Drawer`, Escape closes only the
|
|
262
|
-
- **Not shown:** in `readOnly`, with `toolbar="none"`, above the selection in `toolbar="floating"` and in the
|
|
263
|
+
- **One command table is the source of truth.** The tooltips and `aria-keyshortcuts` of Toolbar buttons, the labels in the Slash menu and the Keyboard shortcuts popover read shortcuts from one table (`src/editor/core/shortcuts.ts`: command id, Tool, keyboard shortcut, a label from the message catalog). The extensions bind the keyboard shortcuts, and the table labels them; so that a label never promises what is not there, `tests/rich-text-editor-shortcuts.test.tsx` presses every shortcut of the table in a live Editor and checks the result.
|
|
264
|
+
- **Closing.** Escape and a click outside close the popover and return focus to the button (if the click itself put focus on a field or button, it is not taken away). Inside `Dialog` and `Drawer`, Escape closes only the Keyboard shortcuts popover, not the dialog or drawer ([ADR-0041 (ADR-0041)).
|
|
265
|
+
- **Not shown:** in `readOnly`, with `toolbar="none"`, above the selection in `toolbar="floating"` and in the Bubble menu (`toolbar="bubble"`); on a touch screen they are replaced by the fixed Toolbar, but the Keyboard shortcuts button is hidden there by the rule at the start of the section: on a phone and on a tablet without a trackpad it is absent.
|
|
263
266
|
|
|
264
267
|
Message catalog strings: `editor.shortcuts` (the name of the button and popover), `shortcutsCommand` and `shortcutsKeys` (column headers), and the labels of commands without a button of their own — `selectBlock`, `selectDocument`, `duplicateBlock`, `moveBlockUp`, `moveBlockDown`, `blockActions`, `indentListItem`, `outdentListItem`, `focusToolbar`.
|
|
265
268
|
|
|
@@ -280,14 +283,14 @@ A click or `Ctrl/⌘+/` from the Document opens a menu — the kit's `Menu`:
|
|
|
280
283
|
- **The Block as a whole.** The handle sits at a Document Block: a paragraph, a heading, a whole list, a checklist, a quote, a code block, a table, an image, a rule. With the caret in a list's second item, the handle is at the list's first line, and the menu actions apply to the whole list; "Format › Heading 2" turns all its items into headings. A selection across several Blocks has no handle.
|
|
281
284
|
- **Shortcut labels only when the shortcut does the same thing.** For a top-level paragraph or heading the items are labeled: `⌥+⇧+↑/↓`, `⌘+⇧+D`, the "¶" shortcuts in "Format ›". In a list item `⌥⇧↑` moves the item, not the list, so there are no labels; in a table `⌥⇧↑/↓` belong to rows, so "Up" and "Down" have no label, while `⌘⇧D` copies the whole table, so "Duplicate" has one. "Add ›" has no labels: the shortcuts change the current Block, and the item puts a new one.
|
|
282
285
|
- **One edit to undo**, and focus after the action is in the Document. Escape closes the submenu, then the menu, and returns focus to the Document; inside `Dialog` and `Drawer` the dialog or drawer does not close (ADR-0041).
|
|
283
|
-
- **Keyboard.** The handle is outside the Tab order (in the Document Tab indents a list item); `Ctrl/⌘+/` moves focus to it — like "Block actions" in Notion, and browsers and systems leave this shortcut free in a field. The row is in the
|
|
286
|
+
- **Keyboard.** The handle is outside the Tab order (in the Document Tab indents a list item); `Ctrl/⌘+/` moves focus to it — like "Block actions" in Notion, and browsers and systems leave this shortcut free in a field. The row is in the Keyboard shortcuts popover; the handle has `aria-keyshortcuts`.
|
|
284
287
|
- **Tool actions.** Custom Block actions (`blockActions` of a Tool description, like "Unwrap" of the Wrapper) appear as a separate section before "Delete": only for Blocks whose names are listed in `blocks`, and filtered by a visibility predicate; `run` receives the Block's position. Custom Insert items go into "Add ›" and "Format ›" (see the "Custom Tool" section).
|
|
285
288
|
- **There is no dragging by the handle** (the owner's decision).
|
|
286
|
-
- **
|
|
289
|
+
- **Column layout** (see the "Column layout" section). A column is a container of Blocks, like the Document: a Block in a column has its own handle at the column's left edge, and its actions do not leave the column. In "Add ›" the Column layout is a submenu of six variants; a Block in a column has the "Layout ›" item (a variant of its Column layout), and for a Column layout selected as a whole, "Format ›" holds these six variants with the current one checked.
|
|
287
290
|
- **Wrapper** (see the "Wrapper" section). A Wrapper is also a container of Blocks: a Block in it has its own handle at the Wrapper's left edge, and its actions do not leave it. The items **"Tone ›"** and **"Unwrap"** are available for a Block directly in a Wrapper and for the Wrapper itself selected as a whole (Backspace on the Block under it); for the Wrapper, "Up", "Down", "Duplicate" and "Delete" apply to the Wrapper together with its content, and "Format ›" is unavailable — it has no Block type.
|
|
288
291
|
- **Placement.** The handle lives in the Editor's DOM and travels with the text in any scroll container, in `Dialog` and `Drawer`; the sticky Toolbar and popovers cover it. The position is recomputed on every edit and on resize.
|
|
289
292
|
|
|
290
|
-
Message catalog strings: `editor.blockActions` (the name of the handle, menu and
|
|
293
|
+
Message catalog strings: `editor.blockActions` (the name of the handle, menu and Keyboard shortcuts popover row), `blockAdd`, `blockFormat`, `blockMoveUp`, `blockMoveDown`, `blockDuplicate`, `blockDelete`. Icons — `MoreVerticalIcon`, `AddIcon`, `ParagraphIcon`, `ArrowUpIcon`, `ArrowDownIcon`, `DuplicateIcon`, `DeleteIcon` from `@olddevs/ui/icons`.
|
|
291
294
|
|
|
292
295
|
## Keyboard: Block commands
|
|
293
296
|
|
|
@@ -330,40 +333,41 @@ Presets are also exported as arrays of names — `RICH_TEXT_PRESETS.full` and `R
|
|
|
330
333
|
|
|
331
334
|
**A disabled Tool removes both the button and the Schema extension.** Its Block or Mark cannot be created with a keyboard shortcut, a Markdown shortcut or a paste, and from `value` they arrive without Block markup, and the text stays.
|
|
332
335
|
|
|
333
|
-
| `Tool` | Button | Markup
|
|
334
|
-
| ----------------- | ---------------------------------------------------- |
|
|
335
|
-
| `heading` | "¶": Heading 2–4 | `h2`, `h3`, `h4`
|
|
336
|
-
| `heading1` | "¶": adds Heading 1 | `h1`
|
|
337
|
-
| `history` | Undo, Redo | —
|
|
338
|
-
| `bold` | Bold | `strong`
|
|
339
|
-
| `italic` | Italic | `em`
|
|
340
|
-
| `underline` | "…": Underline | `u`
|
|
341
|
-
| `strike` | Strikethrough | `s`
|
|
342
|
-
| `code` | "…": Inline code | `code`
|
|
343
|
-
| `superscript` | "…": Superscript | `sup`
|
|
344
|
-
| `subscript` | "…": Subscript | `sub`
|
|
345
|
-
| `clearFormatting` | "…": Clear formatting | — (removes Marks)
|
|
346
|
-
| `link` | Link (address popover) | `a[href]`
|
|
347
|
-
| `color` | Color ▾ (
|
|
348
|
-
| `highlight` | Highlight ▾ (Toolbar), "Color ▾" | `mark[data-color]`, `mark[style]`
|
|
349
|
-
| `align` | Alignment ▾ (menu) | `p`, `h2`–`h4` with `data-align`
|
|
350
|
-
| `bulletList` | Lists ▾: Bulleted list | `ul`, `li`
|
|
351
|
-
| `orderedList` | Lists ▾: Numbered list | `ol`, `li`
|
|
352
|
-
| `taskList` | Lists ▾: Checklist | `ul[data-type=taskList]` and items
|
|
353
|
-
| `blockquote` | "…": Quote | `blockquote`
|
|
354
|
-
| `codeBlock` | "…": Code block | `pre`, `code class="language-*"`
|
|
355
|
-
| `horizontalRule` | "+": Divider | `hr`
|
|
356
|
-
| `table` | Table ▾ (menu) | `table`, `tbody`, `tr`, `th`, `td`
|
|
357
|
-
| `image` | Image (address popover) | `figure`, `img`, `figcaption`
|
|
358
|
-
| `emoji` | Emoji (popover with a grid) | — (plain text)
|
|
359
|
-
| `source` | HTML (mode toggle) | — (edits the Document's HTML)
|
|
360
|
-
| `search` | Find (find and replace panel) | — (searches the Document)
|
|
361
|
-
| `layout` | "+": Layout | `div` with `data-block` and `data-layout`
|
|
362
|
-
| `wrapper` | "+": Wrapper | `div[data-block=wrapper]`
|
|
363
|
-
| `
|
|
364
|
-
| `
|
|
365
|
-
|
|
366
|
-
|
|
336
|
+
| `Tool` | Button | Markup | Shortcut | Markdown while typing | `basic` |
|
|
337
|
+
| ----------------- | ---------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------- | ---------------------- | ------- |
|
|
338
|
+
| `heading` | "¶": Heading 2–4 | `h2`, `h3`, `h4` | `Ctrl/⌘+Alt+2…4` | `## `, `### `, `#### ` | — |
|
|
339
|
+
| `heading1` | "¶": adds Heading 1 | `h1` | `Ctrl/⌘+Alt+1` | `# ` | — |
|
|
340
|
+
| `history` | Undo, Redo | — | `Ctrl/⌘+Z`, `+Shift+Z` | — | yes |
|
|
341
|
+
| `bold` | Bold | `strong` | `Ctrl/⌘+B` | `**text**` | yes |
|
|
342
|
+
| `italic` | Italic | `em` | `Ctrl/⌘+I` | `*text*` | yes |
|
|
343
|
+
| `underline` | "…": Underline | `u` | `Ctrl/⌘+U` | — | — |
|
|
344
|
+
| `strike` | Strikethrough | `s` | `Ctrl/⌘+Shift+S` | `~~text~~` | yes |
|
|
345
|
+
| `code` | "…": Inline code | `code` | `Ctrl/⌘+E` | `` `text` `` | yes |
|
|
346
|
+
| `superscript` | "…": Superscript | `sup` | `Ctrl/⌘+.` | — | — |
|
|
347
|
+
| `subscript` | "…": Subscript | `sub` | `Ctrl/⌘+,` | — | — |
|
|
348
|
+
| `clearFormatting` | "…": Clear formatting | — (removes Marks) | `Ctrl/⌘+\` | — | — |
|
|
349
|
+
| `link` | Link (address popover) | `a[href]` | `Ctrl/⌘+K` | `[text](url)` on paste | yes |
|
|
350
|
+
| `color` | Color ▾ (Bubble menu): "Text color" | `span[data-color]`, `span[style]` | — | — | — |
|
|
351
|
+
| `highlight` | Highlight ▾ (Toolbar), "Color ▾" | `mark[data-color]`, `mark[style]` | — | — | — |
|
|
352
|
+
| `align` | Alignment ▾ (menu) | `p`, `h2`–`h4` with `data-align` | `Ctrl/⌘+Shift+L`, `+E`, `+R` | — | — |
|
|
353
|
+
| `bulletList` | Lists ▾: Bulleted list | `ul`, `li` | `Ctrl/⌘+Shift+8` | `- ` or `* ` | yes |
|
|
354
|
+
| `orderedList` | Lists ▾: Numbered list | `ol`, `li` | `Ctrl/⌘+Shift+7` | `1. ` | yes |
|
|
355
|
+
| `taskList` | Lists ▾: Checklist | `ul[data-type=taskList]` and items | `Ctrl/⌘+Shift+9` | `[ ] ` or `[x] ` | — |
|
|
356
|
+
| `blockquote` | "…": Quote | `blockquote` | `Ctrl/⌘+Shift+B` | `> ` | yes |
|
|
357
|
+
| `codeBlock` | "…": Code block | `pre`, `code class="language-*"` | `Ctrl/⌘+Alt+C` | ` ``` ` | — |
|
|
358
|
+
| `horizontalRule` | "+": Divider | `hr` | — | `---` | — |
|
|
359
|
+
| `table` | Table ▾ (menu) | `table`, `tbody`, `tr`, `th`, `td` | `Tab`, `Shift+Tab`, `Alt+↓` in a table | — | — |
|
|
360
|
+
| `image` | Image (address popover) | `figure`, `img`, `figcaption` | — | `` on paste | — |
|
|
361
|
+
| `emoji` | Emoji (popover with a grid) | — (plain text) | — | — | — |
|
|
362
|
+
| `source` | HTML (mode toggle) | — (edits the Document's HTML) | — | — | — |
|
|
363
|
+
| `search` | Find (find and replace panel) | — (searches the Document) | `Ctrl/⌘+F`, `Ctrl/⌘+Alt+F` | — | yes |
|
|
364
|
+
| `layout` | "+": Layout | `div` with `data-block` and `data-layout` | `Tab`, `Shift+Tab`, arrow keys at the column edge | — | — |
|
|
365
|
+
| `wrapper` | "+": Wrapper | `div[data-block=wrapper]` | `Enter` in an empty last paragraph — exit | — | — |
|
|
366
|
+
| `details` | "+": Collapsible block | `details`, `summary`, `div[data-type=detailsContent]` | `Enter` in the title and at the empty end of the content | — | — |
|
|
367
|
+
| `insertMenu` | "+" Slash menu | — (Blocks of other Tools) | — | `/` | — |
|
|
368
|
+
| `toc` | Table of contents in the left gutter (not in `full`) | — (reads the headings) | — | — | — |
|
|
369
|
+
|
|
370
|
+
A paragraph is always there; a soft line break is `Shift+Enter`. `insertMenu` and `emoji`, like `clearFormatting`, have no markup of their own: `insertMenu` provides access to other Tools' Blocks, and it is disabled where `/` is an ordinary character (paths, "and/or"); `basic` lacks it for the same reason. `emoji` inserts plain text — the Schema, the sanitizer and Markdown do not change because of it (see the "Emoji" section); `basic` lacks it: in a comment the OS's own palette is easier to reach, and a button would take up room in a short row. `source` is the Source mode (see the "Source mode" section): it has no markup of its own either, an HTML edit goes through the same Schema as `value`, and it cannot be used to get around the set of Tools; `basic` lacks it — in a comment an "HTML" button is superfluous unless the screen explicitly asks for it.
|
|
367
371
|
|
|
368
372
|
**The level-one heading is `heading1`** (an add-on to `heading`): `full` has it, `basic` does not. With it the Block levels are 1–4: `Ctrl/⌘+Alt+1` and `# ` with a space give H1, `<h1>` from `value`, `defaultValue` and paste stays H1, and the "¶" menu on the Toolbar offers "Heading 1". Without it the old behavior holds: `# ` while typing stays text, `Ctrl/⌘+Alt+1` does nothing, and `<h1>` in incoming HTML becomes a paragraph. Disable `heading1` on a screen where the page heading already sits above the Editor: a second H1 breaks the page structure. Without the `heading` Tool there is no H1, even if `heading1` is passed.
|
|
369
373
|
|
|
@@ -375,7 +379,7 @@ A name enters the `Tool` type together with the Tool itself, not earlier: wideni
|
|
|
375
379
|
|
|
376
380
|
### Options of a built-in Tool: `tool()`
|
|
377
381
|
|
|
378
|
-
|
|
382
|
+
Three built-in Tools have options: `search`, `toc` and `details`. You set them with the `tool(name, options)` helper, which returns a Configured Tool: an entry for `tools` that sits next to the names and the descriptions of custom Tools (ADR-0069).
|
|
379
383
|
|
|
380
384
|
```tsx
|
|
381
385
|
import { RICH_TEXT_PRESETS, RichTextEditor, tool } from "@olddevs/ui/editor";
|
|
@@ -393,23 +397,23 @@ const tools = [
|
|
|
393
397
|
- **An entry switches the Tool on.** `tool("toc", …)` enables `toc` without the `"toc"` string. If `tools` also holds the bare name (for example, `search` from `full`), the Configured Tool wins: there is still one Find button and one find panel.
|
|
394
398
|
- **Pass only what you change.** The remaining options come from the kit's defaults. Nested objects are merged and arrays are replaced whole: `levels: [2, 3]` means exactly these levels, not an addition to the default.
|
|
395
399
|
- **The position in the UI does not change.** A configured built-in Tool sits where it would sit by name; a custom Tool sits in `tools` order.
|
|
396
|
-
- **Types infer the options from the name.** `tool("toc", { matchCase: true })` and `tool("bold", {})` do not compile: `tool()` accepts only Tools that have options, which today means `search` and `
|
|
400
|
+
- **Types infer the options from the name.** `tool("toc", { matchCase: true })` and `tool("bold", {})` do not compile: `tool()` accepts only Tools that have options, which today means `search`, `toc` and `details`.
|
|
397
401
|
- **Two `tool()` entries with the same name** are a set error, like two custom Tools with the same name: a field error ("The editor couldn't start") and a `console.error` in development.
|
|
398
402
|
- **A wrong value** (from JS without types: `levels: [5]`, `offsetVar: "header"`) is replaced with the default, with a single `console.error` per Tool in development; the field keeps working.
|
|
399
403
|
- **Options are read once**, together with the `tools` set, when the engine is created. To change them on a mounted field, remount it with a key (`key`) — the edit history is lost in the process.
|
|
400
404
|
- **Server.** `tool()` pulls neither Tiptap nor React into the module, so a `tools` set that uses it can be built in a server module. A configured built-in Tool is made of data, and if its options are data, you can pass it to a client component as a prop.
|
|
401
405
|
|
|
402
|
-
The options of each Tool are described in its own section: "Find and replace"
|
|
406
|
+
The options of each Tool are described in its own section: "Find and replace", "Table of contents" and "Collapsible block". The options of a custom Tool are set by `defineTool` (["Options, state and commands"](../../api/editor-extension.md#опции-состояние-и-команды)).
|
|
403
407
|
|
|
404
408
|
## Custom Tool
|
|
405
409
|
|
|
406
|
-
A custom Tool is the description of one Editor capability: its Blocks and Marks, Schema rules as data, and
|
|
410
|
+
A custom Tool is the description of one Editor capability: its Blocks and Marks, Schema rules as data, and Extensions (Tiptap extensions). It is passed in `tools` mixed with the kit's names and follows the same rules: **a Tool that is not passed removes its Blocks and Marks from the Schema** — they do not pass through `value`, paste or commands; a Block unwraps into its content and an Atom disappears. The decision is ADR-0058, sections 1 and 2.
|
|
407
411
|
|
|
408
412
|
```tsx
|
|
409
413
|
import { Node } from "@tiptap/core";
|
|
410
414
|
import { RICH_TEXT_PRESETS, RichTextEditor, type CustomTool } from "@olddevs/ui/editor";
|
|
411
415
|
|
|
412
|
-
// The
|
|
416
|
+
// The Extensions: behavior only. The kit sets the group, HTML parsing and attributes.
|
|
413
417
|
const MergeTag = Node.create({
|
|
414
418
|
name: "acme_mergeTag",
|
|
415
419
|
atom: true,
|
|
@@ -435,40 +439,40 @@ export const mergeTagTool: CustomTool = {
|
|
|
435
439
|
/>;
|
|
436
440
|
```
|
|
437
441
|
|
|
438
|
-
**The description (`CustomTool`).** Required: a name,
|
|
442
|
+
**The description (`CustomTool`).** Required: a name, Extensions, and Schema rules for every Node and Mark they define. The rest has defaults: an ordinary Block with no Block traits, no buttons of its own and no menu items.
|
|
439
443
|
|
|
440
|
-
| Field | What it is
|
|
441
|
-
| -------------- |
|
|
442
|
-
| `name` | The Tool's name with a Consumer prefix, unique within `tools`
|
|
443
|
-
| `engine` |
|
|
444
|
-
| `blocks` | Block Schema rules — by Node name (`CustomBlock`)
|
|
445
|
-
| `marks` | Mark Schema rules — by Mark name (`CustomMark`)
|
|
446
|
-
| `buttons` | Toolbar buttons — see the "Custom Tool's button" section (`CustomToolButton`)
|
|
447
|
-
| `insertItems` |
|
|
448
|
-
| `blockActions` | Block actions in the Block handle menu (`CustomBlockAction`) — the same section
|
|
449
|
-
| `textCards` | Text cards (`CustomTextCard`) — see the "Text card" section
|
|
450
|
-
| `trigger` | A custom Trigger character (`CustomTrigger`) — see the "Custom Trigger character" section
|
|
444
|
+
| Field | What it is |
|
|
445
|
+
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
446
|
+
| `name` | The Tool's name with a Consumer prefix, unique within `tools` |
|
|
447
|
+
| `engine` | Extensions — an array of Tiptap extensions (Node, Mark, Extension, plugins, a React node view; Node and Mark at the top level) or a Loader that returns such an array (below) |
|
|
448
|
+
| `blocks` | Block Schema rules — by Node name (`CustomBlock`) |
|
|
449
|
+
| `marks` | Mark Schema rules — by Mark name (`CustomMark`) |
|
|
450
|
+
| `buttons` | Toolbar buttons — see the "Custom Tool's button" section (`CustomToolButton`) |
|
|
451
|
+
| `insertItems` | Slash menu items (`CustomInsertItem`) — see the "Insert items and Block actions" section |
|
|
452
|
+
| `blockActions` | Block actions in the Block handle menu (`CustomBlockAction`) — the same section |
|
|
453
|
+
| `textCards` | Text cards (`CustomTextCard`) — see the "Text card" section |
|
|
454
|
+
| `trigger` | A custom Trigger character (`CustomTrigger`) — see the "Custom Trigger character" section |
|
|
451
455
|
|
|
452
456
|
**Consumer prefix.** The names of the Tool, its Blocks, Marks and attributes start with a prefix followed by an underscore: `acme_mergeTag`, the attribute `acme_size`. The kit's names never contain an underscore, so custom and built-in names never collide, either in the set or in a saved Document. Attribute names are lowercase: HTML loses the case of an attribute name. A built-in Tool is not replaced: disable it in `tools` and put a custom one under its own name.
|
|
453
457
|
|
|
454
|
-
**HTML convention.** The kit writes and reads it; the
|
|
458
|
+
**HTML convention.** The kit writes and reads it; the Extensions set behavior only:
|
|
455
459
|
|
|
456
460
|
- a block-level Block is `<div data-block="acme_card">…</div>`, an inline Atom is `<span data-block="acme_mergeTag"></span>`;
|
|
457
461
|
- a custom Mark is `<span data-mark="acme_comment">…</span>`;
|
|
458
462
|
- a custom attribute `acme_size` is `data-acme_size`; a custom Block and Mark have no other attributes.
|
|
459
463
|
|
|
460
|
-
The kit completes the
|
|
464
|
+
The kit completes the Node and Mark from the Extensions: it sets the group (`block`, `inline` or top level) and inline-ness from the Block's kind; it parses HTML strictly according to this convention (the kit's tags `p`, `table` and `img` cannot be taken by a custom Block); and for attributes from the rules it parses and outputs `data-*` with value validation. You may keep your own `renderHTML` (for example, to put text into an Atom), but it must produce the same tag and `data-block`. You do not have to declare custom attributes on the Node: the kit adds them from the rules.
|
|
461
465
|
|
|
462
|
-
**Schema rules are data, without functions.** The kit's code validates them, and
|
|
466
|
+
**Schema rules are data, without functions.** The kit's code validates them, and Extensions cannot weaken the validation: a value outside the rules does not reach the Document's HTML from `value`, paste or a command. When parsing, the attribute gets its default value, and on output it is not written.
|
|
463
467
|
|
|
464
468
|
| `CustomBlock` field | Value |
|
|
465
469
|
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
466
470
|
| `kind` | `"block"` — block-level (`div`) or `"inline"` — inline (`span`). Required |
|
|
467
|
-
| `place` | `"anywhere"` (default) — wherever a Block can appear; `"top"` — only the top level of the Document, like a
|
|
471
|
+
| `place` | `"anywhere"` (default) — wherever a Block can appear; `"top"` — only the top level of the Document, like a Column layout |
|
|
468
472
|
| `attributes` | Custom attributes: name → value kind (`CustomAttribute`) |
|
|
469
473
|
| `container` | The "container" Block trait: Blocks inside get their own Block handle, as in the Wrapper |
|
|
470
474
|
| `turnInto` | The "can be turned into" Block trait: the Block handle's "Format ›" is available for this Block too |
|
|
471
|
-
| `plainAtom` | The "Atom without a formatting menu" Block trait: a selected Block has no
|
|
475
|
+
| `plainAtom` | The "Atom without a formatting menu" Block trait: a selected Block has no Bubble menu and no floating Toolbar above it |
|
|
472
476
|
| `island` | The Block's Island in `RichTextView` — a client component from a module with `"use client"` (`CustomIslandProps`); the Editor does not read the field |
|
|
473
477
|
|
|
474
478
|
`CustomMark` has only `attributes`. The kind of an attribute value (`CustomAttribute`):
|
|
@@ -479,25 +483,25 @@ The kit completes the Engine part's Node and Mark: it sets the group (`block`, `
|
|
|
479
483
|
|
|
480
484
|
An attribute cannot contain HTML: a value with `<` does not pass any rule, and the content of a non-editable Block is stored as the Document's markup inside the Block.
|
|
481
485
|
|
|
482
|
-
**Block traits** are off by default: an ordinary Block is not a container, has no "Turn into", and has the
|
|
486
|
+
**Block traits** are off by default: an ordinary Block is not a container, has no "Turn into", and has the Bubble menu. It has a Block handle, like any Block: "Add ›", "Format ›" (unavailable), "Up", "Down", "Duplicate", "Delete". The kit copies the traits into the node spec itself; their vocabulary belongs to the kit, not to Tiptap.
|
|
483
487
|
|
|
484
488
|
**A failed validation when the set is assembled is a field error.** The set is validated once, when the engine is created; with Loaders, after they have run, based on what they returned. It is an error if:
|
|
485
489
|
|
|
486
490
|
- the name of a Tool, Block, Mark or attribute lacks a Consumer prefix;
|
|
487
491
|
- two Tools have the same name, or two have the same Block or Mark name;
|
|
488
|
-
- the
|
|
492
|
+
- the Extensions disagree with the rules: a Node or Mark without a rule, a rule without a top-level Node or Mark in `engine`, a node attribute without a rule, a `renderHTML` that breaks the convention, a Schema the engine did not build;
|
|
489
493
|
- a button has no `id` or icon, two `id` values in one Tool coincide, or exactly one of `run` and `popover` is not set (the same for an Insert item and a Block action).
|
|
490
494
|
|
|
491
495
|
On an error the engine is not created: under the field's frame there is a `role="alert"` line "The editor couldn't start. The document is unchanged." (`editor.toolsFailed`), the hidden form field and `getHTML()` return the incoming value, `onChange` stays silent, and `ref.current.editor` is `null`. The cause is in `console.error` in development mode, with the Tool's name. This way data loss is visible before the first save, not on the server. **An unknown string in `tools` is still dropped silently**: the error applies only to descriptions of custom Tools.
|
|
492
496
|
|
|
493
|
-
Buttons,
|
|
497
|
+
Buttons, Slash menu items, Block actions and Text cards are described in the sections below; a Tool can report an error to the author with the `showNotice` command (see the "Status bar and Field notice" section). The whole extension API and the boundary of its stability (what falls under semver, what counts as breaking) are described on the [Editor extension API](../../api/editor-extension.md) page. If a Tool has settings and the screen needs its state and commands, describe it with `defineTool`: ["Options, state and commands"](../../api/editor-extension.md#опции-состояние-и-команды).
|
|
494
498
|
|
|
495
|
-
###
|
|
499
|
+
### Extensions Loader
|
|
496
500
|
|
|
497
|
-
Instead of the `engine` array of a custom Tool you can pass a function with a dynamic import — it returns a promise of the same array (ADR-0058, items 1.3 and 11.3–11.6). That way the module with the description can also be imported on the server (Schema rules are data), while Tiptap and React of the
|
|
501
|
+
Instead of the `engine` array of a custom Tool you can pass a function with a dynamic import — it returns a promise of the same array (ADR-0058, items 1.3 and 11.3–11.6). That way the module with the description can also be imported on the server (Schema rules are data), while Tiptap and React of the Extensions go to a separate chunk:
|
|
498
502
|
|
|
499
503
|
```tsx
|
|
500
|
-
// acme-merge-tag.ts — no Tiptap in the graph: only the
|
|
504
|
+
// acme-merge-tag.ts — no Tiptap in the graph: only the Extensions chunk pulls it in.
|
|
501
505
|
export const mergeTagTool: CustomTool = {
|
|
502
506
|
name: "acme_mergeTag",
|
|
503
507
|
engine: () => import("./acme-merge-tag-engine.js").then((module) => module.engine),
|
|
@@ -510,7 +514,7 @@ export const mergeTagTool: CustomTool = {
|
|
|
510
514
|
};
|
|
511
515
|
```
|
|
512
516
|
|
|
513
|
-
- **Readiness waits for the Loaders.** The engine is created when the Loaders of all the set's Tools have run: the Schema is fixed at creation, and Blocks from `value` without their
|
|
517
|
+
- **Readiness waits for the Loaders.** The engine is created when the Loaders of all the set's Tools have run: the Schema is fixed at creation, and Blocks from `value` without their Extensions would be lost on parsing. Until then the Handle's methods are silent, `ref.current.editor` is `null`, and `onReady` has not been called (see the "Events" section).
|
|
514
518
|
- **Loading starts from the first render in the browser**, before mount, not in an effect; on the server the Loader is not called. The promise is cached on the Loader function itself: a second Editor on the page with the same Tool does not wait for anything and does not call the Loader. So the Loader is one function per module, not a new arrow function on every render.
|
|
515
519
|
- **A load failure** (network, a stale chunk after a deploy) is a field error "The editor couldn't start. The document is unchanged." and a "Retry" button (`editor.retry`). It calls the Loaders that failed again; successful ones stay in the cache. The Document stays visible as a Skeleton the whole time. The Editor does not start without the Tool: its Blocks would vanish from the Document. `onReady` is not called on a failure, and the cause is in `console.error` in development mode.
|
|
516
520
|
- **The kit does not publish preloads.** For the chunk to arrive with the page rather than after its script, add `modulepreload` for it yourself. The chunk's hashed URL comes from the bundler manifest (in Vite, `build.manifest`):
|
|
@@ -525,8 +529,8 @@ export const mergeTagTool: CustomTool = {
|
|
|
525
529
|
|
|
526
530
|
Every Editor has a Skeleton (ADR-0058, item 11.4), with Loaders and without: until Readiness and after a Loader failure the field renders the frame, the space for the fixed Toolbar, and the Document as static HTML — with the same class and rhythm as the engine's Document, without input and without the `textbox` role. The engine takes its place without a height jump.
|
|
527
531
|
|
|
528
|
-
- **The Skeleton's Document is `value` cleaned by the Schema**, not the raw string: the kit parses it in an inert browser document (scripts and handlers do not run there) against the Schema of the set's built-in Tools and of custom Tools whose
|
|
529
|
-
- **The code block and the table in `full` are lazy too** (ADR-0058, item 11.1): they are not in `basic`, and their
|
|
532
|
+
- **The Skeleton's Document is `value` cleaned by the Schema**, not the raw string: the kit parses it in an inert browser document (scripts and handlers do not run there) against the Schema of the set's built-in Tools and of custom Tools whose Extensions have already arrived, and serializes it back to HTML. Anything the Schema does not recognize (`<script>`, `on*`, `javascript:`, unknown tags and attributes) does not reach the page even before Readiness (see the "Schema and security" section). A Block of a custom Tool whose Loader has not run yet is visible as text: the Schema removes its markup, and once the Extensions have arrived, the engine parses `value` again.
|
|
533
|
+
- **The code block and the table in `full` are lazy too** (ADR-0058, item 11.1): they are not in `basic`, and their Extensions are larger than 10 kB gzipped, so they arrive as a separate chunk through the same Loader as in a custom Tool. The "Table" menu, the "Insert table" dialog and the code block header arrive with them. `basic` loads no chunks and is ready at once.
|
|
530
534
|
- Until the chunks arrive, an Editor with them is a Skeleton, and `onReady` waits for both. Their Blocks are visible in the Skeleton by their shape (the code block with a header, the table with cells), so the field does not jump when the chunks arrive. For the chunks to arrive with the page, put `modulepreload` on them, as on a custom Tool's chunk. The size of "start" and "full" is in the "Import size" table in [getting-started](../../getting-started.md#размер-импорта).
|
|
531
535
|
- **On the server and before hydration the Skeleton is only the frame and the space for the Toolbar**, without a Document: parsing against the Schema happens in the browser DOM. The Document appears after mount, so on an SSR page the field's height changes once, as before the Skeleton. In client rendering the Skeleton's Document exists from the first frame.
|
|
532
536
|
|
|
@@ -534,10 +538,10 @@ The look of the Skeleton is not part of the contract.
|
|
|
534
538
|
|
|
535
539
|
### Atom text
|
|
536
540
|
|
|
537
|
-
One rule for built-in and custom Atoms (ADR-0058, section 4): the Atom text is what
|
|
541
|
+
One rule for built-in and custom Atoms (ADR-0058, section 4): the Atom text is what `renderText` in the Extensions returns; an Atom with no content and no `renderText` yields an empty string, an Atom with content inside (an image with a caption) yields the text of its children. The kit builds from it everything that produces text:
|
|
538
542
|
|
|
539
|
-
- **The weight in `maxLength`** equals the Atom text length, so the counter matches `getText()` without Block separators. For built-ins: a divider weighs 0, a soft break 1, an image the caption length; the Merge Tag from the example above weighs the length of `{{key}}`. There is no separate weight field: it would drift from the text. The kit has no server counter — the weight depends on the
|
|
540
|
-
- **`getText()`, `getSelectedText()`, text on copy and `getMarkdown()`** return the same Atom text; an Atom is included in the selected text only as a whole. An Atom without `renderMarkdown` is exported to Markdown as its Atom text; only an Atom without text disappears; the
|
|
543
|
+
- **The weight in `maxLength`** equals the Atom text length, so the counter matches `getText()` without Block separators. For built-ins: a divider weighs 0, a soft break 1, an image the caption length; the Merge Tag from the example above weighs the length of `{{key}}`. There is no separate weight field: it would drift from the text. The kit has no server counter — the weight depends on the Extensions.
|
|
544
|
+
- **`getText()`, `getSelectedText()`, text on copy and `getMarkdown()`** return the same Atom text; an Atom is included in the selected text only as a whole. An Atom without `renderMarkdown` is exported to Markdown as its Atom text; only an Atom without text disappears; the Extensions' own `renderMarkdown` wins.
|
|
541
545
|
- **Emptiness is a kit rule, with no description field.** Any Atom, custom or built-in, is content for `required`, `isEmpty()` and `getHTML()`: inserting a video without a caption passes the required check. An empty non-Atom Block (a paragraph without text) does not count as content.
|
|
542
546
|
- **Pasting from the clipboard beyond `maxLength`** cuts the tail by characters, not by positions. An Atom is not cut: one that does not fit is dropped whole, together with everything pasted after it; if the limit is still exceeded, the paste is rejected entirely.
|
|
543
547
|
|
|
@@ -545,8 +549,8 @@ One rule for built-in and custom Atoms (ADR-0058, section 4): the Atom text is w
|
|
|
545
549
|
|
|
546
550
|
The same `tools` array is understood by server sanitization and the View (ADR-0058, item 2.3 and section 3).
|
|
547
551
|
|
|
548
|
-
- **The server** passes the same `tools` array to sanitization: `sanitizeRichText(html, DOMPurify, tools)` lets custom Blocks, Atoms and Marks through by their rules and strips everything else, and `richTextSchema(tools)` returns these rules as data. Names of kit Tools do not narrow the allowlist; without the third argument sanitization is as before. Both names exist in `@olddevs/ui/editor` and in the lightweight `@olddevs/ui/editor/schema`; the lightweight entry point's graph still has neither Tiptap nor React, and the description of a custom Tool is data that the server reads without
|
|
549
|
-
- **Markdown.** Custom rules live in the
|
|
552
|
+
- **The server** passes the same `tools` array to sanitization: `sanitizeRichText(html, DOMPurify, tools)` lets custom Blocks, Atoms and Marks through by their rules and strips everything else, and `richTextSchema(tools)` returns these rules as data. Names of kit Tools do not narrow the allowlist; without the third argument sanitization is as before. Both names exist in `@olddevs/ui/editor` and in the lightweight `@olddevs/ui/editor/schema`; the lightweight entry point's graph still has neither Tiptap nor React, and the description of a custom Tool is data that the server reads without the Extensions. Details are in [RichTextView, "Custom Tools: the third argument"](../content/rich-text-view.md#свои-инструменты-третий-аргумент).
|
|
553
|
+
- **Markdown.** Custom rules live in the Extensions, as in any Tiptap extension: `renderMarkdown`, and for parsing `markdownTokenizer` and `parseMarkdown`; with them a Block survives the `getMarkdown()` → `setMarkdown()` round trip. Without them `getMarkdown()` does not lose text: a Block is exported as its content (a container as its Blocks), an Atom as its Atom text from `renderText` (escaped, like any text), a Mark as its text; only an Atom without text disappears. Such a Block is not created back from Markdown — its text lands as paragraphs. In development mode there is one `console.warn` per Tool when this default first kicks in.
|
|
550
554
|
- **`RichTextView`** shows a custom Block as sanitized HTML with the paragraph's vertical rhythm — and nothing more; the look is set by your CSS: selectors on `data-block` and the kit's tokens ([RichTextView, "Custom Block"](../content/rich-text-view.md#свой-блок)). You can bring a Block to life in the View with an Island — the Block's `island` field; `RichTextView` receives the same `tools` ([RichTextView, "Custom Block's Island"](../content/rich-text-view.md#остров-своего-блока)).
|
|
551
555
|
|
|
552
556
|
## Custom Tool's button
|
|
@@ -559,7 +563,7 @@ import { SparkleIcon } from "@olddevs/ui/icons";
|
|
|
559
563
|
|
|
560
564
|
export const stampTool: CustomTool = {
|
|
561
565
|
name: "acme_stamp",
|
|
562
|
-
engine: [], // a button needs neither its own Schema nor
|
|
566
|
+
engine: [], // a button needs neither its own Schema nor Extensions
|
|
563
567
|
buttons: [
|
|
564
568
|
{
|
|
565
569
|
id: "stamp",
|
|
@@ -581,7 +585,7 @@ export const stampTool: CustomTool = {
|
|
|
581
585
|
| `can` | Required. Whether the button is available now. An unavailable one is `aria-disabled`, stays in the arrow-key order, and a click or shortcut does nothing |
|
|
582
586
|
| `isActive` | If present, the button is a toggle with `aria-pressed`; if absent, a one-off action without `aria-pressed` |
|
|
583
587
|
| `isVisible` | A visibility predicate that depends on context: the button is hidden while it returns `false`. If absent, always visible |
|
|
584
|
-
| `selectionMenu` | `true` — the button also appears in the
|
|
588
|
+
| `selectionMenu` | `true` — the button also appears in the Bubble menu (`toolbar="bubble"`) |
|
|
585
589
|
| `shortcut` | A shortcut: `mod`, `alt`, `shift` and a key — `["mod", "shift", "Y"]` |
|
|
586
590
|
| `run` | `(editor) => void` — runs on a click or the shortcut |
|
|
587
591
|
| `popover` | `({ editor, close }) => ReactNode` — a popover instead of an action. Exactly one of `run` and `popover` |
|
|
@@ -589,13 +593,13 @@ export const stampTool: CustomTool = {
|
|
|
589
593
|
- **Placement.** Custom buttons appear on the Toolbar as one group before "Undo" and "Redo", after the kit's groups, in `tools` order and `buttons` array order. The fixed and the floating Toolbar show the same set. The kit sets the order of its own buttons; a built-in one can be hidden only by disabling its Tool, and to get a short Toolbar, narrow `tools`. A disabled Tool removes both its buttons and its shortcut.
|
|
590
594
|
- **State.** `can`, `isActive` and `isVisible` are read on every transaction, like the kit's buttons, so they can use the Document's state: `editor.isActive("acme_pin")`. In a non-editable field (`readOnly`, `disabled`) the kit disables the buttons itself.
|
|
591
595
|
- **Popover.** The kit manages the frame: the `Popover` layer, an anchor at the button, the Escape stack (inside a `Dialog`, Escape closes the popover, not the dialog), and the frame's name is the button's label. The content is mounted again each time the popover opens. `close` closes the popover the same way as Escape. Closing returns focus and selection to the Document, not to the button; a click elsewhere does not touch focus. A popover triggered by the shortcut opens at the caret, not at the button: the button may not be on screen at all (`toolbar="none"`).
|
|
592
|
-
- **The shortcut is the single source of truth.** The kit itself binds the shortcut to `run` (or opens the popover), writes it into the tooltip with `Kbd`, into the button's `aria-keyshortcuts` and into the
|
|
596
|
+
- **The shortcut is the single source of truth.** The kit itself binds the shortcut to `run` (or opens the popover), writes it into the tooltip with `Kbd`, into the button's `aria-keyshortcuts` and into the Keyboard shortcuts popover: custom rows appear there as a separate group without a heading after the kit's groups, in `tools` order. An unavailable button does not take the key, and the key passes on into the Document. Keyboard shortcuts bound by the Extensions (`addKeyboardShortcuts`) do not appear in the Keyboard shortcuts popover: set the button's shortcut with the `shortcut` field, not with an extension.
|
|
593
597
|
- **Label.** The function receives `{ locale }` (the `UiProvider` locale), and the label re-renders when it changes; in a single-language app a string is enough. Label overrides go through the Tool factory's options (for example, a `labels` field; the recipe is [Extension API, "Labels through factory options"](../../api/editor-extension.md#подписи-через-опции-фабрики)); the `UiProvider` message catalog is not extended for the consumer's own Tools. If the label is empty, development mode logs a `console.error` with the Tool's and button's names, and the Tool's name is used in place of the label in the button's name, the tooltip and the popover's name.
|
|
594
598
|
- **Menu buttons** (with radio items or a submenu) are not available to custom Tools: a popover is enough for choosing from a list.
|
|
595
599
|
|
|
596
600
|
## Insert items and Block actions
|
|
597
601
|
|
|
598
|
-
A custom Tool adds an item to the
|
|
602
|
+
A custom Tool adds an item to the Slash menu and an action to the Block handle menu **by description, without editing the menu**: the kit renders, the consumer describes (ADR-0058, sections 5 and 6). A disabled Tool removes its items and actions everywhere.
|
|
599
603
|
|
|
600
604
|
```tsx
|
|
601
605
|
import { ColumnLayoutIcon } from "@olddevs/ui/icons";
|
|
@@ -630,15 +634,15 @@ export const calloutTool: CustomTool = {
|
|
|
630
634
|
|
|
631
635
|
**Insert item (`CustomInsertItem`).**
|
|
632
636
|
|
|
633
|
-
| Field | What it is
|
|
634
|
-
| ---------- |
|
|
635
|
-
| `section` | `"insert"` ("Insert" — a new Block after the current one) or `"turnInto"` ("Turn into" — change the current Block, keeping its text). Required
|
|
636
|
-
| `label` | The label: a string or a function that receives `{ locale }` (the `UiProvider` locale), as for a button. Required
|
|
637
|
-
| `icon` | A component with the `IconProps` contract from `@olddevs/ui/icons` or your own. Required: there is no fallback icon
|
|
638
|
-
| `aliases` | An array of strings in any language — search words in addition to the label
|
|
639
|
-
| `shortcut` | A shortcut in the Toolbar's notation: `["mod", "alt", "N"]`. It is the only source of the key: the kit binds it and shows it in the item's row and in the
|
|
640
|
-
| `run` | `(chain, editor) => chain` — a chain command: return the chain with the Block's command; the kit calls `.run()`
|
|
641
|
-
| `popover` | `({ editor, close }) => ReactNode` — a popover at the caret instead of a command. Exactly one of `run` and `popover` is required
|
|
637
|
+
| Field | What it is |
|
|
638
|
+
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
639
|
+
| `section` | `"insert"` ("Insert" — a new Block after the current one) or `"turnInto"` ("Turn into" — change the current Block, keeping its text). Required |
|
|
640
|
+
| `label` | The label: a string or a function that receives `{ locale }` (the `UiProvider` locale), as for a button. Required |
|
|
641
|
+
| `icon` | A component with the `IconProps` contract from `@olddevs/ui/icons` or your own. Required: there is no fallback icon |
|
|
642
|
+
| `aliases` | An array of strings in any language — search words in addition to the label |
|
|
643
|
+
| `shortcut` | A shortcut in the Toolbar's notation: `["mod", "alt", "N"]`. It is the only source of the key: the kit binds it and shows it in the item's row and in the Keyboard shortcuts popover |
|
|
644
|
+
| `run` | `(chain, editor) => chain` — a chain command: return the chain with the Block's command; the kit calls `.run()` |
|
|
645
|
+
| `popover` | `({ editor, close }) => ReactNode` — a popover at the caret instead of a command. Exactly one of `run` and `popover` is required |
|
|
642
646
|
|
|
643
647
|
- **Where the item appears.** It shows up in "/", the Toolbar's "+", and the Block handle's "Add ›" and "Format ›" automatically: the `turnInto` section goes into both "Format ›" and "¶"; "Add ›" holds the items of both sections (a new empty Block below, as with built-ins). Custom items appear **at the end of their section**, in `tools` order; inside a Tool, the array order. Without an enabled `insertMenu` the item does not appear, as with built-ins.
|
|
644
648
|
- **One edit, one undo.** The section decides what happens to the Document, and `run` decides which Block: in `insert` the kit prepares an empty paragraph (the current one if empty, otherwise a new one after the Block), in `turnInto` it removes the list or quote around the Block. The preparation and `run` go in one transaction.
|
|
@@ -659,13 +663,13 @@ export const calloutTool: CustomTool = {
|
|
|
659
663
|
| `isVisible` | `(editor, pos) => boolean` — a predicate that decides visibility from the context; if absent, the action is visible for all its `blocks` |
|
|
660
664
|
| `shortcut` | A shortcut in the Toolbar's notation: it acts on the Block under the caret and only where the action is visible in the handle menu; on another Block the key goes to the Document |
|
|
661
665
|
|
|
662
|
-
Custom actions appear in the Block handle menu **in their own section before "Delete"**, in `tools` order. An action with a `popover` opens the popover **at the Block** — the popover, Escape and focus return are the same as for a button, and the popover's accessible name is the action's label: a choice in the handle menu opens it at the handle's Block, a shortcut at the Block under the caret. `pos` follows Document edits while the popover is open. The shortcut is in the item's row (as with built-ins, and only when the shortcut does the same thing as the item) and in the
|
|
666
|
+
Custom actions appear in the Block handle menu **in their own section before "Delete"**, in `tools` order. An action with a `popover` opens the popover **at the Block** — the popover, Escape and focus return are the same as for a button, and the popover's accessible name is the action's label: a choice in the handle menu opens it at the handle's Block, a shortcut at the Block under the caret. `pos` follows Document edits while the popover is open. The shortcut is in the item's row (as with built-ins, and only when the shortcut does the same thing as the item) and in the Keyboard shortcuts popover: the Tool's rows are a separate group without a heading after the kit's groups.
|
|
663
667
|
|
|
664
668
|
**Tool set validation** adds field errors for UI contributions: `insertItems`/`blockActions` is not an array or an item is not an object; no label or icon; not exactly one of `run` and `popover`; the section is not `insert`/`turnInto`; `aliases` is not an array of strings; `blocks` is empty; `isVisible` is not a function; the shortcut is shorter than two keys (a modifier and a key).
|
|
665
669
|
|
|
666
670
|
## Custom Trigger character
|
|
667
671
|
|
|
668
|
-
A custom Tool opens the same list as "/" with **its own character**: `@` for mentions, `#` for tags. The decision is ADR-0064. The kit renders the list — the same component as the
|
|
672
|
+
A custom Tool opens the same list as "/" with **its own character**: `@` for mentions, `#` for tags. The decision is ADR-0064. The kit renders the list — the same component as the Slash menu: a popover at the caret, search by what is typed, arrow keys, `Enter`, `Escape`, the "Nothing found" row; focus stays in the Document. The consumer describes the character, the items on demand and what happens when an item is picked.
|
|
669
673
|
|
|
670
674
|
```tsx
|
|
671
675
|
import { RichTextEditor, type CustomTool } from "@olddevs/ui/editor";
|
|
@@ -717,10 +721,10 @@ const mentionTool: CustomTool = {
|
|
|
717
721
|
- **When it opens.** The same rules as "/": the character is at the start of a Block or after a space (a non-breaking one counts as a space), and the query ends at a space. Inside a word (`a@b.com`), in a code block and in inline code the list does not open. A space ends the query: the list closes and the text stays.
|
|
718
722
|
- **Keyboard.** As with "/": `↓`/`↑` move through the items, wrapping around, `Enter` picks, `Escape` closes and keeps what was typed (it does not reopen on the same character), `Enter` on an empty list is an ordinary line break. An `Enter` that confirms IME composition is handled by the IME, not by the list.
|
|
719
723
|
- **Asynchronous items.** While a promise is pending, the previous list (or nothing) stays, and the "Nothing found" row does not flicker. The result of a stale query is discarded. A rejected promise gives an empty list and, in development, a `console.error`.
|
|
720
|
-
- **Independence from "/".** The trigger works without `insertMenu` in `tools`; only one list is open at a time, because the query begins with exactly one trigger character. It opens only while editing: in `readOnly`, `disabled` and
|
|
724
|
+
- **Independence from "/".** The trigger works without `insertMenu` in `tools`; only one list is open at a time, because the query begins with exactly one trigger character. It opens only while editing: in `readOnly`, `disabled` and Source mode there is no list.
|
|
721
725
|
- **Accessibility.** The field stays `role="textbox"` and while the list is open gets `aria-controls` and `aria-activedescendant`, as with "/"; the list is `role="listbox"` named by `label`.
|
|
722
|
-
- **Tool set errors** (the field shows an error, and the cause is reported with `console.error` in development): two Tools with one `char`; a `char` that is a space, `/`, or not one printable character; `items` or `onSelect` that are not functions; a `label` that is neither a string nor a function. A custom Trigger character does not collide with built-in ones: `/` is taken by the
|
|
723
|
-
- **A mention is the consumer's node.** The kit does not know what is inserted: a mention Atom, a link, a tag — `onSelect` decides. The Atom is described like any custom Block (
|
|
726
|
+
- **Tool set errors** (the field shows an error, and the cause is reported with `console.error` in development): two Tools with one `char`; a `char` that is a space, `/`, or not one printable character; `items` or `onSelect` that are not functions; a `label` that is neither a string nor a function. A custom Trigger character does not collide with built-in ones: `/` is taken by the Slash menu.
|
|
727
|
+
- **A mention is the consumer's node.** The kit does not know what is inserted: a mention Atom, a link, a tag — `onSelect` decides. The Atom is described like any custom Block (Extensions, Schema rules, `renderText` — Atom text), so a mention reaches `getText()`, Markdown and `maxLength`.
|
|
724
728
|
|
|
725
729
|
## Text card
|
|
726
730
|
|
|
@@ -765,7 +769,7 @@ export const commentTool: CustomTool = {
|
|
|
765
769
|
|
|
766
770
|
**`markRange(markName)`** is a ready-made `rangeAt` for a Mark: the continuous stretch of text with this Mark and the same values of its attributes around `pos`. **The edges are included**: a caret right after a word or before it is still in the range (otherwise a click on the first letter would not open the Text card); between two stretches, the one that ends at `pos` wins, so text typed next continues that stretch. For a Block or any other range, write your own `rangeAt` — it reads only `state` and is called on every transaction, so it must be fast and free of side effects. The kit discards a range that lies outside the Document, has `from > to`, or consists of values that are not numbers; an exception in `rangeAt` does not crash the field, but writes `console.error` in development. `markRange` is also in the lightweight `@olddevs/ui/editor/schema` entry point: the description of a custom Tool with a Text card is data, and server sanitization reads it too, so the description module needs neither Tiptap nor React.
|
|
767
771
|
|
|
768
|
-
- **When it is open.** The caret (a collapsed selection) is inside the range, or a click on it. **A Text card does not open on hover.** An expanded selection closes the Text card: the
|
|
772
|
+
- **When it is open.** The caret (a collapsed selection) is inside the range, or a click on it. **A Text card does not open on hover.** An expanded selection closes the Text card: the Bubble menu works there. The range is recomputed on every transaction: the Text card follows the range when you edit, and closes when the range disappears. A field out of focus (except `readOnly`) does not show the Text card, and neither do `disabled` and Source mode.
|
|
769
773
|
- **One Text card.** No more than one is open: **the narrowest range** under the caret wins, and with equal width, the contribution that comes earlier, that is, the Tool earlier in `tools` (inside a Tool, earlier in the array).
|
|
770
774
|
- **The kit controls the card.** The layer and the Escape stack work as for the kit's `Popover` (ADR-0041: Escape closes the Text card, not the `Dialog` or `Drawer` around the field); the card is anchored under the range, **flips above the range at the bottom edge of the viewport**, shifts away from the side edges and looks like the kit's popover. There is no heading and no buttons defined by data: the body is your React node, mounted again each time the card opens.
|
|
771
775
|
- **Non-modal.** Focus stays in the Document, you can keep typing, and the Text card follows the range. **`Alt+F10` moves focus into an open Text card** (to the body's first focusable element, otherwise to the card itself), as it moves focus to the panel of a selected image. **Escape returns the caret to its place** and hides the Text card.
|
|
@@ -777,7 +781,7 @@ export const commentTool: CustomTool = {
|
|
|
777
781
|
|
|
778
782
|
## Code block
|
|
779
783
|
|
|
780
|
-
The `codeBlock` Tool: the "Code block" checkbox item of the "…" menu and
|
|
784
|
+
The `codeBlock` Tool: the "Code block" checkbox item of the "…" menu and a Slash menu item, `Ctrl/⌘+Alt+C` and a ` ``` ` fence at the start of a line. A Block has a language and syntax highlighting — the decision of ADR-0051: lowlight with highlight.js grammars, only in the `@olddevs/ui/editor` entry point (the kit's `CodeView` still does not highlight code, ADR-0039).
|
|
781
785
|
|
|
782
786
|
**Languages.** Plain text (no language), Python, TypeScript, JavaScript, Go, HTML, JSON, YAML and Markdown — data in one module, `src/editor/features/code/codeLanguages.ts`, and the names used in the Document are `RICH_TEXT_SCHEMA.codeLanguages`. In HTML the language is a class on `<code>`: `<pre><code class="language-python">…</code></pre>`; a Block without a language has no class. Highlighting is **not written** into the Document: the `span class="hljs-…"` tokens are Editor decorations and are absent from `getHTML()`.
|
|
783
787
|
|
|
@@ -808,7 +812,7 @@ The language button has `aria-keyshortcuts="Alt+F10"`. **In `readOnly` and `disa
|
|
|
808
812
|
|
|
809
813
|
## Images
|
|
810
814
|
|
|
811
|
-
The `image` Tool (in `full`, not in `basic`): the "Image" button on the Toolbar, the "Image" item in the
|
|
815
|
+
The `image` Tool (in `full`, not in `basic`): the "Image" button on the Toolbar, the "Image" item in the Slash menu (`/img`) and Markdown `` on paste. An image is written into the Document as a Block:
|
|
812
816
|
|
|
813
817
|
```html
|
|
814
818
|
<figure>
|
|
@@ -840,7 +844,7 @@ The `image` Tool (in `full`, not in `basic`): the "Image" button on the Toolbar,
|
|
|
840
844
|
```
|
|
841
845
|
|
|
842
846
|
- **With an upload handler (`onUploadImage`)** an image can be pasted from the clipboard (a screenshot, "Copy image" in the browser), dropped into the field as a file, or chosen with the "Upload from device" button in the popover. Several files are uploaded one by one and land in file order.
|
|
843
|
-
- **While the promise is pending**, a placeholder "Uploading photo.png…" appears where the image will be (with a preview where the browser supports `blob:`), and the Document has **no** image: not in `getHTML()`, not in `onChange`, not in the form. The upload progress is announced by the Field notice's live region (`role="status"`, see "Status
|
|
847
|
+
- **While the promise is pending**, a placeholder "Uploading photo.png…" appears where the image will be (with a preview where the browser supports `blob:`), and the Document has **no** image: not in `getHTML()`, not in `onChange`, not in the form. The upload progress is announced by the Field notice's live region (`role="status"`, see "Status bar and Field notice").
|
|
844
848
|
- **On success** the image takes the placeholder's place in one edit: `Ctrl/⌘+Z` removes exactly that image. **On failure** (the promise is rejected or the URL is outside `http`/`https`, for example `data:` or a relative one), the placeholder disappears, the Document does not change, nothing is added to the edit history, and under the field there is a `role="alert"` line: "Couldn't upload photo.png. The document is unchanged." The next edit clears it. This is a Field notice: file messages go through the same channel as `showNotice`, and a new one replaces the previous one.
|
|
845
849
|
- **Without an upload handler**, files pasted from the clipboard or dropped into the field are not accepted: under the field there is a hint to insert the image by URL. "Copy image" from the browser still works — alongside the file the clipboard holds HTML with the image URL, and the image is inserted by URL if the protocol policy allows it (`http`/`https`).
|
|
846
850
|
- **What is not uploaded.** A non-image file (`notes.pdf`) is rejected with a message. A paste from Word and from pages where the clipboard holds HTML with text next to the file goes through ordinary HTML parsing: Word also puts a snapshot of the selection as a picture on the clipboard, and uploading it would be a mistake.
|
|
@@ -951,7 +955,7 @@ The `table` Tool is part of `preset="full"`, not of `basic`. It needs the `@tipt
|
|
|
951
955
|
| Sort › | unavailable | "Sort ascending", "Sort descending" — by the column under the caret (rules below). In a table with merged cells both items are unavailable, and the submenu's section label explains why |
|
|
952
956
|
| Delete table | unavailable | Removes the whole table; the item is red (`danger`) and separated by a divider |
|
|
953
957
|
|
|
954
|
-
A table inserted from the menu or the dialog lands as a new Block after the current one; in an empty paragraph, it takes the paragraph's place; from inside a table, it goes **after** that table: the Editor does not create nested tables. The "Table" item of the
|
|
958
|
+
A table inserted from the menu or the dialog lands as a new Block after the current one; in an empty paragraph, it takes the paragraph's place; from inside a table, it goes **after** that table: the Editor does not create nested tables. The "Table" item of the Slash menu (`/table`, `/grid`, "+") still inserts 3×3 immediately, without a size choice and without remembering it.
|
|
955
959
|
|
|
956
960
|
**Keyboard.** To open the menu, press `Alt+F10` (it moves focus into the Toolbar), use the arrow keys to reach "Table ▾", then press `Enter` or `↓`.
|
|
957
961
|
|
|
@@ -999,18 +1003,18 @@ The grid follows the APG `grid` pattern: `role="grid"` → `row` → `gridcell`.
|
|
|
999
1003
|
**The output.** `<table><tbody><tr><th>…</th></tr><tr><td>…</td></tr></tbody></table>`, a cell is `<th><p>…</p></th>`. A cell has only `colspan`, `rowspan` and `data-align`; no `style`, `colwidth`, `class` or `scope`; `thead`, `colgroup` and `caption` are not written (the header row is an ordinary first row of `th`). The standard Tiptap extension writes `style="min-width: …"` on the table, a `colgroup` with column widths, `colwidth` and `style="text-align"` on cells — the kit turns this off (`src/editor/features/table/tables.ts`), and such attributes are dropped on load and paste; the alignment in them is kept as `data-align`.
|
|
1000
1004
|
|
|
1001
1005
|
- **Column widths are not configurable.** There are no resize handles in v1 (the Tiptap option `resizable: false`): the browser computes the width from the content.
|
|
1002
|
-
- **Rows with a different number of cells** (external HTML with an error or with a merge over the limit) are evened out with empty cells when the Editor starts — without `onChange`: this is not
|
|
1006
|
+
- **Rows with a different number of cells** (external HTML with an error or with a merge over the limit) are evened out with empty cells when the Editor starts — without `onChange`: this is not a User edit.
|
|
1003
1007
|
- **A cell holds any Schema Blocks** (paragraphs, lists, a quote, a code block), not only paragraphs: if a cell accepted only one paragraph, inserting a list would cut the table in two.
|
|
1004
1008
|
- **A disabled `table`** removes the button, the menu item and the extension: a table from a paste and from `value` arrives as the text of the cells, as paragraphs.
|
|
1005
1009
|
- **Scrolling on a narrow screen.** A table wider than its block scrolls horizontally inside itself (`display: block; overflow-x: auto` on `<table>`, `prose.ts`) and does not stretch the page; the minimum column width is 6 rem. There is no wrapping element: `RichTextView` renders the saved HTML as is, and scrolling must work on a bare `<table>`. Details and the trade-off are on the [RichTextView](../content/rich-text-view.md#таблицы) page.
|
|
1006
1010
|
|
|
1007
1011
|
Live examples are the stories `TableMenu`, `TableMerged`, `TableSortAndAlign`, `TableRussian`, `TableNarrow`, `TableInsertSlash` and, for the grid alone, `TableSizeGrid`, `TableSizeGridFields`, `TableSizeGridRussian`.
|
|
1008
1012
|
|
|
1009
|
-
##
|
|
1013
|
+
## Slash menu
|
|
1010
1014
|
|
|
1011
1015
|
A list of Blocks that opens **on `/` in the text** and **on the "+" button**, the first button on the Toolbar (the `insertMenu` Tool, included in `full`). It has two sections of items:
|
|
1012
1016
|
|
|
1013
|
-
- **"Insert"** — a new Block after the current one: code block, divider, table, image,
|
|
1017
|
+
- **"Insert"** — a new Block after the current one: code block, divider, table, image, Column layout (two columns; the other variants are in the Block handle), Wrapper. An image opens the URL popover and lands in place of a prepared empty paragraph. If the caret is in an empty paragraph, the Block takes its place; otherwise it goes as a new Block right after the current top-level Block, not inside a quote or a list; with an image selected, after it. The current Block's text is not touched. The Block handle's "Add ›" inserts a Block the same way, except that it always adds a new Block below, even from an empty paragraph.
|
|
1014
1018
|
- **"Turn into"** — change the current Block's type, keeping the text: paragraph, H2–H4 (and H1 with `heading1`), bulleted, numbered and checklist, quote, code block. The Block first leaves its list or quote, then becomes the chosen type: "Turn a list item into a heading" gives a heading outside the list.
|
|
1015
1019
|
|
|
1016
1020
|
An item has an icon, a label and a reminder: a Markdown shortcut (`##`, `-`, `>`, `---`), and where there is none, a keyboard shortcut. Items exist only for enabled Tools: a disabled `codeBlock` disappears from both sections, and a section without items is not shown. A menu without items other than the paragraph does not appear at all.
|
|
@@ -1029,23 +1033,23 @@ A custom Trigger character (`@`) opens a similar list, independently of "/" —
|
|
|
1029
1033
|
|
|
1030
1034
|
"+" on the Toolbar is an ordinary menu button (the kit's `Menu`): it supports the arrow keys, `Enter`, `Escape` and the first letter of the label; an item applies to the Block under the caret or to the selection, after which focus returns to the Document.
|
|
1031
1035
|
|
|
1032
|
-
##
|
|
1036
|
+
## Column layout
|
|
1033
1037
|
|
|
1034
|
-
The `layout` Tool (present in `full`, not in `basic`) is a Block of columns. It is inserted with the **"Layout"** item of the
|
|
1038
|
+
The `layout` Tool (present in `full`, not in `basic`) is a Block of columns. It is inserted with the **"Layout"** item of the Slash menu (`/layout` or "+"): two equal columns, the caret in the first. Six variants — **Single column, Two columns, Three columns, Four columns, 60/40, 40/60** — are chosen in the Block handle menu: "Add › Layout ›" inserts a Column layout under the Block (from a column, under the whole Column layout), and the variant is switched with "Layout ›" in the handle of a Block in a column and with "Format ›" in the handle of a Column layout selected as a whole (Backspace on the Block under it); in both, the current variant is checked. The Column layout has no button of its own on the Toolbar.
|
|
1035
1039
|
|
|
1036
|
-
- **The Blocks of a column have their own handle**, at the left edge of the column at the level of the Block's first line (for the right column, in the gap between the columns). For them a column works like the Document: "Add ›" puts a new Block below in the same column, "Move up" and "Move down" do not leave it, "Delete" on the only Block leaves an empty column, "Format ›" changes the type of the Block itself. The
|
|
1040
|
+
- **The Blocks of a column have their own handle**, at the left edge of the column at the level of the Block's first line (for the right column, in the gap between the columns). For them a column works like the Document: "Add ›" puts a new Block below in the same column, "Move up" and "Move down" do not leave it, "Delete" on the only Block leaves an empty column, "Format ›" changes the type of the Block itself. The Column layout's variant is a separate "Layout ›" item: the caret is almost always in a column, and the handle of the whole Column layout is rarely visible.
|
|
1037
1041
|
|
|
1038
|
-
- **A column is a separate area.** It holds any Blocks except another
|
|
1042
|
+
- **A column is a separate area.** It holds any Blocks except another Column layout: there is no nesting at the Schema level, so you cannot create a nested Column layout by pasting or from `value`. A Column layout is only a top-level Block: the "Layout" item from a column, list or quote puts a new Column layout under the current top-level Block (from a column, under the whole Column layout), and a Column layout from the clipboard pasted into a column, list, quote or cell unwraps into its Blocks. The other "Insert" items from a column land under the current Block **in the same column**; moving (`Alt+Shift+↑/↓`) and duplicating a Block do not leave the column either.
|
|
1039
1043
|
- **In the Editor the columns are outlined with a gray dashed line**; in `RichTextView` there is no outline — the border is for the author, not the reader. The variant sets the column widths, and dragging does not change them.
|
|
1040
|
-
- **Changing the variant does not lose text.** If there are fewer columns, the Blocks of the extra columns move to the end of the last remaining one (empty extra columns simply go away); if there are more, empty ones are added. Insertion and a variant change are one edit, undone with one `Ctrl/⌘+Z`. "Single column" is a
|
|
1041
|
-
- **A narrow screen.** Columns sit side by side only when the Document itself is wide enough: two columns from 30 rem (480 px), three from 40 rem (640 px), four from 48 rem (768 px); at a smaller width they stack in reading order. The threshold is based on the Document's width (a container query), not the window's width, so in a narrow `Drawer` on a wide monitor the columns also stack. The root of a Document with a
|
|
1044
|
+
- **Changing the variant does not lose text.** If there are fewer columns, the Blocks of the extra columns move to the end of the last remaining one (empty extra columns simply go away); if there are more, empty ones are added. Insertion and a variant change are one edit, undone with one `Ctrl/⌘+Z`. "Single column" is a Column layout of one column; to remove the whole Column layout, use "Delete" on its handle or `Backspace` on the Block under it (the first press selects the Column layout, the second deletes it).
|
|
1045
|
+
- **A narrow screen.** Columns sit side by side only when the Document itself is wide enough: two columns from 30 rem (480 px), three from 40 rem (640 px), four from 48 rem (768 px); at a smaller width they stack in reading order. The threshold is based on the Document's width (a container query), not the window's width, so in a narrow `Drawer` on a wide monitor the columns also stack. The root of a Document with a Column layout becomes a container (`container-type: inline-size`); in a shrink-to-fit context (a flex-row item without a width), give the field or `RichTextView` a width.
|
|
1042
1046
|
|
|
1043
1047
|
| Key in a column | What it does |
|
|
1044
1048
|
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1045
1049
|
| `Tab` / `Shift+Tab` | To the start of the next / previous column. In a list, table and code block, Tab keeps its own behavior; from the outermost column it goes to the browser — focus leaves the Editor |
|
|
1046
|
-
| `→` / `←` at the text edge | To the start of the neighboring column on the right / to the end of the one on the left; from the outermost one, out of the
|
|
1047
|
-
| `↓` / `↑` at the bottom / top line | Out of the
|
|
1048
|
-
| `Backspace` at the start of a column | Columns do not merge and text does not leave the
|
|
1050
|
+
| `→` / `←` at the text edge | To the start of the neighboring column on the right / to the end of the one on the left; from the outermost one, out of the Column layout |
|
|
1051
|
+
| `↓` / `↑` at the bottom / top line | Out of the Column layout — to the Block under it / above it; at the Document edge, a gap cursor (a caret between two Blocks where an ordinary caret cannot go) |
|
|
1052
|
+
| `Backspace` at the start of a column | Columns do not merge and text does not leave the Column layout; an empty heading, as everywhere, becomes text |
|
|
1049
1053
|
|
|
1050
1054
|
**The markup** is a `div` with `data-block`; the widths are set by the variant in `data-layout`, with no `style` (the column shares in percent, joined by hyphens):
|
|
1051
1055
|
|
|
@@ -1056,20 +1060,20 @@ The `layout` Tool (present in `full`, not in `basic`) is a Block of columns. It
|
|
|
1056
1060
|
</div>
|
|
1057
1061
|
```
|
|
1058
1062
|
|
|
1059
|
-
The `data-layout` values are `100`, `50-50`, `33-33-33`, `25-25-25-25`, `60-40`, `40-60` (`RICH_TEXT_SCHEMA.layouts`). HTML from another editor with `div[data-block="column"]` columns and widths in `style="flex-basis: 40%"` is read as the
|
|
1063
|
+
The `data-layout` values are `100`, `50-50`, `33-33-33`, `25-25-25-25`, `60-40`, `40-60` (`RICH_TEXT_SCHEMA.layouts`). HTML from another editor with `div[data-block="column"]` columns and widths in `style="flex-basis: 40%"` is read as the Column layout of the nearest variant, and `style` is dropped. If the number of columns disagrees with the variant, the Editor adjusts the variant to the number of columns. Without the `layout` Tool, a Column layout from `value` and paste unwraps into Blocks one after another.
|
|
1060
1064
|
|
|
1061
|
-
**Markdown does not support columns**: `getMarkdown()` exports the columns' Blocks one after another, left to right, and `setMarkdown()` does not create a
|
|
1065
|
+
**Markdown does not support columns**: `getMarkdown()` exports the columns' Blocks one after another, left to right, and `setMarkdown()` does not create a Column layout, even from raw HTML. The strings are `editor.layout` and `editor.layoutVariant` in the English and Russian message catalogs; the item's icon is `ColumnLayoutIcon`. The decision is ADR-0056.
|
|
1062
1066
|
|
|
1063
1067
|
## Wrapper
|
|
1064
1068
|
|
|
1065
|
-
The `wrapper` Tool (present in `full`, not in `basic`) is a Block that gathers a group of Blocks, like the Wrapper in Redactor. Without a tone the Wrapper has no look of its own: it keeps Blocks together — they can be moved, duplicated and deleted as one Block. With a tone it becomes a note or a warning, like alerts in GitHub and callouts in Notion (see the "Tone" subsection below). It is inserted with the **"Wrapper"** item of the
|
|
1069
|
+
The `wrapper` Tool (present in `full`, not in `basic`) is a Block that gathers a group of Blocks, like the Wrapper in Redactor. Without a tone the Wrapper has no look of its own: it keeps Blocks together — they can be moved, duplicated and deleted as one Block. With a tone it becomes a note or a warning, like alerts in GitHub and callouts in Notion (see the "Tone" subsection below). It is inserted with the **"Wrapper"** item of the Slash menu (`/wrapper` or "+") and with **"Add › Wrapper"** in the Block handle: an empty Wrapper with one paragraph lands under the current Block, and the caret goes into the paragraph inside. The Wrapper has no button of its own on the Toolbar.
|
|
1066
1070
|
|
|
1067
|
-
- **Inside — any Blocks:** a paragraph, a heading, lists, a quote, a code block, a table, an image, a divider, but not another Wrapper or a
|
|
1068
|
-
- **Placement.** A Wrapper can appear in the Document and in a
|
|
1071
|
+
- **Inside — any Blocks:** a paragraph, a heading, lists, a quote, a code block, a table, an image, a divider, but not another Wrapper or a Column layout. There is no nesting at the Schema level, so you cannot create a nested Wrapper or Column layout by pasting or from `value`. A Wrapper pasted into a Wrapper, list, quote or cell unwraps into its Blocks, and a Column layout inside a Wrapper unwraps into the columns' Blocks.
|
|
1072
|
+
- **Placement.** A Wrapper can appear in the Document and in a column of a Column layout. From a column, the "Wrapper" item puts it under the current Block in the same column; from a Wrapper, list, quote or cell, under their Block. Insertion is one edit, undone with one `Ctrl/⌘+Z`.
|
|
1069
1073
|
- **The Blocks of a Wrapper have their own handle** at the left edge of the Wrapper. For them a Wrapper works like the Document: "Add ›" puts a new Block below in the same Wrapper, "Move up", "Move down" and `Alt+Shift+↑/↓` do not leave it, and "Delete" on the only Block leaves an empty paragraph.
|
|
1070
1074
|
- **The Wrapper's own handle** appears when it is selected as a whole (`Backspace` on the Block under it): "Move up", "Move down", "Duplicate" and "Delete" act on it together with its content.
|
|
1071
1075
|
- **"Unwrap"** is a Block action of the `wrapper` Tool: an item of the handle menu on the Wrapper itself and on a Block directly in it, in the Tools' actions section before "Delete". The Blocks take the Wrapper's place, and the caret stays in its text. One edit, one undo.
|
|
1072
|
-
- **In the Editor a Wrapper without a tone is outlined with a gray dashed line** and inner padding — the same look as
|
|
1076
|
+
- **In the Editor a Wrapper without a tone is outlined with a gray dashed line** and inner padding — the same look as the columns of a Column layout; in `RichTextView` there is no outline, and the spacing between Blocks is as in the Document: the border is for the author, not the reader. A Wrapper with a tone is visible to the reader too — with the same look as in the Editor.
|
|
1073
1077
|
|
|
1074
1078
|
| Key in a Wrapper | What it does |
|
|
1075
1079
|
| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
@@ -1108,7 +1112,7 @@ It is chosen in the **"Tone ›"** submenu of the handle menu — on a Block dir
|
|
|
1108
1112
|
|
|
1109
1113
|
- **The names are the kit's `Tone` vocabulary**, not the menu words: `data-tone="warning"` goes to the database, and neither the menu label nor the interface language changes it. The theme substitutes the color, as with `data-color`: a saved Document reads in `dark`, `light`, `dim`, with any accent and brand. The values are the roles `--rte-tone-<tone>-background` and `--rte-tone-<tone>-border` (`src/editor/styles/tokens.ts`): in a theme they resolve to the kit's status tokens, on the Paper (`surface="paper"`) to the primitives of the light theme. The Schema does not support a custom tone color (`style`).
|
|
1110
1114
|
- **"Important" is a neutral tone**, and it has two departures from `TONE_TOKENS.neutral`. The background is `bg-3`, not `bg-2`: the Editor's field itself sits on `bg-2`, and in the field "Important" would be left without a background; `bg-3` is the app background that the text tokens are calibrated for, and in weight it is closer to the soft backgrounds of the other tones in dark themes than `bg-4` is. In the light theme this background is almost white — the stripe carries the tone there. The stripe is `fg-3`, not the decorative `line`: it carries meaning and holds 3:1.
|
|
1111
|
-
- **Contrast** (`npm run contrast`, the color pairs for
|
|
1115
|
+
- **Contrast** (`npm run contrast`, the color pairs for Callout tones): the Document's text on each background is at least 4.5:1 (the worst is `warning`, 7.15 in the dark theme on `surface-elevated`; on the Paper 13.3), the stripe is at least 3:1 both against the app background around the Wrapper (the worst is 4.51 for "Note" on the Paper) and against its own fill (the worst is 3.68, "Note" on the Paper; in the app theme 4.60). This holds in all themes, accents and brands, and on the Paper.
|
|
1112
1116
|
- **Secondary text in a Wrapper with a tone uses higher-contrast roles.** The `fg-1`–`fg-3` and `accent-strong` steps are calibrated for app backgrounds, and on the colored tone backgrounds the contrast of a quote would be 4.23:1, of a link 3.97, of a caption and gray text 3.59. So inside a Wrapper with a tone a link, a quote (and the language label in the code block header), an image caption, a done checklist item, gray text color (`data-color="muted"`) and the empty caption placeholder use the roles `--rte-tone-link`, `-secondary`, `-muted`, `-muted-foreground`:
|
|
1113
1117
|
|
|
1114
1118
|
| What | Role outside a tone | In the app theme | On the Paper (`paper`) |
|
|
@@ -1120,9 +1124,9 @@ It is chosen in the **"Tone ›"** submenu of the handle menu — on a Block dir
|
|
|
1120
1124
|
|
|
1121
1125
|
In the app theme, on all five backgrounds only `fg-0` holds 4.5:1 (`fg-1` gives 4.23 on "Note"), so inside a tone secondary text uses the main text's colors: a quote is still told apart by the stripe, a done item by strikethrough, a caption by font size, a link by underline (a Document link always has it). On the Paper, `cloud-2` and `cloud-3` pass on the tone backgrounds too and stay; the link (the accent gave 3.68) and the placeholder are moved to higher-contrast roles. The worst pair is 7.15:1 in a theme (`warning`, dark theme), 5.30:1 on the Paper (caption and placeholder on "Note"). Outside a Wrapper and in a Wrapper without a tone, the roles do not change. The gate does not measure the author's text color — palette and custom color — on a tone background: that is the author's choice.
|
|
1122
1126
|
|
|
1123
|
-
- **An unknown tone is dropped** — both on parsing (`value`, paste,
|
|
1127
|
+
- **An unknown tone is dropped** — both on parsing (`value`, paste, Source mode) and in `sanitizeRichText`: `data-tone="purple"`, `"Accent"` and `data-tone` on any other tag are removed, and the Wrapper stays without a tone. A Wrapper in a Wrapper, list, quote or cell is unwrapped into its Blocks, and its tone goes away. If unsanitized HTML with an unrecognized tone reaches `RichTextView`, it renders the Wrapper without any tone styling.
|
|
1124
1128
|
- **A tone is styling.** The saved HTML has no ARIA roles (`note`, `alert`) and no `aria-*` attributes: the screen reader reads the Wrapper's Blocks like the Blocks around it. So the meaning of a note is stated **in words** in its text — "Warning: …", "Tip: …": the background color must not be the only way the meaning is conveyed (WCAG 1.4.1).
|
|
1125
|
-
- **
|
|
1129
|
+
- **Source mode** shows `data-tone` on a Wrapper as is; the field text passes through the Schema, like `value`.
|
|
1126
1130
|
|
|
1127
1131
|
**Markdown — GitHub alerts.** `getMarkdown()` exports a Wrapper with a tone as a quote whose first line holds the alert keyword (`[!NOTE]`), followed by its Blocks inside the quote:
|
|
1128
1132
|
|
|
@@ -1138,6 +1142,54 @@ It is chosen in the **"Tone ›"** submenu of the handle menu — on a Block dir
|
|
|
1138
1142
|
|
|
1139
1143
|
The strings are `editor.wrapper` ("Wrapper"), `editor.unwrap` ("Unwrap"), `editor.wrapperTone` ("Tone: {current}"), `editor.wrapperToneNone` ("None") and `editor.wrapperToneName` (the names of the five tones) in the English and Russian message catalogs; the icons are `WrapperIcon`, `UnwrapIcon` and `PaletteIcon` (for "Tone ›"). The decision is ADR-0057.
|
|
1140
1144
|
|
|
1145
|
+
## Collapsible block
|
|
1146
|
+
|
|
1147
|
+
The `details` Tool (present in `full`, not in `basic`) is a Block made of a title and content that the reader opens and closes: details under a heading, like a spoiler on GitHub or a toggle in Notion. The engine is the Details extension from Tiptap, and the API follows it (ADR-0048). It is inserted with the **"Collapsible block"** item of the Slash menu (`/collapsible`, `/details` or "+") and with **"Add › Collapsible block"** in the Block handle: an empty open Block with the caret in the title. Insertion is one edit, undone with one `Ctrl/⌘+Z`.
|
|
1148
|
+
|
|
1149
|
+
- **Title and content.** The title (`summary`) is one line of text with Marks, with no line breaks or Blocks. The content is any Blocks: a paragraph, a heading, lists, a quote, a code block, a table, an image, a divider, another collapsible Block. There are no Column layouts or Wrappers inside: there is no nesting at the Schema level, and a Column layout or a Wrapper from `value` or paste unwraps into its Blocks. The Block itself stands wherever any Block can: in the Document, a column of a Column layout, a Wrapper, a quote, a cell and the content of another Block. The Slash menu puts it under the handle's Block: from a paragraph, under it; from a list or a quote, under the whole list or quote; from a Wrapper, a column or the content of another Block, inside them.
|
|
1150
|
+
- **Opening.** In the Editor, to the left of the title there is a button with a chevron: a real `button` named from the catalog ("Details: {title}") with `aria-expanded`. It is in the Tab order (from the Document, Tab leads to the button of the nearest Block below the caret), Enter and Space open and close the Block, and focus stays on the button. Collapsed content is hidden from the caret as well: the arrow keys do not take it inside. In `readOnly` the Block still opens and closes.
|
|
1151
|
+
- **The `persist` option.** `tool("details", { persist: true })`, `false` by default (as in Tiptap). Without it the open state lives only in the display: opening does not reach the Document, the undo history or `onChange`, `open` is not written to the HTML and is not read from `value`, and a Block opened during editing is not published open without anyone asking. With `persist: true` an open Block writes `open=""`, `open` is read from `value`, paste and `setMarkdown()`, and opening or closing is an edit of the Document (`onChange`, undo). A Block inserted from the menu is open with any `persist`. The options are read once, when the engine is created; an invalid value (not a boolean) is replaced by the default with a `console.error` in development.
|
|
1152
|
+
- **The Block handle works like a Wrapper's.** The handle at the title is the handle of the whole Block: "Move up", "Move down", "Duplicate" and "Delete" take it with its content. The Blocks in the content have their own handle at the left edge of the content: "Add ›" puts a new Block below in the content, "Move up", "Move down" and `Alt+Shift+↑/↓` do not leave it. There is no "Tone ›" for the Block. **"Remove collapsing"** is a Block action of the `details` Tool: an item on the Block itself and on a Block directly in its content, in the Tools' actions section before "Delete". The title becomes a paragraph, the content Blocks follow it, and the caret stays in its text. One edit, one undo.
|
|
1153
|
+
- **In the Editor the Block is outlined with a frame** (`--rte-hr`) and inner padding, the title is semibold, and the `summary` marker is replaced by the button; in `RichTextView` there is the same frame and the same title, but with the browser's native marker.
|
|
1154
|
+
|
|
1155
|
+
| Key | What it does |
|
|
1156
|
+
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
|
1157
|
+
| `Enter` in the title of an open Block | The caret goes into the content: into the empty first paragraph if there is one, otherwise into a new paragraph at the start |
|
|
1158
|
+
| `Enter` in the title of a collapsed Block | A new paragraph under the Block |
|
|
1159
|
+
| `Enter` in the empty last content paragraph | A way out: the paragraph moves under the Block, and the caret with it |
|
|
1160
|
+
| `Backspace` at the start of the title | The Block falls apart, like "Remove collapsing" |
|
|
1161
|
+
| `Backspace` further into the title | Deletes one character; an emoji or a composed character whole |
|
|
1162
|
+
| `↓` / `→` from the title of a collapsed Block | A gap cursor right under the Block, past the hidden content |
|
|
1163
|
+
| `Tab` / `Shift+Tab` | Not intercepted: as in the Document. No keyboard trap |
|
|
1164
|
+
|
|
1165
|
+
**The markup** is the kit's tags `details` and `summary` and the content wrapper `div[data-type="detailsContent"]`, with no classes or `style`:
|
|
1166
|
+
|
|
1167
|
+
```html
|
|
1168
|
+
<details>
|
|
1169
|
+
<summary>How to build the package</summary>
|
|
1170
|
+
<div data-type="detailsContent"><p>Run the gates, then the build.</p></div>
|
|
1171
|
+
</details>
|
|
1172
|
+
```
|
|
1173
|
+
|
|
1174
|
+
`open` is written only with `persist`, and empty: `<details open="">`. HTML without the content wrapper (GitHub, browsers: `<details><summary>…</summary><p>…</p></details>`) reads the same way — the Schema completes it; a Block without content gets an empty paragraph. Without the `details` Tool a Block from `value` and paste unwraps: the title becomes a paragraph, the content Blocks follow, and no text is lost.
|
|
1175
|
+
|
|
1176
|
+
**The sanitizer** lets `details`, `summary` and the content wrapper through only in this shape. `summary` only directly in `details`; `div[data-type="detailsContent"]` only directly in `details` and only with that `data-type` value; `open` only on `details` and only empty (`open="open"` is removed, and the Block stays closed). Outside such a place the tags lose their wrapper, and their content stays as Blocks in a row. `iframe`, `class`, `style` and handlers are stripped, as everywhere. The allow-list is not narrowed by the `tools` set: `open` passes even with `persist: false` — another screen may have written it, and a server has no right to cut what it did not write.
|
|
1177
|
+
|
|
1178
|
+
**Markdown is raw HTML, as on GitHub.** `getMarkdown()` writes the Block with `details` and `summary` tags and Markdown inside:
|
|
1179
|
+
|
|
1180
|
+
```md
|
|
1181
|
+
<details>
|
|
1182
|
+
<summary>How to build the package</summary>
|
|
1183
|
+
|
|
1184
|
+
Run the gates, then the build.
|
|
1185
|
+
|
|
1186
|
+
</details>
|
|
1187
|
+
```
|
|
1188
|
+
|
|
1189
|
+
The blank line after `summary` makes GitHub read the content as Markdown, and the Block collapses there. The title is inline Markdown on one line: GitHub will show its asterisks as they are, but the `getMarkdown()` → `setMarkdown()` round trip loses nothing; `<` in a title is written as `<`. With `persist` an open Block is written as `<details open>`. `setMarkdown()` reads such a Block back — with nested Blocks, lists and code, where `</details>` inside a fence is not taken for the closing tag; `<details open>` without `persist` gives a closed Block. A tag with no matching `</details>` stays text. This differs from a Column layout and a Wrapper without a tone, which unwrap in Markdown: a collapsible Block has a common form in Markdown, and columns do not. Without the `details` Tool the same lines unwrap into Blocks in a row (an empty paragraph may remain after the closing tag).
|
|
1190
|
+
|
|
1191
|
+
The strings are `editor.details` ("Collapsible block"), `editor.unwrapDetails` ("Remove collapsing") and `editor.detailsToggle` ("Details: {title}", "Details: no title" for a Block without a title) in the message catalog (English and Russian); the icons are `DetailsIcon` for the insert item and `UnwrapIcon` for "Remove collapsing". The decision is ADR-0048 ("for the future": there was no customer at release).
|
|
1192
|
+
|
|
1141
1193
|
## Emoji
|
|
1142
1194
|
|
|
1143
1195
|
The `emoji` Tool (present in `full`, not in `basic`) is the "Emoji" button on the Toolbar right after "Table ▾", before undo and redo. It opens a **kit popover** (`Popover`) with a grid of a curated set: "Favorites" (20) and six categories of ten — "Smileys", "Gestures", "Animals", "Food", "Activities", "Travel". There is a single grid; the categories follow one another, each under its label. There is no full Unicode set, search, "recent", skin tones or `:shortcodes:`, and there never will be: this is a quick path to familiar symbols, not an OS emoji picker.
|
|
@@ -1161,16 +1213,16 @@ The `emoji` Tool (present in `full`, not in `basic`) is the "Emoji" button on th
|
|
|
1161
1213
|
|
|
1162
1214
|
Inside `Dialog` and `Drawer`, you can choose an emoji with both the mouse and the keyboard, and Escape closes only the popover: Radix layers share one stack (ADR-0041). The strings are `editor.emoji` (the button's name), `editor.emojiGrid` (the grid's name), `editor.emojiCategory` (category labels) and `editor.emojiName` (the name of each emoji) — in the message catalog, in English and Russian.
|
|
1163
1215
|
|
|
1164
|
-
##
|
|
1216
|
+
## Source mode
|
|
1165
1217
|
|
|
1166
|
-
The `source` Tool (present in `full`, not in `basic`) is the "HTML" toggle button on the **fixed** Toolbar, in the "Whole Block" group right after "+", as in Redactor. It replaces the Document with a monospace field holding the current Document's HTML; pressing it again switches back to the visual mode. The mode has no keyboard shortcut; the
|
|
1218
|
+
The `source` Tool (present in `full`, not in `basic`) is the "HTML" toggle button on the **fixed** Toolbar, in the "Whole Block" group right after "+", as in Redactor. It replaces the Document with a monospace field holding the current Document's HTML; pressing it again switches back to the visual mode. The mode has no keyboard shortcut; the Keyboard shortcuts popover is available in the mode.
|
|
1167
1219
|
|
|
1168
1220
|
```tsx
|
|
1169
1221
|
<RichTextEditor preset="full" value={html} onChange={setHtml} /> // the "HTML" button is already on the Toolbar
|
|
1170
1222
|
<RichTextEditor tools={["bold", "italic", "link", "history", "source"]} /> // or explicitly in tools
|
|
1171
1223
|
```
|
|
1172
1224
|
|
|
1173
|
-
**What is visible.** The HTML is formatted: a top-level Block on its own line, the children of Block containers (list items, table rows and cells,
|
|
1225
|
+
**What is visible.** The HTML is formatted: a top-level Block on its own line, the children of Block containers (list items, table rows and cells, the columns of a Column layout, parts of a `figure`, a checklist item) indented by two spaces, a Block's content on its tag's line (text whitespace is unchanged), the content of `pre` as is. The formatting is lossless: applying unchanged text produces the same Document. An empty Document opens as an empty field, not as `<p></p>`. There is no syntax highlighting.
|
|
1174
1226
|
|
|
1175
1227
|
**Look.** The field is **always dark, using the kit's colors**, not the code block's syntax highlighting palette: in `dark` and `dim`, the theme's deepest background with its text and border; in `light` and on the Paper (`paper`), the same values of the dark theme (the variables `--rte-source-background`, `--rte-source-foreground`, `--rte-source-border`, which can be overridden). On the Paper (`paper`), the light sheet stays under the dark field: the sheet's contents change, not the sheet itself. The corner radius is as for code, the font is monospace 13 px, as in `CodeView`; the padding is as for the Document area. On entry the height is at least the Document's height, then the field grows with its content and can be stretched vertically by hand. With `maxHeight` or `resizable` the field lives in the same scroll area as the Document (see the "Field height" section). Text selection in the field also comes from the dark theme for the current accent (`--selection-dark`), with the field's light text: the light theme's light selection was unreadable on the dark field.
|
|
1176
1228
|
|
|
@@ -1187,19 +1239,19 @@ If the text did not change since entry or the last time it was applied, or parsi
|
|
|
1187
1239
|
|
|
1188
1240
|
The `required` check in the mode is evaluated against **the field's text** on every change, not the applied Document: the browser validates the form before the `submit` event, so `form.requestSubmit()` with focus in the HTML field lets through text typed into the field while the Document is empty, and blocks the form when the field has been cleared but the Document is not empty. The text is parsed by the same Schema as on applying, and it is empty exactly when the Document would be empty: `<p></p>` or only tags the Schema drops block submission, `<hr>` does not. Without `required` the text is not parsed while typing.
|
|
1189
1241
|
|
|
1190
|
-
**
|
|
1242
|
+
**Programmatic replacements in the mode.** A new `value` (not equal to the last one emitted), `setMarkdown()` and `clear()` replace the Document, and the field's text is overwritten with the formatting of the new Document: the screen that holds `value` outranks an unsaved edit. The field is not reset when the `value` it emitted comes back as a prop.
|
|
1191
1243
|
|
|
1192
1244
|
- **`maxLength`** does not truncate the text when it is applied — like `setMarkdown`: the counter shows the excess. In the mode the counter reflects the last applied state.
|
|
1193
1245
|
- **`disabled`** and `<fieldset disabled>` disable the HTML field and the button; the mode stays open.
|
|
1194
|
-
- **What is off in the mode.** The other Toolbar buttons and menus are `aria-disabled` (they stay in the arrow-key order but do not fire); there is no Block handle,
|
|
1195
|
-
- **Where the button is absent.** The button is absent from the floating Toolbar and the
|
|
1246
|
+
- **What is off in the mode.** The other Toolbar buttons and menus are `aria-disabled` (they stay in the arrow-key order but do not fire); there is no Block handle, Bubble menu or `/` Slash menu. The engine stays mounted and hidden: the edit history is preserved.
|
|
1247
|
+
- **Where the button is absent.** The button is absent from the floating Toolbar and the Bubble menu (`toolbar="floating"`, `"bubble"`): they appear above a selection in the Document, and in this mode there is no Document; with `toolbar="none"` and in `readOnly` there is no Toolbar. On a touch screen the Toolbar is always fixed — so the mode exists there.
|
|
1196
1248
|
- **SSR.** The mode is internal state, not a prop; it always starts visual, so the server output is the same as without the `source` Tool.
|
|
1197
1249
|
|
|
1198
1250
|
`RichTextView`, the Schema, the `sanitizeRichText` sanitizer and the allowlist are not changed by the mode: it does not widen the Schema: HTML from the field goes through it the same way as an incoming `value`. The strings are `editor.source` (the button's name, "HTML") and `editor.sourceField` (the field's name, "HTML source" in English, "HTML-код документа" in Russian) in the message catalog, in English and Russian; the icon is `SourceIcon`.
|
|
1199
1251
|
|
|
1200
1252
|
## Find and replace
|
|
1201
1253
|
|
|
1202
|
-
The `search` Tool (present in `full` and in `basic`) is find and replace over the Document. The "Find" button (a magnifying glass icon) sits at the right edge of the fixed Toolbar, before the
|
|
1254
|
+
The `search` Tool (present in `full` and in `basic`) is find and replace over the Document. The "Find" button (a magnifying glass icon) sits at the right edge of the fixed Toolbar, before the Keyboard shortcuts popover; `Ctrl/⌘+F` with focus in the Document opens the find panel (outside the field the browser's find works), `Ctrl/⌘+Alt+F` opens it with focus in the replace field, and the `open` command of the Tool (`ref.current?.tool("search")?.open()`) opens it from screen code.
|
|
1203
1255
|
|
|
1204
1256
|
```tsx
|
|
1205
1257
|
<RichTextEditor preset="basic" /> // the "Find" button and Ctrl/⌘+F are already there
|
|
@@ -1216,7 +1268,7 @@ The `search` Tool (present in `full` and in `basic`) is find and replace over th
|
|
|
1216
1268
|
- **Accessibility.** The panel is a `role="search"` region named "Find and replace"; the counter is announced through a live region with `aria-live="polite"` ("3 of 12", "No matches"), the fields have labels, and the switch is `role="switch"`.
|
|
1217
1269
|
- **Weight.** The match highlighting plugin and the shortcuts are in the engine from the start; the find panel (fields, checkboxes, switch) is a lazy chunk that loads the first time the panel opens and does not delay Readiness.
|
|
1218
1270
|
|
|
1219
|
-
Message catalog strings: `editor.search` (the button and the `Ctrl/⌘+F`
|
|
1271
|
+
Message catalog strings: `editor.search` (the button and the `Ctrl/⌘+F` Keyboard shortcuts popover row), `searchReplace` (`Ctrl/⌘+Alt+F`), `searchPanel`, `searchField`, `replaceField`, `searchPrevious`, `searchNext`, `searchClose`, `searchMatchCase`, `searchWholeWord`, `searchRegex`, `replaceOne`, `replaceAll`, `searchCount({ current, total })`, `searchNoMatches`, `searchInvalid`.
|
|
1220
1272
|
|
|
1221
1273
|
### Find options
|
|
1222
1274
|
|
|
@@ -1248,18 +1300,18 @@ The state is read with `useRichTextToolState(ref, "search")`, and the commands a
|
|
|
1248
1300
|
|
|
1249
1301
|
The state has no match positions.
|
|
1250
1302
|
|
|
1251
|
-
| Command | What it does
|
|
1252
|
-
| ---------------------------- |
|
|
1253
|
-
| `open({ replace?, query? })` | Opens the panel, like the "Find" button and `Ctrl/⌘+F`. `query` puts the query in the field and in the Document highlighting at once; without it, the text selected within one Block becomes the query. `replace: true` puts focus in the replace field. `false` in
|
|
1254
|
-
| `close()` | Closes the panel and removes the highlighting. Focus and the caret stay where they were, unlike `Escape` in the panel. A panel that is already closed is not an error.
|
|
1255
|
-
| `setQuery(query, flags?)` | Sets the query. Flags not named in `flags` (and flags set to `undefined`) come from the open panel, or from the options when there is none. A non-empty query scrolls to the first match after the caret. `true` means the query was applied, even with no matches; `false` in
|
|
1256
|
-
| `next()`, `previous()` | They select the next or previous match, wrapping around at either end, and scroll the field's scroll area or the window to it, even when focus is outside the Document; they do not take focus. `false` means no matches.
|
|
1257
|
-
| `replace(text)` | Replaces the current match: one edit, one undo; the next match becomes current. `false` means no matches or a field the author cannot edit.
|
|
1258
|
-
| `replaceAll(text)` | Replaces all matches: one edit, one undo. The conditions are the same as for `replace`.
|
|
1303
|
+
| Command | What it does |
|
|
1304
|
+
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1305
|
+
| `open({ replace?, query? })` | Opens the panel, like the "Find" button and `Ctrl/⌘+F`. `query` puts the query in the field and in the Document highlighting at once; without it, the text selected within one Block becomes the query. `replace: true` puts focus in the replace field. `false` in Source mode. |
|
|
1306
|
+
| `close()` | Closes the panel and removes the highlighting. Focus and the caret stay where they were, unlike `Escape` in the panel. A panel that is already closed is not an error. |
|
|
1307
|
+
| `setQuery(query, flags?)` | Sets the query. Flags not named in `flags` (and flags set to `undefined`) come from the open panel, or from the options when there is none. A non-empty query scrolls to the first match after the caret. `true` means the query was applied, even with no matches; `false` in Source mode. |
|
|
1308
|
+
| `next()`, `previous()` | They select the next or previous match, wrapping around at either end, and scroll the field's scroll area or the window to it, even when focus is outside the Document; they do not take focus. `false` means no matches. |
|
|
1309
|
+
| `replace(text)` | Replaces the current match: one edit, one undo; the next match becomes current. `false` means no matches or a field the author cannot edit. |
|
|
1310
|
+
| `replaceAll(text)` | Replaces all matches: one edit, one undo. The conditions are the same as for `replace`. |
|
|
1259
1311
|
|
|
1260
1312
|
The query and the flags live in the engine, so `setQuery`, `next` and `replace` work without an open panel: you can build your own find interface on them. The panel and the commands share one search, so a replacement made from code shows up in the panel. Scrolling after `next`, `previous` and `setQuery` arrives a moment after the call: its code loads on first use and is not part of the Editor's starting chunk.
|
|
1261
1313
|
|
|
1262
|
-
**A counter in the Status
|
|
1314
|
+
**A counter in the Status bar.** While the find panel is open, the screen can show "3 of 12" next to the character counter. Closing the panel removes the query, and the counter disappears.
|
|
1263
1315
|
|
|
1264
1316
|
```tsx
|
|
1265
1317
|
import { useRef } from "react";
|
|
@@ -1300,7 +1352,7 @@ A `tool("toc", options)` entry also switches the Tool on and configures it at th
|
|
|
1300
1352
|
- **Stickiness.** The strip sticks under the fixed Toolbar (and under the app header: `--topbar-h`, ADR-0016); without a Toolbar, to the edge of the window. In a field with `maxHeight` or `resizable` it sticks to the top of the scrolling area, and the highlight and the jump count from there; the page does not scroll.
|
|
1301
1353
|
- **The list** works as in `RichTextToc`: hover opens it to the right of the strip, Enter and Space open it from the keyboard, arrows move between items, Escape closes it; the current section is highlighted in the strip and in the list; the width is 240px, and a long heading is cut with an ellipsis. A click on an item does not close the list: moving the pointer away does.
|
|
1302
1354
|
- **A jump while editing** puts the caret at the end of the heading, so you can type right away, as in the document outline of Google Docs; the Table of contents does the scrolling, and the heading flashes a fading background. In `readOnly` there is no caret, so focus goes to the heading itself.
|
|
1303
|
-
- **A live Document.** The list is rebuilt shortly after each edit: a new, renamed or removed heading shows in the strip. In
|
|
1355
|
+
- **A live Document.** The list is rebuilt shortly after each edit: a new, renamed or removed heading shows in the strip. In Source mode there is no Table of contents, because the Document is hidden.
|
|
1304
1356
|
- **No Toolbar button.** To show or hide the Table of contents, the screen changes `tools` (and remounts the field with a `key`, as for any set of Tools).
|
|
1305
1357
|
|
|
1306
1358
|
Message catalog string: `editor.toc` ("Table of contents"), the name of the strip and the label of the list.
|
|
@@ -1334,7 +1386,7 @@ The state is read with `useRichTextToolState(ref, "toc")`, and the command is ca
|
|
|
1334
1386
|
|
|
1335
1387
|
The headings are read from the Document, not from the DOM: the list is ready on the first frame without waiting for the lazy Table of contents UI, and it changes together with the edit. The state has no DOM nodes or ProseMirror positions.
|
|
1336
1388
|
|
|
1337
|
-
There is one command, `jumpTo(index)`: go to a heading of the list, like a click on an item. The index is counted in the same list (with `levels` applied, from 0, empty headings skipped) and is taken from the state snapshot: right after the Document is edited, it leads to the heading that the snapshot names, even though the Table of contents UI rebuilds its list with a delay. While editing, the caret goes to the end of the heading; in `readOnly`, the heading itself gets focus; scrolling puts it under the sticky header (`offsetVar`) and the Toolbar, and the heading's highlight fades. `false` means there is no heading with that index, the Table of contents UI has not loaded yet, or
|
|
1389
|
+
There is one command, `jumpTo(index)`: go to a heading of the list, like a click on an item. The index is counted in the same list (with `levels` applied, from 0, empty headings skipped) and is taken from the state snapshot: right after the Document is edited, it leads to the heading that the snapshot names, even though the Table of contents UI rebuilds its list with a delay. While editing, the caret goes to the end of the heading; in `readOnly`, the heading itself gets focus; scrolling puts it under the sticky header (`offsetVar`) and the Toolbar, and the heading's highlight fades. `false` means there is no heading with that index, the Table of contents UI has not loaded yet, or Source mode is on. Fewer than two headings is not a reason to refuse: there is no gutter, but the list exists, and the jump works.
|
|
1338
1390
|
|
|
1339
1391
|
**External navigation.** The screen builds breadcrumbs, a minimap or a list of sections next to the field from the state, instead of parsing the Document's DOM a second time.
|
|
1340
1392
|
|
|
@@ -1369,11 +1421,11 @@ function SectionNav({ target }: { target: RefObject<RichTextEditorHandle | null>
|
|
|
1369
1421
|
}
|
|
1370
1422
|
```
|
|
1371
1423
|
|
|
1372
|
-
## Status
|
|
1424
|
+
## Status bar and Field notice
|
|
1373
1425
|
|
|
1374
|
-
Under the field inside the frame there are two independent areas. The **Status
|
|
1426
|
+
Under the field inside the frame there are two independent areas. The **Status bar** is what the screen puts there itself: "Saved", a word count, a co-author. The **Field notice** is what the kit and Tools report: an upload result, a paste rejection. The first is a React node in the `status` prop, the second is data and an engine command.
|
|
1375
1427
|
|
|
1376
|
-
### Status
|
|
1428
|
+
### Status bar: `status`
|
|
1377
1429
|
|
|
1378
1430
|
```tsx
|
|
1379
1431
|
<RichTextEditor
|
|
@@ -1393,9 +1445,9 @@ Under the field inside the frame there are two independent areas. The **Status l
|
|
|
1393
1445
|
- **The container has no `role` and no `aria-live`: the kit does not announce the line.** A word count changes on every edit, and reading it aloud would mean talking over typing. If you need "Saved" to be announced, wrap only that in `role="status"` (the example above; with autosave on `onBlur`, see [the recipe on the extension API page](../../api/editor-extension.md#сохранено-в-строке-статуса)). Do not wrap the whole line in `role="status"`.
|
|
1394
1446
|
- It is visible in `readOnly` and in `disabled` alike: it is part of the field's frame, not of editing. With `disabled` the frame is muted as a whole, like other fields, together with the line.
|
|
1395
1447
|
- The counter keeps its `id` and stays in the field's `aria-describedby` while visible; the screen's content is not part of the field's description.
|
|
1396
|
-
- Take the data from your own state: the word count from `value`/`onChange`, "Saved" from the server's response. Tools have no access to the Status
|
|
1448
|
+
- Take the data from your own state: the word count from `value`/`onChange`, "Saved" from the server's response. Tools have no access to the Status bar: it has no owning Tool.
|
|
1397
1449
|
- The default color is the second text step; the size is 11 px, with monospace digits. Set your own color on your own node: the third text step does not reach a 4.5:1 contrast ratio on the field's background.
|
|
1398
|
-
- `RichTextView` has no Status
|
|
1450
|
+
- `RichTextView` has no Status bar: it shows sanitized HTML without an engine.
|
|
1399
1451
|
|
|
1400
1452
|
### Field notice: `showNotice`
|
|
1401
1453
|
|
|
@@ -1409,12 +1461,12 @@ ref.current?.editor?.commands.showNotice({ tone: "error", text: "Couldn't insert
|
|
|
1409
1461
|
ref.current?.editor?.commands.showNotice(null);
|
|
1410
1462
|
```
|
|
1411
1463
|
|
|
1412
|
-
- **`status`** is invisible and only announced: the text goes to a `role="status"` live region outside the Document. Visually, the upload progress is shown by a placeholder in the Document, and live regions inside `contenteditable` are not read everywhere. It stays until it is replaced or cleared;
|
|
1413
|
-
- **`error`** is visible as a `role="alert"` line under the field, above the Status
|
|
1464
|
+
- **`status`** is invisible and only announced: the text goes to a `role="status"` live region outside the Document. Visually, the upload progress is shown by a placeholder in the Document, and live regions inside `contenteditable` are not read everywhere. It stays until it is replaced or cleared; a User edit does not touch it.
|
|
1465
|
+
- **`error`** is visible as a `role="alert"` line under the field, above the Status bar, **until the next User edit**, a new notice or `showNotice(null)`. An edit is any change to the Document; a programmatic `value` replacement and `clear()` do not count as an edit; `setMarkdown()` and `setJSON()` clear the error. Show an error when the Document did not change as a result: without the line it would look like "nothing happened".
|
|
1414
1466
|
- The notice has no durations, close buttons or spinners of its own: it goes away on its own only after an edit; otherwise it is cleared by a command.
|
|
1415
1467
|
- **The command does not change the Document**: no transaction, no history step, no `onChange`. `editor.can().showNotice(…)` does not show a notice.
|
|
1416
1468
|
- A notice without text (`text: ""`) is the same as `null`. The kit does not translate the text: it is shown as is, without markup. The kit's own messages (files) go through the `editor.*` message catalog, and the strings of custom Tools come from their own message catalog.
|
|
1417
|
-
- **Who calls it.** The screen — `ref.current.editor.commands.showNotice(…)`. A custom Tool — from
|
|
1469
|
+
- **Who calls it.** The screen — `ref.current.editor.commands.showNotice(…)`. A custom Tool — from its Extensions: `this.editor.commands.showNotice(…)` in a Tiptap extension. Every Editor has the command regardless of `tools`, including without `image`. The `FieldNotice` type is exported from `@olddevs/ui/editor`; it also provides the types for `editor.commands.showNotice`.
|
|
1418
1470
|
- Image messages go through the same channel: "Uploading photo.png…" and "photo.png uploaded" are `status`; an upload failure, "not an image" and "files can't be uploaded" are `error`. An `error` from a Tool and a file message share one area: a new one replaces the previous one.
|
|
1419
1471
|
|
|
1420
1472
|
## Field height: `maxHeight` and `resizable`
|
|
@@ -1427,10 +1479,10 @@ By default the Editor grows with the Document, and a long text stretches the pag
|
|
|
1427
1479
|
```
|
|
1428
1480
|
|
|
1429
1481
|
- **Units.** A number is pixels, a string is any CSS length (`"24rem"`, `"60vh"`). There is no row-count option like `maxRows` in `TextControl`: Blocks have different heights.
|
|
1430
|
-
- **What scrolls.** Only the Document area: the Document, its Skeleton, the
|
|
1482
|
+
- **What scrolls.** Only the Document area: the Document, its Skeleton, the Source mode field and the Block handle. The fixed Toolbar above it and the Status bar with the Field notice under it stay in place, the frame does not move. The scrollbar sits at the frame.
|
|
1431
1483
|
- **`resizable` with `maxHeight`** — `maxHeight` becomes the **starting height**, not a limit: the field stretches both taller and shorter. Two systems that control height at the same time produce a field that jumps on every edit. The same reasoning applies to `autosize` in `TextControl`. Without `maxHeight` the start is the Document's height. The area does not shrink below 128 px (a few lines).
|
|
1432
1484
|
- **The height handle is the kit's own, not the browser's corner.** The browser renders the native `resize: vertical` as its own corner, which ignores the theme: in a dark one it is a white square on the frame. The kit's handle is a strip at the area's bottom edge with a grip in the middle, styled like the `SplitPanel` strip: the grip is `--line`, on hover `--fg-2`, while dragging and on focus the accent. The visible strip is 10 px, the hit zone 26 px (WCAG 2.5.8). For keyboard users there is a slider "Resize the editor" (`editor.resizeField`, the value is `editor.fieldHeight`): the Up and Down arrow keys move it by 16 px, with Shift by a pixel; dragging with the mouse moves focus to it, so you can fine-tune the height with the arrow keys. A double click on the strip returns the starting height. A height set by dragging persists until the field unmounts: saving it between visits is the screen's job.
|
|
1433
|
-
- **The Block handle and popup surfaces travel with the text.** The "⋮" handle sits inside the area and scrolls together with the Block; beyond the area's edge it is clipped and does not overlap the Toolbar. The floating Toolbar, the
|
|
1485
|
+
- **The Block handle and popup surfaces travel with the text.** The "⋮" handle sits inside the area and scrolls together with the Block; beyond the area's edge it is clipped and does not overlap the Toolbar. The floating Toolbar, the Bubble menu and the image panel listen to the area's scroll, not the page's; at the area's edge they flip below the selection. The Text card and the "/" Slash menu follow the range and the caret the same way: their anchor is tied to the Document (`contextElement`).
|
|
1434
1486
|
- **Without the props nothing changes:** the area is a plain element without styles, the field grows with the Document, and the Toolbar sticks to the top of the browser window.
|
|
1435
1487
|
|
|
1436
1488
|
## Paper: `surface`
|
|
@@ -1443,14 +1495,14 @@ By default the Editor grows with the Document, and a long text stretches the pag
|
|
|
1443
1495
|
|
|
1444
1496
|
**When to use the Paper.** A Document that will leave the app with a light look: a print or PDF preparation screen, an email editor, a document template. The author sees the text as it will be read, and the author's color does not lose contrast when the theme changes. For notes, comments and task descriptions the Paper is not needed: the Document lives in the app and follows its theme.
|
|
1445
1497
|
|
|
1446
|
-
**What stays in the app theme.** The Paper is only the editable area. The Toolbar (fixed and floating), the
|
|
1498
|
+
**What stays in the app theme.** The Paper is only the editable area. The Toolbar (fixed and floating), the Slash menu, the link, image and color popovers, the table menu, dialogs, the labels and counter under the field, the field's frame and the focus ring around it are in the app theme: this is the interface, not the Document. The Paper's edge is the app theme's `--line` frame and `--shadow-1` shadow, so the Paper stands apart on both a dark and a light background.
|
|
1447
1499
|
|
|
1448
1500
|
**How it is built.** The Paper carries a nested `data-theme="light"` — through it the highlights (`--marker-*`) and the syntax highlighting theme take light values. The kit tokens (`--fg-0`, surfaces, `--accent-strong`) are declared only on `:root[data-theme]` and do not understand a nested theme, so the Document reads not them but the `--rte-*` roles (`src/editor/styles/tokens.ts`): in a theme they map to kit tokens, on the Paper to the same `--od-*` primitives that the light theme assigns. A test checks the match with the light theme by parsing `styles.css`.
|
|
1449
1501
|
|
|
1450
1502
|
- **The accent on the Paper is the app theme's fill** (`--accent`), not the light `accent-strong`: for a brand and the violet accent the light step exists only in a light app. The fill is designed for white text on top of it, so used as text on the white Paper it meets 4.5:1 with any brand. In a light app the link, the `accent` color and focus on the Paper use exactly the light `accent-strong`.
|
|
1451
1503
|
- **Focus inside the Paper** (the code block language button, checklist checkboxes) is an outline of the same accent: the dark theme's light `accent-strong` does not reach 3:1 contrast on white.
|
|
1452
1504
|
- **The code block header on the Paper** is light: the background and text come from the syntax highlighting theme selected by the nested `data-theme`, and the border and line are the Paper's roles. The language button, the language label and "Copy" use the `--rte-secondary` role, hover is `--rte-control-hover`, not the tokens of a `ghost` button: those would have stayed with dark theme values, and a light icon would end up on a white header.
|
|
1453
|
-
- **The
|
|
1505
|
+
- **The Callout tone on the Paper** has its own `--rte-tone-*` roles with light theme values (see the "Wrapper" section, "Tone"): the app theme's status `*-soft` and `*-strong` would be dark on a white Paper.
|
|
1454
1506
|
- **Contrast** is checked by `npm run contrast`: every Paper pair against the Paper itself, in each theme × accent × brand.
|
|
1455
1507
|
- `RichTextView` with the same `surface` shows the Document on the same Paper.
|
|
1456
1508
|
|
|
@@ -1484,17 +1536,17 @@ import type { RichTextEditorHandle } from "@olddevs/ui/editor";
|
|
|
1484
1536
|
|
|
1485
1537
|
**Insertion.** `insertHTML` and `insertText` return `true` if the Document changed and `false` if not: there is no engine yet, the field is read-only or disabled, the insertion would go past `maxLength`, or the Schema dropped everything inserted. The HTML goes through the Schema like an incoming `value`: a Block of a disabled Tool is dropped (the text inside it stays as an ordinary paragraph), and if nothing is left, nothing is inserted. **An insertion that goes past `maxLength` is not performed at all** and returns `false`: a template cut off in the middle is worse than a refusal; what to tell the user is up to the screen. The caret is placed after the inserted content; the method does not take focus itself — if the insertion comes from a button or a dialog, start with `focus("restore")`. In a field that has never been focused, the caret is at the start of the Document.
|
|
1486
1538
|
|
|
1487
|
-
**
|
|
1539
|
+
**User edit and Programmatic replacement.** Insertion, `undo` and `redo` count as User edits: a separate history step (undo removes the insertion alone, not together with the typing before it), `onChange` fires after `changeDelay`, and the `maxLength` limit applies. `clear()`, `setMarkdown()`, `setJSON()` and a new `value` count as Programmatic replacements, and the limit does not truncate them; `onChange` fires immediately for `clear()`, `setMarkdown()` and `setJSON()`, but is not called for a new `value`. In `readOnly` and in a disabled field, insertion returns `false`, and `undo` and `redo` do nothing.
|
|
1488
1540
|
|
|
1489
1541
|
**Calling a method before Readiness is safe** — on the server, before mount and while Tool Loaders are running (see the "Custom Tool" section). The engine is created in the browser after the first render and the Loaders, so a call at this moment neither throws nor queues.
|
|
1490
1542
|
|
|
1491
|
-
- Reads return a known value: `getHTML()` the incoming `value`, `getText()` and `getSelectedText()` `""`, `getJSON()` `null`, and `isEmpty()` evaluates the incoming value by the same rule as after mount (a custom Block before mount is always content: only its
|
|
1543
|
+
- Reads return a known value: `getHTML()` the incoming `value`, `getText()` and `getSelectedText()` `""`, `getJSON()` `null`, and `isEmpty()` evaluates the incoming value by the same rule as after mount (a custom Block before mount is always content: only its Extensions can tell whether it is an Atom).
|
|
1492
1544
|
- Writes and focus do nothing.
|
|
1493
1545
|
- Insertion and `setJSON()` return `false`, and `canUndo()` and `canRedo()` return `false` as well.
|
|
1494
1546
|
|
|
1495
1547
|
On the server `ref.current` is empty until the Editor mounts, so write `ref.current?.…`.
|
|
1496
1548
|
|
|
1497
|
-
**
|
|
1549
|
+
**Source mode.** `getText()` applies the HTML field's edit, like `getHTML()`; insertion applies it, inserts into the Document and overwrites the field; `focus` and `blur` act on the HTML field; `undo` and `redo` do nothing (a `textarea` has its own undo); `getSelectedText()` returns `""`.
|
|
1498
1550
|
|
|
1499
1551
|
### Your own toolbar: `useRichTextEditorState`
|
|
1500
1552
|
|
|
@@ -1540,13 +1592,13 @@ function Screen() {
|
|
|
1540
1592
|
}
|
|
1541
1593
|
```
|
|
1542
1594
|
|
|
1543
|
-
**Recipe: the buttons of an external toolbar call `preventDefault` on `mousedown`.** A click on a button moves focus to it, the field loses focus, and a command without `.focus()` in the chain would go astray. `preventDefault` on `mousedown` keeps focus in the Document, so the selection does not disappear either visually or for the floating Toolbar and the
|
|
1595
|
+
**Recipe: the buttons of an external toolbar call `preventDefault` on `mousedown`.** A click on a button moves focus to it, the field loses focus, and a command without `.focus()` in the chain would go astray. `preventDefault` on `mousedown` keeps focus in the Document, so the selection does not disappear either visually or for the floating Toolbar and the Bubble menu, which hide when focus is lost. ProseMirror's selection survives losing focus even without this, but the visibility of the floating Toolbar, the Bubble menu and the image panel depends on focus. The rule "a click on a custom toolbar does not clear the selection" is not part of the public contract: this is a recipe, not a property of the field. A button that opens its own dialog, where focus inevitably moves away, calls `ref.current?.focus("restore")` when the dialog closes.
|
|
1544
1596
|
|
|
1545
1597
|
**History with an external toolbar.** The Handle provides `undo()` and `redo()` and their availability. For the `disabled` state of an "Undo" button, get availability from the hook's slice — `editor.can().undo?.() === true`: the slice re-renders the toolbar right after an edit. `ref.current.canUndo()` is a one-off read in a handler, and it is not subscribed to re-rendering. Without the `history` Tool the engine has no `undo` command (hence the `?.`), and both buttons stay unavailable.
|
|
1546
1598
|
|
|
1547
1599
|
### Tool state: `useRichTextToolState`
|
|
1548
1600
|
|
|
1549
|
-
`useRichTextToolState(ref, name, selector?)` gives the screen the state of a Tool: a "3 of 12" counter from Find in the Status
|
|
1601
|
+
`useRichTextToolState(ref, name, selector?)` gives the screen the state of a Tool: a "3 of 12" counter from Find in the Status bar, or external navigation built from the headings of the Table of contents. The hook sits next to `useRichTextEditorState` and follows the Handle's engine in the same way, but it returns not a slice of the Tiptap engine but a typed snapshot of the kit's contract. The state fields of the built-in Tools are in the "Find and replace" and "Table of contents" sections.
|
|
1550
1602
|
|
|
1551
1603
|
- **Name and type.** `name` is a Tool from the `tools` set; the snapshot type is inferred from the `RichTextTools` map: `search` returns `SearchToolState`, `toc` returns `TocToolState`. A name outside the map (a custom Tool without typing) gives `unknown`.
|
|
1552
1604
|
- **Re-renders only when the snapshot changes.** The snapshot is recomputed on every engine transaction and on kit signals that the engine does not have: the find panel opening and closing, and the current section of the Table of contents changing on scroll. The screen re-renders when the result becomes different (plain data — primitives, arrays and plain objects — is compared by value, like `useRichTextEditorState`; `Date`, `Map`, `Set` and class instances are compared by reference), so typing that does not change the snapshot does not touch it. A custom Tool's `state` that creates such values anew on every call re-renders the screen on every transaction.
|
|
@@ -1569,7 +1621,7 @@ The state of a custom Tool is declared with `defineTool` (the `state` field) and
|
|
|
1569
1621
|
|
|
1570
1622
|
- **The type is inferred from the `RichTextTools` map**, by the Tool's name. A name outside the map gives `unknown`.
|
|
1571
1623
|
- **`null`** when the Tool is not in `tools`, has no commands, or the engine has not been created yet. That is why the commands of a disabled Tool are unavailable both in types (`?.`) and at run time.
|
|
1572
|
-
- **Every command returns a `boolean`:** done or not (
|
|
1624
|
+
- **Every command returns a `boolean`:** done or not (Source mode, no matches, no such heading, a field that cannot be edited). A refusal is `false`, as with `insertHTML`, not an exception.
|
|
1573
1625
|
- **Take the object on every call** instead of keeping it between renders: it is cheap, and this way you always work with the live engine.
|
|
1574
1626
|
- **The Handle gets no new method for each command.** The commands of the built-in Tools go through `tool(name)`, not through new methods; `openSearch` remains for compatibility only. Commands of Marks, Blocks and anything a Tool has not declared are still called through `ref.current.editor`.
|
|
1575
1627
|
|
|
@@ -1590,14 +1642,14 @@ The screen learns about the Editor through callback props; there is no event bus
|
|
|
1590
1642
|
```
|
|
1591
1643
|
|
|
1592
1644
|
- **`onReady()`** takes no arguments and is called **once per mount**, when the engine is created. By then `ref` is already usable: in the callback you can insert a template or set focus. It fires in `readOnly` and in `disabled` too; a re-render, a `placeholder` change or a change of the callback itself do not cause a second call. An error in the `tools` set (there is no engine, see "Custom Tool") and server rendering do not call `onReady`. Readiness means the engine is created **and all Tool Loaders have run** (see the "Custom Tool" section): `onReady` waits for them, and on a Loader failure it is not called — and after "Retry" it is called once, when the engine is created.
|
|
1593
|
-
- **`onFocus()` and `onBlur()`** report Field focus and take no arguments (the DOM event is not passed through). Here the field means the whole field: the Document, the Toolbar, popovers and kit dialogs (link, color, image, "Insert table"), the
|
|
1645
|
+
- **`onFocus()` and `onBlur()`** report Field focus and take no arguments (the DOM event is not passed through). Here the field means the whole field: the Document, the Toolbar, popovers and kit dialogs (link, color, image, "Insert table"), the Source mode field. Moving between them does not lose Field focus: from the text to the Toolbar, to the link popover and back, neither `onBlur` nor `onFocus` fires — as with a composite form field. The Document's focus (the engine's `focus` and `blur`) is different: a click on the Toolbar removes it, while Field focus stays.
|
|
1594
1646
|
- **Before `onBlur` a pending `onChange` is flushed**, wherever focus moves away from (the Document, the Toolbar, a popover), so autosave on `onBlur` gets the fresh Document. `onBlur` fires a tick after focus leaves: in that time focus could have come back into the field. If the Editor is unmounted with focus inside, `onBlur` is not called (a pending `onChange` is still delivered).
|
|
1595
1647
|
|
|
1596
1648
|
### Paste and drop order
|
|
1597
1649
|
|
|
1598
|
-
Only the
|
|
1650
|
+
Only the Extensions of a custom Tool can intercept pasting from the clipboard and dropping — through the ProseMirror plugins `transformPastedHTML`, `transformPasted`, `handlePaste` and `handleDrop` in `engine`. The Editor has no hooks or props of its own named `onPaste` and `onDrop`.
|
|
1599
1651
|
|
|
1600
|
-
- **The handlers of custom Tools see the event before the built-in ones** — Markdown paste, image upload and
|
|
1652
|
+
- **The handlers of custom Tools see the event before the built-in ones** — Markdown paste, image upload and Column layout — wherever a custom Tool appears in `tools`. A `handlePaste` or `handleDrop` that returns `true` takes the whole event: the built-in ones never get it. This way a link to a video can be turned into a custom Block before Markdown parses it.
|
|
1601
1653
|
- **Schema parsing and the limit always apply.** What a paste brought beyond the Schema (`<script>`, `onclick`, unknown tags, values that break the attribute rules) is dropped, as with an incoming `value`; HTML cleanup (`transformPastedHTML`) happens before the Schema and cannot weaken it. A transaction of your own handler that takes the text beyond `maxLength` is **rejected entirely**; tail trimming exists only in the built-in paste from the clipboard (a transaction flagged `paste`).
|
|
1602
1654
|
- **Files other than images are handled by a custom Tool** in `handlePaste` and `handleDrop`: its own upload function and its own upload URL, set through the options of its extension. The Editor has no general `onUploadFile`. A file that no handler claims reaches the kit: an image is uploaded through `onUploadImage`, and anything else gets the message "`name` is not an image" (`editor.imageNotImage` in the message catalog).
|
|
1603
1655
|
|
|
@@ -1633,9 +1685,9 @@ export const pdfTool: CustomTool = { name: "acme_pdfDrop", engine: [PdfDrop] };
|
|
|
1633
1685
|
- `onUploadImage(file) → Promise<string>` — the image uploader, see "Images". Without it files are not accepted.
|
|
1634
1686
|
- `maxHeight` — the height limit of the Document area (a number is pixels, a string is a CSS length), beyond it the Document scrolls inside the frame; `resizable` — a height handle at the area's bottom edge (a slider for keyboard users), and `maxHeight` with it is the starting height. See "Field height".
|
|
1635
1687
|
- `surface` — `"theme"` (default) or `"paper"`: a light Paper in any theme, see "Surface". The type is `RichTextSurface`.
|
|
1636
|
-
- `status` — a React node of the Status
|
|
1637
|
-
- `tools` — kit names, Configured Tools `tool(name, options)` for `search` and `
|
|
1638
|
-
- `ref` (`RichTextEditorHandle`): `getHTML()` — the current HTML, without waiting for the delay and without `onChange` (in
|
|
1688
|
+
- `status` — a React node of the Status bar inside the field's frame, at the start of the counter row; the kit does not announce it. The engine command `showNotice({ tone, text } | null)` is the Field notice. Both are in "Status bar and Field notice"; they follow the kit's semver.
|
|
1689
|
+
- `tools` — kit names, Configured Tools `tool(name, options)` for `search`, `toc` and `details`, `defineTool(…)` definitions and descriptions of custom Tools (`CustomTool`, including their `buttons`, `insertItems`, `blockActions`, `textCards` and `trigger`), Presets as arrays — `RICH_TEXT_PRESETS`; see "Tools and Presets" and "Custom Tool". A description with an error is a field error.
|
|
1690
|
+
- `ref` (`RichTextEditorHandle`): `getHTML()` — the current HTML, without waiting for the delay and without `onChange` (in Source mode it first applies the field's edit — then `onChange` is called if the Document changed; the same for `getMarkdown()`); `getMarkdown()` and `setMarkdown(md)` — see "Markdown"; `getJSON()` and `setJSON(document)` — see "JSON"; `clear()` — clear the Document, and `onChange("")` notifies the screen; `focus(position?)`, `blur()`, `insertHTML`, `insertText`, `getText`, `getSelectedText`, `isEmpty`, `undo`, `redo`, `canUndo`, `canRedo` — see "Handle (`ref`) and your own toolbar outside the field"; `tool(name)` — the commands of a Tool, see "Tool commands: `handle.tool()`"; `openSearch({ replace? })` — deprecated, removed in 5.0, replaced by `tool("search")?.open({ replace })`; `editor` — the underlying Tiptap `Editor`, its API **follows Tiptap's major version**, and before mount and on the server it is `null`. `focus()` without an argument puts the caret at the end of the Document (in Source mode, in the HTML field).
|
|
1639
1691
|
- `useRichTextEditorState(ref, selector)` — a hook for your own toolbar outside the field, see "Handle (`ref`) and your own toolbar outside the field".
|
|
1640
1692
|
- `useRichTextToolState(ref, name, selector?)` — the hook for Tool state (`search`, `toc` and custom Tools with `defineTool`): a data snapshot, `null` before mount, after unmount and without the Tool; see "Tool state: `useRichTextToolState`". It follows the kit's semver, as do `tool()`, `defineTool()`, `handle.tool()` and the `RichTextTools` map.
|
|
1641
1693
|
- SSR: the engine is created only in the browser (`immediatelyRender: false`). On the server it renders a Skeleton without a Document — the frame and the space reserved for the Toolbar — and a hidden field with the incoming value. After hydration the Skeleton shows the Document cleaned by the Schema, and the editable area takes its place after the Loaders, without a height jump. The exception is a JSON envelope in `defaultValue`: only the engine builds its HTML, so with it the Skeleton is a frame without a Document, the hidden field is empty until Readiness, and the height changes once when the Document appears.
|
|
@@ -1650,14 +1702,14 @@ The Markdown engine is the `@tiptap/markdown` package (the same version range as
|
|
|
1650
1702
|
- **When a paste stays text.** If the clipboard holds any other HTML next to the text (a copy from a browser, Word, Google Docs), the usual HTML parsing applies — Markdown is not looked for in it. Pasting "as plain text" (`Ctrl/⌘+Shift+V`) and pasting into code also give the original characters. If nothing the Schema can accept is left of the pasted Markdown (with `table` disabled, a lone table; with `codeBlock` disabled, a lone code block), the original text is pasted rather than nothing.
|
|
1651
1703
|
- **Tables.** A GFM table (`| a | b |` and a delimiter row, at least `---` in a cell for paste recognition) becomes a table, with the first row as the header; `getMarkdown()` exports the table back, always with a header row (without one, with an empty one). Multiline cell content goes into Markdown as one line, with line breaks as `<br>`; `|` in a cell is escaped. **Column alignment is preserved in both directions:** `:---:` and `---:` become `data-align` on the column's cells on paste and `setMarkdown`, and `getMarkdown()` writes them back; `:---` is the default and gives no attribute. **Markdown cannot express cell merging:** `getMarkdown()` spreads a merged cell over the grid — the text stays in its top-left position, the covered positions become empty cells, so the columns below do not shift; the merge itself is lost, and `setMarkdown(getMarkdown())` returns the table without it.
|
|
1652
1704
|
- **`getMarkdown()`** is the Document as Markdown, right now, without waiting for `changeDelay`. An empty Document and a call before mount (there is no engine on the server) give `""`.
|
|
1653
|
-
- **`setMarkdown(md)`** replaces the Document with the parsed Markdown. Like `clear()`, this is
|
|
1654
|
-
- **The Schema wins.** The parsed result goes through the Editor's Schema: everything not in it now is dropped, `setMarkdown` included. In this version of the kit the following are lost: images with a URL outside `http`/`https` (they are removed entirely, with their `alt`), links with a URL outside the `http`/`https`/`mailto` policy (the link text stays) and raw HTML inside Markdown (it is removed entirely). Without the `taskList` Tool, checklists become ordinary list items, without `codeBlock` the code block is removed, and without `table` the table is removed. Markdown never yields a
|
|
1655
|
-
- **Custom Tools.** Custom Markdown rules live in the
|
|
1705
|
+
- **`setMarkdown(md)`** replaces the Document with the parsed Markdown. Like `clear()`, this is a Programmatic replacement: `onChange` receives the new HTML immediately and only once, and the `maxLength` limit does not truncate it. Before mount it does nothing.
|
|
1706
|
+
- **The Schema wins.** The parsed result goes through the Editor's Schema: everything not in it now is dropped, `setMarkdown` included. In this version of the kit the following are lost: images with a URL outside `http`/`https` (they are removed entirely, with their `alt`), links with a URL outside the `http`/`https`/`mailto` policy (the link text stays) and raw HTML inside Markdown (it is removed entirely, except the `details` and `summary` of a collapsible Block). Without the `taskList` Tool, checklists become ordinary list items, without `codeBlock` the code block is removed, and without `table` the table is removed. Markdown never yields a Column layout or a Wrapper without a tone: their Blocks follow one another. A collapsible Block is read from raw HTML `<details><summary>` and exported as the same (see the "Collapsible block" section). A Wrapper with a tone comes only from a GitHub alert (`> [!NOTE]` and four more values, see the "Wrapper" section, "Tone"), and `getMarkdown()` exports it the same way. With `heading1`, `#` gives H1, and `getMarkdown` writes H1 as `#`. Without it, `#` is demoted to H2; `#####` is demoted to H4 in any case. What exactly is accepted is decided by the set of Tools, not by this list.
|
|
1707
|
+
- **Custom Tools.** Custom Markdown rules live in the Extensions; without them a custom Block is exported as its content, and an Atom as its Atom text (see the "Custom Tool" section, "Outside the Editor").
|
|
1656
1708
|
- **What is lost on export.** Markdown does not express text color and highlight color, Block alignment, underline, an image's width and caption, table cell merging and a list inside a cell: an image is exported as ``, `getMarkdown()` omits the rest, and `setMarkdown(getMarkdown())` returns the Document without them. This is how the format works: for lossless storage use HTML, and for a lossless file use the JSON envelope (see the "JSON" section).
|
|
1657
1709
|
|
|
1658
1710
|
## JSON
|
|
1659
1711
|
|
|
1660
|
-
A JSON envelope is the Document for a file, a template or a transfer between products **without loss**: it keeps everything Markdown loses (color, highlight, alignment, underline, an image with width and caption, cell merging,
|
|
1712
|
+
A JSON envelope is the Document for a file, a template or a transfer between products **without loss**: it keeps everything Markdown loses (color, highlight, alignment, underline, an image with width and caption, cell merging, Column layout, a Wrapper with a tone). The envelope is an exchange format **alongside HTML, not instead of it**: `value`, `onChange`, the hidden form field, `RichTextView` and server sanitization still work with HTML (ADR-0061).
|
|
1661
1713
|
|
|
1662
1714
|
```ts
|
|
1663
1715
|
import type { RichTextDocument } from "@olddevs/ui/editor";
|
|
@@ -1668,8 +1720,8 @@ const opened: boolean = ref.current?.setJSON(JSON.parse(fileText)) ?? false;
|
|
|
1668
1720
|
```
|
|
1669
1721
|
|
|
1670
1722
|
- **Shape.** `format` is always `"olddevs-rich-text"`, `version` is the tree format number (currently `1`), `doc` is the Document tree with a root node `"doc"`. The node and attribute names in `doc` are the format's contract: if the Schema renames them, the kit bumps `version` and reads old files itself. Do not check or build `doc` by hand: use what `getJSON()` returned.
|
|
1671
|
-
- **`getJSON()`** is the Document as an envelope, right now, without waiting for `changeDelay`. In
|
|
1672
|
-
- **`setJSON(document): boolean`** replaces the Document with an envelope. The parameter is `unknown`: pass the result of `JSON.parse` of the file directly, and the Editor checks the shape. Like `setMarkdown()`, this is
|
|
1723
|
+
- **`getJSON()`** is the Document as an envelope, right now, without waiting for `changeDelay`. In Source mode the HTML field's edit is applied first, as with `getHTML()`. Before mount and on the server it gives `null`.
|
|
1724
|
+
- **`setJSON(document): boolean`** replaces the Document with an envelope. The parameter is `unknown`: pass the result of `JSON.parse` of the file directly, and the Editor checks the shape. Like `setMarkdown()`, this is a Programmatic replacement: `onChange` receives the new HTML immediately and only once, the `maxLength` limit does not truncate it, the method does not take focus, and in Source mode the field's text is rewritten. `false` means the envelope was rejected or there is no engine yet; the Document is untouched, and what to tell the user ("the file does not fit") is up to the screen.
|
|
1673
1725
|
- **What is rejected entirely:** not an object, an unrecognized `format`, a different `version` (including its absence), a `doc` without a root `"doc"` node, and a tree from which nodes cannot be built (a malformed node, an empty text node). A bare Tiptap tree from `editor.getJSON()` is rejected too: without an envelope an Editor file cannot be told from any other JSON.
|
|
1674
1726
|
- **The Schema wins — for JSON too.** An envelope goes through the Schema as strictly as HTML: a Block of a disabled Tool unwraps into its text, a Mark that is not in the Schema is removed, and attributes are checked by **the same rules as for HTML**. A dangerous link or image URL, a color outside the palette, an author's color not in the `#rrggbb` form, `data-*` outside the Schema's list, and values of custom Tools outside their rules are dropped in the Document itself, not only on output. The author's whitespace is not collapsed by the `getJSON()` → `setJSON()` round trip.
|
|
1675
1727
|
- **`defaultValue` with an envelope** is the initial Document from JSON, uncontrolled mode only (`value` wins). It passes the same check as `setJSON()`, is applied when the engine appears, without `onChange` and without an undo step; a rejected one gives an empty Document and one error in the development console. Until Readiness, with an envelope the Skeleton is a frame without a Document, `getHTML()` is `""`, and `isEmpty()` evaluates the envelope's tree by the `required` rule.
|
|
@@ -1684,17 +1736,17 @@ The allowlist of this release with `preset="full"` (disabled Tools narrow it):
|
|
|
1684
1736
|
|
|
1685
1737
|
<!-- allow-list -->
|
|
1686
1738
|
|
|
1687
|
-
| What | Allowed
|
|
1688
|
-
| ---------- |
|
|
1689
|
-
| Blocks | `p`, `h1`, `h2`, `h3`, `h4`, `ul`, `ol`, `li`, `blockquote`, `hr`, `pre`, `table`, `tbody`, `tr`, `th`, `td`; `label`, `input`, `span` — only inside a checklist item; `div` — there too, and also the
|
|
1690
|
-
| Marks | `strong`, `em`, `u`, `s`, `code`, `sup`, `sub`, `mark` (only with a color), `br`, `a`
|
|
1691
|
-
| Attributes | `ol[start]` — only when numbering does not start at 1; `ul[data-type]`, `li[data-type]`, `li[data-checked]` — a checklist; `input[type]`, `input[checked]` — its checkbox; `code[class]` — the code block language, only `language-python`, `language-typescript`, `language-javascript`, `language-go`, `language-html`, `language-json`, `language-yaml` or `language-markdown` (no class without a language); `span[data-color]`, `mark[data-color]` — text color and highlight, only names from the Editor's own palette — text color on `span`, highlight on `mark`; `span[style]`, `mark[style]` — an arbitrary author's color, only `color:#rrggbb` and `background-color:#rrggbb`, and not together with data-color; `p[data-align]`, `h1[data-align]`, `h2[data-align]`, `h3[data-align]`, `h4[data-align]` — Block alignment, center or right; `th[colspan]`, `td[colspan]` — horizontal merge, an integer from 2 to 20; `th[rowspan]`, `td[rowspan]` — vertical, from 2 to 50; `th[data-align]`, `td[data-align]` — column alignment, center or right, on each of its cells; `a[href]` — only http, https or mailto; `a[rel]` — always `noopener noreferrer`; `img[src]` — only absolute http or https, no data and blob; `img[alt]` — always, empty for an image without a description; `img[data-width]` — 50, 75 or 100; `div[data-block]` — the
|
|
1739
|
+
| What | Allowed |
|
|
1740
|
+
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1741
|
+
| Blocks | `p`, `h1`, `h2`, `h3`, `h4`, `ul`, `ol`, `li`, `blockquote`, `hr`, `pre`, `table`, `tbody`, `tr`, `th`, `td`; `label`, `input`, `span` — only inside a checklist item; `div` — there too, and also the Column layout and its columns (a Column layout only at the top level, a column only directly in it) and the Wrapper (at the top level and directly in a column, not in another Wrapper); `details` — a collapsible Block, with `summary` and a content `div` inside it (both only directly in `details`; outside it the tags are removed); `figure` — an image, with `img` and an optional `figcaption` inside it (they do not exist outside `figure`) |
|
|
1742
|
+
| Marks | `strong`, `em`, `u`, `s`, `code`, `sup`, `sub`, `mark` (only with a color), `br`, `a` |
|
|
1743
|
+
| Attributes | `ol[start]` — only when numbering does not start at 1; `ul[data-type]`, `li[data-type]`, `li[data-checked]` — a checklist; `input[type]`, `input[checked]` — its checkbox; `code[class]` — the code block language, only `language-python`, `language-typescript`, `language-javascript`, `language-go`, `language-html`, `language-json`, `language-yaml` or `language-markdown` (no class without a language); `span[data-color]`, `mark[data-color]` — text color and highlight, only names from the Editor's own palette — text color on `span`, highlight on `mark`; `span[style]`, `mark[style]` — an arbitrary author's color, only `color:#rrggbb` and `background-color:#rrggbb`, and not together with data-color; `p[data-align]`, `h1[data-align]`, `h2[data-align]`, `h3[data-align]`, `h4[data-align]` — Block alignment, center or right; `th[colspan]`, `td[colspan]` — horizontal merge, an integer from 2 to 20; `th[rowspan]`, `td[rowspan]` — vertical, from 2 to 50; `th[data-align]`, `td[data-align]` — column alignment, center or right, on each of its cells; `a[href]` — only http, https or mailto; `a[rel]` — always `noopener noreferrer`; `img[src]` — only absolute http or https, no data and blob; `img[alt]` — always, empty for an image without a description; `img[data-width]` — 50, 75 or 100; `div[data-block]` — the Column layout (`layout`), its column (`column`) and the Wrapper (`wrapper`); `div[data-layout]` — the Column layout variant, column shares in percent: `100`, `50-50`, `33-33-33`, `25-25-25-25`, `60-40` or `40-60`; `div[data-tone]` — the Callout tone, only on it: `neutral`, `accent`, `success`, `warning` or `danger` (no attribute without a tone); `div[data-type]` — the content wrapper of a collapsible Block, only the value "detailsContent" and only directly in `details`; `details[open]` — an open Block, only an empty "open", written with the "persist" option |
|
|
1692
1744
|
|
|
1693
1745
|
Tiptap writes a checklist item like this: `<li data-type="taskItem" data-checked="true"><label><input type="checkbox" checked><span></span></label><div><p>…</p></div></li>`. A sanitizer should let `input`, `label`, `span` and `div` through only inside such an item, and `input` only with `type="checkbox"`.
|
|
1694
1746
|
|
|
1695
1747
|
A table is `table > tbody > tr > th|td`; a cell has only `colspan` and `rowspan` (`colspan` is an integer from 2 to 20, `rowspan` from 2 to 50) and `data-align` (`center` or `right`), no `style` and no `colwidth`. `thead`, `tfoot`, `colgroup` and `caption` are not in the Schema and the Editor does not write them (the header row is the first row of `th`); `tbody`, `tr`, `th` and `td` outside a table are thrown out by the HTML parser itself, so no hook is needed for them.
|
|
1696
1748
|
|
|
1697
|
-
There are no other classes, no `style` (except the author's color), no `aria-*` and no `data-*` (except the eight Schema names: `data-type
|
|
1749
|
+
There are no other classes, no `style` (except the author's color), no `aria-*` and no `data-*` (except the eight Schema names: `data-type` (a checklist and the content wrapper of a collapsible Block), `data-checked`, `data-color`, `data-align`, `data-width`, `data-block`, `data-layout`, `data-tone`; custom Tools add `data-mark` and `data-<name>` to them) in the emitted HTML: typography lives on the container, not on the Document's nodes.
|
|
1698
1750
|
|
|
1699
1751
|
**`style` is a narrow exception.** The author's color is written as `<span style="color:#rrggbb">` and `<mark style="background-color:#rrggbb">`, and nothing else is ever in `style`: no other properties, no other tags, no other color forms. DOMPurify's `ALLOWED_ATTR` is shared by all tags and does not check values, so the bare `richTextSanitizeConfig()` has no `style` at all, and only `sanitizeRichText` lets it through ([RichTextView](../content/rich-text-view.md#санитизация--на-сервере-потребителя)), checking the tag and value against `RICH_TEXT_SCHEMA.customColor.styles`.
|
|
1700
1752
|
|
|
@@ -1704,11 +1756,11 @@ There are no other classes, no `style` (except the author's color), no `aria-*`
|
|
|
1704
1756
|
|
|
1705
1757
|
- The editable area is `role="textbox"` with `aria-multiline`.
|
|
1706
1758
|
- **The Toolbar** is `role="toolbar"` named from the message catalog (`editor.toolbar`, "Formatting") with **one Tab stop**: Tab enters the Toolbar, the Left and Right arrow keys move between the buttons, Home and End go to the edges, and the next Tab moves focus to the Document. The Toolbar remembers where focus was. It is built on the kit's `Toolbar`.
|
|
1707
|
-
- Buttons are icons with `aria-label`; toggle buttons (bold, italic, underline in the
|
|
1759
|
+
- Buttons are icons with `aria-label`; toggle buttons (bold, italic, underline in the Bubble menu, link, image) announce their state with **`aria-pressed`** — a screen reader announces whether italic is on at the caret. One-off actions (undo, redo) are ordinary buttons. A button of a custom Tool announces `aria-pressed` only if it has `isActive`, and its shortcut is in the tooltip and in `aria-keyshortcuts`; an empty label is replaced with the Tool's name. An action unavailable right now is `aria-disabled`, not `disabled`: it stays in the arrow-key order.
|
|
1708
1760
|
- A hover and focus tooltip names the action and the shortcut; the shortcut is also announced with `aria-keyshortcuts` (`Control+B` or `Meta+B` on Apple). On Apple the label is `⌘+⇧+S`, elsewhere `Ctrl+Shift+S`, using the same check the engine uses to decide which key is `mod`.
|
|
1709
|
-
- **The
|
|
1761
|
+
- **The Slash menu on `/`** follows the `combobox` pattern from the WAI-ARIA Authoring Practices Guide (APG): focus stays in the field, the list is `role="listbox"` with `role="group"` sections, and the field points to it with `aria-controls` and to the current row with `aria-activedescendant` (the row has `aria-selected`). The field announces `aria-autocomplete="list"`. `aria-expanded` is not set on the field: ARIA 1.2 does not allow it on `role="textbox"`, and the role of a multiline Document cannot be changed to `combobox`. A row's name is its label; the `##` reminder is not read to the screen reader.
|
|
1710
1762
|
- The list on `/` is a layer of the kit's `Popover` (Radix): Escape closes only it, not the `Dialog` or `Drawer` around the field, and what was typed stays as text. "+" is the kit's `Menu` with `aria-haspopup`, and Escape in it behaves the same way (ADR-0041).
|
|
1711
|
-
- **The
|
|
1763
|
+
- **The Keyboard shortcuts popover** is the kit's `Popover`: a button with `aria-haspopup="dialog"` and `aria-expanded`, the popover is `role="dialog"` named "Keyboard shortcuts", with a `table` inside having column headers (`columnheader`) and a row header (`rowheader`) for each command, and each shortcut is marked up with `<kbd>`. Escape and a click outside close the popover and return focus to the button; in a `Dialog`, only the popover (ADR-0041). Tests: `tests/rich-text-editor-shortcuts.test.tsx`, in the browser `tests-visual/interactions.spec.ts` ("RichTextEditor inside Dialog: Escape closes the shortcuts reference").
|
|
1712
1764
|
- **The floating Toolbar** is the same `role="toolbar"` with one Tab stop and the same arrow keys; it appears above a non-empty selection, and on a touch screen it is replaced by the fixed one. To move from the Document to any Toolbar, press `Alt+F10` (see the "Keyboard: `Alt+F10`" section).
|
|
1713
1765
|
- **The link popover** is `role="dialog"` named "Link" (`editor.link`); the field is labeled (`editor.linkAddress`), the URL error (`editor.linkInvalid`) is associated with the field through `aria-describedby` and sets `aria-invalid`. The "Link" button is a toggle with `aria-pressed` when the caret is in a link.
|
|
1714
1766
|
- **Image.** The popover is `role="dialog"` named "Image" (`editor.image`); the "Image address" and "Alternative text" fields are labeled, the second one's hint is associated through `aria-describedby`, and the URL error (`editor.imageInvalid`) sets `aria-invalid`. The width presets are a `role="group"` "Width" of toggle buttons with `aria-pressed`. The selected image's panel is a `role="toolbar"` "Image" with one Tab stop. The width handles are for the mouse only (`aria-hidden`, no focus): the same width is in the popover and on the panel. Upload progress is a `role="status"` live region outside the Document (live regions inside `contenteditable` are not read everywhere), and a failure is a visible `role="alert"` line under the field: this is the Field notice, the same one that `showNotice` shows.
|
|
@@ -1719,15 +1771,16 @@ There are no other classes, no `style` (except the author's color), no `aria-*`
|
|
|
1719
1771
|
- **The "Alignment" and "Lists" menus** are like "¶": a button with `aria-haspopup="menu"` and the current value in its name ("Alignment: Align center", "List: numbered"), radio items with `aria-checked`, shortcuts labeled in the items; Enter, Space and `↓` open, Escape closes only the menu and returns focus to the button. Alignment and lists are menus rather than three toggle buttons each in a row: a value announced in the button's name and a check in the menu convey the same as `aria-pressed`, and the row is four buttons shorter — ADR-0054.
|
|
1720
1772
|
- **The "Table" menu** is the kit's `Menu` with submenus (`SubmenuTrigger`): a button with `aria-haspopup="menu"`, items with `›` having `aria-haspopup` and `aria-expanded`, "Header row" is `menuitemcheckbox`, column alignment is `menuitemradio`, and unavailable items get `aria-disabled` and stay in the arrow-key order. The "Table ›" submenu is a react-aria popover with a grid (the `grid` pattern from APG, `aria-activedescendant`, the size in words in a live region). Escape closes one level at a time, and in a `Dialog` the dialog goes last (ADR-0041). The "Insert table" dialog is the kit's `Dialog`: focus on open is in the "Columns" field, and on close it goes back to the Document. Tests: `tests/rich-text-editor-table-menu.test.tsx`, in the browser `tests-visual/rich-text-editor-table-menu.spec.ts`.
|
|
1721
1773
|
- **Emoji.** The button is `aria-haspopup="dialog"`; the popover is `role="dialog"` named "Emoji" (`editor.emoji`) with a `role="grid"` ("Choose an emoji") where focus stays on the grid and `aria-activedescendant` names the current cell, as in the table size grid. Categories are a `rowgroup` with a name ("Favorites", "Smileys"…), an emoji is a `gridcell` named from the message catalog (`editor.emojiName`: "Fire", "Red heart"), not by the symbol: screen readers pronounce symbols differently, and sometimes stay silent. The symbol in the cell is hidden from the screen reader. Escape closes the popover and returns focus to the Document.
|
|
1722
|
-
- **
|
|
1774
|
+
- **Column layout.** Columns go in the markup left to right — the screen reader reads them in the same order, and they stack in the same order on a narrow screen. A column has no role of its own: it is a group of Document Blocks, not a widget. A Tab press that the Document handles (columns, list, table) does not reach the focus trap of the `Dialog` and `Drawer` around the field; a Tab press that it lets through (the last column on Tab, the first on Shift+Tab; a paragraph outside a list) moves focus on — there is no keyboard trap.
|
|
1723
1775
|
- **Wrapper.** It has no role of its own: it is a group of Document Blocks, not a widget, and the screen reader reads its Blocks in order, like the Blocks around it. `Tab` in a Wrapper is not intercepted — there is no keyboard trap; `Enter` in an empty last paragraph and the arrow keys at its edge move the caret out of a Wrapper. **A tone** adds no roles or `aria-*` — it is styling only: the meaning of a note ("Warning: …") is written by the author in words (WCAG 1.4.1). The "Tone ›" submenu is the kit's `Menu`: radio items with `aria-checked`, and the submenu item's name includes the current tone.
|
|
1724
|
-
- **
|
|
1725
|
-
- **
|
|
1776
|
+
- **Collapsible block.** The opening button is a real `button` named from the catalog (`editor.detailsToggle`: "Details: {title}") with `aria-expanded`; it is in the Tab order, Enter and Space open the Block, and focus stays on the button. The content of a collapsed Block is hidden from both the screen reader and the caret. In `RichTextView` the Block is a native `details` and `summary`: the kit adds no role or name, the browser opens it, from the keyboard and without JavaScript. `Tab` in the Block is not intercepted — there is no keyboard trap.
|
|
1777
|
+
- **Source mode.** The "HTML" button is a toggle with `aria-pressed`, in the Toolbar's arrow-key order like any button. The field is a native `textarea` named from the message catalog ("HTML source", `editor.sourceField`); the hint and error of `FormField` go into its `aria-describedby`, merged the same way as for the Document; `aria-invalid` mirrors the Document. Autocorrections are off: `spellcheck="false"`, `autocapitalize`, `autocorrect` and `autocomplete` are `off`, `dir="ltr"`. Tab and `Shift+Tab` are native: the field inserts no tab character, and there is no keyboard trap; undo inside the field is the ordinary text field undo. Entering puts focus in the field (caret at the start), leaving with the button puts it in the Document (caret at the end). `Alt+F10` moves focus from the field to the "HTML" button, as from the Document to the Toolbar: the Toolbar announces this shortcut in the mode too. The other Toolbar buttons in the mode are `aria-disabled`, in the arrow-key order. A click on the `FormField` label and a `required` failure move focus into the HTML field. Tests: `tests/rich-text-editor-source.test.tsx`, in the browser `tests-visual/rich-text-editor-source.spec.ts`.
|
|
1778
|
+
- **Field notice and Status bar.** A `role="status"` live region is present in every Editor and is empty while there is no `status` notice; an error is `role="alert"` under the field. The Status bar is a container without `role` and `aria-live`: the kit does not announce it, and the screen wraps "Saved" in `role="status"` itself. The line is visible with `readOnly` and `disabled`, and the counter stays in `aria-describedby`. Tests: `tests/rich-text-editor-notice.test.tsx`; in the showcase — `StatusLine`, `StatusLineReadOnly`, `FieldNotice`.
|
|
1726
1779
|
- **Text card.** `role="dialog"` without `aria-modal`, named by the contribution's label (`label`); focus on open stays in the Document. A `role="status"` live region announces the appearance with the phrase `editor.textCardShown` including the label and the `Alt+F10` shortcut; `Alt+F10` moves focus into the Text card, and Escape returns the caret and hides it. See the "Text card" section.
|
|
1727
1780
|
- A checklist item's checkbox is named from the message catalog (`editor.taskCheckbox`): "Done: <item text>".
|
|
1728
1781
|
- **Name.** The browser associates `<label for>` only with native form controls, and `contenteditable` is not one of them. So `FormField` publishes the `id` of its label through context, and the `RichTextEditor` inside it references the label through `aria-labelledby` and puts focus in the Document on a click on the label. For this the Editor needs no `id` and `FormField` needs no `htmlFor` (but set `htmlFor` out of habit — it does no harm). The Editor does not touch the label's DOM, it only listens for the click. An explicit `aria-label` and `aria-labelledby` outrank the label. If none of this is set, the name comes from the message catalog (`editor.label`) — there is no nameless field, but such a name says nothing about the content: give your own.
|
|
1729
1782
|
- The `FormField` hint and error are announced together with the field: their `id` values go into `aria-describedby` and are **merged** with its own description from the prop. An error sets `aria-invalid` and styles the frame as invalid; an explicit `aria-invalid` prop outranks it.
|
|
1730
|
-
- **`required` works like the native one.** An empty Document (no text, no Atom — `hr`, an image, a custom Atom — and no table) blocks submission of an **ordinary** `<form>`: the browser shows a validation message with the text from the message catalog (`editor.required`, follows the kit's locale), and focus goes to the Editor. As soon as the Document has content, the block is lifted immediately, without waiting for `changeDelay`. In
|
|
1783
|
+
- **`required` works like the native one.** An empty Document (no text, no Atom — `hr`, an image, a custom Atom — and no table) blocks submission of an **ordinary** `<form>`: the browser shows a validation message with the text from the message catalog (`editor.required`, follows the kit's locale), and focus goes to the Editor. As soon as the Document has content, the block is lifted immediately, without waiting for `changeDelay`. In Source mode, emptiness is decided by the HTML field's text parsed by the Schema — also immediately, while typing (see the "Source mode" section). `readOnly` and `disabled` are not checked, as with native fields. The asterisk on the label comes only from `FormField required`.
|
|
1731
1784
|
- How it works: a hidden `<input type="hidden" name>` is **excluded from constraint validation by the specification**, and `contenteditable` is not form-associated, so next to it there is an invisible text field without `name` — it carries `setCustomValidity`, while the hidden field still puts the value into `FormData`.
|
|
1732
1785
|
- In the kit's `Form` (`noValidate`) the browser validates no field — there the consumer signals that a field is required through `FormField error`, as for `TextControl`.
|
|
1733
1786
|
- `readOnly` and `disabled` are announced with `aria-readonly` and `aria-disabled`.
|
|
@@ -1745,18 +1798,18 @@ There are no other classes, no `style` (except the author's color), no `aria-*`
|
|
|
1745
1798
|
- Using the author's color where the Document is shown in several themes: unlike the palette, it does not follow the theme.
|
|
1746
1799
|
- Setting `changeDelay={0}` "just in case" on long Documents: the form gets fresh HTML before submission anyway.
|
|
1747
1800
|
- Setting a link with a `ref.editor` command that bypasses the popover, using a relative URL or a scheme outside `http`/`https`/`mailto`: in HTML such a link goes out without `href`.
|
|
1748
|
-
- Putting an Editor with `toolbar="floating"` or `"bubble"` in a container with `overflow: hidden` right against its top edge: the Toolbar or the
|
|
1801
|
+
- Putting an Editor with `toolbar="floating"` or `"bubble"` in a container with `overflow: hidden` right against its top edge: the Toolbar or the Bubble menu above the first line is clipped.
|
|
1749
1802
|
- Repainting the Editor's popup surfaces with a solid color through the consumer's `className` or CSS: the Glass is removed by the system's transparency and contrast setting, and a hand-made background stops respecting that setting.
|
|
1750
|
-
- Overriding the
|
|
1803
|
+
- Overriding the Bubble menu's token colors with your own: the inverse pair is checked for contrast in all themes and brands, and a hand-picked color is not. A brand of your own overrides `--accent-on-inverse` in its own CSS rule, like `--accent-strong`.
|
|
1751
1804
|
- Calling `ref.editor` methods as part of the kit's contract — Tiptap commands follow the engine's major versions; common tasks are covered by the Handle (`insertHTML`, `getText`, `focus`, `undo`), and `editor` is the escape hatch for commands of Marks, Blocks and custom Tools. The exception is `showNotice`: it is a kit command and follows semver.
|
|
1752
|
-
- Building the buttons of your own toolbar without `preventDefault` on `mousedown`: a click takes focus out of the field, and the floating Toolbar together with the
|
|
1805
|
+
- Building the buttons of your own toolbar without `preventDefault` on `mousedown`: a click takes focus out of the field, and the floating Toolbar together with the Bubble menu disappear.
|
|
1753
1806
|
- Reading `ref.current.editor.isActive(...)` during render of your own toolbar instead of `useRichTextEditorState`: the toolbar is not notified of a selection change and does not re-render.
|
|
1754
|
-
- Wrapping the whole Status
|
|
1807
|
+
- Wrapping the whole Status bar in `role="status"`: the word count would be read aloud on every edit. Announce only what is needed (`Saved`).
|
|
1755
1808
|
- Rendering your own error line under the field, duplicating the Field notice (paste and upload errors): `showNotice` already provides a line with `role="alert"`, and your own copy would be announced twice.
|
|
1756
1809
|
- Wrapping short labels and headings in `RichTextEditor`: it is a field for a Document, not for a line.
|
|
1757
1810
|
- Hiding a button with a style while leaving the Tool in `tools`: the Schema still lets it through — from a paste and by shortcut. Disable the Tool, not the button.
|
|
1758
1811
|
- Changing `tools` on a mounted field expecting the Schema to be rebuilt: the set is read when the engine is created.
|
|
1759
|
-
- Conveying the meaning of a note by the
|
|
1812
|
+
- Conveying the meaning of a note by the Callout tone alone: a screen reader cannot see the background, a color-blind person cannot tell the tones apart, and in Markdown without alert support only a quote remains. Write it in words — "Warning: …" (WCAG 1.4.1).
|
|
1760
1813
|
- Coloring text inside a Wrapper that has a tone with a palette color or a custom color: on a colored background its contrast may fall below 4.5:1 — the gate checks the Document's main and secondary text, not the author's color.
|
|
1761
1814
|
- Changing `tool(…)` on a mounted field expecting new options: they are read when the engine is created, like `tools`. To change options, remount the field with a key.
|
|
1762
1815
|
- Taking the number of Find matches or the headings of the Table of contents from `ref.current.editor`, from the Document's DOM or from internal plugin keys: that is not the kit's contract and it follows the Tiptap major. Use `useRichTextToolState` for this.
|
|
@@ -1765,8 +1818,8 @@ There are no other classes, no `style` (except the author's color), no `aria-*`
|
|
|
1765
1818
|
|
|
1766
1819
|
## Links
|
|
1767
1820
|
|
|
1768
|
-
- Source: `src/editor/RichTextEditor.tsx`; the Toolbar is `src/editor/ui/EditorToolbar.tsx`, the formatting menus ("¶", "…", "Alignment", "Lists") are `src/editor/ui/FormatMenus.tsx` and `src/editor/core/blockTypes.ts`, the floating one is `src/editor/ui/FloatingToolbar.tsx`, the
|
|
1769
|
-
- Tests: `tests/rich-text-editor-text-card.test.tsx` (Text card), `tests/rich-text-editor.test.tsx`, `tests/rich-text-editor-handle.test.tsx` and `tests/rich-text-editor-handle-ssr.test.tsx` (Handle and hook), `tests/rich-text-editor-toolbar.test.tsx`, `tests/rich-text-editor-block-menu.test.tsx`, `tests/rich-text-editor-more-menu.test.tsx`, `tests/rich-text-editor-align-list-menus.test.tsx`, `tests/rich-text-editor-insert-menu.test.tsx`, `tests/rich-text-editor-schema.test.tsx`, `tests/rich-text-editor-markdown.test.tsx`, `tests/rich-text-editor-color.test.tsx`, `tests/rich-text-editor-custom-color.test.tsx`, `tests/rich-text-editor-link.test.tsx`, `tests/rich-text-editor-floating.test.tsx`, `tests/rich-text-editor-bubble.test.tsx`, `tests/rich-text-editor-glass.test.tsx`, `tests/rich-text-editor-table.test.tsx`, `tests/rich-text-editor-lazy-builtins.test.tsx` (lazy code block and table: `basic` does not load them, `full` shows a Skeleton until the chunks load), `tests/rich-text-editor-image.test.tsx`, `tests/rich-text-editor-notice.test.tsx`, `tests/rich-text-editor-code-block.test.tsx`, `tests/rich-text-editor-block-commands.test.tsx`, `tests/rich-text-editor-block-handle.test.tsx`, `tests/rich-text-editor-shortcuts.test.tsx`, `tests/rich-text-editor-layout.test.tsx`, `tests/rich-text-editor-wrapper.test.tsx`, `tests/rich-text-editor-wrapper-tone.test.tsx`, `tests/rich-text-editor-source.test.tsx`, `tests/rich-text-editor-custom-tool.test.tsx` (a custom Tool; test Tools are in `tests/editor-test-tools.ts`), `tests/rich-text-editor-custom-ui.test.tsx` (Insert items and Block actions of a custom Tool), `tests/rich-text-editor-trigger.test.tsx` (a custom Trigger character: opening, picking, the query, asynchronous items, set errors), `tests/rich-text-editor-custom-button.test.tsx` (buttons of a custom Tool), `tests/rich-text-editor-custom-markdown.test.tsx` (Markdown of a custom Block by default and by its own rules), `tests/rich-text-editor-atom-text.test.tsx` (Atom text, weight in `maxLength`, emptiness), `tests/rich-text-editor-empty-table.test.tsx` (an empty table is content), `tests/rich-text-editor-max-height.test.tsx` (`maxHeight` and `resizable`: what is inside the scroll area, the position of the Block handle during scrolling), `tests/rich-text-editor-events.test.tsx` (`onReady`, `onFocus`, `onBlur`, paste and drop order), `tests/rich-text-editor-loaders.test.tsx` (
|
|
1821
|
+
- Source: `src/editor/RichTextEditor.tsx`; the Toolbar is `src/editor/ui/EditorToolbar.tsx`, the formatting menus ("¶", "…", "Alignment", "Lists") are `src/editor/ui/FormatMenus.tsx` and `src/editor/core/blockTypes.ts`, the floating one is `src/editor/ui/FloatingToolbar.tsx`, the Bubble menu is `src/editor/ui/SelectionBubble.tsx`, the shared display condition and position are `src/editor/ui/selectionMenu.ts`; the link is `src/editor/features/link/link.ts`, the Tool description `src/editor/features/link/linkTool.tsx` and the URL form `src/editor/features/link/LinkPopover.tsx`; emoji are the Tool description `src/editor/features/emoji/emojiTool.tsx` and the grid `src/editor/features/emoji/EmojiGrid.tsx`; buttons from Tool descriptions and the frame of their popovers are `src/editor/ui/ToolButtonView.tsx`, their shortcuts are `src/editor/core/toolButtons.ts`, the shortcuts of their Insert items and Block actions are `src/editor/core/toolEntryShortcuts.ts`; the image is `src/editor/features/image/image.ts` (node, upload, file paste), `src/editor/features/image/ImagePopover.tsx`, `src/editor/features/image/ImageToolbar.tsx`; the Field notice is the command `src/editor/core/fieldNotice.ts`, the file texts `src/editor/features/image/imageNotice.ts`, the live region, the error line and the Status bar `src/editor/ui/FieldStatus.tsx`; a custom Tool is the public form `src/editor/core/customTool.ts`, the engine and set validation `src/editor/core/customToolEngine.ts`, the `tools` expansion and the options of built-in Tools `src/editor/features/builtins.ts`; `tool()` and the `RichTextTools` map `src/editor/core/toolOptions.ts`, the command types `src/editor/core/toolCommands.ts`, the state types `src/editor/core/toolState.ts`, the state hook `src/editor/hooks/useRichTextToolState.ts`, the Find and Table of contents descriptions `src/editor/features/search/searchTool.ts` and `src/editor/features/toc/tocTool.ts`; the table is `src/editor/features/table/tables.ts` (nodes, merging, alignment, Markdown), `src/editor/features/table/tableActions.ts` (menu commands, sorting, size memory), `src/editor/features/table/TableMenuButton.tsx` (the "Table" menu), `src/editor/features/table/TableSizeGrid.tsx` and `src/editor/features/table/TableInsertDialog.tsx`; the Column layout is `src/editor/features/layout/layout.ts` (nodes, commands, keyboard) and `src/editor/features/layout/columnLayouts.ts` (variants as data); the Wrapper is `src/editor/features/wrapper/wrapper.ts` (node, insertion, unwrapping, tone, keyboard, Markdown) and `src/editor/features/wrapper/wrapperTones.ts` (tones and GitHub alerts labels as data); the collapsible Block is `src/editor/features/details/details.ts` (nodes, insertion, unwrapping, keyboard), `detailsMarkdown.ts` (Markdown as raw HTML) and `detailsTool.ts` (the Tool description); the Document with Block containers is `src/editor/core/document.ts`; the Handle is `src/editor/core/handle.ts`, the hook is `src/editor/hooks/useRichTextEditorState.ts`; Source mode is the state and applying in `src/editor/RichTextEditor.tsx`, HTML formatting in `src/editor/core/sourceFormat.ts`; Tools and Presets are `src/editor/core/tools.ts`; Block commands are `src/editor/core/blockCommands.ts`; the Block handle is `src/editor/ui/BlockHandle.tsx`; the shortcut table and labels are `src/editor/core/shortcuts.ts`, the Keyboard shortcuts popover is `src/editor/ui/ShortcutsButton.tsx`; the Glass of popup surfaces is `src/editor/styles/glass.ts`; the Slash menu is `src/editor/core/insertItems.ts` (the item registry), `src/editor/ui/SlashMenu.tsx`, `src/editor/ui/InsertMenuButton.tsx`, `src/editor/core/slashCommand.ts`; the Schema as data is `src/editor/core/schema.ts`; the palette and alignments are `src/editor/styles/palette.ts`, the color extensions are `src/editor/features/color/styleExtensions.ts`; the author's color is the form and parsing `src/editor/features/color/customColor.ts`, contrast `src/editor/features/color/contrast.ts`, the popover `src/editor/features/color/CustomColorPopover.tsx`; typography is `src/editor/styles/prose.ts`; the code block is `src/editor/features/code/codeBlock.ts` (extension, aliases, `Alt+F10`), `src/editor/features/code/CodeBlockView.tsx` (the header: language and "Copy"), `src/editor/features/code/CodeCopyButton.tsx` (the button with a tooltip), `src/editor/features/code/codeHeader.ts` (header classes shared with the View), the Paper (`surface="paper"`) is `src/editor/styles/surface.ts` and `src/editor/styles/tokens.ts`, `src/editor/features/code/codeLanguages.ts` (languages as data), `src/editor/features/code/lowlight.ts` (grammars), `src/editor/styles/codeTheme.ts` (the syntax highlighting theme), the server helper is `src/editor/features/code/highlight.ts`; find and replace is `src/editor/features/search/searchTool.ts` (the Tool), `src/editor/features/search/searchPlugin.ts` and `src/editor/features/search/SearchPanel.tsx`; JSON is `src/editor/core/jsonDocument.ts`; Field height is the resize handle `src/editor/ui/FieldResizer.tsx`; emoji (the set's data) is `src/editor/features/emoji/emoji.ts`; the character limit is `src/editor/core/maxLength.ts`; Markdown is `src/editor/core/markdown.ts`.
|
|
1822
|
+
- Tests: `tests/rich-text-editor-text-card.test.tsx` (Text card), `tests/rich-text-editor.test.tsx`, `tests/rich-text-editor-handle.test.tsx` and `tests/rich-text-editor-handle-ssr.test.tsx` (Handle and hook), `tests/rich-text-editor-toolbar.test.tsx`, `tests/rich-text-editor-block-menu.test.tsx`, `tests/rich-text-editor-more-menu.test.tsx`, `tests/rich-text-editor-align-list-menus.test.tsx`, `tests/rich-text-editor-insert-menu.test.tsx`, `tests/rich-text-editor-schema.test.tsx`, `tests/rich-text-editor-markdown.test.tsx`, `tests/rich-text-editor-color.test.tsx`, `tests/rich-text-editor-custom-color.test.tsx`, `tests/rich-text-editor-link.test.tsx`, `tests/rich-text-editor-floating.test.tsx`, `tests/rich-text-editor-bubble.test.tsx`, `tests/rich-text-editor-glass.test.tsx`, `tests/rich-text-editor-table.test.tsx`, `tests/rich-text-editor-lazy-builtins.test.tsx` (lazy code block and table: `basic` does not load them, `full` shows a Skeleton until the chunks load), `tests/rich-text-editor-image.test.tsx`, `tests/rich-text-editor-notice.test.tsx`, `tests/rich-text-editor-code-block.test.tsx`, `tests/rich-text-editor-block-commands.test.tsx`, `tests/rich-text-editor-block-handle.test.tsx`, `tests/rich-text-editor-shortcuts.test.tsx`, `tests/rich-text-editor-layout.test.tsx`, `tests/rich-text-editor-wrapper.test.tsx`, `tests/rich-text-editor-wrapper-tone.test.tsx`, `tests/rich-text-editor-details.test.tsx`, `tests/rich-text-editor-source.test.tsx`, `tests/rich-text-editor-custom-tool.test.tsx` (a custom Tool; test Tools are in `tests/editor-test-tools.ts`), `tests/rich-text-editor-custom-ui.test.tsx` (Insert items and Block actions of a custom Tool), `tests/rich-text-editor-trigger.test.tsx` (a custom Trigger character: opening, picking, the query, asynchronous items, set errors), `tests/rich-text-editor-custom-button.test.tsx` (buttons of a custom Tool), `tests/rich-text-editor-custom-markdown.test.tsx` (Markdown of a custom Block by default and by its own rules), `tests/rich-text-editor-atom-text.test.tsx` (Atom text, weight in `maxLength`, emptiness), `tests/rich-text-editor-empty-table.test.tsx` (an empty table is content), `tests/rich-text-editor-max-height.test.tsx` (`maxHeight` and `resizable`: what is inside the scroll area, the position of the Block handle during scrolling), `tests/rich-text-editor-events.test.tsx` (`onReady`, `onFocus`, `onBlur`, paste and drop order), `tests/rich-text-editor-loaders.test.tsx` (Extensions Loaders, "Retry"), `tests/rich-text-editor-skeleton.test.tsx` (Skeleton), `tests/rich-text-editor-emoji.test.tsx` (emoji), `tests/rich-text-editor-json.test.tsx` (JSON), `tests/editor-search.test.tsx`, `tests/editor-search-panel.test.tsx` and `tests/editor-search-replace.test.tsx` (find and replace), `tests/rich-text-editor-tool-options.test.tsx` and `tests/editor-tool-options-types.test.ts` (`tool()` options), `tests/rich-text-editor-tool-state.test.tsx`, `tests/rich-text-editor-tool-state-ssr.test.tsx` and `tests/editor-tool-state-types.test.ts` (state), `tests/rich-text-editor-tool-commands.test.tsx`, `tests/editor-search-commands-reveal.test.tsx` and `tests/editor-tool-commands-types.test.ts` (`handle.tool()` commands), `tests/rich-text-editor-define-tool.test.tsx`, `tests/editor-define-tool-types.test.ts` and `tests/editor-define-tool-server.test.tsx` (`defineTool`), `tests/rich-text-paper.test.tsx`, `tests/rich-text-paper-css.test.tsx`, `tests/editor-entry.test.ts`, `tests/editor-schema-entry.test.ts`, `tests/editor-highlight-entry.test.ts`; in the browser — `tests-visual/interactions.spec.ts` ("RichTextEditor inside Dialog" — Escape in the style list, in the Slash menu, in the link popover and in the code block language menu; "RichTextEditor inside-dialog" and "inside-drawer" — the "…" menu and the color popovers with mouse and keyboard; "RichTextEditor insert menu"; "RichTextEditor block commands" — real `⌥⇧↑/↓`, `⌘]`, `⌘[` and `⌘⇧D`), `tests-visual/rich-text-editor-image.spec.ts` (file drag and width handles), `tests-visual/rich-text-editor-bubble.spec.ts` (the Bubble menu in `Dialog`, `Drawer` and a scroll container, at the viewport edge, the inverse surface in three themes), `tests-visual/rich-text-editor-layout.spec.ts` (Column layout in a `Dialog`: columns side by side and Tab across them, arrow keys, a stack on a phone), `tests-visual/rich-text-editor-wrapper.spec.ts` (Wrapper: a dashed outline in the Editor and no frame in `RichTextView`, the tone looks the same in both, exit with Enter, `↑` out of a Wrapper), `tests-visual/rich-text-editor-details.spec.ts` (the collapsible Block: opening in the Editor and in `RichTextView`, the button from the keyboard), `tests-visual/rich-text-editor-block-handle.spec.ts` (the handle menu in a `Dialog`: `Ctrl/⌘+/`, Escape by levels; the handle's position at the Block's first line), `tests-visual/rich-text-editor-trigger.spec.ts` (a custom Trigger character: the list at the caret, real typing of `@`, picking by mouse and Enter, `a@b` inside a word, axe in three themes), `tests-visual/rich-text-editor-source.spec.ts` (Source mode: field height, growth with content, focus on entry and exit), `tests-visual/rich-text-editor-height.spec.ts` (Field height), `tests-visual/rich-text-editor-emoji.spec.ts` (emoji), `tests-visual/rich-text-editor-skeleton.spec.ts` (Skeleton).
|
|
1770
1823
|
- The extension API at a glance, "Stability" and recipes — [Editor extension API](../../api/editor-extension.md).
|
|
1771
1824
|
- Viewing without editing — [RichTextView](../content/rich-text-view.md).
|
|
1772
1825
|
- Storybook: `Inputs/RichTextEditor` (the `SearchStatusLine`, `TableOfContentsOptions`, `TableOfContentsExternalNavigation`, `ToolCommands`, `DefinedTool` and `DefinedToolConfigured` stories).
|