@openleaf-editor/core 0.1.0-beta.2 → 0.1.0-beta.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 (104) hide show
  1. package/README.md +127 -0
  2. package/dist/autolink.d.ts +7 -4
  3. package/dist/autolink.d.ts.map +1 -1
  4. package/dist/autolink.js +90 -11
  5. package/dist/autolink.js.map +1 -1
  6. package/dist/command-helpers.d.ts +8 -0
  7. package/dist/command-helpers.d.ts.map +1 -0
  8. package/dist/command-helpers.js +36 -0
  9. package/dist/command-helpers.js.map +1 -0
  10. package/dist/commands.d.ts +21 -63
  11. package/dist/commands.d.ts.map +1 -1
  12. package/dist/commands.js +430 -346
  13. package/dist/commands.js.map +1 -1
  14. package/dist/css.d.ts +1 -175
  15. package/dist/css.d.ts.map +1 -1
  16. package/dist/css.js +1 -516
  17. package/dist/css.js.map +1 -1
  18. package/dist/disclosure.d.ts +21 -0
  19. package/dist/disclosure.d.ts.map +1 -0
  20. package/dist/disclosure.js +144 -0
  21. package/dist/disclosure.js.map +1 -0
  22. package/dist/elements.d.ts +2 -0
  23. package/dist/elements.d.ts.map +1 -0
  24. package/dist/elements.js +2 -0
  25. package/dist/elements.js.map +1 -0
  26. package/dist/embed.d.ts +1 -51
  27. package/dist/embed.d.ts.map +1 -1
  28. package/dist/embed.js +1 -115
  29. package/dist/embed.js.map +1 -1
  30. package/dist/errors.d.ts +38 -0
  31. package/dist/errors.d.ts.map +1 -0
  32. package/dist/errors.js +40 -0
  33. package/dist/errors.js.map +1 -0
  34. package/dist/extensions.d.ts +46 -5
  35. package/dist/extensions.d.ts.map +1 -1
  36. package/dist/extensions.js +272 -52
  37. package/dist/extensions.js.map +1 -1
  38. package/dist/figure-drag.d.ts +26 -0
  39. package/dist/figure-drag.d.ts.map +1 -0
  40. package/dist/figure-drag.js +75 -0
  41. package/dist/figure-drag.js.map +1 -0
  42. package/dist/formats.d.ts.map +1 -1
  43. package/dist/formats.js +19 -7
  44. package/dist/formats.js.map +1 -1
  45. package/dist/gapcursor.d.ts +14 -0
  46. package/dist/gapcursor.d.ts.map +1 -0
  47. package/dist/gapcursor.js +17 -0
  48. package/dist/gapcursor.js.map +1 -0
  49. package/dist/html.d.ts +32 -2
  50. package/dist/html.d.ts.map +1 -1
  51. package/dist/html.js +185 -31
  52. package/dist/html.js.map +1 -1
  53. package/dist/index.d.ts +23 -6
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +34 -6
  56. package/dist/index.js.map +1 -1
  57. package/dist/insert-commands.d.ts +172 -0
  58. package/dist/insert-commands.d.ts.map +1 -0
  59. package/dist/insert-commands.js +560 -0
  60. package/dist/insert-commands.js.map +1 -0
  61. package/dist/isolating-selection.d.ts +59 -0
  62. package/dist/isolating-selection.d.ts.map +1 -0
  63. package/dist/isolating-selection.js +251 -0
  64. package/dist/isolating-selection.js.map +1 -0
  65. package/dist/noneditable.d.ts.map +1 -1
  66. package/dist/noneditable.js +18 -4
  67. package/dist/noneditable.js.map +1 -1
  68. package/dist/plugins.d.ts +20 -2
  69. package/dist/plugins.d.ts.map +1 -1
  70. package/dist/plugins.js +27 -2
  71. package/dist/plugins.js.map +1 -1
  72. package/dist/preserve.d.ts +48 -17
  73. package/dist/preserve.d.ts.map +1 -1
  74. package/dist/preserve.js +214 -43
  75. package/dist/preserve.js.map +1 -1
  76. package/dist/schema.d.ts +15 -0
  77. package/dist/schema.d.ts.map +1 -1
  78. package/dist/schema.js +157 -28
  79. package/dist/schema.js.map +1 -1
  80. package/dist/structure.d.ts +32 -0
  81. package/dist/structure.d.ts.map +1 -1
  82. package/dist/structure.js +53 -10
  83. package/dist/structure.js.map +1 -1
  84. package/dist/tables.d.ts +47 -1
  85. package/dist/tables.d.ts.map +1 -1
  86. package/dist/tables.js +417 -40
  87. package/dist/tables.js.map +1 -1
  88. package/dist/testing.d.ts +3 -0
  89. package/dist/testing.d.ts.map +1 -0
  90. package/dist/testing.js +3 -0
  91. package/dist/testing.js.map +1 -0
  92. package/dist/tokens.d.ts +8 -0
  93. package/dist/tokens.d.ts.map +1 -1
  94. package/dist/tokens.js +54 -6
  95. package/dist/tokens.js.map +1 -1
  96. package/dist/url.d.ts +1 -25
  97. package/dist/url.d.ts.map +1 -1
  98. package/dist/url.js +1 -77
  99. package/dist/url.js.map +1 -1
  100. package/dist/visual-aids.d.ts +11 -1
  101. package/dist/visual-aids.d.ts.map +1 -1
  102. package/dist/visual-aids.js +124 -36
  103. package/dist/visual-aids.js.map +1 -1
  104. package/package.json +17 -1
package/dist/commands.js CHANGED
@@ -13,14 +13,15 @@
13
13
  * button disabled-state free -- the same function answers "can I?" and "do it".
14
14
  */
15
15
  import { redo, undo } from 'prosemirror-history';
16
- import { setBlockType, toggleMark, wrapIn } from 'prosemirror-commands';
17
- import { Slice } from 'prosemirror-model';
16
+ import { toggleMark, wrapIn } from 'prosemirror-commands';
17
+ import { Fragment, } from 'prosemirror-model';
18
18
  import { liftListItem, sinkListItem, splitListItem, wrapInList, } from 'prosemirror-schema-list';
