@llui/markdown-editor 0.3.0 → 0.5.0

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 (54) hide show
  1. package/dist/editor.d.ts +15 -2
  2. package/dist/editor.d.ts.map +1 -1
  3. package/dist/editor.js +12 -2
  4. package/dist/editor.js.map +1 -1
  5. package/dist/index.d.ts +6 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +15 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/plugins/block-drag.d.ts +74 -0
  10. package/dist/plugins/block-drag.d.ts.map +1 -0
  11. package/dist/plugins/block-drag.js +983 -0
  12. package/dist/plugins/block-drag.js.map +1 -0
  13. package/dist/plugins/code-language.d.ts +60 -0
  14. package/dist/plugins/code-language.d.ts.map +1 -0
  15. package/dist/plugins/code-language.js +308 -0
  16. package/dist/plugins/code-language.js.map +1 -0
  17. package/dist/plugins/frontmatter.d.ts +45 -0
  18. package/dist/plugins/frontmatter.d.ts.map +1 -0
  19. package/dist/plugins/frontmatter.js +281 -0
  20. package/dist/plugins/frontmatter.js.map +1 -0
  21. package/dist/plugins/overlay.d.ts +1 -0
  22. package/dist/plugins/overlay.d.ts.map +1 -1
  23. package/dist/plugins/overlay.js +7 -0
  24. package/dist/plugins/overlay.js.map +1 -1
  25. package/dist/plugins/wikilink.d.ts +110 -0
  26. package/dist/plugins/wikilink.d.ts.map +1 -0
  27. package/dist/plugins/wikilink.js +692 -0
  28. package/dist/plugins/wikilink.js.map +1 -0
  29. package/dist/styles/block-drag.css +118 -0
  30. package/dist/styles/editor.css +112 -0
  31. package/dist/transformers/code.d.ts +24 -0
  32. package/dist/transformers/code.d.ts.map +1 -0
  33. package/dist/transformers/code.js +167 -0
  34. package/dist/transformers/code.js.map +1 -0
  35. package/dist/transformers/gfm.d.ts.map +1 -1
  36. package/dist/transformers/gfm.js +7 -2
  37. package/dist/transformers/gfm.js.map +1 -1
  38. package/dist/transformers/registry.d.ts +3 -0
  39. package/dist/transformers/registry.d.ts.map +1 -1
  40. package/dist/transformers/registry.js +26 -0
  41. package/dist/transformers/registry.js.map +1 -1
  42. package/package.json +42 -28
  43. package/src/editor.ts +27 -4
  44. package/src/index.ts +58 -1
  45. package/src/plugins/block-drag.ts +1175 -0
  46. package/src/plugins/code-language.ts +378 -0
  47. package/src/plugins/frontmatter.ts +324 -0
  48. package/src/plugins/overlay.ts +7 -0
  49. package/src/plugins/wikilink.ts +843 -0
  50. package/src/styles/block-drag.css +118 -0
  51. package/src/styles/editor.css +112 -0
  52. package/src/transformers/code.ts +174 -0
  53. package/src/transformers/gfm.ts +6 -2
  54. package/src/transformers/registry.ts +27 -0
