@openleaf-editor/core 0.1.0-beta.2 → 0.1.0-beta.3

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 (95) hide show
  1. package/README.md +115 -0
  2. package/dist/autolink.d.ts.map +1 -1
  3. package/dist/autolink.js +45 -7
  4. package/dist/autolink.js.map +1 -1
  5. package/dist/command-helpers.d.ts +8 -0
  6. package/dist/command-helpers.d.ts.map +1 -0
  7. package/dist/command-helpers.js +36 -0
  8. package/dist/command-helpers.js.map +1 -0
  9. package/dist/commands.d.ts +15 -61
  10. package/dist/commands.d.ts.map +1 -1
  11. package/dist/commands.js +295 -343
  12. package/dist/commands.js.map +1 -1
  13. package/dist/css.d.ts +1 -175
  14. package/dist/css.d.ts.map +1 -1
  15. package/dist/css.js +1 -516
  16. package/dist/css.js.map +1 -1
  17. package/dist/disclosure.d.ts +21 -0
  18. package/dist/disclosure.d.ts.map +1 -0
  19. package/dist/disclosure.js +144 -0
  20. package/dist/disclosure.js.map +1 -0
  21. package/dist/elements.d.ts +2 -0
  22. package/dist/elements.d.ts.map +1 -0
  23. package/dist/elements.js +2 -0
  24. package/dist/elements.js.map +1 -0
  25. package/dist/embed.d.ts +1 -51
  26. package/dist/embed.d.ts.map +1 -1
  27. package/dist/embed.js +1 -115
  28. package/dist/embed.js.map +1 -1
  29. package/dist/errors.d.ts +38 -0
  30. package/dist/errors.d.ts.map +1 -0
  31. package/dist/errors.js +40 -0
  32. package/dist/errors.js.map +1 -0
  33. package/dist/extensions.d.ts +46 -5
  34. package/dist/extensions.d.ts.map +1 -1
  35. package/dist/extensions.js +272 -52
  36. package/dist/extensions.js.map +1 -1
  37. package/dist/formats.d.ts.map +1 -1
  38. package/dist/formats.js +19 -7
  39. package/dist/formats.js.map +1 -1
  40. package/dist/html.d.ts +32 -2
  41. package/dist/html.d.ts.map +1 -1
  42. package/dist/html.js +185 -31
  43. package/dist/html.js.map +1 -1
  44. package/dist/index.d.ts +21 -6
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +32 -6
  47. package/dist/index.js.map +1 -1
  48. package/dist/insert-commands.d.ts +172 -0
  49. package/dist/insert-commands.d.ts.map +1 -0
  50. package/dist/insert-commands.js +560 -0
  51. package/dist/insert-commands.js.map +1 -0
  52. package/dist/isolating-selection.d.ts +41 -0
  53. package/dist/isolating-selection.d.ts.map +1 -0
  54. package/dist/isolating-selection.js +162 -0
  55. package/dist/isolating-selection.js.map +1 -0
  56. package/dist/noneditable.d.ts.map +1 -1
  57. package/dist/noneditable.js +18 -4
  58. package/dist/noneditable.js.map +1 -1
  59. package/dist/plugins.d.ts +20 -2
  60. package/dist/plugins.d.ts.map +1 -1
  61. package/dist/plugins.js +27 -2
  62. package/dist/plugins.js.map +1 -1
  63. package/dist/preserve.d.ts +48 -17
  64. package/dist/preserve.d.ts.map +1 -1
  65. package/dist/preserve.js +214 -43
  66. package/dist/preserve.js.map +1 -1
  67. package/dist/schema.d.ts +15 -0
  68. package/dist/schema.d.ts.map +1 -1
  69. package/dist/schema.js +157 -28
  70. package/dist/schema.js.map +1 -1
  71. package/dist/structure.d.ts +32 -0
  72. package/dist/structure.d.ts.map +1 -1
  73. package/dist/structure.js +53 -10
  74. package/dist/structure.js.map +1 -1
  75. package/dist/tables.d.ts +47 -1
  76. package/dist/tables.d.ts.map +1 -1
  77. package/dist/tables.js +417 -40
  78. package/dist/tables.js.map +1 -1
  79. package/dist/testing.d.ts +3 -0
  80. package/dist/testing.d.ts.map +1 -0
  81. package/dist/testing.js +3 -0
  82. package/dist/testing.js.map +1 -0
  83. package/dist/tokens.d.ts +8 -0
  84. package/dist/tokens.d.ts.map +1 -1
  85. package/dist/tokens.js +54 -6
  86. package/dist/tokens.js.map +1 -1
  87. package/dist/url.d.ts +1 -25
  88. package/dist/url.d.ts.map +1 -1
  89. package/dist/url.js +1 -77
  90. package/dist/url.js.map +1 -1
  91. package/dist/visual-aids.d.ts +11 -1
  92. package/dist/visual-aids.d.ts.map +1 -1
  93. package/dist/visual-aids.js +124 -36
  94. package/dist/visual-aids.js.map +1 -1
  95. package/package.json +15 -1
