@malva-ui/editor 0.1.13 → 0.1.14

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 (134) hide show
  1. package/README.md +5 -5
  2. package/fesm2022/malva-ui-editor-ai.mjs +17 -0
  3. package/fesm2022/malva-ui-editor-ai.mjs.map +1 -0
  4. package/fesm2022/malva-ui-editor.mjs +10227 -0
  5. package/fesm2022/malva-ui-editor.mjs.map +1 -0
  6. package/package.json +35 -26
  7. package/types/malva-ui-editor-ai.d.ts +1 -0
  8. package/types/malva-ui-editor.d.ts +2995 -0
  9. package/ai/index.d.ts +0 -12
  10. package/ai/malva-ui-editor-ai.d.ts +0 -5
  11. package/esm2022/ai/index.js +0 -12
  12. package/esm2022/ai/index.js.map +0 -1
  13. package/esm2022/ai/malva-ui-editor-ai.js +0 -5
  14. package/esm2022/ai/malva-ui-editor-ai.js.map +0 -1
  15. package/esm2022/index.js +0 -37
  16. package/esm2022/index.js.map +0 -1
  17. package/esm2022/lib/ai/editor-ai-actions.js +0 -69
  18. package/esm2022/lib/ai/editor-ai-actions.js.map +0 -1
  19. package/esm2022/lib/ai/editor-ai-context.js +0 -612
  20. package/esm2022/lib/ai/editor-ai-context.js.map +0 -1
  21. package/esm2022/lib/ai/editor-ai-menu.js +0 -288
  22. package/esm2022/lib/ai/editor-ai-menu.js.map +0 -1
  23. package/esm2022/lib/ai/editor-ai-review-bar.js +0 -223
  24. package/esm2022/lib/ai/editor-ai-review-bar.js.map +0 -1
  25. package/esm2022/lib/ai/editor-ai-stream.js +0 -677
  26. package/esm2022/lib/ai/editor-ai-stream.js.map +0 -1
  27. package/esm2022/lib/ai/editor-ai-suggestions.js +0 -751
  28. package/esm2022/lib/ai/editor-ai-suggestions.js.map +0 -1
  29. package/esm2022/lib/ai/editor-ai.tokens.js +0 -10
  30. package/esm2022/lib/ai/editor-ai.tokens.js.map +0 -1
  31. package/esm2022/lib/ai/editor-ai.types.js +0 -1
  32. package/esm2022/lib/ai/editor-ai.types.js.map +0 -1
  33. package/esm2022/lib/editor/editor.js +0 -985
  34. package/esm2022/lib/editor/editor.js.map +0 -1
  35. package/esm2022/lib/editor-extension-preflight.js +0 -83
  36. package/esm2022/lib/editor-extension-preflight.js.map +0 -1
  37. package/esm2022/lib/editor-serialization.js +0 -152
  38. package/esm2022/lib/editor-serialization.js.map +0 -1
  39. package/esm2022/lib/editor-toolbar-context.js +0 -259
  40. package/esm2022/lib/editor-toolbar-context.js.map +0 -1
  41. package/esm2022/lib/editor.tokens.js +0 -10
  42. package/esm2022/lib/editor.tokens.js.map +0 -1
  43. package/esm2022/lib/editor.types.js +0 -48
  44. package/esm2022/lib/editor.types.js.map +0 -1
  45. package/esm2022/lib/extensions/editor-block-handle.js +0 -760
  46. package/esm2022/lib/extensions/editor-block-handle.js.map +0 -1
  47. package/esm2022/lib/extensions/editor-extensions.js +0 -321
  48. package/esm2022/lib/extensions/editor-extensions.js.map +0 -1
  49. package/esm2022/lib/extensions/editor-markdown-compatibility.js +0 -162
  50. package/esm2022/lib/extensions/editor-markdown-compatibility.js.map +0 -1
  51. package/esm2022/lib/extensions/editor-upload-placeholder.js +0 -156
  52. package/esm2022/lib/extensions/editor-upload-placeholder.js.map +0 -1
  53. package/esm2022/lib/status/editor-status.js +0 -64
  54. package/esm2022/lib/status/editor-status.js.map +0 -1
  55. package/esm2022/lib/toolbar/editor-alignment.js +0 -203
  56. package/esm2022/lib/toolbar/editor-alignment.js.map +0 -1
  57. package/esm2022/lib/toolbar/editor-block-insert.js +0 -101
  58. package/esm2022/lib/toolbar/editor-block-insert.js.map +0 -1
  59. package/esm2022/lib/toolbar/editor-color-control.js +0 -71
  60. package/esm2022/lib/toolbar/editor-color-control.js.map +0 -1
  61. package/esm2022/lib/toolbar/editor-command-button.js +0 -115
  62. package/esm2022/lib/toolbar/editor-command-button.js.map +0 -1
  63. package/esm2022/lib/toolbar/editor-heading.js +0 -266
  64. package/esm2022/lib/toolbar/editor-heading.js.map +0 -1
  65. package/esm2022/lib/toolbar/editor-highlight.js +0 -84
  66. package/esm2022/lib/toolbar/editor-highlight.js.map +0 -1
  67. package/esm2022/lib/toolbar/editor-image-upload-dialog.js +0 -289
  68. package/esm2022/lib/toolbar/editor-image-upload-dialog.js.map +0 -1
  69. package/esm2022/lib/toolbar/editor-image-upload.js +0 -315
  70. package/esm2022/lib/toolbar/editor-image-upload.js.map +0 -1
  71. package/esm2022/lib/toolbar/editor-inline-marks.js +0 -124
  72. package/esm2022/lib/toolbar/editor-inline-marks.js.map +0 -1
  73. package/esm2022/lib/toolbar/editor-link.js +0 -265
  74. package/esm2022/lib/toolbar/editor-link.js.map +0 -1
  75. package/esm2022/lib/toolbar/editor-list.js +0 -249
  76. package/esm2022/lib/toolbar/editor-list.js.map +0 -1
  77. package/esm2022/lib/toolbar/editor-table.js +0 -292
  78. package/esm2022/lib/toolbar/editor-table.js.map +0 -1
  79. package/esm2022/lib/toolbar/editor-text-color.js +0 -82
  80. package/esm2022/lib/toolbar/editor-text-color.js.map +0 -1
  81. package/esm2022/lib/toolbar/editor-toolbar-widget.js +0 -41
  82. package/esm2022/lib/toolbar/editor-toolbar-widget.js.map +0 -1
  83. package/esm2022/lib/toolbar/editor-toolbar.defs.js +0 -60
  84. package/esm2022/lib/toolbar/editor-toolbar.defs.js.map +0 -1
  85. package/esm2022/lib/toolbar/editor-toolbar.js +0 -826
  86. package/esm2022/lib/toolbar/editor-toolbar.js.map +0 -1
  87. package/esm2022/lib/toolbar/editor-undo-redo.js +0 -72
  88. package/esm2022/lib/toolbar/editor-undo-redo.js.map +0 -1
  89. package/esm2022/lib/toolbar/editor-zoom.js +0 -330
  90. package/esm2022/lib/toolbar/editor-zoom.js.map +0 -1
  91. package/esm2022/lib/upload/editor-image-upload-coordinator.js +0 -480
  92. package/esm2022/lib/upload/editor-image-upload-coordinator.js.map +0 -1
  93. package/esm2022/malva-ui-editor.js +0 -5
  94. package/esm2022/malva-ui-editor.js.map +0 -1
  95. package/index.d.ts +0 -35
  96. package/lib/ai/editor-ai-actions.d.ts +0 -27
  97. package/lib/ai/editor-ai-context.d.ts +0 -307
  98. package/lib/ai/editor-ai-menu.d.ts +0 -142
  99. package/lib/ai/editor-ai-review-bar.d.ts +0 -116
  100. package/lib/ai/editor-ai-stream.d.ts +0 -207
  101. package/lib/ai/editor-ai-suggestions.d.ts +0 -219
  102. package/lib/ai/editor-ai.tokens.d.ts +0 -10
  103. package/lib/ai/editor-ai.types.d.ts +0 -176
  104. package/lib/editor/editor.d.ts +0 -243
  105. package/lib/editor-extension-preflight.d.ts +0 -23
  106. package/lib/editor-serialization.d.ts +0 -74
  107. package/lib/editor-toolbar-context.d.ts +0 -116
  108. package/lib/editor.tokens.d.ts +0 -6
  109. package/lib/editor.types.d.ts +0 -194
  110. package/lib/extensions/editor-block-handle.d.ts +0 -59
  111. package/lib/extensions/editor-extensions.d.ts +0 -165
  112. package/lib/extensions/editor-markdown-compatibility.d.ts +0 -11
  113. package/lib/extensions/editor-upload-placeholder.d.ts +0 -75
  114. package/lib/status/editor-status.d.ts +0 -24
  115. package/lib/toolbar/editor-alignment.d.ts +0 -61
  116. package/lib/toolbar/editor-block-insert.d.ts +0 -26
  117. package/lib/toolbar/editor-color-control.d.ts +0 -32
  118. package/lib/toolbar/editor-command-button.d.ts +0 -29
  119. package/lib/toolbar/editor-heading.d.ts +0 -68
  120. package/lib/toolbar/editor-highlight.d.ts +0 -21
  121. package/lib/toolbar/editor-image-upload-dialog.d.ts +0 -96
  122. package/lib/toolbar/editor-image-upload.d.ts +0 -71
  123. package/lib/toolbar/editor-inline-marks.d.ts +0 -30
  124. package/lib/toolbar/editor-link.d.ts +0 -78
  125. package/lib/toolbar/editor-list.d.ts +0 -67
  126. package/lib/toolbar/editor-table.d.ts +0 -133
  127. package/lib/toolbar/editor-text-color.d.ts +0 -21
  128. package/lib/toolbar/editor-toolbar-widget.d.ts +0 -19
  129. package/lib/toolbar/editor-toolbar.d.ts +0 -157
  130. package/lib/toolbar/editor-toolbar.defs.d.ts +0 -35
  131. package/lib/toolbar/editor-undo-redo.d.ts +0 -26
  132. package/lib/toolbar/editor-zoom.d.ts +0 -108
  133. package/lib/upload/editor-image-upload-coordinator.d.ts +0 -55
  134. package/malva-ui-editor.d.ts +0 -5