@@ -0,0 +1,983 @@
1
+ // Block drag-and-drop reordering — a hover gutter grip that reorders TOP-LEVEL
2
+ // blocks, by pointer drag or by keyboard.
3
+ //
4
+ // ## Why not HTML5 drag-and-drop
5
+ // `draggable=true` + `dragover`/`drop` cannot work here: the drag source lives in
6
+ // a portaled overlay while the drop zone is a `contenteditable`, and Lexical owns
7
+ // the native drag/drop pipeline for content insertion. Hijacking it fights the
8
+ // editor. Raw `mousedown` → document `mousemove`/`mouseup` (the same mechanics the
9
+ // reference ProseMirror implementation uses) keeps the editor's own drag handling
10
+ // untouched and gives exact control over the drop-slot geometry.
11
+ //
12
+ // ## Why the reorder goes through Lexical, never the DOM
13
+ // Moving DOM nodes under a `contenteditable` desynchronizes Lexical's node map
14
+ // from the DOM and is reverted on the next reconcile. The commit is a single
15
+ // `target.insertAfter(source)` / `insertBefore` inside one `editor.update`, so it
16
+ // is ONE undo step, replays correctly through the collab CRDT (`@lexical/yjs`
17
+ // observes node moves, not DOM mutations), and preserves the moved node's key —
18
+ // selection anchored inside it (or inside any other block) survives.
19
+ //
20
+ // ## Shape
21
+ // All geometry is PURE (see {@link blockAtPoint} / {@link findDropTarget} /
22
+ // {@link indicatorRect}): the DOM layer only measures rects and feeds numbers in.
23
+ // Everything rendered lives in this plugin's JSON state slice and is drawn with
24
+ // the package's shared {@link overlayRoot} primitive — no competing overlay
25
+ // system, no imperative DOM writes in the view.
26
+ //
27
+ // ## Stacking
28
+ // Both surfaces sit BELOW the `OVERLAY_Z` scale in `overlay.ts` (60+): the gutter
29
+ // grip and the drop indicator are chrome for the document body and must never
30
+ // cover a typeahead, the context menu, or the floating toolbar.
31
+ import { $getNodeByKey, $getRoot, $getSelection, $isElementNode, $isRangeSelection, COMMAND_PRIORITY_LOW, KEY_DOWN_COMMAND, } from 'lexical';
32
+ import { mergeRegister } from '@lexical/utils';
33
+ import { button, div, onMount, text } from '@llui/dom';
34
+ import { definePluginUI } from './ui.js';
35
+ import { onViewportChange, overlayRoot } from './overlay.js';
36
+ /** Stacking levels for this plugin's two surfaces — deliberately below the
37
+ * shared `OVERLAY_Z` scale (60+) so document chrome never covers a menu. */
38
+ export const BLOCK_DRAG_Z = { handle: 58, indicator: 59, menu: 62 };
39
+ /** Pixels of pointer travel before a grip mousedown becomes a drag (below the
40
+ * threshold it is a click, which toggles keyboard grab mode instead). */
41
+ const DRAG_THRESHOLD = 5;
42
+ /** Vertical slack when deciding which block the pointer is "on" — block margins
43
+ * leave real gaps between rects, and the gutter must not flicker across them. */
44
+ const HOVER_TOLERANCE = 6;
45
+ /** How far LEFT of the editor's content box the hover zone extends, i.e. how
46
+ * much room the gutter grip is given. */
47
+ const GUTTER_WIDTH = 40;
48
+ // ── Pure geometry ───────────────────────────────────────────────────────────
49
+ /**
50
+ * The block whose vertical band contains `clientY`, or `null` when the pointer
51
+ * is in no block's band.
52
+ *
53
+ * TWO passes, and the order matters. A block's OWN rect always wins outright;
54
+ * only a point in no block at all falls through to the widened search, where
55
+ * the NEAREST band within `tolerance` wins (ties biased upward, matching how a
56
+ * reader attributes a gap to the block above it).
57
+ *
58
+ * A single widened pass with first-match-wins — which this was — is wrong
59
+ * wherever two rects touch or nearly touch, and touching rects are the common
60
+ * case, not the exotic one: list items, table rows, consecutive lines, and any
61
+ * margin-collapsed heading. With `tolerance = 6` and adjacent rects [0,20] and
62
+ * [20,40], every y in [20,26] resolved to the FIRST block, so the block below
63
+ * lost the top 6px of its own body — the grip targeted, grabbed and dragged the
64
+ * wrong block. Generally, for an inter-block gap `g < tolerance`, block N stole
65
+ * the first `tolerance - g` px of block N+1.
66
+ */
67
+ export function blockAtPoint(blocks, clientY, tolerance = HOVER_TOLERANCE) {
68
+ for (const block of blocks) {
69
+ if (clientY >= block.top && clientY <= block.bottom)
70
+ return block;
71
+ }
72
+ let best = null;
73
+ let bestDistance = Infinity;
74
+ for (const block of blocks) {
75
+ const distance = clientY < block.top
76
+ ? block.top - clientY
77
+ : clientY > block.bottom
78
+ ? clientY - block.bottom
79
+ : 0;
80
+ if (distance <= tolerance && distance < bestDistance) {
81
+ best = block;
82
+ bestDistance = distance;
83
+ }
84
+ }
85
+ return best;
86
+ }
87
+ /**
88
+ * The slot `clientY` points at, expressed relative to a neighbouring block.
89
+ *
90
+ * The document has `n + 1` slots for `n` blocks; the slot index is the count of
91
+ * blocks whose vertical midpoint is above the pointer. Two of those slots are
92
+ * where `sourceKey` already sits — dropping there is a no-op, so both return
93
+ * `null` and the caller shows no indicator and commits nothing. That check is
94
+ * what stops a 1px twitch from producing a spurious undo entry.
95
+ */
96
+ export function findDropTarget(blocks, clientY, sourceKey) {
97
+ const sourceIndex = blocks.findIndex((b) => b.key === sourceKey);
98
+ if (sourceIndex === -1)
99
+ return null;
100
+ let slot = 0;
101
+ for (const block of blocks) {
102
+ if (clientY > (block.top + block.bottom) / 2)
103
+ slot++;
104
+ else
105
+ break;
106
+ }
107
+ // The two boundaries of the source's own slot are both no-ops.
108
+ if (slot === sourceIndex || slot === sourceIndex + 1)
109
+ return null;
110
+ if (slot === 0)
111
+ return { key: blocks[0].key, place: 'before' };
112
+ return { key: blocks[slot - 1].key, place: 'after' };
113
+ }
114
+ /** Where to draw the indicator line for a resolved {@link DropTarget}: on the
115
+ * target's top edge for `before`, its bottom edge for `after`. */
116
+ export function indicatorRect(blocks, target) {
117
+ const block = blocks.find((b) => b.key === target.key);
118
+ if (!block)
119
+ return null;
120
+ return {
121
+ x: block.left,
122
+ y: target.place === 'before' ? block.top : block.bottom,
123
+ width: block.width,
124
+ };
125
+ }
126
+ // ── Lexical reads / writes ──────────────────────────────────────────────────
127
+ /** Measure every top-level block. Blocks with no rendered element (never the
128
+ * case in practice, but possible mid-reconcile) are skipped rather than
129
+ * measured as zeros, which would corrupt every midpoint comparison. */
130
+ function readBlockRects(editor) {
131
+ const keys = editor.getEditorState().read(() => $getRoot()
132
+ .getChildren()
133
+ .map((n) => n.getKey()));
134
+ const rects = [];
135
+ for (const key of keys) {
136
+ const el = editor.getElementByKey(key);
137
+ if (!el)
138
+ continue;
139
+ const r = el.getBoundingClientRect();
140
+ rects.push({ key, top: r.top, bottom: r.bottom, left: r.left, width: r.width });
141
+ }
142
+ return rects;
143
+ }
144
+ /** A short, speakable name for a block: its leading text, else its node type. */
145
+ function blockLabel(node) {
146
+ const content = node.getTextContent().trim().replace(/\s+/g, ' ');
147
+ if (content === '')
148
+ return node.getType();
149
+ return content.length > 32 ? `${content.slice(0, 32)}…` : content;
150
+ }
151
+ /** Move `sourceKey` to `place` `targetKey` among the root's children, inside the
152
+ * caller's `editor.update`. Returns `null` (no mutation, so no undo entry) when
153
+ * either key is stale, they are the same node, or the move is a no-op. */
154
+ function $moveBlock(sourceKey, targetKey, place) {
155
+ if (sourceKey === targetKey)
156
+ return null;
157
+ const root = $getRoot();
158
+ const children = root.getChildren();
159
+ const source = children.find((n) => n.getKey() === sourceKey);
160
+ const target = children.find((n) => n.getKey() === targetKey);
161
+ if (!source || !target)
162
+ return null;
163
+ const sourceIndex = source.getIndexWithinParent();
164
+ const targetIndex = target.getIndexWithinParent();
165
+ // Already in that slot — mutating would push an empty undo step.
166
+ if (place === 'after' && targetIndex === sourceIndex - 1)
167
+ return null;
168
+ if (place === 'before' && targetIndex === sourceIndex + 1)
169
+ return null;
170
+ const label = blockLabel(source);
171
+ // `insertAfter`/`insertBefore` detach the node from its current parent first
172
+ // and re-anchor an element-anchored selection, so this is a true move.
173
+ if (place === 'after')
174
+ target.insertAfter(source);
175
+ else
176
+ target.insertBefore(source);
177
+ return {
178
+ key: sourceKey,
179
+ announcement: `${label} moved to position ${source.getIndexWithinParent() + 1} of ${root.getChildrenSize()}.`,
180
+ };
181
+ }
182
+ /** Move the block one slot up (`-1`) or down (`1`). Clamped at both ends, where
183
+ * it reports the boundary rather than silently doing nothing. */
184
+ function $shiftBlock(key, direction) {
185
+ const root = $getRoot();
186
+ const children = root.getChildren();
187
+ const index = children.findIndex((n) => n.getKey() === key);
188
+ if (index === -1)
189
+ return null;
190
+ const next = index + direction;
191
+ if (next < 0)
192
+ return { key, announcement: 'Already at the first position.' };
193
+ if (next >= children.length)
194
+ return { key, announcement: 'Already at the last position.' };
195
+ const neighbour = children[next];
196
+ return $moveBlock(key, neighbour.getKey(), direction === 1 ? 'after' : 'before');
197
+ }
198
+ /** Deep-clone a node with FRESH keys via serialize→deserialize. `constructor.clone`
199
+ * is unusable here — it preserves the key, so the "copy" would collide with the
200
+ * original. `importJSON(exportJSON())` mints a new node; element children are not
201
+ * carried by `exportJSON`, so they are cloned and re-appended recursively. */
202
+ function $cloneNode(node) {
203
+ const klass = node.constructor;
204
+ const clone = klass.importJSON(node.exportJSON());
205
+ if ($isElementNode(node) && $isElementNode(clone)) {
206
+ for (const child of node.getChildren())
207
+ clone.append($cloneNode(child));
208
+ }
209
+ return clone;
210
+ }
211
+ /** Insert a deep copy of the top-level block `key` immediately after it. */
212
+ function $duplicateBlock(key) {
213
+ const node = $getNodeByKey(key);
214
+ if (node === null)
215
+ return null;
216
+ const clone = $cloneNode(node);
217
+ node.insertAfter(clone);
218
+ return { key: clone.getKey(), announcement: `${blockLabel(node)} duplicated.` };
219
+ }
220
+ /** Remove the top-level block `key`. Returns the announcement, or `null` when the
221
+ * key is stale. */
222
+ function $deleteBlock(key) {
223
+ const node = $getNodeByKey(key);
224
+ if (node === null)
225
+ return null;
226
+ const label = blockLabel(node);
227
+ node.remove();
228
+ return { announcement: `${label} deleted.` };
229
+ }
230
+ /** Put a collapsed selection at the start of block `key` so a selection-based
231
+ * command (the turn-into items) acts on THAT block. Returns whether it selected. */
232
+ function $selectBlockStart(key) {
233
+ const node = $getNodeByKey(key);
234
+ if (node === null)
235
+ return false;
236
+ node.selectStart();
237
+ return true;
238
+ }
239
+ const INITIAL = {
240
+ handleVisible: false,
241
+ handleX: 0,
242
+ handleY: 0,
243
+ hoverKey: '',
244
+ dragging: false,
245
+ indicatorVisible: false,
246
+ indicatorX: 0,
247
+ indicatorY: 0,
248
+ indicatorWidth: 0,
249
+ grabbedKey: '',
250
+ menuOpen: false,
251
+ menuKey: '',
252
+ menuX: 0,
253
+ menuY: 0,
254
+ announce: '',
255
+ announceNonce: 0,
256
+ keyboardReveal: false,
257
+ };
258
+ function hideIndicator(state) {
259
+ return state.indicatorVisible ? { ...state, indicatorVisible: false } : state;
260
+ }
261
+ /**
262
+ * INVARIANT: a grab cannot outlive the grip that owns it —
263
+ * `handleVisible === false` implies `grabbedKey === ''`.
264
+ *
265
+ * Enforced in ONE place, on every reducer result, rather than at each case that
266
+ * hides the handle. Violating it wedges the gutter permanently and
267
+ * unrecoverably: `hover` and `hoverOut` are both hard-gated on
268
+ * `grabbedKey === ''`, so once the handle is hidden with a grab still pending,
269
+ * every subsequent hover is swallowed and the grip never renders again — and the
270
+ * only senders of `releaseGrab` are the grip's own `onKeyDown`/`onBlur`, which
271
+ * cannot fire on a grip that is not in the DOM.
272
+ *
273
+ * It was originally three separate clears (`dragStart`, `dragEnd`, `drop`).
274
+ * That worked, but it was untestable: the three were mutually redundant, so
275
+ * deleting any ONE of them left every test green while re-arming the bug for the
276
+ * next path someone added. A single invariant is both DRY and pinnable.
277
+ */
278
+ function normalize(state) {
279
+ if (state.handleVisible || state.grabbedKey === '')
280
+ return state;
281
+ return { ...state, grabbedKey: '' };
282
+ }
283
+ function reduceRaw(state, msg) {
284
+ switch (msg.type) {
285
+ case 'hover':
286
+ // While a block is grabbed, being dragged, or the menu is open the gutter is
287
+ // pinned — a stray pointer move must not silently re-target the pending op.
288
+ if (state.grabbedKey !== '' || state.dragging || state.menuOpen)
289
+ return state;
290
+ if (state.handleVisible &&
291
+ state.hoverKey === msg.key &&
292
+ state.handleX === msg.x &&
293
+ state.handleY === msg.y)
294
+ return state;
295
+ return {
296
+ ...state,
297
+ handleVisible: true,
298
+ hoverKey: msg.key,
299
+ handleX: msg.x,
300
+ handleY: msg.y,
301
+ keyboardReveal: false,
302
+ };
303
+ case 'revealAtSelection':
304
+ // The KEYBOARD entry point. The grip is rendered only while
305
+ // `handleVisible`, which `hover` alone could set — and `hover` is produced
306
+ // only by `mousemove`. So without this message the grip was never in the
307
+ // DOM and never in the tab order for a keyboard or screen-reader user, and
308
+ // the unconditional help text ("Press Enter or Space to grab this block…")
309
+ // described an affordance they could not reach. Reordering was mouse-only.
310
+ // Reveal AND grab in one step: the grip's Enter/Space now opens the actions
311
+ // menu, so the keyboard reorder path (Mod+Shift+D / the "Move block" command)
312
+ // enters grab mode directly rather than only revealing a grip the user would
313
+ // then have to Enter — which would open the menu instead of grabbing.
314
+ return [
315
+ {
316
+ ...state,
317
+ handleVisible: true,
318
+ hoverKey: msg.key,
319
+ handleX: msg.x,
320
+ handleY: msg.y,
321
+ keyboardReveal: true,
322
+ grabbedKey: msg.key,
323
+ },
324
+ [{ type: 'describe', key: msg.key, grabbed: true }],
325
+ ];
326
+ case 'hoverOut':
327
+ if (state.grabbedKey !== '' || state.dragging || state.menuOpen)
328
+ return state;
329
+ return state.handleVisible ? { ...state, handleVisible: false } : state;
330
+ case 'dragStart':
331
+ // The grip is hidden for the duration: it sits under the cursor and would
332
+ // otherwise read as a second, stationary drop affordance.
333
+ //
334
+ // Any pending grab is dropped by `normalize` below, because the handle is
335
+ // going away.
336
+ return { ...state, dragging: true, handleVisible: false };
337
+ case 'dragOver':
338
+ return {
339
+ ...state,
340
+ indicatorVisible: true,
341
+ indicatorX: msg.x,
342
+ indicatorY: msg.y,
343
+ indicatorWidth: msg.width,
344
+ };
345
+ case 'dragOverNone':
346
+ return hideIndicator(state);
347
+ case 'dragEnd':
348
+ return { ...hideIndicator(state), dragging: false };
349
+ case 'drop':
350
+ return [
351
+ { ...state, dragging: false, indicatorVisible: false, handleVisible: false },
352
+ [{ type: 'move', sourceKey: msg.sourceKey, targetKey: msg.targetKey, place: msg.place }],
353
+ ];
354
+ case 'toggleGrab': {
355
+ if (state.grabbedKey !== '')
356
+ return [
357
+ { ...state, grabbedKey: '' },
358
+ [{ type: 'describe', key: state.grabbedKey, grabbed: false }],
359
+ ];
360
+ if (state.hoverKey === '')
361
+ return state;
362
+ return [
363
+ { ...state, grabbedKey: state.hoverKey },
364
+ [{ type: 'describe', key: state.hoverKey, grabbed: true }],
365
+ ];
366
+ }
367
+ case 'moveGrabbed':
368
+ if (state.grabbedKey === '')
369
+ return state;
370
+ return [state, [{ type: 'shift', key: state.grabbedKey, direction: msg.direction }]];
371
+ case 'releaseGrab':
372
+ // Announce the cancellation rather than BLANKING the live region. This is
373
+ // also the `onBlur` handler, so clearing it meant Escape (and any focus
374
+ // loss) silently ended a grab with nothing spoken.
375
+ return state.grabbedKey === ''
376
+ ? state
377
+ : {
378
+ ...state,
379
+ grabbedKey: '',
380
+ announce: 'Reorder cancelled.',
381
+ announceNonce: state.announceNonce + 1,
382
+ };
383
+ case 'openMenu':
384
+ // The grip stays visible as the menu's anchor; the hover gate below then
385
+ // pins the gutter (a stray pointer move must not re-target while the menu
386
+ // is open). A menu with no target block is meaningless.
387
+ if (msg.key === '')
388
+ return state;
389
+ return { ...state, menuOpen: true, menuKey: msg.key, menuX: msg.x, menuY: msg.y };
390
+ case 'closeMenu':
391
+ return state.menuOpen ? { ...state, menuOpen: false } : state;
392
+ case 'shiftBlock':
393
+ return [
394
+ { ...state, menuOpen: false },
395
+ [{ type: 'shift', key: msg.key, direction: msg.direction }],
396
+ ];
397
+ case 'duplicate':
398
+ return [{ ...state, menuOpen: false }, [{ type: 'duplicate', key: msg.key }]];
399
+ case 'deleteBlock':
400
+ return [{ ...state, menuOpen: false }, [{ type: 'delete', key: msg.key }]];
401
+ case 'announce':
402
+ // The nonce is what makes a REPEATED announcement audible. `aria-live`
403
+ // regions re-announce on text CHANGE, so two identical strings in a row —
404
+ // pressing ArrowUp twice at the top of the document, which yields
405
+ // 'Already at the first position.' both times — left the second one
406
+ // silent. The nonce is not rendered; it only forces the text binding to
407
+ // re-commit. (Bumped unconditionally, so even a deduped-by-value message
408
+ // still speaks.)
409
+ return { ...state, announce: msg.text, announceNonce: state.announceNonce + 1 };
410
+ case 'reposition':
411
+ return state.handleX === msg.x && state.handleY === msg.y
412
+ ? state
413
+ : { ...state, handleX: msg.x, handleY: msg.y };
414
+ }
415
+ }
416
+ function reduce(state, msg) {
417
+ const out = reduceRaw(state, msg);
418
+ return Array.isArray(out) ? [normalize(out[0]), out[1]] : normalize(out);
419
+ }
420
+ let seq = 0;
421
+ /**
422
+ * Reorder top-level blocks by dragging a hover gutter grip, or from the keyboard
423
+ * (focus the grip, Enter/Space to grab, ↑/↓ to move, Enter/Space to drop, Escape
424
+ * to cancel). Every reorder is one Lexical node move, hence one undo step.
425
+ */
426
+ export function blockDragPlugin(options = {}) {
427
+ const gutterOffset = options.gutterOffset ?? 28;
428
+ const sessions = new WeakMap();
429
+ /** Per-editor keyboard entry point, so the command item can reach it. */
430
+ const revealers = new WeakMap();
431
+ const uid = `md-block-drag-${++seq}`;
432
+ const helpId = `${uid}-help`;
433
+ const gripId = `${uid}-grip`;
434
+ const menuId = `${uid}-menu`;
435
+ // The block-type conversions offered in the actions menu's "Turn into" section,
436
+ // captured from the merged command items so any plugin's block type appears
437
+ // automatically. Turn-into items are the block/list-group items that report an
438
+ // active block type (`isActive`), which excludes inserts and this plugin's own
439
+ // "Move block" command. Read once at construction.
440
+ let turnIntoItems = [];
441
+ return {
442
+ name: 'blockDrag',
443
+ onItems: (all) => {
444
+ turnIntoItems = all.filter((i) => (i.group === 'block' || i.group === 'list') && i.isActive !== undefined);
445
+ },
446
+ // The command-surface half of the keyboard entry point (Mod+Shift+D is the
447
+ // direct binding). Without one of these two, the entire reorder protocol —
448
+ // and the help text that describes it — is unreachable without a pointer.
449
+ items: [
450
+ {
451
+ id: 'blockDrag',
452
+ label: 'Move block',
453
+ icon: 'blockDrag',
454
+ group: 'block',
455
+ keywords: ['move', 'reorder', 'drag', 'block'],
456
+ run: (editor) => {
457
+ revealers.get(editor)?.();
458
+ },
459
+ surfaces: ['slash', 'context'],
460
+ },
461
+ ],
462
+ // Hover tracking only. The drag session itself is owned by the view (it is
463
+ // started by the grip's own mousedown), which keeps the two concerns apart:
464
+ // `register` answers "which block is the pointer near", the view answers
465
+ // "where would a drop land".
466
+ register: (editor, ctx) => {
467
+ const session = { dragging: false };
468
+ sessions.set(editor, session);
469
+ let lastKey = '';
470
+ let pending = 0;
471
+ const measure = (event) => {
472
+ // A read-only editor gets no reorder affordance at all.
473
+ if (!editor.isEditable() || session.dragging)
474
+ return;
475
+ const root = editor.getRootElement();
476
+ if (!root)
477
+ return;
478
+ const bounds = root.getBoundingClientRect();
479
+ // The live zone is the content box widened LEFT by the gutter, so the
480
+ // pointer can travel onto the grip without the handle vanishing.
481
+ const inside = event.clientX >= bounds.left - GUTTER_WIDTH &&
482
+ event.clientX <= bounds.right &&
483
+ event.clientY >= bounds.top - HOVER_TOLERANCE &&
484
+ event.clientY <= bounds.bottom + HOVER_TOLERANCE;
485
+ if (!inside) {
486
+ if (lastKey !== '') {
487
+ lastKey = '';
488
+ ctx.emit({ type: 'plugin', name: 'blockDrag', msg: { type: 'hoverOut' } });
489
+ }
490
+ return;
491
+ }
492
+ const blocks = readBlockRects(editor);
493
+ const block = blockAtPoint(blocks, event.clientY);
494
+ if (!block) {
495
+ if (lastKey !== '') {
496
+ lastKey = '';
497
+ ctx.emit({ type: 'plugin', name: 'blockDrag', msg: { type: 'hoverOut' } });
498
+ }
499
+ return;
500
+ }
501
+ lastKey = block.key;
502
+ ctx.emit({
503
+ type: 'plugin',
504
+ name: 'blockDrag',
505
+ msg: {
506
+ type: 'hover',
507
+ key: block.key,
508
+ x: block.left - gutterOffset,
509
+ y: block.top,
510
+ },
511
+ });
512
+ };
513
+ // Coalesce to one measurement per frame: `mousemove` fires far faster than
514
+ // the handle can meaningfully move, and each tick costs a layout read.
515
+ // Keep the LATEST event, not the first of the frame. Discarding the rest
516
+ // positioned the grip from stale coordinates: during a fast sweep the
517
+ // pointer can cross two or three blocks inside one frame, and the handle
518
+ // would resolve to the block the pointer was over when the frame STARTED.
519
+ let latest = null;
520
+ const onMouseMove = (event) => {
521
+ latest = event;
522
+ if (pending)
523
+ return;
524
+ pending = requestAnimationFrame(() => {
525
+ pending = 0;
526
+ if (latest)
527
+ measure(latest);
528
+ });
529
+ };
530
+ const hide = () => {
531
+ if (session.dragging)
532
+ return;
533
+ lastKey = '';
534
+ ctx.emit({ type: 'plugin', name: 'blockDrag', msg: { type: 'hoverOut' } });
535
+ };
536
+ /**
537
+ * Reveal the grip for the block containing the caret and focus it, so the
538
+ * whole reorder protocol is reachable without a pointer. Bound to
539
+ * Mod+Shift+D below and also exposed as a command item.
540
+ */
541
+ const revealAtSelection = () => {
542
+ if (!editor.isEditable())
543
+ return false;
544
+ const key = editor.getEditorState().read(() => {
545
+ const selection = $getSelection();
546
+ if (!$isRangeSelection(selection))
547
+ return null;
548
+ return selection.anchor.getNode().getTopLevelElement()?.getKey() ?? null;
549
+ });
550
+ if (!key)
551
+ return false;
552
+ const block = readBlockRects(editor).find((b) => b.key === key);
553
+ if (!block)
554
+ return false;
555
+ lastKey = key;
556
+ ctx.emit({
557
+ type: 'plugin',
558
+ name: 'blockDrag',
559
+ msg: {
560
+ type: 'revealAtSelection',
561
+ key,
562
+ x: block.left - gutterOffset,
563
+ y: block.top,
564
+ },
565
+ });
566
+ return true;
567
+ };
568
+ revealers.set(editor, revealAtSelection);
569
+ // Right-click a block → the same actions menu, at the pointer. Scoped to the
570
+ // editor's own content (a contextmenu elsewhere keeps the native menu).
571
+ const onContextMenu = (event) => {
572
+ if (!editor.isEditable() || session.dragging)
573
+ return;
574
+ const root = editor.getRootElement();
575
+ if (!root || !(event.target instanceof Node) || !root.contains(event.target))
576
+ return;
577
+ const block = blockAtPoint(readBlockRects(editor), event.clientY);
578
+ if (!block)
579
+ return;
580
+ event.preventDefault();
581
+ ctx.emit({
582
+ type: 'plugin',
583
+ name: 'blockDrag',
584
+ msg: { type: 'openMenu', key: block.key, x: event.clientX, y: event.clientY },
585
+ });
586
+ };
587
+ document.addEventListener('mousemove', onMouseMove, { passive: true });
588
+ document.addEventListener('contextmenu', onContextMenu);
589
+ return mergeRegister(() => {
590
+ document.removeEventListener('mousemove', onMouseMove);
591
+ document.removeEventListener('contextmenu', onContextMenu);
592
+ if (pending)
593
+ cancelAnimationFrame(pending);
594
+ sessions.delete(editor);
595
+ revealers.delete(editor);
596
+ }, editor.registerCommand(KEY_DOWN_COMMAND, (event) => {
597
+ if (event.key !== 'D' && event.key !== 'd')
598
+ return false;
599
+ if (!event.shiftKey || !(event.metaKey || event.ctrlKey))
600
+ return false;
601
+ if (!revealAtSelection())
602
+ return false;
603
+ event.preventDefault();
604
+ return true;
605
+ }, COMMAND_PRIORITY_LOW),
606
+ // Any scroll invalidates the measured gutter position; recomputing from a
607
+ // stale pointer is wrong, so simply retract the handle.
608
+ onViewportChange(hide));
609
+ },
610
+ ui: definePluginUI({
611
+ init: () => ({ ...INITIAL }),
612
+ update: reduce,
613
+ onEffect: (effect, ctx) => {
614
+ const editor = ctx.editor();
615
+ if (!editor)
616
+ return;
617
+ if (effect.type === 'describe') {
618
+ const label = editor.getEditorState().read(() => {
619
+ const node = $getRoot()
620
+ .getChildren()
621
+ .find((n) => n.getKey() === effect.key);
622
+ return node ? blockLabel(node) : '';
623
+ });
624
+ ctx.send({
625
+ type: 'announce',
626
+ text: effect.grabbed
627
+ ? `${label} grabbed. Use the up and down arrow keys to move it, Enter to drop, Escape to cancel.`
628
+ : `${label} dropped.`,
629
+ });
630
+ return;
631
+ }
632
+ // Held in a box rather than a bare `let`: the assignment happens inside
633
+ // the update callback, which TypeScript's control-flow analysis cannot
634
+ // see, so a plain local would narrow to `null` at every read below. `key`
635
+ // is the block to re-anchor the gutter to (`null` for delete — the block
636
+ // is gone, so there is nothing to follow).
637
+ const box = {
638
+ outcome: null,
639
+ };
640
+ editor.update(() => {
641
+ switch (effect.type) {
642
+ case 'move':
643
+ box.outcome = $moveBlock(effect.sourceKey, effect.targetKey, effect.place);
644
+ break;
645
+ case 'shift':
646
+ box.outcome = $shiftBlock(effect.key, effect.direction);
647
+ break;
648
+ case 'duplicate':
649
+ box.outcome = $duplicateBlock(effect.key);
650
+ break;
651
+ case 'delete': {
652
+ const removed = $deleteBlock(effect.key);
653
+ box.outcome = removed ? { key: null, announcement: removed.announcement } : null;
654
+ break;
655
+ }
656
+ }
657
+ }, {
658
+ // Re-measure AFTER reconciliation so the gutter follows the block it
659
+ // is pinned to; measuring inside the update would read the old layout.
660
+ onUpdate: () => {
661
+ const moved = box.outcome;
662
+ if (!moved || moved.key === null)
663
+ return;
664
+ const el = editor.getElementByKey(moved.key);
665
+ if (!el)
666
+ return;
667
+ const r = el.getBoundingClientRect();
668
+ ctx.send({ type: 'reposition', x: r.left - gutterOffset, y: r.top });
669
+ },
670
+ });
671
+ const moved = box.outcome;
672
+ if (moved)
673
+ ctx.send({ type: 'announce', text: moved.announcement });
674
+ },
675
+ view: ({ state, send, editor }) => {
676
+ // The pointer drag session. Installed on `document` for the duration of a
677
+ // single drag and torn down on mouseup, so nothing lingers between drags;
678
+ // `abort` covers the unmount-mid-drag case.
679
+ let abort = null;
680
+ const beginDrag = (event) => {
681
+ const live = editor();
682
+ if (!live || !live.isEditable())
683
+ return;
684
+ const sourceKey = state.peek().hoverKey;
685
+ if (sourceKey === '')
686
+ return;
687
+ // Keep the editor's SELECTION exactly where it was — a reorder must
688
+ // never move the caret.
689
+ event.preventDefault();
690
+ // …but `preventDefault` on mousedown also cancels the default FOCUS
691
+ // action, and the sub-threshold path below deliberately falls through
692
+ // to the keyboard protocol. Without an explicit focus the grip was
693
+ // never `document.activeElement`, so `onKeyDown` (bound to the grip)
694
+ // never saw the arrow keys — they went to the contenteditable and
695
+ // moved the caret instead of the block, while `aria-pressed="true"`
696
+ // and the grabbed highlight claimed otherwise. `:focus-visible` never
697
+ // applied and `onBlur`/`releaseGrab` could never fire, so the user was
698
+ // stuck in a visible mode with no working keys.
699
+ if (event.currentTarget instanceof HTMLElement)
700
+ event.currentTarget.focus();
701
+ const session = sessions.get(live);
702
+ const startX = event.clientX;
703
+ const startY = event.clientY;
704
+ let dragging = false;
705
+ let target = null;
706
+ const onMove = (ev) => {
707
+ if (!dragging) {
708
+ if (Math.abs(ev.clientX - startX) + Math.abs(ev.clientY - startY) < DRAG_THRESHOLD)
709
+ return;
710
+ dragging = true;
711
+ if (session)
712
+ session.dragging = true;
713
+ send({ type: 'dragStart' });
714
+ }
715
+ const blocks = readBlockRects(live);
716
+ target = findDropTarget(blocks, ev.clientY, sourceKey);
717
+ const rect = target ? indicatorRect(blocks, target) : null;
718
+ if (rect)
719
+ send({ type: 'dragOver', ...rect });
720
+ else
721
+ send({ type: 'dragOverNone' });
722
+ };
723
+ const finish = () => {
724
+ document.removeEventListener('mousemove', onMove);
725
+ document.removeEventListener('mouseup', onUp);
726
+ abort = null;
727
+ if (session)
728
+ session.dragging = false;
729
+ };
730
+ const onUp = () => {
731
+ finish();
732
+ if (!dragging) {
733
+ // Below the threshold this was a click, not a drag: open the block
734
+ // actions menu anchored at the grip. (Mouse users still get free
735
+ // reordering from the drag itself, or Move up/down in the menu;
736
+ // keyboard-grab is Mod+Shift+D.)
737
+ const s = state.peek();
738
+ send({ type: 'openMenu', key: sourceKey, x: s.handleX, y: s.handleY });
739
+ return;
740
+ }
741
+ if (target) {
742
+ send({ type: 'drop', sourceKey, targetKey: target.key, place: target.place });
743
+ }
744
+ else {
745
+ send({ type: 'dragEnd' });
746
+ }
747
+ };
748
+ abort = () => {
749
+ finish();
750
+ send({ type: 'dragEnd' });
751
+ };
752
+ document.addEventListener('mousemove', onMove);
753
+ document.addEventListener('mouseup', onUp);
754
+ };
755
+ const onKeyDown = (event) => {
756
+ switch (event.key) {
757
+ case 'Enter':
758
+ case ' ': {
759
+ // Handled here rather than via `click` so Space cannot ALSO fire the
760
+ // button's synthetic click and act twice. While grabbed, Enter/Space
761
+ // DROPS the block; otherwise it opens the actions menu at the grip.
762
+ event.preventDefault();
763
+ const s = state.peek();
764
+ if (s.grabbedKey !== '')
765
+ send({ type: 'toggleGrab' });
766
+ else
767
+ send({ type: 'openMenu', key: s.hoverKey, x: s.handleX, y: s.handleY });
768
+ return;
769
+ }
770
+ case 'ArrowUp':
771
+ event.preventDefault();
772
+ send({ type: 'moveGrabbed', direction: -1 });
773
+ return;
774
+ case 'ArrowDown':
775
+ event.preventDefault();
776
+ send({ type: 'moveGrabbed', direction: 1 });
777
+ return;
778
+ case 'Escape':
779
+ event.preventDefault();
780
+ send({ type: 'releaseGrab' });
781
+ return;
782
+ }
783
+ };
784
+ const grabbed = state.map((s) => s.grabbedKey !== '');
785
+ /** Convert the menu's target block by reusing a merged command item: put a
786
+ * selection at the block, then run the item (which converts the selection).
787
+ * A no-op `send` — these items never talk back to the host here. */
788
+ const runTurnInto = (item) => {
789
+ const live = editor();
790
+ if (!live)
791
+ return;
792
+ const key = state.peek().menuKey;
793
+ if (key === '')
794
+ return;
795
+ live.update(() => {
796
+ $selectBlockStart(key);
797
+ });
798
+ item.run(live, { send: () => { } });
799
+ send({ type: 'closeMenu' });
800
+ };
801
+ const menuButton = (label, onClick) => button({
802
+ type: 'button',
803
+ role: 'menuitem',
804
+ 'data-scope': 'md-block-drag',
805
+ 'data-part': 'menu-item',
806
+ tabindex: '-1',
807
+ // `mousedown` preventDefault keeps focus/selection stable; the action
808
+ // runs on click.
809
+ onMouseDown: (e) => e.preventDefault(),
810
+ onClick,
811
+ }, [text(label)]);
812
+ /** Roving focus + dismissal for the menu. Native click/Enter activates a
813
+ * focused item; here we add ↑/↓ traversal and Escape-to-close. */
814
+ const onMenuKeyDown = (event) => {
815
+ const menu = event.currentTarget;
816
+ if (!(menu instanceof HTMLElement))
817
+ return;
818
+ if (event.key === 'Escape') {
819
+ event.preventDefault();
820
+ send({ type: 'closeMenu' });
821
+ return;
822
+ }
823
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp')
824
+ return;
825
+ event.preventDefault();
826
+ const items = [...menu.querySelectorAll('[data-part="menu-item"]')];
827
+ if (items.length === 0)
828
+ return;
829
+ const active = menu.ownerDocument.activeElement;
830
+ const current = items.findIndex((el) => el === active);
831
+ const delta = event.key === 'ArrowDown' ? 1 : -1;
832
+ const nextIndex = (current + delta + items.length) % items.length;
833
+ items[nextIndex]?.focus();
834
+ };
835
+ return [
836
+ // The live region and the instructions are rendered UNCONDITIONALLY.
837
+ // A live region injected at announcement time is frequently missed by
838
+ // screen readers, and `aria-describedby` must resolve even before the
839
+ // grip exists. `sr-only` is inlined rather than left to the stylesheet
840
+ // because a missing rule here would leak debug text into the page.
841
+ div({
842
+ 'data-scope': 'md-block-drag',
843
+ 'data-part': 'a11y',
844
+ style: 'position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0',
845
+ }, [
846
+ div({ 'data-scope': 'md-block-drag', 'data-part': 'help', id: helpId }, [
847
+ text('Press Enter or Space to grab this block, then the up and down arrow keys to move it. Press Enter to drop, or Escape to cancel.'),
848
+ ]),
849
+ div({
850
+ 'data-scope': 'md-block-drag',
851
+ 'data-part': 'announcer',
852
+ role: 'status',
853
+ 'aria-live': 'polite',
854
+ 'aria-atomic': 'true',
855
+ }, [
856
+ text(state.map((s) =>
857
+ // The zero-width space alternates with the nonce, so a
858
+ // repeated boundary bump ('Already at the first
859
+ // position.') is a NEW string and gets spoken again. It
860
+ // renders as nothing and is not selectable text.
861
+ s.announce === '' ? '' : s.announce + (s.announceNonce % 2 ? '\u200B' : ''))),
862
+ ]),
863
+ ]),
864
+ ...overlayRoot({
865
+ open: state.at('handleVisible'),
866
+ x: state.at('handleX'),
867
+ y: state.at('handleY'),
868
+ zIndex: BLOCK_DRAG_Z.handle,
869
+ attrs: { 'data-scope': 'md-block-drag', 'data-part': 'handle' },
870
+ children: () => [
871
+ button({
872
+ type: 'button',
873
+ 'data-scope': 'md-block-drag',
874
+ 'data-part': 'grip',
875
+ id: gripId,
876
+ 'aria-label': 'Reorder block',
877
+ 'aria-roledescription': 'draggable block handle',
878
+ 'aria-describedby': helpId,
879
+ 'aria-pressed': grabbed.map((g) => (g ? 'true' : 'false')),
880
+ 'data-grabbed': grabbed.map((g) => (g ? '' : undefined)),
881
+ title: 'Drag to reorder — or press Enter and use the arrow keys',
882
+ onMouseDown: beginDrag,
883
+ onKeyDown,
884
+ // Losing focus abandons a keyboard grab rather than leaving an
885
+ // invisible mode armed on an element the user can no longer see.
886
+ onBlur: () => send({ type: 'releaseGrab' }),
887
+ }, [
888
+ text('⠿'),
889
+ // Take focus ONLY when the keyboard revealed us. `overlayRoot`
890
+ // rebuilds this subtree each time it opens, so this runs once
891
+ // per reveal. Focusing on a mouse hover would yank the caret
892
+ // out of the contenteditable on every pointer move.
893
+ onMount((root) => {
894
+ if (!state.peek().keyboardReveal)
895
+ return;
896
+ // Looked up by id on the OWNER DOCUMENT, not under `root`:
897
+ // `onMount` hands back the component's root element, while
898
+ // `overlayRoot` portals the grip to a body-level sibling, so
899
+ // the grip is never a descendant of `root`. The id is derived
900
+ // from this plugin instance's `uid`, which keeps it correct
901
+ // when several editors are mounted at once.
902
+ const grip = root.ownerDocument.getElementById(gripId);
903
+ grip?.focus();
904
+ }),
905
+ ]),
906
+ ],
907
+ }),
908
+ ...overlayRoot({
909
+ open: state.at('menuOpen'),
910
+ x: state.at('menuX'),
911
+ y: state.at('menuY'),
912
+ zIndex: BLOCK_DRAG_Z.menu,
913
+ attrs: { 'data-scope': 'md-block-drag', 'data-part': 'menu-root' },
914
+ children: () => [
915
+ div({
916
+ 'data-scope': 'md-block-drag',
917
+ 'data-part': 'menu',
918
+ id: menuId,
919
+ role: 'menu',
920
+ 'aria-label': 'Block actions',
921
+ onKeyDown: onMenuKeyDown,
922
+ }, [
923
+ ...(turnIntoItems.length > 0
924
+ ? [
925
+ div({ 'data-scope': 'md-block-drag', 'data-part': 'menu-label' }, [
926
+ text('Turn into'),
927
+ ]),
928
+ ...turnIntoItems.map((item) => menuButton(item.label, () => runTurnInto(item))),
929
+ div({ 'data-scope': 'md-block-drag', 'data-part': 'menu-sep' }, []),
930
+ ]
931
+ : []),
932
+ menuButton('Duplicate', () => send({ type: 'duplicate', key: state.peek().menuKey })),
933
+ menuButton('Move up', () => send({ type: 'shiftBlock', key: state.peek().menuKey, direction: -1 })),
934
+ menuButton('Move down', () => send({ type: 'shiftBlock', key: state.peek().menuKey, direction: 1 })),
935
+ menuButton('Delete', () => send({ type: 'deleteBlock', key: state.peek().menuKey })),
936
+ // Focus the first item on open, and close on an outside pointer.
937
+ // The mousedown listener is armed on the NEXT tick so it never
938
+ // catches the very click that opened the menu.
939
+ onMount((root) => {
940
+ const menu = root.ownerDocument.getElementById(menuId);
941
+ menu?.querySelector('[data-part="menu-item"]')?.focus();
942
+ const onDocDown = (e) => {
943
+ if (menu && e.target instanceof Node && menu.contains(e.target))
944
+ return;
945
+ send({ type: 'closeMenu' });
946
+ };
947
+ const armed = setTimeout(() => document.addEventListener('mousedown', onDocDown), 0);
948
+ return () => {
949
+ clearTimeout(armed);
950
+ document.removeEventListener('mousedown', onDocDown);
951
+ };
952
+ }),
953
+ ]),
954
+ ],
955
+ }),
956
+ ...overlayRoot({
957
+ open: state.at('indicatorVisible'),
958
+ x: state.at('indicatorX'),
959
+ y: state.at('indicatorY'),
960
+ zIndex: BLOCK_DRAG_Z.indicator,
961
+ attrs: { 'data-scope': 'md-block-drag', 'data-part': 'indicator-root' },
962
+ children: () => [
963
+ div({
964
+ 'data-scope': 'md-block-drag',
965
+ 'data-part': 'indicator',
966
+ 'aria-hidden': 'true',
967
+ // Only the WIDTH is inline (it is measured, hence dynamic); the
968
+ // line's appearance belongs to `styles/block-drag.css`.
969
+ style: state.at('indicatorWidth').map((w) => `width:${w}px`),
970
+ }),
971
+ ],
972
+ }),
973
+ // Abandon any in-flight pointer session if the editor unmounts mid-drag,
974
+ // so the document listeners never outlive the component. `onMount`'s
975
+ // returned cleanup is the sanctioned teardown hook; the Mountable MUST
976
+ // be placed in the view array or it registers nothing.
977
+ onMount(() => () => abort?.()),
978
+ ];
979
+ },
980
+ }),
981
+ };
982
+ }
983
+ //# sourceMappingURL=block-drag.js.map