package/dist/commands.js CHANGED
@@ -13,14 +13,12 @@
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 {} from 'prosemirror-model';
18
18
  import { liftListItem, sinkListItem, splitListItem, wrapInList, } from 'prosemirror-schema-list';
19
19
  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';
20
+ import { canInsertNode, markCommand, markIn, nodeCommand, nodeIn } from './command-helpers.js';
21
+ import { CARRIED_ATTR } from './extensions.js';
24
22
  /**
25
23
  * Types are resolved from the state's schema, never from a captured singleton.
26
24
  *
@@ -36,28 +34,183 @@ import { isSafeUrl } from './url.js';
36
34
  * renders that as a disabled button. Throwing would take the editor down
37
35
  * because a plugin trimmed the schema.
38
36
  */
39
- function markIn(state, name) {
40
- return state.schema.marks[name];
41
- }
42
- function nodeIn(state, name) {
43
- return state.schema.nodes[name];
37
+ /* ------------------------------------------------------------------ *
38
+ * Two things every command in this file has to get right
39
+ * ------------------------------------------------------------------ */
40
+ /**
41
+ * The selected ranges. For an ordinary selection there is exactly one; for a
42
+ * table selection there is one per cell.
43
+ *
44
+ * `selection.from` and `selection.to` are the bounds of a SINGLE range, not the
45
+ * union of all of them. That is fine for a text selection and silently wrong for
46
+ * a `CellSelection` from prosemirror-tables: selecting a column of a 3x2 table
47
+ * gives `ranges.length === 2` while `from`/`to` describe the interior of one
48
+ * cell. Every command that read those two numbers therefore coloured, sized or
49
+ * cleared one cell of a selected column and left the others untouched.
50
+ *
51
+ * What made it a bug rather than an unimplemented feature is that `toggleBold`
52
+ * was never affected: it goes through prosemirror-commands' `toggleMark`, which
53
+ * iterates ranges. So on one identical selection, Bold worked and colour did
54
+ * not, which an author reads as the editor failing at random rather than as a
55
+ * limitation they could work around.
56
+ *
57
+ * A named accessor rather than inlining `state.selection.ranges` everywhere,
58
+ * because the point that needs to survive future edits is "never destructure
59
+ * from/to for an operation over a selection", and a function is where that
60
+ * reasoning can live.
61
+ */
62
+ function selectedRanges(state) {
63
+ return state.selection.ranges;
64
+ }
65
+ /** True when any selected range carries this mark. */
66
+ function anyRangeHasMark(state, type) {
67
+ return selectedRanges(state).some((range) => state.doc.rangeHasMark(range.$from.pos, range.$to.pos, type));
68
+ }
69
+ /** Remove a mark from every selected range, optionally re-adding it. */
70
+ function replaceMarkInRanges(state, tr, type, attrs) {
71
+ for (const range of selectedRanges(state)) {
72
+ const { pos: from } = range.$from;
73
+ const { pos: to } = range.$to;
74
+ tr.removeMark(from, to, type);
75
+ if (attrs !== null)
76
+ tr.addMark(from, to, type.create(attrs));
77
+ }
44
78
  }
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
- };
79
+ /**
80
+ * The attributes to give a node being retyped, so that changing a block's TYPE
81
+ * does not also change everything else about it.
82
+ *
83
+ * Every attribute the destination type also declares comes across unchanged;
84
+ * anything it does not declare is dropped, because there is nowhere to put it.
85
+ * The one that matters most is `__openleafCarried`, the residue holding `class`,
86
+ * every `data-*` and all unmodelled CSS -- and `dir`, which schema.ts makes a
87
+ * first-class attribute precisely because dropping it silently breaks Arabic,
88
+ * Hebrew and Persian content.
89
+ *
90
+ * Three separate mechanisms used to lose all of it, which is why the fix is a
91
+ * shared helper rather than four local patches. `setHeading` hand-built its
92
+ * attribute object and listed four names, so anything added to the schema
93
+ * afterwards fell back to a default. `setParagraph` and `toggleCodeBlock` passed
94
+ * no attributes at all. `toggleList` called `setNodeMarkup(pos, type)` and
95
+ * prosemirror-transform does `type.create(undefined, ...)` there -- it does NOT
96
+ * fall back to the old node's attributes, which is the part that reads as though
97
+ * it should be safe and is not.
98
+ *
99
+ * It also silently broke `formats.ts`: `setBlockClass` writes the chosen format
100
+ * class into the carried residue, so applying a format and then changing the
101
+ * heading level removed the format.
102
+ */
103
+ function carryOver(from, to, overrides = {}) {
104
+ const out = {};
105
+ const declared = new Set(Object.keys(to.spec.attrs ?? {}));
106
+ for (const name of declared) {
107
+ if (name in from.attrs)
108
+ out[name] = from.attrs[name];
109
+ }
110
+ /*
111
+ * An `id` outlives the block type it was written on.
112
+ *
113
+ * `heading` declares `id` and `paragraph` does not, so demoting a heading to
114
+ * a paragraph dropped it -- and an id on a heading is not decoration, it is
115
+ * the target of every in-page link and every table-of-contents entry pointing
116
+ * at that section. Changing a block's type is not a request to break the
117
+ * links to it.
118
+ *
119
+ * It moves into the carried residue rather than into an attribute, because
120
+ * the destination type has no such attribute to move it into. That is exactly
121
+ * what residue is for, and it is already where a `<p id="x">` parsed from
122
+ * stored content keeps its id, so both routes arrive at one representation.
123
+ */
124
+ if (!declared.has('id') && declared.has(CARRIED_ATTR)) {
125
+ const id = from.attrs['id'];
126
+ if (typeof id === 'string' && id !== '') {
127
+ const carried = { ...(out[CARRIED_ATTR] ?? {}) };
128
+ if (carried['id'] === undefined)
129
+ carried['id'] = id;
130
+ out[CARRIED_ATTR] = carried;
131
+ }
132
+ }
133
+ return { ...out, ...overrides };
53
134
  }
