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