@llui/markdown-editor 0.4.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.
@@ -30,8 +30,10 @@
30
30
  // cover a typeahead, the context menu, or the floating toolbar.
31
31
 
32
32
  import {
33
+ $getNodeByKey,
33
34
  $getRoot,
34
35
  $getSelection,
36
+ $isElementNode,
35
37
  $isRangeSelection,
36
38
  COMMAND_PRIORITY_LOW,
37
39
  KEY_DOWN_COMMAND,
@@ -43,11 +45,11 @@ import { mergeRegister } from '@lexical/utils'
43
45
  import { button, div, onMount, text, type Renderable, type Signal } from '@llui/dom'
44
46
  import { definePluginUI } from './ui.js'
45
47
  import { onViewportChange, overlayRoot } from './overlay.js'
46
- import type { MarkdownPlugin } from './types.js'
48
+ import type { CommandItem, MarkdownPlugin } from './types.js'
47
49
 
48
50
  /** Stacking levels for this plugin's two surfaces — deliberately below the
49
51
  * shared `OVERLAY_Z` scale (60+) so document chrome never covers a menu. */
50
- export const BLOCK_DRAG_Z = { handle: 58, indicator: 59 } as const
52
+ export const BLOCK_DRAG_Z = { handle: 58, indicator: 59, menu: 62 } as const
51
53
 
52
54
  /** Pixels of pointer travel before a grip mousedown becomes a drag (below the
53
55
  * threshold it is a click, which toggles keyboard grab mode instead). */
@@ -249,6 +251,54 @@ function $shiftBlock(key: NodeKey, direction: -1 | 1): MoveOutcome | null {
249
251
  return $moveBlock(key, neighbour.getKey(), direction === 1 ? 'after' : 'before')
250
252
  }
251
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
+
252
302
  // ── TEA slice ───────────────────────────────────────────────────────────────
253
303
 
254
304
  interface BlockDragState {
@@ -264,6 +314,13 @@ interface BlockDragState {
264
314
  indicatorWidth: number
265
315
  /** Key held in keyboard grab mode (`''` = not grabbed). */
266
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
267
324
  /** Live-region text. */
268
325
  announce: string
269
326
  /**
@@ -293,12 +350,19 @@ type BlockDragMsg =
293
350
  | { type: 'toggleGrab' }
294
351
  | { type: 'moveGrabbed'; direction: -1 | 1 }
295
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 }
296
358
  | { type: 'announce'; text: string }
297
359
  | { type: 'reposition'; x: number; y: number }
298
360
 
299
361
  type BlockDragEffect =
300
362
  | { type: 'move'; sourceKey: string; targetKey: string; place: Place }
301
363
  | { type: 'shift'; key: string; direction: -1 | 1 }
364
+ | { type: 'duplicate'; key: string }
365
+ | { type: 'delete'; key: string }
302
366
  | { type: 'describe'; key: string; grabbed: boolean }
303
367
 
304
368
  const INITIAL: BlockDragState = {
@@ -312,6 +376,10 @@ const INITIAL: BlockDragState = {
312
376
  indicatorY: 0,
313
377
  indicatorWidth: 0,
314
378
  grabbedKey: '',
379
+ menuOpen: false,
380
+ menuKey: '',
381
+ menuX: 0,
382
+ menuY: 0,
315
383
  announce: '',
316
384
  announceNonce: 0,
317
385
  keyboardReveal: false,
@@ -349,9 +417,9 @@ function reduceRaw(
349
417
  ): BlockDragState | [BlockDragState, BlockDragEffect[]] {
350
418
  switch (msg.type) {
351
419
  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
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
355
423
  if (
356
424
  state.handleVisible &&
357
425
  state.hoverKey === msg.key &&
@@ -374,16 +442,24 @@ function reduceRaw(
374
442
  // DOM and never in the tab order for a keyboard or screen-reader user, and
375
443
  // the unconditional help text ("Press Enter or Space to grab this block…")
376
444
  // 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
- }
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
+ ]
385
461
  case 'hoverOut':
386
- if (state.grabbedKey !== '' || state.dragging) return state
462
+ if (state.grabbedKey !== '' || state.dragging || state.menuOpen) return state
387
463
  return state.handleVisible ? { ...state, handleVisible: false } : state
388
464
  case 'dragStart':
389
465
  // The grip is hidden for the duration: it sits under the cursor and would
@@ -436,6 +512,23 @@ function reduceRaw(
436
512
  announce: 'Reorder cancelled.',
437
513
  announceNonce: state.announceNonce + 1,
438
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 }]]
439
532
  case 'announce':
440
533
  // The nonce is what makes a REPEATED announcement audible. `aria-live`
441
534
  // regions re-announce on text CHANGE, so two identical strings in a row —
@@ -489,10 +582,24 @@ export function blockDragPlugin(options: BlockDragOptions = {}): MarkdownPlugin
489
582
  const uid = `md-block-drag-${++seq}`
490
583
  const helpId = `${uid}-help`
491
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[] = []
492
593
 
493
594
  return {
494
595
  name: 'blockDrag',
495
596
 
597
+ onItems: (all) => {
598
+ turnIntoItems = all.filter(
599
+ (i) => (i.group === 'block' || i.group === 'list') && i.isActive !== undefined,
600
+ )
601
+ },
602
+
496
603
  // The command-surface half of the keyboard entry point (Mod+Shift+D is the
497
604
  // direct binding). Without one of these two, the entire reorder protocol —
498
605
  // and the help text that describes it — is unreachable without a pointer.
@@ -613,10 +720,28 @@ export function blockDragPlugin(options: BlockDragOptions = {}): MarkdownPlugin
613
720
  }
614
721
  revealers.set(editor, revealAtSelection)
615
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
+
616
739
  document.addEventListener('mousemove', onMouseMove, { passive: true })
740
+ document.addEventListener('contextmenu', onContextMenu)
617
741
  return mergeRegister(
618
742
  () => {
619
743
  document.removeEventListener('mousemove', onMouseMove)
744
+ document.removeEventListener('contextmenu', onContextMenu)
620
745
  if (pending) cancelAnimationFrame(pending)
621
746
  sessions.delete(editor)
622
747
  revealers.delete(editor)
@@ -664,21 +789,37 @@ export function blockDragPlugin(options: BlockDragOptions = {}): MarkdownPlugin
664
789
 
665
790
  // Held in a box rather than a bare `let`: the assignment happens inside
666
791
  // 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 }
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
+ }
669
798
  editor.update(
670
799
  () => {
671
- box.outcome =
672
- effect.type === 'move'
673
- ? $moveBlock(effect.sourceKey, effect.targetKey, effect.place)
674
- : $shiftBlock(effect.key, effect.direction)
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
+ }
675
816
  },
676
817
  {
677
818
  // Re-measure AFTER reconciliation so the gutter follows the block it
678
819
  // is pinned to; measuring inside the update would read the old layout.
679
820
  onUpdate: () => {
680
821
  const moved = box.outcome
681
- if (!moved) return
822
+ if (!moved || moved.key === null) return
682
823
  const el = editor.getElementByKey(moved.key)
683
824
  if (!el) return
684
825
  const r = el.getBoundingClientRect()
@@ -746,10 +887,12 @@ export function blockDragPlugin(options: BlockDragOptions = {}): MarkdownPlugin
746
887
  const onUp = (): void => {
747
888
  finish()
748
889
  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' })
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 })
753
896
  return
754
897
  }
755
898
  if (target) {
@@ -770,12 +913,16 @@ export function blockDragPlugin(options: BlockDragOptions = {}): MarkdownPlugin
770
913
  const onKeyDown = (event: KeyboardEvent): void => {
771
914
  switch (event.key) {
772
915
  case 'Enter':
773
- case ' ':
916
+ case ' ': {
774
917
  // Handled here rather than via `click` so Space cannot ALSO fire the
775
- // button's synthetic click and toggle twice.
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.
776
920
  event.preventDefault()
777
- send({ type: 'toggleGrab' })
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 })
778
924
  return
925
+ }
779
926
  case 'ArrowUp':
780
927
  event.preventDefault()
781
928
  send({ type: 'moveGrabbed', direction: -1 })
@@ -793,6 +940,58 @@ export function blockDragPlugin(options: BlockDragOptions = {}): MarkdownPlugin
793
940
 
794
941
  const grabbed = state.map((s) => s.grabbedKey !== '')
795
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
+
796
995
  return [
797
996
  // The live region and the instructions are rendered UNCONDITIONALLY.
798
997
  // A live region injected at announcement time is frequently missed by
@@ -882,6 +1081,70 @@ export function blockDragPlugin(options: BlockDragOptions = {}): MarkdownPlugin
882
1081
  ],
883
1082
  }),
884
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
+
885
1148
  ...overlayRoot({
886
1149
  open: state.at('indicatorVisible'),
887
1150
  x: state.at('indicatorX'),