54
- /** Build a command that needs one node type, declining when it is absent. */
55
- function nodeCommand(name, build) {
56
- return (state, dispatch, view) => {
135
+ /**
136
+ * `setBlockType`, with per-node attributes and every selected range.
137
+ *
138
+ * prosemirror-commands' own `setBlockType` takes ONE attribute object and
139
+ * applies it to every block in the selection, which cannot express "keep each
140
+ * block's own residue". prosemirror-transform's `Transform.setBlockType` does
141
+ * accept a function, so the applicability check and the loop are reproduced here
142
+ * against that. The shape is deliberately the same as upstream's so the two stay
143
+ * comparable.
144
+ */
145
+ function setBlockTypeCarrying(name, overrides = () => ({})) {
146
+ return (state, dispatch) => {
57
147
  const type = nodeIn(state, name);
58
148
  if (!type)
59
149
  return false;
60
- return build(type)(state, dispatch, view);
150
+ const attrsFor = (node) => carryOver(node, type, overrides(node));
151
+ let applicable = false;
152
+ for (const range of selectedRanges(state)) {
153
+ if (applicable)
154
+ break;
155
+ state.doc.nodesBetween(range.$from.pos, range.$to.pos, (node, pos) => {
156
+ if (applicable)
157
+ return false;
158
+ // `hasMarkup` is what makes "set paragraph on a plain paragraph" report
159
+ // false, and it has to be asked with the attributes this call would
160
+ // actually write -- otherwise a no-op conversion looks like a change.
161
+ if (!node.isTextblock || node.hasMarkup(type, attrsFor(node)))
162
+ return;
163
+ // A figure is a textblock (`content: 'inline+'`) but its children are an
164
+ // image and a caption -- retyping it produces an <h2> holding a
165
+ // <figcaption>. `heading` and `paragraph` also accept inline content, so
166
+ // `validContent` is not enough; isolating is what marks a textblock that
167
+ // is not a retypable paragraph.
168
+ if (node.type.spec.isolating)
169
+ return;
170
+ // `code_block` cannot hold an image or a figcaption, which is where the
171
+ // TransformError came from when this check was missing.
172
+ if (!type.validContent(node.content))
173
+ return;
174
+ if (node.type === type) {
175
+ applicable = true;
176
+ return;
177
+ }
178
+ const $pos = state.doc.resolve(pos);
179
+ const index = $pos.index();
180
+ applicable = $pos.parent.canReplaceWith(index, index + 1, type);
181
+ return;
182
+ });
183
+ }
184
+ if (!applicable)
185
+ return false;
186
+ if (dispatch) {
187
+ const tr = state.tr;
188
+ for (const range of selectedRanges(state)) {
189
+ // Per node, not the whole range: `setBlockType` on a span that also
190
+ // covers a figure would still try to retype that figure, which is the
191
+ // TransformError a select-all used to throw even after the
192
+ // applicability check started skipping it.
193
+ state.doc.nodesBetween(range.$from.pos, range.$to.pos, (node, pos) => {
194
+ if (!node.isTextblock || node.hasMarkup(type, attrsFor(node)))
195
+ return;
196
+ if (node.type.spec.isolating)
197
+ return;
198
+ if (!type.validContent(node.content))
199
+ return;
200
+ if (node.type !== type) {
201
+ const $pos = state.doc.resolve(pos);
202
+ const index = $pos.index();
203
+ if (!$pos.parent.canReplaceWith(index, index + 1, type))
204
+ return;
205
+ }
206
+ const mappedFrom = tr.mapping.map(pos);
207
+ const mappedTo = tr.mapping.map(pos + node.nodeSize);
208
+ tr.setBlockType(mappedFrom, mappedTo, type, attrsFor);
209
+ });
210
+ }
211
+ dispatch(tr.scrollIntoView());
212
+ }
213
+ return true;
61
214
  };
62
215
  }
63
216
  /* ------------------------------------------------------------------ *
@@ -75,11 +228,11 @@ export function isMarkActive(state, markName) {
75
228
  const type = markIn(state, markName);
76
229
  if (!type)
77
230
  return false;
78
- const { from, $from, to, empty } = state.selection;
231
+ const { $from, empty } = state.selection;
79
232
  if (empty) {
80
233
  return !!type.isInSet(state.storedMarks ?? $from.marks());
81
234
  }
82
- return state.doc.rangeHasMark(from, to, type);
235
+ return anyRangeHasMark(state, type);
83
236
  }
84
237
  /** Is the selection inside a node of this type (with these attributes)? */
85
238
  export function isNodeActive(state, nodeName, attrs) {
@@ -105,16 +258,7 @@ export function isNodeActive(state, nodeName, attrs) {
105
258
  }
106
259
  /** Can a node of this type be inserted at the current selection? */
107
260
  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;
261
+ return canInsertNode(state, nodeName);
118
262
  }
119
263
  /** The heading level at the cursor, or null when not in a heading. */
120
264
  export function activeHeadingLevel(state) {
@@ -134,20 +278,22 @@ export function activeLink(state) {
134
278
  const type = markIn(state, 'link');
135
279
  if (!type)
136
280
  return null;
137
- const { $from, empty, from, to } = state.selection;
281
+ const { $from, empty } = state.selection;
138
282
  if (empty) {
139
283
  const found = type.isInSet($from.marks());
140
284
  return found ? found.attrs : null;
141
285
  }
142
286
  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
- });
287
+ for (const range of selectedRanges(state)) {
288
+ state.doc.nodesBetween(range.$from.pos, range.$to.pos, (child) => {
289
+ if (attrs)
290
+ return false;
291
+ const found = type.isInSet(child.marks);
292
+ if (found)
293
+ attrs = found.attrs;
294
+ return true;
295
+ });
296
+ }
151
297
  return attrs;
152
298
  }
153
299
  /* ------------------------------------------------------------------ *
@@ -163,21 +309,19 @@ export const toggleSuperscript = markCommand('superscript', toggleMark);
163
309
  /* ------------------------------------------------------------------ *
164
310
  * Block commands
165
311
  * ------------------------------------------------------------------ */
166
- export const setParagraph = nodeCommand('paragraph', (type) => setBlockType(type));
312
+ export const setParagraph = setBlockTypeCarrying('paragraph');
313
+ /**
314
+ * `level` is the only attribute this command decides; everything else about the
315
+ * block is the author's and travels with it.
316
+ *
317
+ * `id` needs no special case any more, and that is a property of the schema
318
+ * rather than a shortcut: `paragraph` does not declare an `id`, so `carryOver`
319
+ * cannot promote one from a paragraph, while a heading's own `id` is declared
320
+ * and does come across. A paragraph stored as `<p id="x">` keeps its id anyway,
321
+ * through the carried residue, which is where an unmodelled attribute belongs.
322
+ */
167
323
  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
- };
324
+ return setBlockTypeCarrying('heading', () => ({ level }));
181
325
  }
