@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.
- package/dist/plugins/block-drag.d.ts +1 -0
- package/dist/plugins/block-drag.d.ts.map +1 -1
- package/dist/plugins/block-drag.js +251 -28
- package/dist/plugins/block-drag.js.map +1 -1
- package/dist/plugins/wikilink.d.ts +19 -0
- package/dist/plugins/wikilink.d.ts.map +1 -1
- package/dist/plugins/wikilink.js +235 -10
- package/dist/plugins/wikilink.js.map +1 -1
- package/dist/styles/block-drag.css +55 -0
- package/dist/styles/editor.css +48 -0
- package/package.json +6 -6
- package/src/plugins/block-drag.ts +291 -28
- package/src/plugins/wikilink.ts +301 -11
- package/src/styles/block-drag.css +55 -0
- package/src/styles/editor.css +48 -0
|
@@ -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
|
|
353
|
-
// a stray pointer move must not silently re-target the pending
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
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
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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:
|
|
750
|
-
//
|
|
751
|
-
//
|
|
752
|
-
|
|
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
|
|
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
|
-
|
|
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'),
|