u2ui 1.5.3 → 1.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/el/ico/ico.js +5 -5
  2. package/el/rte/README.md +53 -13
  3. package/el/rte/manifest.json +19 -4
  4. package/el/rte/rte.css +31 -6
  5. package/el/rte/rte.js +174 -44
  6. package/el/rte0/README.md +37 -0
  7. package/el/rte0/manifest.json +33 -0
  8. package/el/rte0/rte0.css +9 -0
  9. package/el/rte0/rte0.js +56 -0
  10. package/el/system/styler.js +2 -0
  11. package/el/tooltip/tooltip.css +2 -2
  12. package/js/Placer/Placer.js +2 -1
  13. package/js/rte/AGENTS.md +142 -0
  14. package/js/rte/PLAN.md +488 -0
  15. package/js/rte/README.md +319 -25
  16. package/js/rte/blocks.js +7 -0
  17. package/js/rte/breaks.js +6 -0
  18. package/js/rte/classes.js +7 -0
  19. package/js/rte/docs/usage.md +845 -0
  20. package/js/rte/images.js +7 -0
  21. package/js/rte/link.js +8 -0
  22. package/js/rte/playground/README.md +95 -0
  23. package/js/rte/playground/hosts.html +518 -0
  24. package/js/rte/playground/index.html +824 -0
  25. package/js/rte/rte.js +56 -5
  26. package/js/rte/source.js +12 -0
  27. package/js/rte/src/browser/README.md +40 -0
  28. package/js/rte/src/browser/range-rect.js +82 -0
  29. package/js/rte/src/client/README.md +378 -0
  30. package/js/rte/src/client/blocks.js +38 -0
  31. package/js/rte/src/client/breaks.js +212 -0
  32. package/js/rte/src/client/classes.js +48 -0
  33. package/js/rte/src/client/editor.js +461 -0
  34. package/js/rte/src/client/external-input.js +47 -0
  35. package/js/rte/src/client/history.js +13 -0
  36. package/js/rte/src/client/images.js +275 -0
  37. package/js/rte/src/client/link.js +572 -0
  38. package/js/rte/src/client/marks.js +25 -0
  39. package/js/rte/src/client/source.js +182 -0
  40. package/js/rte/src/client/structure.js +27 -0
  41. package/js/rte/src/client/tables.js +162 -0
  42. package/js/rte/src/client/unstyle.js +32 -0
  43. package/js/rte/src/command/README.md +517 -0
  44. package/js/rte/src/command/block-boundary.js +43 -0
  45. package/js/rte/src/command/block-style.js +120 -0
  46. package/js/rte/src/command/commands.js +158 -0
  47. package/js/rte/src/command/delete.js +112 -0
  48. package/js/rte/src/command/edit.js +72 -0
  49. package/js/rte/src/command/element.js +45 -0
  50. package/js/rte/src/command/enter.js +175 -0
  51. package/js/rte/src/command/fragment.js +76 -0
  52. package/js/rte/src/command/insert.js +52 -0
  53. package/js/rte/src/command/list.js +284 -0
  54. package/js/rte/src/command/mark.js +583 -0
  55. package/js/rte/src/command/pending-marks.js +216 -0
  56. package/js/rte/src/command/table.js +207 -0
  57. package/js/rte/src/command/unstyle.js +248 -0
  58. package/js/rte/src/config/README.md +96 -0
  59. package/js/rte/src/config/config.js +127 -0
  60. package/js/rte/src/core/README.md +41 -0
  61. package/js/rte/src/core/core.js +156 -0
  62. package/js/rte/src/history/README.md +83 -0
  63. package/js/rte/src/history/history.js +283 -0
  64. package/js/rte/src/input/README.md +155 -0
  65. package/js/rte/src/input/external-input.js +130 -0
  66. package/js/rte/src/input/input-pipeline.js +414 -0
  67. package/js/rte/src/mark/README.md +219 -0
  68. package/js/rte/src/mark/dom-adapter.js +100 -0
  69. package/js/rte/src/mark/mark.js +133 -0
  70. package/js/rte/src/mark/standard.js +76 -0
  71. package/js/rte/src/model/README.md +71 -0
  72. package/js/rte/src/model/content-model.js +213 -0
  73. package/js/rte/src/model/html/README.md +51 -0
  74. package/js/rte/src/model/html/html-model.js +90 -0
  75. package/js/rte/src/normalize/execute/README.md +33 -0
  76. package/js/rte/src/normalize/execute/repair-executor.js +129 -0
  77. package/js/rte/src/normalize/normalizer/README.md +48 -0
  78. package/js/rte/src/normalize/normalizer/normalizer.js +141 -0
  79. package/js/rte/src/normalize/repair/README.md +70 -0
  80. package/js/rte/src/normalize/repair/repair-planner.js +208 -0
  81. package/js/rte/src/sanitize/README.md +105 -0
  82. package/js/rte/src/sanitize/native.js +43 -0
  83. package/js/rte/src/sanitize/policy.js +202 -0
  84. package/js/rte/src/selection/README.md +39 -0
  85. package/js/rte/src/selection/map/README.md +53 -0
  86. package/js/rte/src/selection/map/point-map.js +307 -0
  87. package/js/rte/src/selection/ownership/README.md +32 -0
  88. package/js/rte/src/selection/ownership/ownership.js +42 -0
  89. package/js/rte/src/selection/point/README.md +34 -0
  90. package/js/rte/src/selection/point/point.js +83 -0
  91. package/js/rte/src/selection/range/README.md +38 -0
  92. package/js/rte/src/selection/range/edit-range.js +223 -0
  93. package/js/rte/src/selection/snapshot.js +64 -0
  94. package/js/rte/src/source/README.md +64 -0
  95. package/js/rte/src/source/source.js +193 -0
  96. package/js/rte/src/surface/README.md +36 -0
  97. package/js/rte/src/surface/content.js +16 -0
  98. package/js/rte/src/surface/surface.js +87 -0
  99. package/js/rte/src/transaction/README.md +35 -0
  100. package/js/rte/src/transaction/transaction.js +56 -0
  101. package/js/rte/src/ui/README.md +255 -0
  102. package/js/rte/src/ui/chrome.js +214 -0
  103. package/js/rte/src/ui/handles.js +179 -0
  104. package/js/rte/src/ui/place.js +25 -0
  105. package/js/rte/src/ui/toolbar.js +283 -0
  106. package/js/rte/src/unstyle/README.md +103 -0
  107. package/js/rte/src/unstyle/unstyle.js +140 -0
  108. package/js/rte/tables.js +7 -0
  109. package/js/rte/unstyle.js +8 -0
  110. package/js/rte0/README.md +37 -0
  111. package/js/rte0/rte.js +7 -0
  112. package/js/{rte → rte0}/src/behavior.js +0 -1
  113. package/js/{rte → rte0}/src/events.js +0 -1
  114. package/js/{rte → rte0}/src/range.js +0 -2
  115. package/js/{rte → rte0}/src/ui.items.js +0 -2
  116. package/js/{rte → rte0}/src/ui.js +0 -1
  117. package/package.json +1 -1
  118. package/u2/Element.js +2 -2
  119. package/u2/plan-scoped-refactoring.md +133 -303
  120. package/u2/projects.json +18 -2
  121. package/u2/tools/update.repos.json.js +1 -1
  122. package/el/qrcode/package.json +0 -22
  123. /package/js/{rte → rte0}/old/NodeCleaner.js +0 -0
  124. /package/js/{rte → rte0}/old/Rte/Rte.mjs +0 -0
  125. /package/js/{rte → rte0}/old/Rte/Rte.ui.items.mjs +0 -0
  126. /package/js/{rte → rte0}/old/Rte/Rte.ui.mjs +0 -0
  127. /package/js/{rte → rte0}/old/Rte/crossbrowser.mjs +0 -0
  128. /package/js/{rte → rte0}/old/Rte/font/tinymce.svg +0 -0
  129. /package/js/{rte → rte0}/old/Rte/font/tinymce.woff +0 -0
  130. /package/js/{rte → rte0}/old/Rte/helpers.mjs +0 -0
  131. /package/js/{rte → rte0}/old/Rte/htmlparser.mjs +0 -0
  132. /package/js/{rte → rte0}/old/Rte/index.mjs +0 -0
  133. /package/js/{rte → rte0}/old/Rte/main.css +0 -0
  134. /package/js/{rte → rte0}/old/old.rte.js +0 -0
  135. /package/js/{rte → rte0}/old/range-selection.old.js +0 -0
  136. /package/js/{rte → rte0}/src/fixes.js +0 -0
  137. /package/js/{rte → rte0}/src/rte.js +0 -0
  138. /package/js/{rte → rte0}/src/ui/items/ai.js +0 -0
  139. /package/js/{rte → rte0}/src/ui/items/code.js +0 -0
  140. /package/js/{rte → rte0}/src/ui/items/undo.js +0 -0
  141. /package/js/{rte → rte0}/src/util/htmlparser.js +0 -0
  142. /package/js/{rte → rte0}/src/util/indentHtml.js +0 -0