19
+ import { NodeSelection, Selection, } from 'prosemirror-state';
19
20
  import { MAX_INDENT, safeColor, safeDir, safeFontFamily, safeFontSize, safeLang, safeLineHeight, safeListStyle, } from './css.js';
20
- import { safeAllowList, safeEmbedSrc } from './embed.js';
21
- import { parseHtml } from './html.js';
22
- import { IMAGE_ALIGN_CLASSES, safeClassList, safeId } from './tokens.js';
23
- import { isSafeUrl } from './url.js';
21
+ import { canInsertNode, markCommand, markIn, nodeCommand, nodeIn } from './command-helpers.js';
22
+ import { CARRIED_ATTR } from './extensions.js';
23
+ import { selectedImage } from './insert-commands.js';
24
+ import { IMAGE_ALIGNMENTS } from './tokens.js';
24
25
  /**
25
26
  * Types are resolved from the state's schema, never from a captured singleton.
26
27
  *
@@ -36,28 +37,183 @@ import { isSafeUrl } from './url.js';
36
37
  * renders that as a disabled button. Throwing would take the editor down
37
38
  * because a plugin trimmed the schema.
38
39
  */
39
- function markIn(state, name) {
40
- return state.schema.marks[name];
40
+ /* ------------------------------------------------------------------ *
41
+ * Two things every command in this file has to get right
42
+ * ------------------------------------------------------------------ */
43
+ /**
44
+ * The selected ranges. For an ordinary selection there is exactly one; for a
45
+ * table selection there is one per cell.
46
+ *
47
+ * `selection.from` and `selection.to` are the bounds of a SINGLE range, not the
48
+ * union of all of them. That is fine for a text selection and silently wrong for
49
+ * a `CellSelection` from prosemirror-tables: selecting a column of a 3x2 table
50
+ * gives `ranges.length === 2` while `from`/`to` describe the interior of one
51
+ * cell. Every command that read those two numbers therefore coloured, sized or
52
+ * cleared one cell of a selected column and left the others untouched.
53
+ *
54
+ * What made it a bug rather than an unimplemented feature is that `toggleBold`
55
+ * was never affected: it goes through prosemirror-commands' `toggleMark`, which
56
+ * iterates ranges. So on one identical selection, Bold worked and colour did
57
+ * not, which an author reads as the editor failing at random rather than as a
58
+ * limitation they could work around.
59
+ *
60
+ * A named accessor rather than inlining `state.selection.ranges` everywhere,
61
+ * because the point that needs to survive future edits is "never destructure
62
+ * from/to for an operation over a selection", and a function is where that
63
+ * reasoning can live.
64
+ */
65
+ function selectedRanges(state) {
66
+ return state.selection.ranges;
41
67
  }
42
- function nodeIn(state, name) {
43
- return state.schema.nodes[name];
68
+ /** True when any selected range carries this mark. */
69
+ function anyRangeHasMark(state, type) {
70
+ return selectedRanges(state).some((range) => state.doc.rangeHasMark(range.$from.pos, range.$to.pos, type));
44
71
  }
45
- /** Build a command that needs one mark type, declining when it is absent. */
46
- function markCommand(name, build) {
47
- return (state, dispatch, view) => {
48
- const type = markIn(state, name);
49
- if (!type)
50
- return false;
51
- return build(type)(state, dispatch, view);
52
- };
72
+ /** Remove a mark from every selected range, optionally re-adding it. */
73
+ function replaceMarkInRanges(state, tr, type, attrs) {
74
+ for (const range of selectedRanges(state)) {
75
+ const { pos: from } = range.$from;
76
+ const { pos: to } = range.$to;
77
+ tr.removeMark(from, to, type);
78
+ if (attrs !== null)
79
+ tr.addMark(from, to, type.create(attrs));
80
+ }
53
81
  }
54
- /** Build a command that needs one node type, declining when it is absent. */
55
- function nodeCommand(name, build) {
56
- return (state, dispatch, view) => {
82
+ /**
83
+ * The attributes to give a node being retyped, so that changing a block's TYPE
84
+ * does not also change everything else about it.
85
+ *
86
+ * Every attribute the destination type also declares comes across unchanged;
87
+ * anything it does not declare is dropped, because there is nowhere to put it.
88
+ * The one that matters most is `__openleafCarried`, the residue holding `class`,
89
+ * every `data-*` and all unmodelled CSS -- and `dir`, which schema.ts makes a
90
+ * first-class attribute precisely because dropping it silently breaks Arabic,
91
+ * Hebrew and Persian content.
92
+ *
93
+ * Three separate mechanisms used to lose all of it, which is why the fix is a
94
+ * shared helper rather than four local patches. `setHeading` hand-built its
95
+ * attribute object and listed four names, so anything added to the schema
96
+ * afterwards fell back to a default. `setParagraph` and `toggleCodeBlock` passed
97
+ * no attributes at all. `toggleList` called `setNodeMarkup(pos, type)` and
98
+ * prosemirror-transform does `type.create(undefined, ...)` there -- it does NOT
99
+ * fall back to the old node's attributes, which is the part that reads as though
100
+ * it should be safe and is not.
101
+ *
102
+ * It also silently broke `formats.ts`: `setBlockClass` writes the chosen format
103
+ * class into the carried residue, so applying a format and then changing the
104
+ * heading level removed the format.
105
+ */
106
+ function carryOver(from, to, overrides = {}) {
107
+ const out = {};
108
+ const declared = new Set(Object.keys(to.spec.attrs ?? {}));
109
+ for (const name of declared) {
110
+ if (name in from.attrs)
111
+ out[name] = from.attrs[name];
112
+ }
113
+ /*
114
+ * An `id` outlives the block type it was written on.
115
+ *
116
+ * `heading` declares `id` and `paragraph` does not, so demoting a heading to
117
+ * a paragraph dropped it -- and an id on a heading is not decoration, it is
118
+ * the target of every in-page link and every table-of-contents entry pointing
119
+ * at that section. Changing a block's type is not a request to break the
120
+ * links to it.
121
+ *
122
+ * It moves into the carried residue rather than into an attribute, because
123
+ * the destination type has no such attribute to move it into. That is exactly
124
+ * what residue is for, and it is already where a `<p id="x">` parsed from
125
+ * stored content keeps its id, so both routes arrive at one representation.
126
+ */
127
+ if (!declared.has('id') && declared.has(CARRIED_ATTR)) {
128
+ const id = from.attrs['id'];
129
+ if (typeof id === 'string' && id !== '') {
130
+ const carried = { ...(out[CARRIED_ATTR] ?? {}) };
131
+ if (carried['id'] === undefined)
132
+ carried['id'] = id;
133
+ out[CARRIED_ATTR] = carried;
134
+ }
135
+ }
136
+ return { ...out, ...overrides };
137
+ }
138
+ /**
139
+ * `setBlockType`, with per-node attributes and every selected range.
140
+ *
141
+ * prosemirror-commands' own `setBlockType` takes ONE attribute object and
142
+ * applies it to every block in the selection, which cannot express "keep each
143
+ * block's own residue". prosemirror-transform's `Transform.setBlockType` does
144
+ * accept a function, so the applicability check and the loop are reproduced here
145
+ * against that. The shape is deliberately the same as upstream's so the two stay
146
+ * comparable.
147
+ */
148
+ function setBlockTypeCarrying(name, overrides = () => ({})) {
149
+ return (state, dispatch) => {
57
150
  const type = nodeIn(state, name);
58
151
  if (!type)
59
152
  return false;
60
- return build(type)(state, dispatch, view);
153
+ const attrsFor = (node) => carryOver(node, type, overrides(node));
154
+ let applicable = false;
155
+ for (const range of selectedRanges(state)) {
156
+ if (applicable)
157
+ break;
158
+ state.doc.nodesBetween(range.$from.pos, range.$to.pos, (node, pos) => {
159
+ if (applicable)
160
+ return false;
161
+ // `hasMarkup` is what makes "set paragraph on a plain paragraph" report
162
+ // false, and it has to be asked with the attributes this call would
163
+ // actually write -- otherwise a no-op conversion looks like a change.
164
+ if (!node.isTextblock || node.hasMarkup(type, attrsFor(node)))
165
+ return;
166
+ // A figure is a textblock (`content: 'inline+'`) but its children are an
167
+ // image and a caption -- retyping it produces an <h2> holding a
168
+ // <figcaption>. `heading` and `paragraph` also accept inline content, so
169
+ // `validContent` is not enough; isolating is what marks a textblock that
170
+ // is not a retypable paragraph.
171
+ if (node.type.spec.isolating)
172
+ return;
173
+ // `code_block` cannot hold an image or a figcaption, which is where the
174
+ // TransformError came from when this check was missing.
175
+ if (!type.validContent(node.content))
176
+ return;
177
+ if (node.type === type) {
178
+ applicable = true;
179
+ return;
180
+ }
181
+ const $pos = state.doc.resolve(pos);
182
+ const index = $pos.index();
183
+ applicable = $pos.parent.canReplaceWith(index, index + 1, type);
184
+ return;
185
+ });
186
+ }
187
+ if (!applicable)
188
+ return false;
189
+ if (dispatch) {
190
+ const tr = state.tr;
191
+ for (const range of selectedRanges(state)) {
192
+ // Per node, not the whole range: `setBlockType` on a span that also
193
+ // covers a figure would still try to retype that figure, which is the
194
+ // TransformError a select-all used to throw even after the
195
+ // applicability check started skipping it.
196
+ state.doc.nodesBetween(range.$from.pos, range.$to.pos, (node, pos) => {
197
+ if (!node.isTextblock || node.hasMarkup(type, attrsFor(node)))
198
+ return;
199
+ if (node.type.spec.isolating)
200
+ return;
201
+ if (!type.validContent(node.content))
202
+ return;
203
+ if (node.type !== type) {
204
+ const $pos = state.doc.resolve(pos);
205
+ const index = $pos.index();
206
+ if (!$pos.parent.canReplaceWith(index, index + 1, type))
207
+ return;
208
+ }
209
+ const mappedFrom = tr.mapping.map(pos);
210
+ const mappedTo = tr.mapping.map(pos + node.nodeSize);
211
+ tr.setBlockType(mappedFrom, mappedTo, type, attrsFor);
212
+ });
213
+ }
214
+ dispatch(tr.scrollIntoView());
215
+ }
216
+ return true;
61
217
  };
62
218
  }
63
219
  /* ------------------------------------------------------------------ *
@@ -75,11 +231,11 @@ export function isMarkActive(state, markName) {
75
231
  const type = markIn(state, markName);
76
232
  if (!type)
77
233
  return false;
78
- const { from, $from, to, empty } = state.selection;
234
+ const { $from, empty } = state.selection;
79
235
  if (empty) {
80
236
  return !!type.isInSet(state.storedMarks ?? $from.marks());
81
237
  }
82
- return state.doc.rangeHasMark(from, to, type);
238
+ return anyRangeHasMark(state, type);
83
239
  }
84
240
  /** Is the selection inside a node of this type (with these attributes)? */
85
241
  export function isNodeActive(state, nodeName, attrs) {
@@ -105,16 +261,7 @@ export function isNodeActive(state, nodeName, attrs) {
105
261
  }
106
262
  /** Can a node of this type be inserted at the current selection? */
107
263
  export function canInsert(state, nodeName) {
108
- const type = nodeIn(state, nodeName);
109
- if (!type)
110
- return false;
111
- const { $from } = state.selection;
112
- for (let depth = $from.depth; depth >= 0; depth -= 1) {
113
- const index = $from.index(depth);
114
- if ($from.node(depth).canReplaceWith(index, index, type))
115
- return true;
116
- }
117
- return false;
264
+ return canInsertNode(state, nodeName);
118
265
  }
119
266
  /** The heading level at the cursor, or null when not in a heading. */
120
267
  export function activeHeadingLevel(state) {
@@ -134,20 +281,22 @@ export function activeLink(state) {
134
281
  const type = markIn(state, 'link');
135
282
  if (!type)
136
283
  return null;
137
- const { $from, empty, from, to } = state.selection;
284
+ const { $from, empty } = state.selection;
138
285
  if (empty) {
139
286
  const found = type.isInSet($from.marks());
140
287
  return found ? found.attrs : null;
141
288
  }
142
289
  let attrs = null;
143
- state.doc.nodesBetween(from, to, (child) => {
144
- if (attrs)
145
- return false;
146
- const found = type.isInSet(child.marks);
147
- if (found)
148
- attrs = found.attrs;
149
- return true;
150
- });
290
+ for (const range of selectedRanges(state)) {
291
+ state.doc.nodesBetween(range.$from.pos, range.$to.pos, (child) => {
292
+ if (attrs)
293
+ return false;
294
+ const found = type.isInSet(child.marks);
295
+ if (found)
296
+ attrs = found.attrs;
297
+ return true;
298
+ });
299
+ }
151
300
  return attrs;
152
301
  }
153
302
  /* ------------------------------------------------------------------ *
@@ -163,21 +312,19 @@ export const toggleSuperscript = markCommand('superscript', toggleMark);
163
312
  /* ------------------------------------------------------------------ *
164
313
  * Block commands
165
314
  * ------------------------------------------------------------------ */
166
- export const setParagraph = nodeCommand('paragraph', (type) => setBlockType(type));
315
+ export const setParagraph = setBlockTypeCarrying('paragraph');
316
+ /**
317
+ * `level` is the only attribute this command decides; everything else about the
318
+ * block is the author's and travels with it.
319
+ *
320
+ * `id` needs no special case any more, and that is a property of the schema
321
+ * rather than a shortcut: `paragraph` does not declare an `id`, so `carryOver`
322
+ * cannot promote one from a paragraph, while a heading's own `id` is declared
323
+ * and does come across. A paragraph stored as `<p id="x">` keeps its id anyway,
324
+ * through the carried residue, which is where an unmodelled attribute belongs.
325
+ */
167
326
  export function setHeading(level) {
168
- return (state, dispatch, view) => {
169
- const type = nodeIn(state, 'heading');
170
- if (!type)
171
- return false;
172
- const parent = state.selection.$from.parent;
173
- const attrs = {
174
- level,
175
- dir: parent.attrs['dir'] ?? null,
176
- align: parent.attrs['align'] ?? null,
177
- id: parent.type === type ? (parent.attrs['id'] ?? null) : null,
178
- };
179
- return setBlockType(type, attrs)(state, dispatch, view);
180
- };
327
+ return setBlockTypeCarrying('heading', () => ({ level }));
181
328
  }
182
329
  /**
183
330
  * Toggle a heading level: applying the level you are already in returns the
@@ -194,7 +341,7 @@ export function toggleHeading(level) {
194
341
  export const toggleCodeBlock = (state, dispatch, view) => {
195
342
  if (isNodeActive(state, 'code_block'))
196
343
  return setParagraph(state, dispatch, view);
197
- return nodeCommand('code_block', (type) => setBlockType(type))(state, dispatch, view);
344
+ return setBlockTypeCarrying('code_block')(state, dispatch, view);
198
345
  };
199
346
  export const wrapInBlockquote = nodeCommand('blockquote', (type) => wrapIn(type));
200
347
  export const toggleBlockquote = (state, dispatch, view) => {
@@ -271,14 +418,93 @@ function toggleList(target) {
271
418
  const type = nodeIn(state, target);
272
419
  if (!type)
273
420
  return false;
274
- if (dispatch)
275
- dispatch(state.tr.setNodeMarkup(enclosing.pos, type).scrollIntoView());
421
+ const node = state.doc.nodeAt(enclosing.pos);
422
+ if (!node)
423
+ return false;
424
+ // Attributes must be passed explicitly. `setNodeMarkup(pos, type)` reads
425
+ // as "change the type and leave the rest alone" and does not do that:
426
+ // prosemirror-transform calls `type.create(undefined, ...)`, so every
427
+ // attribute silently reverts to its default. `start` is genuinely dropped
428
+ // here because a `<ul>` has no such concept; `listStyle` and the residue
429
+ // holding `class` and every `data-*` are not.
430
+ if (dispatch) {
431
+ dispatch(state.tr.setNodeMarkup(enclosing.pos, type, carryOver(node, type)).scrollIntoView());
432
+ }
276
433
  return true;
277
434
  }
278
435
  return nodeCommand(target, (type) => wrapInList(type))(state, dispatch, view);
279
436
  };
280
437
  }
281
- export const splitListItemCommand = nodeCommand('list_item', (t) => splitListItem(t));
438
+ /**
439
+ * Split a list item, including items that hold extra `block*` after the
440
+ * paragraph.
441
+ *
442
+ * Stock `splitListItem` already splits a non-empty item at depth 2, so
443
+ * following blocks travel with the new item -- Word and Google Docs. Its empty
444
+ * handling only runs when the empty textblock is the *last* child. Extra
445
+ * `block*` after the paragraph skips that branch, and Enter either creates a
446
+ * sibling `<li>` (never leaves) or the chained base keymap inserts a sibling
447
+ * `<p>` inside the same item.
448
+ *
449
+ * Enter on an empty bullet is how authors leave a list. Extra children are
450
+ * promoted to siblings of the list and the empty paragraph is dropped so we
451
+ * do not store `<p></p>` next to a callout. Nested empty last inner items
452
+ * (no extra children) still go through stock, which outdents one level.
453
+ */
454
+ function splitListItemAllowingExtraBlocks(itemType) {
455
+ const split = splitListItem(itemType);
456
+ return (state, dispatch) => {
457
+ const { $from, $to } = state.selection;
458
+ if ($from.depth >= 3 && $from.sameParent($to) && $from.node(-1).type === itemType) {
459
+ if ($from.parent.content.size === 0 && $from.node(-1).childCount > $from.indexAfter(-1)) {
460
+ return leaveListPromotingExtra(state, dispatch);
461
+ }
462
+ }
463
+ return split(state, dispatch);
464
+ };
465
+ }
466
+ function leaveListPromotingExtra(state, dispatch) {
467
+ const { $from } = state.selection;
468
+ const item = $from.node(-1);
469
+ const emptyIndex = $from.index(-1);
470
+ const kept = [];
471
+ for (let i = 0; i < item.childCount; i++) {
472
+ if (i !== emptyIndex)
473
+ kept.push(item.child(i));
474
+ }
475
+ const list = $from.node(-2);
476
+ const itemIndex = $from.index(-2);
477
+ const before = [];
478
+ const after = [];
479
+ for (let i = 0; i < list.childCount; i++) {
480
+ if (i < itemIndex)
481
+ before.push(list.child(i));
482
+ else if (i > itemIndex)
483
+ after.push(list.child(i));
484
+ }
485
+ const beforeList = before.length > 0 ? list.copy(Fragment.from(before)) : null;
486
+ const afterList = after.length > 0 ? list.copy(Fragment.from(after)) : null;
487
+ const replacement = [];
488
+ if (beforeList)
489
+ replacement.push(beforeList);
490
+ replacement.push(...kept);
491
+ if (afterList)
492
+ replacement.push(afterList);
493
+ const parent = $from.node(-3);
494
+ const listIndex = $from.index(-3);
495
+ const replaceFrag = Fragment.from(replacement);
496
+ if (!parent.canReplace(listIndex, listIndex + 1, replaceFrag))
497
+ return false;
498
+ if (dispatch) {
499
+ const listPos = $from.before(-2);
500
+ const tr = state.tr.replaceWith(listPos, listPos + list.nodeSize, replaceFrag);
501
+ const beforeSize = beforeList ? beforeList.nodeSize : 0;
502
+ tr.setSelection(Selection.near(tr.doc.resolve(listPos + beforeSize)));
503
+ dispatch(tr.scrollIntoView());
504
+ }
505
+ return true;
506
+ }
507
+ export const splitListItemCommand = nodeCommand('list_item', splitListItemAllowingExtraBlocks);
282
508
  export const indentListItem = nodeCommand('list_item', (t) => sinkListItem(t));
283
509
  export const outdentListItem = nodeCommand('list_item', (t) => liftListItem(t));
284
510
  /**
@@ -353,14 +579,21 @@ export function activeListStyle(state) {
353
579
  */
354
580
  function blocksWithAttr(state, attr) {
355
581
  const found = [];
356
- const { from, to } = state.selection;
357
- state.doc.nodesBetween(from, to, (node, pos) => {
358
- if (!node.isTextblock)
359
- return true;
360
- if (Object.hasOwn(node.attrs, attr))
361
- found.push({ pos, node });
362
- return false;
363
- });
582
+ // Deduplicated by position: callers write one `setNodeMarkup` per entry, and
583
+ // two writes to the same position in one transaction would have the second
584
+ // one built from a node the first already replaced.
585
+ const seen = new Set();
586
+ for (const range of selectedRanges(state)) {
587
+ state.doc.nodesBetween(range.$from.pos, range.$to.pos, (node, pos) => {
588
+ if (!node.isTextblock)
589
+ return true;
590
+ if (Object.hasOwn(node.attrs, attr) && !seen.has(pos)) {
591
+ seen.add(pos);
592
+ found.push({ pos, node });
593
+ }
594
+ return false;
595
+ });
596
+ }
364
597
  return found;
365
598
  }
366
599
  function uniformBlockAttr(state, attr) {
@@ -371,8 +604,57 @@ function uniformBlockAttr(state, attr) {
371
604
  const value = first.node.attrs[attr] ?? null;
372
605
  return blocks.every((b) => (b.node.attrs[attr] ?? null) === value) ? value : null;
373
606
  }
607
+ /**
608
+ * True when this alignment is one an image can store.
609
+ *
610
+ * `image.attrs.align` is `left` / `right` / `center` (a float or a block
611
+ * centre). Justify is a text-block value only: writing it onto an image
612
+ * would serialize as the class `undefined`.
613
+ */
614
+ function isImageAlign(align) {
615
+ return align === null || IMAGE_ALIGNMENTS.includes(align);
616
+ }
617
+ /**
618
+ * The nodes in the selection that can carry an alignment.
619
+ *
620
+ * Textblocks that declare `align`, and `image` nodes whose alignment is a
621
+ * float/centre class rather than `text-align`. A NodeSelection on an image
622
+ * -- or on the figure wrapping one -- is only that image: walking the parent
623
+ * paragraph as well would float the picture AND centre the paragraph, which
624
+ * is not what Align right means when the author has clicked the picture.
625
+ *
626
+ * `blocksWithAttr` stops inside a textblock, so it never sees an inline
627
+ * image. Alignment has to look inside, but only for a non-empty range: a
628
+ * caret beside an image must not retarget it, the same trap `selectedImage`
629
+ * refuses.
630
+ */
374
631
  function alignableBlocks(state) {
375
- return blocksWithAttr(state, 'align');
632
+ const selected = selectedImage(state);
633
+ if (selected) {
634
+ const node = state.doc.nodeAt(selected.pos);
635
+ return node && node.type.name === 'image' ? [{ pos: selected.pos, node }] : [];
636
+ }
637
+ const found = [];
638
+ const seen = new Set();
639
+ for (const range of selectedRanges(state)) {
640
+ const from = range.$from.pos;
641
+ const to = range.$to.pos;
642
+ const coverImages = from !== to;
643
+ state.doc.nodesBetween(from, to, (node, pos) => {
644
+ if (coverImages && node.type.name === 'image' && Object.hasOwn(node.attrs, 'align') && !seen.has(pos)) {
645
+ seen.add(pos);
646
+ found.push({ pos, node });
647
+ }
648
+ if (!node.isTextblock)
649
+ return true;
650
+ if (Object.hasOwn(node.attrs, 'align') && !seen.has(pos)) {
651
+ seen.add(pos);
652
+ found.push({ pos, node });
653
+ }
654
+ return coverImages;
655
+ });
656
+ }
657
+ return found;
376
658
  }
377
659
  /**
378
660
  * The alignment of the selection, or null when it has none or is mixed.
@@ -398,7 +680,11 @@ export function activeTextAlign(state) {
398
680
  return blocks.every((b) => (b.node.attrs['align'] ?? null) === value) ? value : null;
399
681
  }
400
682
  /**
401
- * Set the alignment of every alignable block in the selection. `null` clears it.
683
+ * Set the alignment of every alignable node in the selection. `null` clears it.
684
+ *
685
+ * Images store `left` / `right` / `center` on `image.attrs.align`; serialization
686
+ * maps those to the `ol-*` classes. Justify is skipped on images, because it
687
+ * is not an image alignment.
402
688
  *
403
689
  * Reports true whenever there is something to align, even if it already carries
404
690
  * this value. The alternative -- returning false because the work is already
@@ -407,17 +693,24 @@ export function activeTextAlign(state) {
407
693
  */
408
694
  export function setTextAlign(align) {
409
695
  return (state, dispatch) => {
410
- const blocks = alignableBlocks(state);
696
+ const blocks = alignableBlocks(state).filter(({ node }) => node.type.name !== 'image' || isImageAlign(align));
411
697
  if (blocks.length === 0)
412
698
  return false;
413
699
  if (dispatch) {
414
700
  const tr = state.tr;
701
+ const selected = selectedImage(state);
415
702
  for (const { pos, node } of blocks) {
416
703
  // No position mapping needed: an attribute-only change replaces a node
417
704
  // with one of identical size, so earlier edits cannot shift later
418
705
  // positions in this loop.
419
706
  tr.setNodeMarkup(pos, undefined, { ...node.attrs, align });
420
707
  }
708
+ // Keep a clicked image selected. `setNodeMarkup` replaces the node, and
709
+ // without this the selection collapses beside it -- `activeTextAlign`
710
+ // would then read the parent paragraph and the toolbar would go dark.
711
+ if (selected) {
712
+ tr.setSelection(NodeSelection.create(tr.doc, tr.mapping.map(selected.pos)));
713
+ }
421
714
  dispatch(tr.scrollIntoView());
422
715
  }
423
716
  return true;
@@ -516,7 +809,7 @@ function colorCommand(name, color) {
516
809
  const value = color === null ? null : safeColor(color);
517
810
  if (color !== null && value === null)
518
811
  return false;
519
- const { empty, from, to } = state.selection;
812
+ const { empty } = state.selection;
520
813
  if (empty) {
521
814
  const current = state.storedMarks ?? state.selection.$from.marks();
522
815
  const stripped = current.filter((mark) => mark.type !== type);
@@ -527,12 +820,11 @@ function colorCommand(name, color) {
527
820
  }
528
821
  return true;
529
822
  }
530
- if (value === null && !state.doc.rangeHasMark(from, to, type))
823
+ if (value === null && !anyRangeHasMark(state, type))
531
824
  return false;
532
825
  if (dispatch) {
533
- const tr = state.tr.removeMark(from, to, type);
534
- if (value !== null)
535
- tr.addMark(from, to, type.create({ color: value }));
826
+ const tr = state.tr;
827
+ replaceMarkInRanges(state, tr, type, value === null ? null : { color: value });
536
828
  dispatch(tr.scrollIntoView());
537
829
  }
538
830
  return true;
@@ -543,24 +835,37 @@ function activeColor(state, name) {
543
835
  const type = markIn(state, name);
544
836
  if (!type)
545
837
  return null;
546
- const { empty, $from, from, to } = state.selection;
838
+ const { empty, $from } = state.selection;
547
839
  if (empty) {
548
840
  const found = type.isInSet(state.storedMarks ?? $from.marks());
549
841
  return found ? found.attrs['color'] : null;
550
842
  }
843
+ // `seen` is load-bearing: `null` means both "not visited yet" and "unmarked
844
+ // text", so without a separate flag a selection that starts unmarked and then
845
+ // hits a coloured run would report the colour as uniform.
846
+ let seen = false;
551
847
  let value = null;
552
848
  let uniform = true;
553
- state.doc.nodesBetween(from, to, (child) => {
554
- if (!child.isText)
849
+ // Over every range, for the same reason the command below writes over every
850
+ // range: reading one cell of a selected column and reporting it as the state
851
+ // of all of them puts a colour in the swatch that most of the selection does
852
+ // not have.
853
+ for (const range of selectedRanges(state)) {
854
+ state.doc.nodesBetween(range.$from.pos, range.$to.pos, (child) => {
855
+ if (!child.isText)
856
+ return true;
857
+ const found = type.isInSet(child.marks);
858
+ const colour = found ? found.attrs['color'] : null;
859
+ if (!seen) {
860
+ value = colour;
861
+ seen = true;
862
+ }
863
+ else if (colour !== value) {
864
+ uniform = false;
865
+ }
555
866
  return true;
556
- const found = type.isInSet(child.marks);
557
- const colour = found ? found.attrs['color'] : null;
558
- if (value === null && uniform)
559
- value = colour;
560
- else if (colour !== value)
561
- uniform = false;
562
- return true;
563
- });
867
+ });
868
+ }
564
869
  // Mixed colours report null for the same reason mixed alignment does: a
565
870
  // swatch showing one of them invites the author to act on all of them.
566
871
  return uniform ? value : null;
@@ -587,7 +892,7 @@ function attrMarkCommand(name, attr, value, validate) {
587
892
  const next = value === null ? null : validate(value);
588
893
  if (value !== null && next === null)
589
894
  return false;
590
- const { empty, from, to } = state.selection;
895
+ const { empty } = state.selection;
591
896
  if (empty) {
592
897
  const current = state.storedMarks ?? state.selection.$from.marks();
593
898
  const stripped = current.filter((mark) => mark.type !== type);
@@ -598,12 +903,11 @@ function attrMarkCommand(name, attr, value, validate) {
598
903
  }
599
904
  return true;
600
905
  }
601
- if (next === null && !state.doc.rangeHasMark(from, to, type))
906
+ if (next === null && !anyRangeHasMark(state, type))
602
907
  return false;
603
908
  if (dispatch) {
604
- const tr = state.tr.removeMark(from, to, type);
605
- if (next !== null)
606
- tr.addMark(from, to, type.create({ [attr]: next }));
909
+ const tr = state.tr;
910
+ replaceMarkInRanges(state, tr, type, next === null ? null : { [attr]: next });
607
911
  dispatch(tr.scrollIntoView());
608
912
  }
609
913
  return true;
@@ -613,24 +917,33 @@ function activeMarkAttr(state, name, attr) {
613
917
  const type = markIn(state, name);
614
918
  if (!type)
615
919
  return null;
616
- const { empty, $from, from, to } = state.selection;
920
+ const { empty, $from } = state.selection;
617
921
  if (empty) {
618
922
  const found = type.isInSet(state.storedMarks ?? $from.marks());
619
923
  return found ? found.attrs[attr] : null;
620
924
  }
925
+ // Same sentinel problem as activeColor: without `seen`, an unmarked run at
926
+ // the start of the selection is overwritten by the first marked value and
927
+ // the dropdown claims the whole range has that family or size.
928
+ let seen = false;
621
929
  let value = null;
622
930
  let uniform = true;
623
- state.doc.nodesBetween(from, to, (child) => {
624
- if (!child.isText)
931
+ for (const range of selectedRanges(state)) {
932
+ state.doc.nodesBetween(range.$from.pos, range.$to.pos, (child) => {
933
+ if (!child.isText)
934
+ return true;
935
+ const found = type.isInSet(child.marks);
936
+ const current = found ? found.attrs[attr] : null;
937
+ if (!seen) {
938
+ value = current;
939
+ seen = true;
940
+ }
941
+ else if (current !== value) {
942
+ uniform = false;
943
+ }
625
944
  return true;
626
- const found = type.isInSet(child.marks);
627
- const current = found ? found.attrs[attr] : null;
628
- if (value === null && uniform)
629
- value = current;
630
- else if (current !== value)
631
- uniform = false;
632
- return true;
633
- });
945
+ });
946
+ }
634
947
  return uniform ? value : null;
635
948
  }
636
949
  export function setFontFamily(family) {
@@ -655,12 +968,14 @@ export function activeLanguage(state) {
655
968
  * Strip character formatting and block decoration from the selection.
656
969
  *
657
970
  * Links stay: they are a destination, not a look. Direction stays: it is
658
- * content. Headings and lists stay: they are structure. Alignment, indent,
659
- * line height, fonts, colours, and the common character marks go.
971
+ * content. Language stays: it is the same kind of fact as direction, only
972
+ * modelled as a mark (`<span lang>`) rather than a node attribute. Headings
973
+ * and lists stay: they are structure. Alignment, indent, line height, fonts,
974
+ * colours, and the common character marks go.
660
975
  */
661
976
  export const clearFormatting = (state, dispatch) => {
662
- const keep = new Set(['link']);
663
- const { empty, from, to, $from } = state.selection;
977
+ const keep = new Set(['link', 'language']);
978
+ const { empty, $from } = state.selection;
664
979
  const marks = Object.values(state.schema.marks).filter((type) => !keep.has(type.name));
665
980
  if (empty) {
666
981
  const current = state.storedMarks ?? $from.marks();
@@ -687,7 +1002,7 @@ export const clearFormatting = (state, dispatch) => {
687
1002
  }
688
1003
  let hasMarks = false;
689
1004
  for (const type of marks) {
690
- if (state.doc.rangeHasMark(from, to, type))
1005
+ if (anyRangeHasMark(state, type))
691
1006
  hasMarks = true;
692
1007
  }
693
1008
  const blocks = blocksWithAttr(state, 'align');
@@ -698,8 +1013,11 @@ export const clearFormatting = (state, dispatch) => {
698
1013
  return false;
699
1014
  if (dispatch) {
700
1015
  let tr = state.tr;
701
- for (const type of marks)
702
- tr = tr.removeMark(from, to, type);
1016
+ for (const type of marks) {
1017
+ for (const range of selectedRanges(state)) {
1018
+ tr = tr.removeMark(range.$from.pos, range.$to.pos, type);
1019
+ }
1020
+ }
703
1021
  for (const { pos, node } of blocks) {
704
1022
  tr = tr.setNodeMarkup(pos, undefined, {
705
1023
  ...node.attrs,
@@ -712,241 +1030,7 @@ export const clearFormatting = (state, dispatch) => {
712
1030
  }
713
1031
  return true;
714
1032
  };
715
- /**
716
- * Apply or update a link across the selection.
717
- *
718
- * Removes any existing link first: without that, updating a link that only
719
- * partially overlaps the selection leaves two adjacent links with different
720
- * hrefs, which looks fine and is wrong.
721
- */
722
- export function setLink(attrs) {
723
- return (state, dispatch) => {
724
- const type = markIn(state, 'link');
725
- if (!type)
726
- return false;
727
- const { from, to, empty } = state.selection;
728
- if (empty)
729
- return false;
730
- if (dispatch) {
731
- const tr = state.tr;
732
- tr.removeMark(from, to, type);
733
- tr.addMark(from, to, type.create({
734
- href: attrs.href,
735
- title: attrs.title ?? null,
736
- target: attrs.target ?? null,
737
- rel: attrs.rel ?? null,
738
- id: safeId(attrs.id),
739
- }));
740
- dispatch(tr.scrollIntoView());
741
- }
742
- return true;
743
- };
744
- }
745
- export const unsetLink = (state, dispatch) => {
746
- const type = markIn(state, 'link');
747
- if (!type)
748
- return false;
749
- const { from, to, empty } = state.selection;
750
- if (empty || !state.doc.rangeHasMark(from, to, type))
751
- return false;
752
- if (dispatch)
753
- dispatch(state.tr.removeMark(from, to, type).scrollIntoView());
754
- return true;
755
- };
756
- export function insertImage(attrs) {
757
- return (state, dispatch) => {
758
- const imageType = nodeIn(state, 'image');
759
- if (!imageType)
760
- return false;
761
- const image = imageType.create({
762
- src: attrs.src,
763
- // An absent alt and alt="" mean different things to a screen reader:
764
- // "undescribed" versus "decorative". Never collapse them.
765
- alt: attrs.alt ?? null,
766
- title: attrs.title ?? null,
767
- width: attrs.width ?? null,
768
- height: attrs.height ?? null,
769
- align: attrs.align ?? null,
770
- className: safeClassList(attrs.className ?? null, IMAGE_ALIGN_CLASSES),
771
- });
772
- if (attrs.caption !== undefined && attrs.caption !== null) {
773
- if (!canInsert(state, 'figure'))
774
- return false;
775
- const figureType = nodeIn(state, 'figure');
776
- const captionType = nodeIn(state, 'figcaption');
777
- if (!figureType || !captionType)
778
- return false;
779
- if (dispatch) {
780
- const caption = attrs.caption === '' ? captionType.create() : captionType.create(null, state.schema.text(attrs.caption));
781
- dispatch(state.tr.replaceSelectionWith(figureType.create(null, [image, caption])).scrollIntoView());
782
- }
783
- return true;
784
- }
785
- if (!canInsert(state, 'image'))
786
- return false;
787
- if (dispatch)
788
- dispatch(state.tr.replaceSelectionWith(image).scrollIntoView());
789
- return true;
790
- };
791
- }
792
- export function insertText(text) {
793
- return (state, dispatch) => {
794
- if (text === '')
795
- return false;
796
- if (dispatch)
797
- dispatch(state.tr.insertText(text).scrollIntoView());
798
- return true;
799
- };
800
- }
801
- export const insertNonBreakingSpace = insertText('\u00a0');
802
- export const insertPageBreak = (state, dispatch) => {
803
- if (!canInsert(state, 'page_break'))
804
- return false;
805
- if (dispatch) {
806
- const type = nodeIn(state, 'page_break');
807
- if (!type)
808
- return false;
809
- dispatch(state.tr.replaceSelectionWith(type.create()).scrollIntoView());
810
- }
811
- return true;
812
- };
813
- export function insertNamedAnchor(id) {
814
- return (state, dispatch) => {
815
- const value = safeId(id);
816
- if (!value || !canInsert(state, 'named_anchor'))
817
- return false;
818
- if (dispatch) {
819
- const type = nodeIn(state, 'named_anchor');
820
- if (!type)
821
- return false;
822
- dispatch(state.tr.replaceSelectionWith(type.create({ id: value })).scrollIntoView());
823
- }
824
- return true;
825
- };
826
- }
827
- export function setHeadingId(id) {
828
- return (state, dispatch) => {
829
- const type = nodeIn(state, 'heading');
830
- if (!type)
831
- return false;
832
- const { $from } = state.selection;
833
- for (let depth = $from.depth; depth >= 0; depth -= 1) {
834
- const node = $from.node(depth);
835
- if (node.type !== type)
836
- continue;
837
- if (dispatch) {
838
- dispatch(state.tr
839
- .setNodeMarkup($from.before(depth), undefined, { ...node.attrs, id: safeId(id) })
840
- .scrollIntoView());
841
- }
842
- return true;
843
- }
844
- return false;
845
- };
846
- }
847
- export function insertDetails(summaryText = 'Details') {
848
- return (state, dispatch) => {
849
- if (!canInsert(state, 'details'))
850
- return false;
851
- const detailsType = nodeIn(state, 'details');
852
- const summaryType = nodeIn(state, 'summary');
853
- const paragraphType = nodeIn(state, 'paragraph');
854
- if (!detailsType || !summaryType || !paragraphType)
855
- return false;
856
- if (dispatch) {
857
- const body = paragraphType.createAndFill();
858
- if (!body)
859
- return false;
860
- const node = detailsType.create(null, [
861
- summaryType.create(null, summaryText === '' ? undefined : state.schema.text(summaryText)),
862
- body,
863
- ]);
864
- dispatch(state.tr.replaceSelectionWith(node).scrollIntoView());
865
- }
866
- return true;
867
- };
868
- }
869
- export function insertVideo(attrs) {
870
- return (state, dispatch) => {
871
- if (!isSafeUrl(attrs.src) || !canInsert(state, 'video'))
872
- return false;
873
- if (dispatch) {
874
- const type = nodeIn(state, 'video');
875
- if (!type)
876
- return false;
877
- dispatch(state.tr
878
- .replaceSelectionWith(type.create({
879
- src: attrs.src,
880
- title: attrs.title ?? null,
881
- width: attrs.width ?? null,
882
- height: attrs.height ?? null,
883
- controls: attrs.controls !== false,
884
- poster: attrs.poster && isSafeUrl(attrs.poster) ? attrs.poster : null,
885
- }))
886
- .scrollIntoView());
887
- }
888
- return true;
889
- };
890
- }
891
- export function insertAudio(attrs) {
892
- return (state, dispatch) => {
893
- if (!isSafeUrl(attrs.src) || !canInsert(state, 'audio'))
894
- return false;
895
- if (dispatch) {
896
- const type = nodeIn(state, 'audio');
897
- if (!type)
898
- return false;
899
- dispatch(state.tr
900
- .replaceSelectionWith(type.create({
901
- src: attrs.src,
902
- title: attrs.title ?? null,
903
- controls: attrs.controls !== false,
904
- }))
905
- .scrollIntoView());
906
- }
907
- return true;
908
- };
909
- }
910
- export function insertIframe(attrs) {
911
- return (state, dispatch) => {
912
- const src = safeEmbedSrc(attrs.src);
913
- if (!src || !canInsert(state, 'iframe'))
914
- return false;
915
- if (dispatch) {
916
- const type = nodeIn(state, 'iframe');
917
- if (!type)
918
- return false;
919
- dispatch(state.tr
920
- .replaceSelectionWith(type.create({
921
- src,
922
- title: attrs.title ?? null,
923
- width: attrs.width ?? null,
924
- height: attrs.height ?? null,
925
- allow: safeAllowList(attrs.allow),
926
- allowfullscreen: attrs.allowfullscreen !== false,
927
- }))
928
- .scrollIntoView());
929
- }
930
- return true;
931
- };
932
- }
933
- /**
934
- * Parse HTML against the live schema and insert it.
935
- *
936
- * Used by the snippet picker. The HTML still goes through the same parse
937
- * pipeline as stored content, so a snippet cannot smuggle in a `<script>`.
938
- */
939
- export function insertHtml(html) {
940
- return (state, dispatch) => {
941
- const parsed = parseHtml(html, { schema: state.schema });
942
- if (parsed.childCount === 0)
943
- return false;
944
- if (dispatch) {
945
- dispatch(state.tr.replaceSelection(new Slice(parsed.content, 0, 0)).scrollIntoView());
946
- }
947
- return true;
948
- };
949
- }
1033
+ export { insertAudio, insertDetails, insertHtml, insertIframe, insertImage, insertNamedAnchor, insertNonBreakingSpace, insertPageBreak, insertText, insertVideo, selectedImage, selectedMedia, setHeadingId, setLink, unsetLink, updateImage, updateMedia, } from './insert-commands.js';
950
1034
  /* ------------------------------------------------------------------ *
951
1035
  * History
952
1036
  * ------------------------------------------------------------------ */