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