@@ -1,760 +0,0 @@
1
- import { Extension } from '@tiptap/core';
2
- import { NodeSelection, Plugin, TextSelection } from '@tiptap/pm/state';
3
- /** @internal Class of the floating drop indicator. */
4
- const MLV_EDITOR_DROP_INDICATOR_CLASS = 'mlv-editor__drop-indicator';
5
- /** @internal Class of the off-screen wrapper handed to `setDragImage`. */
6
- const MLV_EDITOR_DRAG_GHOST_CLASS = 'mlv-editor__drag-ghost';
7
- /**
8
- * @internal Marks the editor DOM while a block drag is in flight, so the
9
- * stylesheet — not this file — owns the partition's transition and its
10
- * reduced-motion fallback. JavaScript writes only the `transform` itself.
11
- */
12
- const MLV_EDITOR_DRAGGING_CLASS = 'ProseMirror--block-dragging';
13
- /** @internal Marks the block currently on the cursor, dimmed in place. */
14
- const MLV_EDITOR_DRAG_SOURCE_CLASS = 'mlv-editor__block--dragging';
15
- /** @internal Duration of the post-drop settle, matching `--mlv-duration-normal`. */
16
- const MLV_EDITOR_SETTLE_DURATION = 200;
17
- /** @internal Sub-pixel movement not worth animating after a drop. */
18
- const MLV_EDITOR_SETTLE_EPSILON = 0.5;
19
- /** @internal Document position where the given top-level child begins. */
20
- function childStart(doc, index) {
21
- let position = 0;
22
- for (let i = 0; i < index; i += 1) {
23
- position += doc.child(i).nodeSize;
24
- }
25
- return position;
26
- }
27
- /**
28
- * @internal Document position `moveBlock({ from, to })` inserts at, expressed
29
- * against the document as it stands **before** the move.
30
- *
31
- * Shared by the command and the drop indicator so the line the user sees can
32
- * never point at a gap other than the one the drop actually uses.
33
- */
34
- function insertAnchor(doc, from, to) {
35
- return to > from
36
- ? childStart(doc, to) + doc.child(to).nodeSize
37
- : childStart(doc, to);
38
- }
39
- /** @internal Reads the live scale of the mount layer instead of trusting a CSS variable. */
40
- function layerScale(mount) {
41
- const width = mount.offsetWidth;
42
- if (!width)
43
- return 1;
44
- const scaled = mount.getBoundingClientRect().width;
45
- return scaled > 0 ? scaled / width : 1;
46
- }
47
- /**
48
- * @internal Top-level child index for one rendered child of the editor DOM, or
49
- * null when that child is not a top-level node at all.
50
- *
51
- * Indexing `view.dom.children` positionally would not do: widget decorations
52
- * are rendered children that own no document node, so the two sequences do not
53
- * line up. This library alone puts three of them at the top level — the drop
54
- * indicator below, `MlvEditorUploadPlaceholder`'s pending image, and
55
- * StarterKit's gap cursor — and any future block-level decoration joins them.
56
- *
57
- * `posAtDOM` on its own does not discriminate either, which is the whole
58
- * difficulty: a widget anchored in a top-level gap maps to a perfectly valid
59
- * index — the one belonging to the block it sits beside. So the resolved index
60
- * is handed back to `nodeDOM`, and only the element ProseMirror actually
61
- * rendered *for* that child may claim it. Containment rather than identity,
62
- * because an outer node decoration wraps a node's own DOM in another element.
63
- */
64
- function topLevelIndexOfDom(view, element) {
65
- let position;
66
- try {
67
- position = view.posAtDOM(element, 0);
68
- }
69
- catch {
70
- // `posAtDOM` throws for DOM the editor view does not own. A foreign child
71
- // must not take a pointer handler down with it.
72
- return null;
73
- }
74
- const doc = view.state.doc;
75
- const index = doc.resolve(position).index(0);
76
- if (index >= doc.childCount)
77
- return null;
78
- return element.contains(view.nodeDOM(childStart(doc, index))) ? index : null;
79
- }
80
- /**
81
- * @internal Top-level block the given viewport y falls on, or the nearest one
82
- * above it. Null for an empty document, or for a point the view cannot map.
83
- *
84
- * Resolves geometrically instead of through `EditorView.posAtCoords`, which
85
- * cannot serve this affordance at all. `.ProseMirror` is the
86
- * `max-inline-size: var(--mlv-editor-measure)` text column, so the gutter the
87
- * handle lives in is outside `view.dom`'s rect by construction — and
88
- * `posAtCoords` falls back to an `inRect(coords, view.dom.getBoundingClientRect())`
89
- * test and returns null for every point outside it, in a real browser as much
90
- * as under a test DOM.
91
- *
92
- * Rendered children lay out in document order, so the scan stops at the first
93
- * one starting below the pointer, costing one `getBoundingClientRect()` and
94
- * one index mapping per child down to the hovered one.
95
- *
96
- * Mapping inside the loop is what makes widget decorations skippable as a
97
- * category rather than by class name, and it is not free: `posAtDOM`,
98
- * `nodeDOM`, and `childStart` each walk linearly to the child they address, so
99
- * the scan down to index `k` is quadratic in `k` — measured at roughly 4x per
100
- * doubling, against 2x for the map-only-the-winner scan this replaced. That
101
- * one was abandoned because it could not tell a widget from a block, and
102
- * pairing a block's index with a widget's box drops the block on the wrong
103
- * side of its neighbour. Correctness first; the cost is only paid down to the
104
- * *hovered* child, so it is deep hovering in a long document that degrades.
105
- * Restoring the linear scan without losing the category skip means deferring
106
- * the mapping until a winner is picked and walking back over the few
107
- * consecutive widgets — the clamp above the first block is what makes that
108
- * more than a one-liner, so it is deliberately left as follow-up.
109
- */
110
- function blockAtPoint(view, clientY) {
111
- const children = view.dom.children;
112
- let candidate = null;
113
- for (let i = 0; i < children.length; i += 1) {
114
- const element = children[i];
115
- // A widget decoration owns no top-level node, and its box is a sliver
116
- // rather than a block's. Letting one become the candidate would pair a
117
- // neighbouring block's index with the widget's geometry, so
118
- // `dropTargetIndex` would take that block's midpoint from the wrong box
119
- // and land the drop on the wrong side of it — silently, in one undo step.
120
- const index = topLevelIndexOfDom(view, element);
121
- if (index === null)
122
- continue;
123
- const rect = element.getBoundingClientRect();
124
- if (clientY < rect.top) {
125
- // Above the first block clamps to it; in the gap between two blocks the
126
- // preceding one was already recorded on the previous iteration.
127
- candidate ??= { index, top: rect.top, bottom: rect.bottom };
128
- break;
129
- }
130
- candidate = { index, top: rect.top, bottom: rect.bottom };
131
- if (clientY <= rect.bottom)
132
- break;
133
- }
134
- return candidate;
135
- }
136
- /**
137
- * @internal Pre-move `to` index for dropping the block at `from` onto `hit`,
138
- * or null when that drop would leave the document unchanged.
139
- *
140
- * `blockAtPoint` clamps to the nearest block and carries no before/after bias,
141
- * so the hovered block's own midpoint decides which of its two edges the
142
- * dragged block would land on. That yields one of the `childCount + 1` gaps,
143
- * which is then folded back onto `moveBlock`'s pre-move index convention:
144
- * `to` names the block the moved one ends up *after* when moving down and
145
- * *before* when moving up, so both gaps bordering the source are no-ops.
146
- */
147
- function dropTargetIndex(hit, clientY, from) {
148
- const gap = clientY < (hit.top + hit.bottom) / 2 ? hit.index : hit.index + 1;
149
- const to = gap <= from ? gap : gap - 1;
150
- return to === from ? null : to;
151
- }
152
- /**
153
- * @internal Measures every top-level block once, in the mount's unscaled space.
154
- *
155
- * Taken at `dragstart` and read by everything downstream, which is a
156
- * correctness requirement rather than an optimisation. The partition applies
157
- * `transform` to blocks, and a transform *is* reflected in
158
- * `getBoundingClientRect()`. Resolving the target from live rects after moving
159
- * them would feed this plugin's own output back into its input: the gap opens,
160
- * the pointer is now over a different block, the target changes, the gap moves.
161
- * A snapshot taken before any transform breaks that loop.
162
- *
163
- * Mount-relative rather than viewport-relative so drag-autoscroll cannot
164
- * invalidate it — only the mount's own rect is re-read per event, one layout
165
- * read instead of one per block. Unscaled for the same reason the handle's
166
- * `top` is: a CSS length on a child of the zoom layer applies in that layer's
167
- * space, while `getBoundingClientRect()` reports scaled screen pixels.
168
- */
169
- function snapshotBlocks(view, mount) {
170
- const scale = layerScale(mount);
171
- const mountTop = mount.getBoundingClientRect().top;
172
- const slots = [];
173
- const children = view.dom.children;
174
- for (let i = 0; i < children.length; i += 1) {
175
- const element = children[i];
176
- // Same category check the hover scan uses: a widget decoration owns no
177
- // top-level node and must not occupy a slot, or the partition would shift
178
- // it as though it were a block.
179
- const index = topLevelIndexOfDom(view, element);
180
- if (index === null)
181
- continue;
182
- const rect = element.getBoundingClientRect();
183
- slots.push({
184
- index,
185
- top: (rect.top - mountTop) / scale,
186
- bottom: (rect.bottom - mountTop) / scale,
187
- element: element,
188
- });
189
- }
190
- return slots;
191
- }
192
- /**
193
- * @internal Slot the given mount-space y falls on, or the nearest one above it.
194
- *
195
- * Reproduces `blockAtPoint`'s clamping exactly — above the first block resolves
196
- * to the first, a point in the gap between two blocks resolves to the
197
- * preceding one, below the last resolves to the last — but against a static
198
- * array, so it is a binary search rather than a scan that maps every child it
199
- * passes.
200
- */
201
- function slotAt(slots, y) {
202
- if (slots.length === 0)
203
- return null;
204
- if (y < slots[0].top)
205
- return slots[0];
206
- let low = 0;
207
- let high = slots.length - 1;
208
- while (low < high) {
209
- const middle = Math.ceil((low + high) / 2);
210
- if (slots[middle].top <= y)
211
- low = middle;
212
- else
213
- high = middle - 1;
214
- }
215
- return slots[low];
216
- }
217
- /**
218
- * @internal Mount-space y of the boundary the given gap sits on, where `gap`
219
- * counts the `slots.length + 1` positions a block can be inserted at.
220
- *
221
- * Deliberately the *pre-partition* boundary: the indicator is offset into the
222
- * opened space by half the gap in CSS, so the gap's size stays a single token
223
- * and this never has to resolve a rem into pixels.
224
- */
225
- function gapOffset(slots, gap) {
226
- if (gap <= 0)
227
- return slots[0].top;
228
- if (gap >= slots.length)
229
- return slots[slots.length - 1].bottom;
230
- return (slots[gap - 1].bottom + slots[gap].top) / 2;
231
- }
232
- /**
233
- * @internal Deep-clones an element with every computed style written inline.
234
- *
235
- * The clone is handed to `setDragImage` from `document.body`, outside every
236
- * `.mlv-editor`/`.ProseMirror` selector that styles it in place. Inlining the
237
- * resolved styles makes it self-contained, which is sturdier than rebuilding
238
- * an ancestor class chain that would have to track whichever selectors happen
239
- * to exist. Adapted from `@tiptap/extension-drag-handle`'s `cloneElement`.
240
- *
241
- * Note what this cannot carry: computed styles are used values, and an
242
- * ancestor `transform: scale()` never reaches them. The editor's zoom is
243
- * applied to the wrapper instead — see `createGhostElement`.
244
- */
245
- function cloneWithComputedStyles(source) {
246
- const clone = source.cloneNode(true);
247
- const sources = [
248
- source,
249
- ...source.querySelectorAll('*'),
250
- ];
251
- const targets = [
252
- clone,
253
- ...clone.querySelectorAll('*'),
254
- ];
255
- sources.forEach((element, index) => {
256
- const target = targets[index];
257
- if (!target)
258
- return;
259
- const computed = getComputedStyle(element);
260
- let declarations = '';
261
- for (let i = 0; i < computed.length; i += 1) {
262
- declarations += `${computed[i]}:${computed.getPropertyValue(computed[i])};`;
263
- }
264
- target.style.cssText = declarations;
265
- });
266
- // The block's own margins would otherwise pad the drag image with empty
267
- // space, since the clone has no siblings to collapse against.
268
- clone.style.margin = '0';
269
- return clone;
270
- }
271
- /**
272
- * @internal Builds the off-screen element handed to `setDragImage`.
273
- *
274
- * `position: absolute; top: -10000px` keeps it rendered — which the drag image
275
- * requires — without ever being seen. `zoom` rather than `transform` carries
276
- * the editor's scale because it affects layout, so the wrapper's own box
277
- * reflects it before the browser rasterizes; a `transform` is honoured
278
- * inconsistently across engines for drag images.
279
- */
280
- function createGhostElement(source, scale) {
281
- const wrapper = document.createElement('div');
282
- wrapper.className = MLV_EDITOR_DRAG_GHOST_CLASS;
283
- wrapper.setAttribute('aria-hidden', 'true');
284
- wrapper.style.position = 'absolute';
285
- wrapper.style.top = '-10000px';
286
- wrapper.style.insetInlineStart = '0';
287
- wrapper.style.inlineSize = `${source.offsetWidth}px`;
288
- if (scale !== 1)
289
- wrapper.style.setProperty('zoom', String(scale));
290
- wrapper.appendChild(cloneWithComputedStyles(source));
291
- document.body.appendChild(wrapper);
292
- return wrapper;
293
- }
294
- /**
295
- * @internal Whether the user asked for reduced motion.
296
- *
297
- * Guarded because jsdom implements no `matchMedia`; absent the query, motion
298
- * is allowed and the animation itself is separately guarded on `animate`.
299
- */
300
- function prefersReducedMotion() {
301
- return (typeof matchMedia === 'function' &&
302
- matchMedia('(prefers-reduced-motion: reduce)').matches);
303
- }
304
- /** @internal Creates the single floating drop indicator element. */
305
- function createIndicatorElement() {
306
- const element = document.createElement('div');
307
- element.className = MLV_EDITOR_DROP_INDICATOR_CLASS;
308
- element.setAttribute('aria-hidden', 'true');
309
- element.setAttribute('data-visible', 'false');
310
- return element;
311
- }
312
- /** @internal Creates the single floating handle element. */
313
- function createHandleElement(label) {
314
- const element = document.createElement('div');
315
- element.className = 'mlv-editor__block-handle';
316
- element.setAttribute('draggable', 'true');
317
- element.setAttribute('aria-hidden', 'true');
318
- element.setAttribute('data-visible', 'false');
319
- element.title = label;
320
- element.innerHTML =
321
- // Sized from `.mlv-editor__block-handle svg`; no intrinsic width/height
322
- // attributes, so the icon scales with the block's tokens.
323
- '<svg viewBox="0 0 24 24" focusable="false" aria-hidden="true">' +
324
- '<circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/>' +
325
- '<circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/>' +
326
- '<circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>';
327
- return element;
328
- }
329
- /** @internal Top-level index containing the selection, or null for an empty document. */
330
- function topLevelIndexAtSelection(state) {
331
- if (state.doc.childCount === 0)
332
- return null;
333
- const index = state.selection.$from.index(0);
334
- return index < state.doc.childCount ? index : null;
335
- }
336
- /**
337
- * Tiptap extension supplying top-level block reordering.
338
- *
339
- * The pointer handle and the keyboard shortcuts both route through `moveBlock`,
340
- * so the two paths cannot drift apart. The handle itself is rendered by this
341
- * extension's ProseMirror plugin into the host-supplied mount container and is
342
- * never part of the document, so no serialized representation can contain it.
343
- */
344
- export const MlvEditorBlockHandle = Extension.create({
345
- name: 'mlvEditorBlockHandle',
346
- addOptions() {
347
- return {
348
- mount: () => null,
349
- label: () => 'Drag block',
350
- announceMove: () => undefined,
351
- enabled: () => true,
352
- };
353
- },
354
- addCommands() {
355
- return {
356
- moveBlock: ({ from, to }) => ({ state, tr, dispatch }) => {
357
- if (!this.options.enabled())
358
- return false;
359
- if (!Number.isInteger(from) || !Number.isInteger(to))
360
- return false;
361
- const total = state.doc.childCount;
362
- if (from < 0 || from >= total)
363
- return false;
364
- if (to < 0 || to >= total)
365
- return false;
366
- if (from === to)
367
- return false;
368
- if (!dispatch)
369
- return true;
370
- const node = state.doc.child(from);
371
- const start = childStart(state.doc, from);
372
- const end = start + node.nodeSize;
373
- const anchor = insertAnchor(state.doc, from, to);
374
- tr.delete(start, end);
375
- const insertAt = tr.mapping.map(anchor);
376
- tr.insert(insertAt, node);
377
- // The selection must land *inside* the moved node so a following
378
- // moveBlockUp/Down keeps acting on it. An atom has no position
379
- // inside it — `insertAt + 1` is already past a leaf such as
380
- // horizontalRule, and TextSelection.near's forward bias would then
381
- // walk into the next block — so select the node itself instead.
382
- tr.setSelection(node.isAtom && NodeSelection.isSelectable(node)
383
- ? NodeSelection.create(tr.doc, insertAt)
384
- : TextSelection.near(tr.doc.resolve(Math.min(insertAt + 1, tr.doc.content.size))));
385
- dispatch(tr);
386
- // Fires before the document actually changes: props `dispatch` is a
387
- // no-op and CommandManager runs the real `view.dispatch(tr)` only
388
- // after this command returns. A host callback therefore still sees
389
- // the pre-move `editor.state` and must rely on this payload alone.
390
- this.options.announceMove({
391
- type: node.type.name,
392
- position: to + 1,
393
- total,
394
- });
395
- return true;
396
- },
397
- moveBlockUp: () => ({ state, commands }) => {
398
- const index = topLevelIndexAtSelection(state);
399
- return (index !== null &&
400
- index > 0 &&
401
- commands.moveBlock({ from: index, to: index - 1 }));
402
- },
403
- moveBlockDown: () => ({ state, commands }) => {
404
- const index = topLevelIndexAtSelection(state);
405
- return (index !== null &&
406
- index < state.doc.childCount - 1 &&
407
- commands.moveBlock({ from: index, to: index + 1 }));
408
- },
409
- };
410
- },
411
- addKeyboardShortcuts() {
412
- return {
413
- 'Alt-Shift-ArrowUp': () => this.editor.commands.moveBlockUp(),
414
- 'Alt-Shift-ArrowDown': () => this.editor.commands.moveBlockDown(),
415
- };
416
- },
417
- addProseMirrorPlugins() {
418
- const options = this.options;
419
- // The plugin view receives an `EditorView`, which carries no command
420
- // interface; the drop path has to reach `moveBlock` through Tiptap.
421
- const editor = this.editor;
422
- return [
423
- new Plugin({
424
- view: (view) => {
425
- const mount = options.mount();
426
- if (!mount)
427
- return { destroy: () => undefined };
428
- const handle = createHandleElement(options.label());
429
- mount.appendChild(handle);
430
- // The indicator lives beside the handle rather than inside the
431
- // document. As a `Decoration.widget` it could never move: a widget
432
- // at a new position is a *different* decoration, so ProseMirror
433
- // destroys and rebuilds its DOM on every change, and an element in
434
- // normal flow has no `top` to transition anyway. Out here it is
435
- // also further from the document than a decoration ever was — the
436
- // mount is a sibling of `view.dom`, so no serializer can reach it.
437
- const indicator = createIndicatorElement();
438
- mount.appendChild(indicator);
439
- const hide = () => handle.setAttribute('data-visible', 'false');
440
- const onMouseMove = (event) => {
441
- if (!options.enabled())
442
- return hide();
443
- const hit = blockAtPoint(view, event.clientY);
444
- if (!hit)
445
- return hide();
446
- // `getBoundingClientRect()` reports scaled screen pixels, but a
447
- // CSS `top` on a child of the scaled layer is applied in the
448
- // layer's own unscaled space. Convert once, deriving the scale
449
- // from the DOM rather than `--mlv-editor-zoom`, so the handle
450
- // stays correct if anything else ever transforms this layer.
451
- const scale = layerScale(mount);
452
- const mountTop = mount.getBoundingClientRect().top;
453
- handle.style.top = `${(hit.top - mountTop) / scale}px`;
454
- handle.dataset['index'] = String(hit.index);
455
- handle.title = options.label();
456
- handle.setAttribute('data-visible', 'true');
457
- };
458
- /** Top-level index being dragged, or null when no drag is active. */
459
- let source = null;
460
- /** The dimmed element the drag started from. */
461
- let sourceElement = null;
462
- /** Geometry measured once at `dragstart`; empty while no drag runs. */
463
- let slots = [];
464
- /** The off-screen element handed to `setDragImage`. */
465
- let ghost = null;
466
- /** Gap the partition is currently opened at, or null when closed. */
467
- let gap = null;
468
- /** Converts a viewport y into the mount's own unscaled space. */
469
- const toMountSpace = (clientY) => (clientY - mount.getBoundingClientRect().top) / layerScale(mount);
470
- /**
471
- * Gap a resolved target index sits on. `dropTargetIndex` folds the
472
- * `slots.length + 1` gaps onto `moveBlock`'s pre-move convention,
473
- * where `to` names the block the moved one ends up after when
474
- * moving down; this is that fold inverted, so the previewed space
475
- * and the committed move come from one resolved index.
476
- */
477
- const gapForTarget = (to, from) => to <= from ? to : to + 1;
478
- /** Opens the partition at `next`, or closes it when null. */
479
- const setGap = (next) => {
480
- if (gap === next)
481
- return;
482
- gap = next;
483
- for (const slot of slots) {
484
- // The gap is a token rather than a number here, so the size
485
- // stays owned by the stylesheet.
486
- slot.element.style.transform =
487
- next !== null && slot.index >= next
488
- ? 'translateY(var(--mlv-editor-drop-gap))'
489
- : '';
490
- }
491
- if (next === null) {
492
- indicator.setAttribute('data-visible', 'false');
493
- return;
494
- }
495
- // The *pre-partition* boundary: the stylesheet offsets the line
496
- // into the opened space by half the gap, so this never has to
497
- // resolve the token into pixels.
498
- indicator.style.top = `${gapOffset(slots, next)}px`;
499
- indicator.setAttribute('data-visible', 'true');
500
- };
501
- const endDrag = () => {
502
- for (const slot of slots)
503
- slot.element.style.transform = '';
504
- indicator.setAttribute('data-visible', 'false');
505
- view.dom.classList.remove(MLV_EDITOR_DRAGGING_CLASS);
506
- sourceElement?.classList.remove(MLV_EDITOR_DRAG_SOURCE_CLASS);
507
- ghost?.remove();
508
- slots = [];
509
- gap = null;
510
- source = null;
511
- sourceElement = null;
512
- ghost = null;
513
- };
514
- /** Target index for the pointer, or null when the drop is a no-op. */
515
- const targetAt = (clientY) => {
516
- if (source === null)
517
- return null;
518
- const y = toMountSpace(clientY);
519
- const slot = slotAt(slots, y);
520
- return slot === null ? null : dropTargetIndex(slot, y, source);
521
- };
522
- /**
523
- * FLIP the blocks the move displaced: ProseMirror has already
524
- * re-rendered them at their new positions, so each is offset back
525
- * to where it was and released. Read after `moveBlock` because the
526
- * DOM update is applied synchronously inside `dispatch`.
527
- *
528
- * Which block was where is derived from the move's own permutation
529
- * rather than by matching DOM elements against their old boxes.
530
- * `moveBlock` deletes the dragged node and reinserts it, so its
531
- * element is not the one that was measured — identity matching
532
- * would silently skip the one block the user actually dragged, and
533
- * leave every other block sliding around it.
534
- *
535
- * Only the span between the two indices moved, so the walk is
536
- * bounded by the distance dragged rather than by document length.
537
- *
538
- * Uses the Web Animations API rather than a transition: nothing has
539
- * to force a reflow between setting and clearing the offset, the
540
- * animation cleans itself up, and it never collides with the inline
541
- * `transform` the partition writes.
542
- */
543
- const settle = (tops, from, to) => {
544
- if (prefersReducedMotion())
545
- return;
546
- // Pre-move index now occupying each post-move index.
547
- const order = tops.map((_, index) => index);
548
- order.splice(from, 1);
549
- order.splice(to, 0, from);
550
- const doc = view.state.doc;
551
- const scale = layerScale(mount);
552
- const mountTop = mount.getBoundingClientRect().top;
553
- const first = Math.min(from, to);
554
- const last = Math.max(from, to);
555
- for (let index = first; index <= last; index += 1) {
556
- const element = view.nodeDOM(childStart(doc, index));
557
- if (!(element instanceof HTMLElement))
558
- continue;
559
- // jsdom implements no WAAPI, and neither did older engines.
560
- if (typeof element.animate !== 'function')
561
- return;
562
- const previous = tops[order[index]];
563
- if (previous === undefined)
564
- continue;
565
- const current = (element.getBoundingClientRect().top - mountTop) / scale;
566
- const delta = previous - current;
567
- if (Math.abs(delta) < MLV_EDITOR_SETTLE_EPSILON)
568
- continue;
569
- element.animate([
570
- { transform: `translateY(${delta}px)` },
571
- { transform: 'translateY(0)' },
572
- ], {
573
- duration: MLV_EDITOR_SETTLE_DURATION,
574
- easing: 'cubic-bezier(0.2, 0, 0, 1)',
575
- });
576
- }
577
- };
578
- const onDragStart = (event) => {
579
- // `data-index` is absent until the first successful hover, and a
580
- // revoked `enabled()` must not start a drag that could never be
581
- // applied. Either way, refuse the drag outright.
582
- const attribute = handle.dataset['index'];
583
- const index = attribute === undefined ? NaN : Number(attribute);
584
- if (!options.enabled() || !Number.isInteger(index)) {
585
- event.preventDefault();
586
- return;
587
- }
588
- const measured = snapshotBlocks(view, mount);
589
- const slot = measured.find((candidate) => candidate.index === index);
590
- // The hovered index outliving its block means the snapshot and
591
- // `data-index` disagree; refusing is the same answer as a drag
592
- // that never hovered.
593
- if (!slot) {
594
- event.preventDefault();
595
- return;
596
- }
597
- slots = measured;
598
- source = index;
599
- // Firefox refuses to begin a drag whose dataTransfer carries no
600
- // data. The payload stays empty on purpose: the reorder replays
601
- // `source` through `moveBlock`, never a parsed slice, so this
602
- // drag cannot reparent into a list or a table cell and cannot be
603
- // dropped into a different editor.
604
- event.dataTransfer?.setData('text/plain', '');
605
- if (event.dataTransfer)
606
- event.dataTransfer.effectAllowed = 'move';
607
- // A `dragstart` that arrives while one is already recorded would
608
- // otherwise strand the previous drag image on `document.body`,
609
- // where nothing else reaches it.
610
- ghost?.remove();
611
- // Cloned *before* the source is dimmed: the clone carries
612
- // resolved computed styles, so dimming first would bake the
613
- // reduced opacity into the drag image.
614
- ghost = createGhostElement(slot.element, layerScale(mount));
615
- event.dataTransfer?.setDragImage(ghost, getComputedStyle(view.dom).direction === 'rtl'
616
- ? ghost.offsetWidth
617
- : 0, 0);
618
- sourceElement = slot.element;
619
- sourceElement.classList.add(MLV_EDITOR_DRAG_SOURCE_CLASS);
620
- view.dom.classList.add(MLV_EDITOR_DRAGGING_CLASS);
621
- };
622
- const onDragOver = (event) => {
623
- if (source === null)
624
- return;
625
- // Claimed in the capture phase on the mount, so ProseMirror's own
626
- // `view.dom` drop handler never sees the event and cannot fall
627
- // back to its native slice path.
628
- event.stopPropagation();
629
- event.preventDefault();
630
- if (event.dataTransfer)
631
- event.dataTransfer.dropEffect = 'move';
632
- const to = targetAt(event.clientY);
633
- setGap(to === null ? null : gapForTarget(to, source));
634
- };
635
- /**
636
- * Whether a `dragleave` is the pointer leaving the mount outright,
637
- * rather than crossing between two elements inside it — the event
638
- * fires for both, and only the first should retract the indicator.
639
- */
640
- const leavesMount = (event) => {
641
- const entered = event.relatedTarget;
642
- // Where the browser populates it, `relatedTarget` names the
643
- // element being entered; one inside the mount means the drag
644
- // never left.
645
- if (entered instanceof Node)
646
- return !mount.contains(entered);
647
- // Chromium leaves it null on every drag event, so fall back to
648
- // the pointer itself. Leaving the window reports (0, 0) in some
649
- // browsers, which may read as inside; the drag's own `dragend`
650
- // still clears the indicator there, exactly as it did before.
651
- const rect = mount.getBoundingClientRect();
652
- return (event.clientX < rect.left ||
653
- event.clientX > rect.right ||
654
- event.clientY < rect.top ||
655
- event.clientY > rect.bottom);
656
- };
657
- const onDragLeave = (event) => {
658
- // Only the preview goes — the partition closes and the line
659
- // retracts. The drag itself is still in flight, and re-entering
660
- // the editor must resolve a target again rather than leave the
661
- // user dragging something that can no longer be dropped.
662
- if (source !== null && leavesMount(event))
663
- setGap(null);
664
- };
665
- const onDrop = (event) => {
666
- if (source === null)
667
- return;
668
- event.stopPropagation();
669
- event.preventDefault();
670
- const from = source;
671
- // Resolved from the drop's own coordinates rather than the last
672
- // `dragover`, so the release position always wins.
673
- const to = targetAt(event.clientY);
674
- // Captured before `endDrag` discards the snapshot. These are the
675
- // pre-partition tops, which is exactly the FLIP's "before": the
676
- // partition's transforms are cleared by `endDrag` in the same
677
- // turn, so nothing the user saw shifted is measured as movement.
678
- const tops = slots.map((slot) => slot.top);
679
- endDrag();
680
- if (to === null)
681
- return;
682
- editor.commands.moveBlock({ from, to });
683
- settle(tops, from, to);
684
- };
685
- const onKeyDown = (event) => {
686
- if (event.key === 'Escape' && source !== null)
687
- endDrag();
688
- };
689
- mount.addEventListener('mousemove', onMouseMove);
690
- mount.addEventListener('mouseleave', hide);
691
- handle.addEventListener('dragstart', onDragStart);
692
- handle.addEventListener('dragend', endDrag);
693
- // On the mount rather than `view.dom`: `.ProseMirror` is the text
694
- // column, so a drag that never leaves the gutter the handle lives
695
- // in would otherwise produce no `dragover` and no droppable target.
696
- mount.addEventListener('dragover', onDragOver, true);
697
- mount.addEventListener('dragleave', onDragLeave, true);
698
- mount.addEventListener('drop', onDrop, true);
699
- // Escape aborts a native drag in a real browser, which fires
700
- // `dragend` anyway; this keeps the cancellation deterministic and
701
- // independent of that.
702
- document.addEventListener('keydown', onKeyDown);
703
- return {
704
- // `enabled()` is checked on every pointer move too, but a host
705
- // can revoke it *between* moves. Disabling the editor puts
706
- // `pointer-events: none` on the surface containing the mount, so
707
- // no further mousemove or mouseleave can ever arrive to retract
708
- // a handle that was left visible. This runs on every state
709
- // update, which is what a `disabled`/`readonly` flip produces.
710
- update: (updated, previous) => {
711
- // `source` is a pre-move top-level index, exactly like the
712
- // anchor `apply` drops on `docChanged`, and it is the one that
713
- // decides *which* block moves. An insertion before it makes it
714
- // name a different block, so the indicator would repaint at the
715
- // gap the user aimed at while the drop silently moved the wrong
716
- // block — one undo step, no error. Abort rather than map: a
717
- // source block that was itself deleted has no mapped index, and
718
- // aborting matches what already happens to the anchor.
719
- //
720
- // Identity, not `Node.eq`: ProseMirror only replaces
721
- // `state.doc` when a step is applied — a meta- or
722
- // selection-only transaction carries the same reference — so
723
- // this is exactly `docChanged` at O(1), where a structural
724
- // compare would walk the whole document on every keystroke.
725
- if (previous.doc !== updated.state.doc && source !== null) {
726
- endDrag();
727
- }
728
- if (options.enabled())
729
- return;
730
- hide();
731
- // Retracts the preview and drops the in-flight source, so
732
- // releasing the button after the revocation cannot reorder
733
- // anything. Previously the line went through `decorations`,
734
- // which read `enabled()` itself; a mount-owned element has to
735
- // be told.
736
- if (source !== null)
737
- endDrag();
738
- },
739
- destroy: () => {
740
- mount.removeEventListener('mousemove', onMouseMove);
741
- mount.removeEventListener('mouseleave', hide);
742
- handle.removeEventListener('dragstart', onDragStart);
743
- handle.removeEventListener('dragend', endDrag);
744
- mount.removeEventListener('dragover', onDragOver, true);
745
- mount.removeEventListener('dragleave', onDragLeave, true);
746
- mount.removeEventListener('drop', onDrop, true);
747
- document.removeEventListener('keydown', onKeyDown);
748
- handle.remove();
749
- indicator.remove();
750
- // A drag interrupted by teardown leaves its drag image on
751
- // `document.body`, outside everything else this removes.
752
- ghost?.remove();
753
- },
754
- };
755
- },
756
- }),
757
- ];
758
- },
759
- });
760
- //# sourceMappingURL=editor-block-handle.js.map