182
326
  /**
183
327
  * Toggle a heading level: applying the level you are already in returns the
@@ -194,7 +338,7 @@ export function toggleHeading(level) {
194
338
  export const toggleCodeBlock = (state, dispatch, view) => {
195
339
  if (isNodeActive(state, 'code_block'))
196
340
  return setParagraph(state, dispatch, view);
197
- return nodeCommand('code_block', (type) => setBlockType(type))(state, dispatch, view);
341
+ return setBlockTypeCarrying('code_block')(state, dispatch, view);
198
342
  };
199
343
  export const wrapInBlockquote = nodeCommand('blockquote', (type) => wrapIn(type));
200
344
  export const toggleBlockquote = (state, dispatch, view) => {
@@ -271,8 +415,18 @@ function toggleList(target) {
271
415
  const type = nodeIn(state, target);
272
416
  if (!type)
273
417
  return false;
274
- if (dispatch)
275
- dispatch(state.tr.setNodeMarkup(enclosing.pos, type).scrollIntoView());
418
+ const node = state.doc.nodeAt(enclosing.pos);
419
+ if (!node)
420
+ return false;
421
+ // Attributes must be passed explicitly. `setNodeMarkup(pos, type)` reads
422
+ // as "change the type and leave the rest alone" and does not do that:
423
+ // prosemirror-transform calls `type.create(undefined, ...)`, so every
424
+ // attribute silently reverts to its default. `start` is genuinely dropped
425
+ // here because a `<ul>` has no such concept; `listStyle` and the residue
426
+ // holding `class` and every `data-*` are not.
427
+ if (dispatch) {
428
+ dispatch(state.tr.setNodeMarkup(enclosing.pos, type, carryOver(node, type)).scrollIntoView());
429
+ }
276
430
  return true;
277
431
  }
278
432
  return nodeCommand(target, (type) => wrapInList(type))(state, dispatch, view);
@@ -353,14 +507,21 @@ export function activeListStyle(state) {
353
507
  */