@@ -0,0 +1,517 @@
1
+ # Commands
2
+
3
+ A command is one named editing action, such as inserting a paragraph, making
4
+ text bold, or creating a link. The same command can be triggered by the
5
+ keyboard, an Input Event, or a toolbar.
6
+
7
+ `commands.js` stores the commands available in one editor. `edit.js` gives a
8
+ running command the selected content and the tools needed to change it safely.
9
+ `block-boundary.js` defines shared editor-empty and exact block-edge semantics.
10
+ `enter.js` contains Enter and line break; `delete.js` owns backward block
11
+ merging. `mark.js` creates generic commands for applying, removing, querying,
12
+ and toggling text formatting; `pending-marks.js` extends them across the next
13
+ ordinary text input at a caret.
14
+
15
+ ## Command contract
16
+
17
+ A command is a plain object. Only `run` is required:
18
+
19
+ - `run(edit)` performs the change and returns whatever the command reports.
20
+ - `enabled(edit)` decides availability without a transaction. The default is
21
+ "the surface owns a range". It must not mutate anything.
22
+ - `state(edit)` optionally derives a UI-facing state without a transaction. It
23
+ must not mutate anything.
24
+ - `inputTypes` lists the native `beforeinput` types this command replaces.
25
+ - `transaction: false` declares a view-only action. It still uses availability,
26
+ state, and `u2-rte-command`, but receives `edit.transaction === null`, opens
27
+ no editing transaction, emits no content change, and never triggers command
28
+ cleanup. Editing commands omit the flag.
29
+
30
+ Commands are policy, not framework: an application may replace `enter` with its
31
+ own object under the same name, or register commands the engine never ships.
32
+
33
+ ## Registry
34
+
35
+ - `add()` indexes `inputTypes`; re-registering a name drops its stale entries.
36
+ - `input(inputType)` answers which command replaces a native input type.
37
+ - `enabled(name, detail)` never opens a transaction.
38
+ - `state(name, detail)` returns a stateful command's state independently of
39
+ `enabled()`; a command returns `null` when no meaningful state exists.
40
+ - `run(name, detail)` normally opens one transaction with `{trigger: 'command',
41
+ command, inputType}`, checks availability again against the state the
42
+ transaction restored, executes, and emits `u2-rte-command` with that
43
+ transaction. A view command runs synchronously without one and reports a
44
+ `null` transaction in the event.
45
+ - An unknown command is a programming error and throws; an unavailable one
46
+ returns `undefined` without touching the DOM.
47
+ - `detail` is passed to the `Edit`; `range` targets a specific range instead of
48
+ the current selection, `inputType` and `data` retain the native input cause
49
+ and text payload, and `value` carries the command-specific value. The registry
50
+ deliberately does not constrain that value; its owning command validates it.
51
+
52
+ ## Edit
53
+
54
+ - `range` is the `EditRange` to act on: the explicit target range if one was
55
+ given, otherwise the current selection. Foreign ranges resolve to `null`.
56
+ - `map` is a fresh `PointMap`. Mutations that go through it keep every tracked
57
+ point, so a command can compute its resulting caret before it exists.
58
+ - `select(start, end, backward)` sets the resulting selection; one point
59
+ collapses it.
60
+ - `transaction` is `null` while availability is checked and a live transaction
61
+ during `run()`.
62
+ - `inputType` is the native operation name, `data` is its inserted text payload,
63
+ `value` is an optional command-specific scalar or structure, and `fragment` is a prepared
64
+ `DocumentFragment` or `null`. Commands never need to recover payloads from
65
+ the DOM.
66
+ - `config`, `model`, `element`, and `document` expose the host context so
67
+ commands never reach into the surface internals.
68
+ - `Commands.model` narrows its base model through the surface's current
69
+ `--u2-rte-elements` policy. Availability and execution therefore make the
70
+ same structural decision as cleanup.
71
+ - `edit.model` resolves that narrowing on first use. Reading the host
72
+ configuration is the expensive part and most availability checks never
73
+ consult the model, so a toolbar refresh does not pay for every control that
74
+ does not care. `narrow(model, elements)` is the one place applying the
75
+ policy; the input pipeline and the source view use it too.
76
+
77
+ ## Enter
78
+
79
+ `enter` replaces `insertParagraph`, `lineBreak` replaces `insertLineBreak`.
80
+ Both act on a collapsed caret only: a selection would have to be deleted first,
81
+ and a caret inside atomic content has no structure to split, so those cases keep
82
+ their native behavior.
83
+
84
+ `--u2-rte-enter` names what Enter splits. `break` inserts a line break,
85
+ `block` splits the nearest text block declared by the content model, falling
86
+ back to the `--u2-rte-block` element for application policies that have not
87
+ classified it. `item`, `row`, and `cell` split the nearest `li`, `tr`, or
88
+ `td`/`th`. The split runs only where the content model allows a second element
89
+ of that kind beside the first; otherwise Enter falls back to a line break. That
90
+ keeps one algorithm for every host:
91
+
92
+ List structure takes precedence over a generic editor's default block: a caret
93
+ inside an `li` creates another `li` even when the editing host is a `div` whose
94
+ ordinary block is `p`. An explicit `--u2-rte-enter: break` still requests a line
95
+ break instead.
96
+
97
+ | Host | Caret in | Result |
98
+ | --- | --- | --- |
99
+ | `div` | `<p>one\|two</p>` | two paragraphs |
100
+ | `div` | `<h1>one\|two</h1>` | two headings |
101
+ | `div` | `<h1>title\|</h1>` | the heading followed by the default paragraph |
102
+ | `div` | `<div class=layout><p>one\|two</p></div>` | two paragraphs inside the wrapper |
103
+ | `ul` | `<li><p>one\|two</p></li>` | two list items |
104
+ | `div` | `<ul><li>one\|two</li></ul>` | two list items inside the list |
105
+ | `div` | `<td>one\|two</td>` | a break inside the cell |
106
+ | `p` | `one\|two` | a break |
107
+
108
+ Splitting keeps the inline context on both sides, never duplicates an `id`, and
109
+ leaves a `<br>` in a block the split emptied — an empty block has no caret
110
+ position of its own. A break at the end of its block gets the same treatment for
111
+ the same reason. At the exact end of a non-default text block, the trailing
112
+ empty half becomes a fresh `--u2-rte-block` element instead. With the defaults,
113
+ Enter therefore continues a heading in its middle but leaves it for a new `p`
114
+ at its end. The replacement is used only when the content model accepts both it
115
+ beside the original block and every cloned inline-context child inside it.
116
+
117
+ In `block` or `item` mode, Enter in an empty item exits a list nested inside the
118
+ surface. A middle item splits the list around the new default block; an edge
119
+ item removes only its empty half. The original list node and its `id` stay with
120
+ the surviving content, and split ordered lists continue their original
121
+ numbering, including `start`, `reversed`, and item `value`. A list that is itself
122
+ the editing surface cannot be exited, because commands never create content
123
+ outside their surface.
124
+
125
+ ## Collapsed deletion
126
+
127
+ `deleteBackward` replaces `deleteContentBackward` only at the leading boundary
128
+ of a content-model `mergeable` block. `deleteForward` symmetrically replaces
129
+ `deleteContentForward` at its trailing boundary. Ordinary character deletion,
130
+ non-collapsed selections, atomic content, and an outer edge remain native.
131
+ Both commands keep the left wrapper and move the right block's children into
132
+ it. Nested mergeable blocks naturally choose the nearest valid boundary; at
133
+ the start of the first paragraph in a list item, for example, the paragraph has
134
+ no preceding sibling so the surrounding item may merge instead.
135
+
136
+ Every DOM position inside an otherwise empty block represents its one visual
137
+ caret. A filler `<br>` is therefore not a special case: positions before and
138
+ after it produce the same merge. Before becoming available, either command
139
+ checks that both blocks are mergeable and the left block's content rule permits
140
+ every moved child. The default HTML policy marks text blocks and list items as
141
+ mergeable; applications can opt any custom block in or out through their model.
142
+ Whitespace and comments between two otherwise adjacent blocks are neutral and
143
+ removed as part of the same mapped merge; meaningful text is never skipped.
144
+
145
+ An atomic block on the other side of the boundary is not merged: it holds
146
+ nothing to join, so it is removed and the caret stays where it already was. A
147
+ horizontal rule between two paragraphs therefore disappears on the first press
148
+ and the paragraphs merge on the second, which is what makes an inserted rule
149
+ removable at all — the browser's own deletion leaves it in place.
150
+
151
+ ## Range marks
152
+
153
+ `applyMark(adapter, value)`, `removeMark(adapter, value)`, and
154
+ `toggleMark(adapter, value)` change a non-collapsed selection. They split only
155
+ its text and mark boundaries, map the selection through each mutation, and
156
+ preserve forward or backward direction.
157
+
158
+ Their state is `true` when all selected editable text has the concrete mark,
159
+ `false` when none has it, and `'mixed'` when only part has it. Atomic content and
160
+ nested editors do not contribute. Toggle removes only the fully active case;
161
+ inactive and mixed selections receive the mark throughout.
162
+
163
+ At a caret, state is `true` when its actual DOM position is inside a matching
164
+ inline wrapper and `false` otherwise. A boundary immediately outside that
165
+ wrapper stays outside; an empty matching wrapper still provides active context.
166
+ Base mark commands stay disabled at a caret. `PendingMarks.toggle()` adds caret
167
+ availability and stores only the difference from that structural state.
168
+
169
+ Application policy stays in the `MarkAdapter`. When its `reuse` policy accepts
170
+ a fully selected phrasing element, applying a class decorates that element
171
+ directly. Partially selected or bare text uses the adapter's canonical wrapper.
172
+ Blocks, atomic content, the editing host, and nested editable hosts are never
173
+ reused or crossed. Removal clears only the requested mark. An attributeless
174
+ `span` is unwrapped automatically; an adapter may also request removal of its
175
+ semantic wrapper. Unrelated attributes then survive on a neutral `span`.
176
+
177
+ Applying also joins adjacent canonical wrappers from the same adapter. Thus
178
+ `<span class="x">first</span><span class="x">second</span>` becomes one wrapper.
179
+ Elements that merely carry the mark in addition to other semantics, such as
180
+ `<b class="x">`, or have unrelated attributes are not merged.
181
+
182
+ The same mapped cleanup removes redundant nested copies and reaches a fixed
183
+ point after a merge exposes new siblings. A complete mark set additionally
184
+ orders exact canonical single-child wrappers by mark rank before merging them.
185
+ It never rotates an augmented wrapper, crosses atomic or nested editable
186
+ content, or creates a relationship rejected by the content model.
187
+
188
+ Applying a different value of an excluding type first removes that mark from
189
+ the selected part. A partial red span therefore becomes red/blue/red instead of
190
+ nesting conflicting wrappers.
191
+
192
+ ```js
193
+ const x = new MarkType('x');
194
+ const xHtml = new MarkAdapter(x, {
195
+ selector: '.x',
196
+ tag: 'span',
197
+ reuse: true,
198
+ write: element => element.classList.add('x'),
199
+ clear: element => element.classList.remove('x'),
200
+ });
201
+
202
+ commands.add('applyX', applyMark(xHtml));
203
+ commands.add('removeX', removeMark(xHtml));
204
+ commands.add('toggleX', toggleMark(xHtml));
205
+ ```
206
+
207
+ For one exact multi-mark operation, `setMarks(adapters)` creates a value-bearing
208
+ command:
209
+
210
+ ```js
211
+ const marks = setMarks([boldHtml, colorHtml, linkHtml]);
212
+ commands.add('marks', marks);
213
+ commands.run('marks', {value: [bold.create(), color.create('blue')]});
214
+ ```
215
+
216
+ The adapter list is the closed universe for that command. It requires one
217
+ removable adapter per type, removes configured marks absent from the target,
218
+ resolves target conflicts through `markSet()`, and applies the canonical result
219
+ in one mapped transaction. Equivalent nested runs settle into the same wrapper
220
+ order and merge where their canonical structure permits it. Formatting from
221
+ unlisted adapters is preserved.
222
+ `state()` returns the current canonical array, `'mixed'`, or `null`; at a caret
223
+ it reports structural context while the command remains disabled.
224
+
225
+ For caret input, bind one lightweight `PendingMarks` instance to the surface.
226
+ Its toggle delegates range selections to the normal command and stores a caret
227
+ override only when needed. Its `insertText` command replaces native input only
228
+ while that override is current:
229
+
230
+ ```js
231
+ const pending = new PendingMarks(surface);
232
+ commands.add('toggleX', pending.toggle(xHtml));
233
+ commands.add('insertText', pending.insertText);
234
+ ```
235
+
236
+ Moving the selection invalidates the override through the surface's existing
237
+ selection snapshot; no selection listener or observer is installed. Ordinary
238
+ text is inserted explicitly once, after which the resulting DOM carries the
239
+ state and native input resumes. IME composition always stays native: a live
240
+ start point follows its mutations, and the final composed range receives the
241
+ pending overrides only after `compositionend`. Canceled composition retains the
242
+ override. Plain-text surfaces, atomic content, and nested editors are excluded.
243
+
244
+ `PendingMarks.dispose()` removes its composition listeners and clears transient
245
+ state. Surface disconnection does this automatically.
246
+
247
+ The standard HTML policies compose these pieces without special commands:
248
+
249
+ ```js
250
+ import {boldHtml, italicHtml, linkHtml} from './rte.js';
251
+
252
+ commands.add('bold', pending.toggle(boldHtml));
253
+ commands.add('italic', pending.toggle(italicHtml));
254
+ commands.add('link', pending.toggle(linkHtml, {href: '/docs'}));
255
+ ```
256
+
257
+ Bold, italic, underline, strike, code, and link use the same range and pending
258
+ input paths as custom marks. Their adapters recognize semantic aliases and emit
259
+ one canonical tag. Link values use `{href, target?, rel?, title?}`; URL policy
260
+ belongs to the application supplying that command value.
261
+
262
+ ## Value marks
263
+
264
+ `valueMark(adapter)` is one command for a mark whose value is content rather
265
+ than a fixed choice. `edit.value` carries it, and a null value removes whatever
266
+ mark of that type is there, so a single control creates, changes, and removes a
267
+ link without a command per URL:
268
+
269
+ ```js
270
+ commands.add('link', valueMark(linkHtml));
271
+ commands.run('link', {value: {href: '/docs', target: '_blank'}});
272
+ commands.run('link', {value: {href: '/other'}});
273
+ commands.run('link');
274
+ ```
275
+
276
+ `state(edit)` returns the mark's value, `'mixed'` when the range carries several
277
+ values or is only partly marked, and null when it carries none.
278
+
279
+ A caret carries no range to mark, but it does sit on one: at a caret inside a
280
+ mark the command acts on that whole mark. That is what makes editing an existing
281
+ link's value possible without selecting its text first, and it applies to any
282
+ value mark, not just links.
283
+
284
+ ## Block styles
285
+
286
+ `BlockStyles` defines one closed group of mutually exclusive text-block
287
+ representations. Its commands replace only matching wrappers and preserve their
288
+ children, unrelated attributes, mapped selection boundaries, and forward or
289
+ backward direction:
290
+
291
+ ```js
292
+ const styles = new BlockStyles([
293
+ {name: 'paragraph', label: 'Paragraph', selector: 'p', tag: 'p'},
294
+ {name: 'h1', label: 'Heading 1', selector: 'h1', tag: 'h1'},
295
+ {
296
+ name: 'lead',
297
+ label: 'Lead',
298
+ selector: 'p.lead',
299
+ tag: 'p',
300
+ write: element => element.classList.add('lead'),
301
+ clear: element => element.classList.remove('lead'),
302
+ },
303
+ ]);
304
+
305
+ commands.add('blockStyle', styles.command());
306
+ commands.run('blockStyle', {value: 'h1'});
307
+ ```
308
+
309
+ The group is deliberately closed: only an element matching one of its
310
+ selectors is a styleable text block. A surrounding `div`, list item, table cell,
311
+ or layout section is not converted merely because the content model classifies
312
+ it as a block. Later matching definitions refine earlier ones, so `p.lead`
313
+ wins over the base `p` definition. Switching styles calls every definition's
314
+ optional `clear()` on a detached replacement and then the target's `write()`;
315
+ unrelated classes and attributes survive.
316
+
317
+ A command is enabled only where the target wrapper is allowed by the active
318
+ content model and can contain all existing children. Known HTML headings work
319
+ with the default model. A custom element needs an application content-model
320
+ rule if the fallback cannot prove its block content valid. State is the active
321
+ style name, `'mixed'`, or `null` when the selection contains no styleable block.
322
+ One value-bearing command avoids traversing the same selected blocks once for
323
+ every represented style.
324
+
325
+ ## Lists
326
+
327
+ `Lists` defines one closed group of list container elements. Item elements are
328
+ never named: they come from the model's `defaultChild`, so the same commands
329
+ serve any configured list-like structure.
330
+
331
+ ```js
332
+ const lists = new Lists(['ul', 'ol']);
333
+
334
+ commands.add('bullets', lists.toggle('ul'));
335
+ commands.add('numbers', lists.toggle('ol'));
336
+ commands.add('indent', lists.indent);
337
+ commands.add('outdent', lists.outdent);
338
+ ```
339
+
340
+ `toggle(tag)` has one boolean state and three behaviors, decided by what the
341
+ selection already is:
342
+
343
+ - Plain blocks become items of a new list. The host's configured default block
344
+ becomes the item itself, so a paragraph turns into `<li>text</li>`; every
345
+ other block keeps its own element inside one, so a heading turns into
346
+ `<li><h2>…</h2></li>`.
347
+ - Items of another kind convert their container, preserving its attributes.
348
+ - Items of the same kind are lifted back out. An item contributes its own
349
+ blocks when the target accepts all of them, and otherwise becomes the host's
350
+ configured text block.
351
+
352
+ Selecting part of a list splits it, so only the selected run changes. A
353
+ resulting list that meets a list of its own kind becomes one list, so applying
354
+ to a paragraph below an existing list extends that list instead of starting a
355
+ second one.
356
+
357
+ `indent` moves a run of items into a nested list inside the previous item,
358
+ reusing one that is already there. `outdent` raises a run into the list that
359
+ owns its parent item, splitting that item so content after the nested list
360
+ stays behind, and lifts the run out of the list entirely at the top level.
361
+ Both claim the `formatIndent` and `formatOutdent` input types.
362
+
363
+ Every step is checked against the content model first, so a host that does not
364
+ allow the container, the item, or the text block keeps its control disabled
365
+ instead of producing invalid structure.
366
+
367
+ ## One selected element
368
+
369
+ `selectedElement(edit, match)` is the element a selection covers when it covers
370
+ exactly that element and nothing else. Clicking an image inside a
371
+ `contenteditable` produces such a selection, which is what makes an atomic
372
+ element addressable at all.
373
+
374
+ `elementAttributes(names, {match})` is a value command over a fixed set of that
375
+ element's attributes:
376
+
377
+ ```js
378
+ commands.add('imageSize', elementAttributes(['width', 'height'], {
379
+ match: element => element.matches('img'),
380
+ }));
381
+ commands.run('imageSize', {value: {width: 320, height: 200}});
382
+ commands.run('imageSize');
383
+ ```
384
+
385
+ A missing or empty value removes its attribute, so one command both sets a size
386
+ and clears it — which is what "back to its own size" means for an image. The
387
+ element stays selected afterwards, so a UI acting on it stays up.
388
+
389
+ ## Tables
390
+
391
+ `Tables` groups the structural table actions. It names no section, row, or cell
392
+ tag: they come from the model's `defaultChild`, one level apart, so a configured
393
+ table-like structure works the same way.
394
+
395
+ ```js
396
+ const tables = new Tables();
397
+ for (const [name, command] of Object.entries(tables.commands)) commands.add(name, command);
398
+ commands.run('insertTable', {value: {rows: 3, columns: 2}});
399
+ ```
400
+
401
+ Everything but `insertTable` needs the caret inside a cell. A new row copies the
402
+ kinds of cell its neighbour has, so a header row grows into header cells; a new
403
+ column takes its kind from the cell it sits beside in each row. Deleting the
404
+ last row or the last column takes the table with it, and the caret always lands
405
+ where the edit happened.
406
+
407
+ A cell spanning several rows or columns makes an index stop naming a column, so
408
+ the actions that count on one report themselves unavailable rather than shifting
409
+ the wrong cells. Row actions still work across a `colspan`, because a row is
410
+ still a row.
411
+
412
+ ## Inserted elements
413
+
414
+ `insertNode(create, inputTypes)` inserts one prepared element at the selection.
415
+ The content model decides where it belongs: the block is split only as far as
416
+ the nearest container that accepts the element, so a block-level rule separates
417
+ paragraphs while an inline element stays inside its text. An empty half left by
418
+ the split receives a filler break so it keeps a caret position.
419
+
420
+ ```js
421
+ commands.add('rule', insertNode(document => document.createElement('hr'),
422
+ ['insertHorizontalRule']));
423
+ ```
424
+
425
+ A non-collapsed selection is not deleted first: the element lands where the
426
+ selection starts and the selected content stays after it. Insertion may not
427
+ depend on a caret, or a toolbar shown only for a selection would never offer it.
428
+ The same holds for `insertTable`.
429
+
430
+ ## Unstyle
431
+
432
+ `unstyleCommand(policy, {blocks})` adapts the presentation policy in
433
+ [`../unstyle/`](../unstyle/README.md) to an editor selection. A collapsed caret
434
+ reaches everything the surface holds: someone who presses with nothing selected
435
+ wants the content cleaned, not nothing to happen, and the caret is put back
436
+ afterwards rather than the whole document being left selected. It
437
+ reports the first level that can change the selection and applies exactly that
438
+ level, so repeated presses become deliberately stronger only after the previous
439
+ level is already a no-op.
440
+
441
+ The command appends one rung of its own to the policy's ladder. `blocks` reduces
442
+ every selected structure to the host's default block: each outermost block
443
+ becomes one default block per unit of content it holds, so lists, tables,
444
+ quotes, and headings all collapse the same way without a single tag being named.
445
+ Atomic blocks are content and stay. It lives here rather than in the policy
446
+ because it needs the content model and mapped mutation, and because the policy
447
+ also runs on detached paste fragments where there is no structure to reduce.
448
+ Pass `{blocks: false}` for an action that only ever removes presentation.
449
+
450
+ Together the rungs mean the action is unavailable only when the selection is
451
+ already plain text in default blocks — someone who presses it wants something to
452
+ happen, and until then there is always something left to remove.
453
+
454
+ Inline boundaries are split only where needed, every mutation uses the point
455
+ map, and forward/backward selection intent survives. A block attribute changes
456
+ only when the entire block content is selected; a partial text selection never
457
+ restyles unselected text through its shared block. Nested editables remain hard
458
+ boundaries. The command owns native `formatRemove` when installed.
459
+
460
+ This explicit action is not automatic normalization. Command-triggered
461
+ normalization may repair the result afterwards, but it does not choose the
462
+ Unstyle strength.
463
+
464
+ ## Prepared fragments
465
+
466
+ `insertFragment` replaces the current selection with an already prepared
467
+ `DocumentFragment` and collapses the caret after it. It never accepts or parses
468
+ an HTML string. The caller owns the ordered external-input stages: security
469
+ sanitizing, optional Unstyle cleanup, then this command.
470
+
471
+ Text boundaries are split through the point map and selected roots are removed
472
+ as mapped operations. An empty fragment therefore acts as explicit selected
473
+ range deletion. For insertion, the command asks the content model whether all
474
+ fragment children fit at the caret. If not, it lifts the boundary, splitting
475
+ only a non-edge ancestor, until it finds a valid context. One algorithm handles
476
+ an inline mark inside a paragraph, a paragraph pasted into a paragraph, an `li`
477
+ inside a list, and a link beside an existing link. Exact edge insertion does
478
+ not create an empty wrapper. Nested editable hosts are never removed.
479
+
480
+ Fragments from a template or another document are accepted because native DOM
481
+ insertion safely adopts their nodes without changing identity. This command
482
+ does not prove that a fragment is safe; passing unsanitized DOM is a caller
483
+ error.
484
+
485
+ ## Invariants
486
+
487
+ - A command may declare a `shortcut`: one or more chords separated by spaces,
488
+ each `[modifier+]*key` with `ctrl` matching Control or Command. The registry
489
+ indexes them like input types, so keys belong to commands rather than to a
490
+ toolbar and work whether or not a control is on screen.
491
+ - `run(name, {trigger: 'input'})` marks a run as ongoing input rather than a
492
+ discrete action, so history groups a live control's edits — a field typed
493
+ into, a slider dragged — instead of recording a step per change.
494
+ - Commands run inside exactly one transaction and report their dirty nodes.
495
+ - A command never crosses its editing host and never splits the host itself.
496
+ - The resulting selection is set by the command, not guessed by the caller.
497
+ - Cleanup is not a command concern; the input pipeline normalizes on
498
+ `u2-rte-command` when `--u2-rte-clean-on` includes `command`.
499
+
500
+ ## TODO
501
+
502
+ - Delete a selection first, then split, instead of leaving it native.
503
+ - Delete non-collapsed ranges explicitly where native DOM results are not
504
+ interoperable.
505
+ - Create the host's default block when Enter is pressed in content that has not
506
+ been wrapped into one yet.
507
+ - Let content policy provide the line separator instead of assuming `<br>`.
508
+ - Merge compatible mark wrappers across nested equivalent structures.
509
+ - Decide whether changing a value mark should keep the attributes its adapter
510
+ does not name, instead of writing the given value exactly.
511
+ - Publish availability and active state as observable state for UI adapters.
512
+ - Decide whether block styles should support an application-defined fallback
513
+ for loose text before normalization has created a text block.
514
+ - Preserve `start` and `value` numbering when a split or lift divides an
515
+ ordered list, the way Enter's list exit already does.
516
+ - Decide whether lifting an item should keep a nested list at its own level or
517
+ raise it with its parent.
@@ -0,0 +1,43 @@
1
+ import {isEditingBoundary} from '../selection/ownership/ownership.js';
2
+
3
+ // Editor emptiness differs from textContent emptiness: a filler br is empty,
4
+ // while atomic content and nested editing boundaries are meaningful.
5
+ export function emptyBlock(node, model, root = node) {
6
+ if (node.nodeType === Node.TEXT_NODE) return !node.data.trim();
7
+ if (node.nodeType !== Node.ELEMENT_NODE) return true;
8
+ if (node.localName === 'br') return true;
9
+ if (node !== root && (isEditingBoundary(node) || model.atomic(node))) return false;
10
+ return [...node.childNodes].every(child => emptyBlock(child, model, root));
11
+ }
12
+
13
+ export function blockEdge(unit, point, edge) {
14
+ if (edge !== 'start' && edge !== 'end') throw new TypeError('Block edge must be start or end');
15
+ let {node, offset} = point;
16
+ while (node !== unit) {
17
+ const length = node.nodeType === Node.TEXT_NODE ? node.length : node.childNodes.length;
18
+ if (offset !== (edge === 'start' ? 0 : length)) return false;
19
+ const parent = node.parentNode;
20
+ if (!parent) return false;
21
+ const index = [...parent.childNodes].indexOf(node);
22
+ offset = index + (edge === 'end' ? 1 : 0);
23
+ node = parent;
24
+ }
25
+ return offset === (edge === 'start' ? 0 : unit.childNodes.length);
26
+ }
27
+
28
+ // Emptiness for structural editing: a filler br already occupies its block, so
29
+ // unlike `emptyBlock` it counts as content and must not be doubled.
30
+ export function blank(model, node) {
31
+ if (node.nodeType === Node.TEXT_NODE) return !node.data.trim();
32
+ if (node.nodeType !== Node.ELEMENT_NODE) return true;
33
+ if (model.atomic(node) || node.textContent.trim()) return false;
34
+ return ![...node.querySelectorAll('*')].some(element => model.atomic(element));
35
+ }
36
+
37
+ // An empty block has no caret position of its own until it holds a break.
38
+ export function fill(edit, element) {
39
+ if (!blank(edit.model, element)) return null;
40
+ const filler = edit.document.createElement('br');
41
+ edit.map.insert(element, element.childNodes.length, filler);
42
+ return filler;
43
+ }
@@ -0,0 +1,120 @@
1
+ import {Point} from '../selection/point/point.js';
2
+
3
+ const TAG = /^[a-z][a-z\d-]*$/;
4
+
5
+ // A closed group of mutually exclusive text-block representations. The group
6
+ // defines which existing elements are styleable, so layout containers are not
7
+ // mistaken for paragraphs merely because the content model calls both blocks.
8
+ export class BlockStyles {
9
+ #styles;
10
+ #byName;
11
+
12
+ constructor(styles) {
13
+ if (!Array.isArray(styles) || !styles.length) throw new TypeError('Block styles require a non-empty array');
14
+ this.#styles = Object.freeze(styles.map(style));
15
+ this.#byName = new Map(this.#styles.map(style => [style.name, style]));
16
+ if (this.#byName.size !== this.#styles.length) throw new RangeError('Block style names must be unique');
17
+ }
18
+
19
+ get styles() { return [...this.#styles]; }
20
+
21
+ command() {
22
+ return {
23
+ enabled: edit => this.#enabled(edit),
24
+ state: edit => this.#state(edit),
25
+ run: edit => this.#apply(edit, this.#byName.get(edit.value)),
26
+ };
27
+ }
28
+
29
+ #enabled(edit) {
30
+ const blocks = this.#blocks(edit);
31
+ const target = edit.value === null ? null : this.#byName.get(edit.value);
32
+ if (edit.value !== null && !target) return false;
33
+ return blocks.some(block => target
34
+ ? this.#render(edit, block, target)
35
+ : this.#styles.some(style => this.#render(edit, block, style))
36
+ );
37
+ }
38
+
39
+ #blocks(edit) {
40
+ if (!edit?.range) return [];
41
+ return edit.range.blocks(element => element !== edit.element && this.#current(element));
42
+ }
43
+
44
+ #current(element) {
45
+ let current = null;
46
+ for (const style of this.#styles) if (matches(style, element)) current = style;
47
+ return current;
48
+ }
49
+
50
+ #state(edit) {
51
+ const blocks = this.#blocks(edit);
52
+ if (!blocks.length) return null;
53
+ let value = null;
54
+ for (const block of blocks) {
55
+ const name = this.#current(block).name;
56
+ if (value !== null && value !== name) return 'mixed';
57
+ value = name;
58
+ }
59
+ return value;
60
+ }
61
+
62
+ #apply(edit, target) {
63
+ if (!target) return [];
64
+ const blocks = this.#blocks(edit);
65
+ if (!blocks.length) return [];
66
+ const start = new Point(edit.range.start.node, edit.range.start.offset, 'forward');
67
+ const end = new Point(edit.range.end.node, edit.range.end.offset, 'backward');
68
+ const backward = !!edit.surface.selection?.backward;
69
+ edit.map.add(start).add(end);
70
+ const changed = [];
71
+ for (const block of blocks) {
72
+ const replacement = this.#render(edit, block, target);
73
+ if (!replacement || block.cloneNode(false).isEqualNode(replacement)) continue;
74
+ const parent = block.parentNode;
75
+ edit.map.replaceWrapper(block, replacement);
76
+ edit.transaction.touch(replacement).touch(parent);
77
+ changed.push(replacement);
78
+ }
79
+ edit.select(edit.map.get(start), edit.map.get(end), backward);
80
+ return changed;
81
+ }
82
+
83
+ #render(edit, block, target) {
84
+ const replacement = edit.document.createElement(target.tag);
85
+ for (const attribute of block.attributes) replacement.setAttribute(attribute.name, attribute.value);
86
+ for (const style of this.#styles) style.clear?.(replacement);
87
+ target.write?.(replacement);
88
+ if (!edit.model.allows(block.parentNode, replacement)) return null;
89
+ if ([...block.childNodes].some(child => !edit.model.allows(replacement, child))) return null;
90
+ return replacement;
91
+ }
92
+ }
93
+
94
+ function style(value) {
95
+ if (!value || typeof value !== 'object') throw new TypeError('A block style must be an object');
96
+ for (const property of ['name', 'label', 'selector', 'tag']) {
97
+ if (typeof value[property] !== 'string' || !value[property].trim()) {
98
+ throw new TypeError(`A block style requires ${property}`);
99
+ }
100
+ }
101
+ if (value.write !== undefined && typeof value.write !== 'function') {
102
+ throw new TypeError('Block style write must be a function');
103
+ }
104
+ if (value.clear !== undefined && typeof value.clear !== 'function') {
105
+ throw new TypeError('Block style clear must be a function');
106
+ }
107
+ if (!TAG.test(value.tag.toLowerCase())) throw new TypeError('A block style tag must be a usable tag name');
108
+ return Object.freeze({
109
+ name: value.name.trim(),
110
+ label: value.label,
111
+ selector: value.selector,
112
+ tag: value.tag.toLowerCase(),
113
+ write: value.write || null,
114
+ clear: value.clear || null,
115
+ });
116
+ }
117
+
118
+ function matches(style, element) {
119
+ return element.matches(style.selector);
120
+ }