354
508
  function blocksWithAttr(state, attr) {
355
509
  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
- });
510
+ // Deduplicated by position: callers write one `setNodeMarkup` per entry, and
511
+ // two writes to the same position in one transaction would have the second
512
+ // one built from a node the first already replaced.
513
+ const seen = new Set();
514
+ for (const range of selectedRanges(state)) {
515
+ state.doc.nodesBetween(range.$from.pos, range.$to.pos, (node, pos) => {
516
+ if (!node.isTextblock)
517
+ return true;
518
+ if (Object.hasOwn(node.attrs, attr) && !seen.has(pos)) {
519
+ seen.add(pos);
520
+ found.push({ pos, node });
521
+ }
522
+ return false;
523
+ });
524
+ }
364
525
  return found;
365
526
  }
366
527
  function uniformBlockAttr(state, attr) {
@@ -516,7 +677,7 @@ function colorCommand(name, color) {
516
677
  const value = color === null ? null : safeColor(color);
517
678
  if (color !== null && value === null)
518
679
  return false;
519
- const { empty, from, to } = state.selection;
680
+ const { empty } = state.selection;
520
681
  if (empty) {
521
682
  const current = state.storedMarks ?? state.selection.$from.marks();
522
683
  const stripped = current.filter((mark) => mark.type !== type);
@@ -527,12 +688,11 @@ function colorCommand(name, color) {
527
688
  }
528
689
  return true;
529
690
  }
530
- if (value === null && !state.doc.rangeHasMark(from, to, type))
691
+ if (value === null && !anyRangeHasMark(state, type))
531
692
  return false;
532
693
  if (dispatch) {
533
- const tr = state.tr.removeMark(from, to, type);
534
- if (value !== null)
535
- tr.addMark(from, to, type.create({ color: value }));
694
+ const tr = state.tr;
695
+ replaceMarkInRanges(state, tr, type, value === null ? null : { color: value });
536
696
  dispatch(tr.scrollIntoView());
537
697
  }
538
698
  return true;
@@ -543,24 +703,37 @@ function activeColor(state, name) {
543
703
  const type = markIn(state, name);
544
704
  if (!type)
545
705
  return null;
546
- const { empty, $from, from, to } = state.selection;
706
+ const { empty, $from } = state.selection;
547
707
  if (empty) {
548
708
  const found = type.isInSet(state.storedMarks ?? $from.marks());
549
709
  return found ? found.attrs['color'] : null;
550
710
  }
711
+ // `seen` is load-bearing: `null` means both "not visited yet" and "unmarked
712
+ // text", so without a separate flag a selection that starts unmarked and then
713
+ // hits a coloured run would report the colour as uniform.
714
+ let seen = false;
551
715
  let value = null;
552
716
  let uniform = true;
553
- state.doc.nodesBetween(from, to, (child) => {
554
- if (!child.isText)
717
+ // Over every range, for the same reason the command below writes over every
718
+ // range: reading one cell of a selected column and reporting it as the state
719
+ // of all of them puts a colour in the swatch that most of the selection does
720
+ // not have.
721
+ for (const range of selectedRanges(state)) {
722
+ state.doc.nodesBetween(range.$from.pos, range.$to.pos, (child) => {
723
+ if (!child.isText)
724
+ return true;
725
+ const found = type.isInSet(child.marks);
726
+ const colour = found ? found.attrs['color'] : null;
727
+ if (!seen) {
728
+ value = colour;
729
+ seen = true;
730
+ }
731
+ else if (colour !== value) {
732
+ uniform = false;
733
+ }
555
734
  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
- });
735
+ });
736
+ }
564
737
  // Mixed colours report null for the same reason mixed alignment does: a
565
738
  // swatch showing one of them invites the author to act on all of them.
566
739
  return uniform ? value : null;
@@ -587,7 +760,7 @@ function attrMarkCommand(name, attr, value, validate) {
587
760
  const next = value === null ? null : validate(value);
588
761
  if (value !== null && next === null)
589
762
  return false;
590
- const { empty, from, to } = state.selection;
763
+ const { empty } = state.selection;
591
764
  if (empty) {
592
765
  const current = state.storedMarks ?? state.selection.$from.marks();
593
766
  const stripped = current.filter((mark) => mark.type !== type);
@@ -598,12 +771,11 @@ function attrMarkCommand(name, attr, value, validate) {
598
771
  }
599
772
  return true;
600
773
  }
601
- if (next === null && !state.doc.rangeHasMark(from, to, type))
774
+ if (next === null && !anyRangeHasMark(state, type))
602
775
  return false;
603
776
  if (dispatch) {
604
- const tr = state.tr.removeMark(from, to, type);
605
- if (next !== null)
606
- tr.addMark(from, to, type.create({ [attr]: next }));
777
+ const tr = state.tr;
778
+ replaceMarkInRanges(state, tr, type, next === null ? null : { [attr]: next });
607
779
  dispatch(tr.scrollIntoView());
608
780
  }
609
781
  return true;
@@ -613,24 +785,33 @@ function activeMarkAttr(state, name, attr) {
613
785
  const type = markIn(state, name);
614
786
  if (!type)
615
787
  return null;
616
- const { empty, $from, from, to } = state.selection;
788
+ const { empty, $from } = state.selection;
617
789
  if (empty) {
618
790
  const found = type.isInSet(state.storedMarks ?? $from.marks());
619
791
  return found ? found.attrs[attr] : null;
620
792
  }
793
+ // Same sentinel problem as activeColor: without `seen`, an unmarked run at
794
+ // the start of the selection is overwritten by the first marked value and
795
+ // the dropdown claims the whole range has that family or size.
796
+ let seen = false;
621
797
  let value = null;
622
798
  let uniform = true;
623
- state.doc.nodesBetween(from, to, (child) => {
624
- if (!child.isText)
799
+ for (const range of selectedRanges(state)) {
800
+ state.doc.nodesBetween(range.$from.pos, range.$to.pos, (child) => {
801
+ if (!child.isText)
802
+ return true;
803
+ const found = type.isInSet(child.marks);
804
+ const current = found ? found.attrs[attr] : null;
805
+ if (!seen) {
806
+ value = current;
807
+ seen = true;
808
+ }
809
+ else if (current !== value) {
810
+ uniform = false;
811
+ }
625
812
  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
- });
813
+ });
814
+ }
634
815
  return uniform ? value : null;
635
816
  }
636
817
  export function setFontFamily(family) {
@@ -655,12 +836,14 @@ export function activeLanguage(state) {
655
836
  * Strip character formatting and block decoration from the selection.
656
837
  *
657
838
  * 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.
839
+ * content. Language stays: it is the same kind of fact as direction, only
840
+ * modelled as a mark (`<span lang>`) rather than a node attribute. Headings
841
+ * and lists stay: they are structure. Alignment, indent, line height, fonts,
842
+ * colours, and the common character marks go.
660
843
  */
661
844
  export const clearFormatting = (state, dispatch) => {
662
- const keep = new Set(['link']);
663
- const { empty, from, to, $from } = state.selection;
845
+ const keep = new Set(['link', 'language']);
846
+ const { empty, $from } = state.selection;
664
847
  const marks = Object.values(state.schema.marks).filter((type) => !keep.has(type.name));
665
848
  if (empty) {
666
849
  const current = state.storedMarks ?? $from.marks();
@@ -687,7 +870,7 @@ export const clearFormatting = (state, dispatch) => {
687
870
  }
688
871
  let hasMarks = false;
689
872
  for (const type of marks) {
690
- if (state.doc.rangeHasMark(from, to, type))
873
+ if (anyRangeHasMark(state, type))
691
874
  hasMarks = true;
692
875
  }
693
876
  const blocks = blocksWithAttr(state, 'align');
@@ -698,8 +881,11 @@ export const clearFormatting = (state, dispatch) => {
698
881
  return false;
699
882
  if (dispatch) {
700
883
  let tr = state.tr;
701
- for (const type of marks)
702
- tr = tr.removeMark(from, to, type);
884
+ for (const type of marks) {
885
+ for (const range of selectedRanges(state)) {
886
+ tr = tr.removeMark(range.$from.pos, range.$to.pos, type);
887
+ }
888
+ }
703
889
  for (const { pos, node } of blocks) {
704
890
  tr = tr.setNodeMarkup(pos, undefined, {
705
891
  ...node.attrs,
@@ -712,241 +898,7 @@ export const clearFormatting = (state, dispatch) => {
712
898
  }
713
899
  return true;
714
900
  };
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
- }
901
+ export { insertAudio, insertDetails, insertHtml, insertIframe, insertImage, insertNamedAnchor, insertNonBreakingSpace, insertPageBreak, insertText, insertVideo, selectedImage, selectedMedia, setHeadingId, setLink, unsetLink, updateImage, updateMedia, } from './insert-commands.js';
950
902
  /* ------------------------------------------------------------------ *
951
903
  * History
952
904
  * ------------------------------------------------------------------ */