@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.
@@ -65,12 +65,18 @@
65
65
 
66
66
  import {
67
67
  $applyNodeReplacement,
68
+ $createTextNode,
68
69
  $getNearestNodeFromDOMNode,
69
70
  $getSelection,
70
71
  $isRangeSelection,
72
+ $isTextNode,
71
73
  CLICK_COMMAND,
74
+ COMMAND_PRIORITY_HIGH,
72
75
  COMMAND_PRIORITY_LOW,
76
+ KEY_ARROW_DOWN_COMMAND,
77
+ KEY_ARROW_UP_COMMAND,
73
78
  KEY_ENTER_COMMAND,
79
+ KEY_ESCAPE_COMMAND,
74
80
  TextNode,
75
81
  type EditorConfig,
76
82
  type LexicalEditor,
@@ -82,8 +88,10 @@ import {
82
88
  } from 'lexical'
83
89
  import type { TextMatchTransformer } from '@lexical/markdown'
84
90
  import { mergeRegister } from '@lexical/utils'
91
+ import { div, each, text, type Signal } from '@llui/dom'
85
92
  import { setTransformerPrecedence } from '../transformers/registry.js'
86
93
  import { definePluginUI } from './ui.js'
94
+ import { OVERLAY_Z, overlayRoot } from './overlay.js'
87
95
  import type { CommandItem, MarkdownPlugin } from './types.js'
88
96
 
89
97
  const PLUGIN = 'wikilink'
@@ -420,14 +428,89 @@ setTransformerPrecedence(WIKILINK_TRANSFORMER, -10)
420
428
 
421
429
  // ── Click → host notification ────────────────────────────────────────────────
422
430
 
431
+ /** A document the host offers as a link target while the user types `[[…`. */
432
+ export interface DocCandidate {
433
+ /** The link target written into the document (`[[target]]`). */
434
+ readonly target: string
435
+ /** Human-facing title shown in the results list (defaults to `target`). */
436
+ readonly title?: string
437
+ /** A short one-line snippet shown under the title. */
438
+ readonly snippet?: string
439
+ /** A longer content excerpt shown in the reference/preview pane. */
440
+ readonly preview?: string
441
+ }
442
+
443
+ /** A results row with the active flag the view renders from. */
444
+ interface DocRow {
445
+ target: string
446
+ title: string
447
+ snippet: string
448
+ preview: string
449
+ active: boolean
450
+ }
451
+
423
452
  interface WikiLinkState {
424
453
  /** The most recently activated link (JSON-serializable, replay-safe). */
425
454
  last: WikiLink | null
455
+ /** Document-search panel: open while typing `[[…` with results to show. */
456
+ searchOpen: boolean
457
+ /** The current `[[` query text (what follows `[[`, before the caret). */
458
+ searchQuery: string
459
+ searchItems: DocRow[]
460
+ searchIndex: number
461
+ searchX: number
462
+ searchY: number
463
+ }
464
+
465
+ type WikiLinkMsg =
466
+ | { type: 'activate'; target: string; alias: string | null }
467
+ | { type: 'searchShow'; query: string; items: readonly DocCandidate[]; x: number; y: number }
468
+ | { type: 'searchHide' }
469
+ | { type: 'searchMove'; delta: number }
470
+ | { type: 'searchChoose' }
471
+ | { type: 'searchClick'; index: number }
472
+ | { type: 'searchHover'; index: number }
473
+
474
+ type WikiLinkEffect =
475
+ | { type: 'navigate'; link: WikiLink }
476
+ | { type: 'insert'; target: string; query: string }
477
+
478
+ const MAX_RESULTS = 8
479
+ /** Debounce before firing the host's (possibly async) `search` for a query. */
480
+ const SEARCH_DEBOUNCE_MS = 120
481
+
482
+ function toRows(items: readonly DocCandidate[], index: number): DocRow[] {
483
+ return items.map((c, i) => ({
484
+ target: c.target,
485
+ title: c.title ?? c.target,
486
+ snippet: c.snippet ?? '',
487
+ preview: c.preview ?? '',
488
+ active: i === index,
489
+ }))
426
490
  }
427
491
 
428
- type WikiLinkMsg = { type: 'activate'; target: string; alias: string | null }
492
+ /** The `[[` query immediately before a collapsed caret, or `null` when the caret
493
+ * is not inside an open `[[…` (no `[[`, or a `]`/`[`/newline already closed it). */
494
+ function $readWikiQuery(): string | null {
495
+ const selection = $getSelection()
496
+ if (!$isRangeSelection(selection) || !selection.isCollapsed()) return null
497
+ const node = selection.anchor.getNode()
498
+ if (!$isTextNode(node)) return null
499
+ const before = node.getTextContent().slice(0, selection.anchor.offset)
500
+ const match = before.match(/\[\[([^[\]\n|]*)$/)
501
+ return match ? (match[1] ?? '') : null
502
+ }
429
503
 
430
- type WikiLinkEffect = { type: 'navigate'; link: WikiLink }
504
+ /** Viewport point just below the caret, for anchoring the panel. */
505
+ function caretXY(): { x: number; y: number } {
506
+ if (typeof window === 'undefined') return { x: 0, y: 0 }
507
+ const sel = window.getSelection()
508
+ if (sel && sel.rangeCount > 0) {
509
+ const rect = sel.getRangeAt(0).getBoundingClientRect()
510
+ return { x: rect.left, y: rect.bottom + 4 }
511
+ }
512
+ return { x: 0, y: 0 }
513
+ }
431
514
 
432
515
  /** Resolve the wikilink a click landed on, if any. Must run in an editor scope. */
433
516
  function $wikiLinkFromEvent(event: MouseEvent): WikiLinkNode | null {
@@ -473,6 +556,14 @@ export interface WikiLinkPluginOptions {
473
556
  onNavigate?: (link: WikiLink) => void
474
557
  /** Text used as the target when the insert command runs with no selection. */
475
558
  placeholderTarget?: string
559
+ /**
560
+ * Document-search seam: as the user types `[[query`, resolve matching
561
+ * documents to offer as link targets, with an optional content preview shown in
562
+ * the panel's reference pane. Sync or async (async is debounced; a stale
563
+ * response for a superseded query is dropped). When omitted, the panel never
564
+ * opens and `[[target]]` still works by typing the closing `]]`.
565
+ */
566
+ search?: (query: string) => readonly DocCandidate[] | Promise<readonly DocCandidate[]>
476
567
  }
477
568
 
478
569
  export function wikilinkPlugin(opts: WikiLinkPluginOptions = {}): MarkdownPlugin {
@@ -480,10 +571,10 @@ export function wikilinkPlugin(opts: WikiLinkPluginOptions = {}): MarkdownPlugin
480
571
 
481
572
  const item: CommandItem = {
482
573
  id: PLUGIN,
483
- label: 'Wiki link',
574
+ label: 'Document link',
484
575
  icon: 'wikilink',
485
576
  group: 'inline',
486
- keywords: ['wiki', 'wikilink', 'backlink', 'reference', '[['],
577
+ keywords: ['document', 'doc', 'link', 'wiki', 'wikilink', 'backlink', 'reference', '[['],
487
578
  run: (editor) =>
488
579
  editor.update(() => {
489
580
  const selection = $getSelection()
@@ -508,12 +599,13 @@ export function wikilinkPlugin(opts: WikiLinkPluginOptions = {}): MarkdownPlugin
508
599
  transformers: [WIKILINK_TRANSFORMER],
509
600
  items: [item],
510
601
  register: (editor, ctx) => {
602
+ const emit = (msg: WikiLinkMsg): void => ctx.emit({ type: 'plugin', name: PLUGIN, msg })
511
603
  const activate = (node: WikiLinkNode): true => {
512
604
  const { target, alias } = node.getLink()
513
- ctx.emit({ type: 'plugin', name: PLUGIN, msg: { type: 'activate', target, alias } })
605
+ emit({ type: 'activate', target, alias })
514
606
  return true
515
607
  }
516
- return mergeRegister(
608
+ const navigation = mergeRegister(
517
609
  editor.registerCommand(
518
610
  CLICK_COMMAND,
519
611
  (event: MouseEvent) => {
@@ -540,14 +632,212 @@ export function wikilinkPlugin(opts: WikiLinkPluginOptions = {}): MarkdownPlugin
540
632
  COMMAND_PRIORITY_LOW,
541
633
  ),
542
634
  )
635
+
636
+ // Document-search typeahead is opt-in: with no `search` seam the panel never
637
+ // opens and `[[target]]` still works by typing the closing `]]`.
638
+ const search = opts.search
639
+ if (search === undefined) return navigation
640
+
641
+ let seq = 0
642
+ let timer: ReturnType<typeof setTimeout> | null = null
643
+ const clearTimer = (): void => {
644
+ if (timer !== null) {
645
+ clearTimeout(timer)
646
+ timer = null
647
+ }
648
+ }
649
+ const activeQuery = (): string | null => editor.getEditorState().read(() => $readWikiQuery())
650
+ const isActive = (): boolean => activeQuery() !== null
651
+
652
+ const refresh = (): void => {
653
+ const query = activeQuery()
654
+ if (query === null) {
655
+ clearTimer()
656
+ emit({ type: 'searchHide' })
657
+ return
658
+ }
659
+ clearTimer()
660
+ const mine = ++seq
661
+ timer = setTimeout(() => {
662
+ void Promise.resolve(search(query))
663
+ .then((results) => {
664
+ // Drop a superseded response (a later keystroke won) or one whose
665
+ // query the caret has since left/changed.
666
+ if (mine !== seq || activeQuery() !== query) return
667
+ const { x, y } = caretXY()
668
+ emit({ type: 'searchShow', query, items: results.slice(0, MAX_RESULTS), x, y })
669
+ })
670
+ .catch(() => {
671
+ /* a failed search simply shows nothing */
672
+ })
673
+ }, SEARCH_DEBOUNCE_MS)
674
+ }
675
+
676
+ const nav = (delta: number, e: KeyboardEvent | null): boolean => {
677
+ if (!isActive()) return false
678
+ e?.preventDefault()
679
+ emit({ type: 'searchMove', delta })
680
+ return true
681
+ }
682
+
683
+ return mergeRegister(
684
+ navigation,
685
+ editor.registerUpdateListener(() => refresh()),
686
+ editor.registerCommand(KEY_ARROW_DOWN_COMMAND, (e) => nav(1, e), COMMAND_PRIORITY_HIGH),
687
+ editor.registerCommand(KEY_ARROW_UP_COMMAND, (e) => nav(-1, e), COMMAND_PRIORITY_HIGH),
688
+ editor.registerCommand(
689
+ KEY_ENTER_COMMAND,
690
+ (e) => {
691
+ if (!isActive()) return false
692
+ e?.preventDefault()
693
+ emit({ type: 'searchChoose' })
694
+ return true
695
+ },
696
+ COMMAND_PRIORITY_HIGH,
697
+ ),
698
+ editor.registerCommand(
699
+ KEY_ESCAPE_COMMAND,
700
+ () => {
701
+ if (!isActive()) return false
702
+ emit({ type: 'searchHide' })
703
+ return true
704
+ },
705
+ COMMAND_PRIORITY_HIGH,
706
+ ),
707
+ clearTimer,
708
+ )
543
709
  },
544
710
  ui: definePluginUI<WikiLinkState, WikiLinkMsg, WikiLinkEffect>({
545
- init: () => ({ last: null }),
546
- update: (_state, msg) => {
547
- const link: WikiLink = { target: msg.target, alias: msg.alias }
548
- return [{ last: link }, [{ type: 'navigate', link }]]
711
+ init: () => ({
712
+ last: null,
713
+ searchOpen: false,
714
+ searchQuery: '',
715
+ searchItems: [],
716
+ searchIndex: 0,
717
+ searchX: 0,
718
+ searchY: 0,
719
+ }),
720
+ update: (state, msg) => {
721
+ switch (msg.type) {
722
+ case 'activate': {
723
+ const link: WikiLink = { target: msg.target, alias: msg.alias }
724
+ return [{ ...state, last: link }, [{ type: 'navigate', link }]]
725
+ }
726
+ case 'searchShow':
727
+ return {
728
+ ...state,
729
+ searchOpen: msg.items.length > 0,
730
+ searchQuery: msg.query,
731
+ searchItems: toRows(msg.items, 0),
732
+ searchIndex: 0,
733
+ searchX: msg.x,
734
+ searchY: msg.y,
735
+ }
736
+ case 'searchHide':
737
+ return state.searchOpen ? { ...state, searchOpen: false } : state
738
+ case 'searchMove': {
739
+ if (!state.searchOpen || state.searchItems.length === 0) return state
740
+ const i =
741
+ (state.searchIndex + msg.delta + state.searchItems.length) % state.searchItems.length
742
+ return {
743
+ ...state,
744
+ searchIndex: i,
745
+ searchItems: state.searchItems.map((r, idx) => ({ ...r, active: idx === i })),
746
+ }
747
+ }
748
+ case 'searchHover': {
749
+ if (msg.index < 0 || msg.index >= state.searchItems.length) return state
750
+ return {
751
+ ...state,
752
+ searchIndex: msg.index,
753
+ searchItems: state.searchItems.map((r, idx) => ({ ...r, active: idx === msg.index })),
754
+ }
755
+ }
756
+ case 'searchChoose': {
757
+ const row = state.searchItems[state.searchIndex]
758
+ if (!state.searchOpen || !row) return { ...state, searchOpen: false }
759
+ return [
760
+ { ...state, searchOpen: false },
761
+ [{ type: 'insert', target: row.target, query: state.searchQuery }],
762
+ ]
763
+ }
764
+ case 'searchClick': {
765
+ const row = state.searchItems[msg.index]
766
+ if (!row) return state
767
+ return [
768
+ { ...state, searchOpen: false },
769
+ [{ type: 'insert', target: row.target, query: state.searchQuery }],
770
+ ]
771
+ }
772
+ }
773
+ },
774
+ onEffect: (effect, ctx) => {
775
+ if (effect.type === 'navigate') {
776
+ opts.onNavigate?.(effect.link)
777
+ return
778
+ }
779
+ // insert: replace the typed `[[query` with a wikilink node + a space.
780
+ const editor = ctx.editor()
781
+ if (!editor) return
782
+ editor.update(() => {
783
+ const selection = $getSelection()
784
+ if (!$isRangeSelection(selection) || !selection.isCollapsed()) return
785
+ const node = selection.anchor.getNode()
786
+ if (!$isTextNode(node)) return
787
+ const offset = selection.anchor.offset
788
+ // Remove the `[[` (2 chars) plus the query text.
789
+ const start = offset - effect.query.length - 2
790
+ if (start < 0) return
791
+ node.spliceText(start, effect.query.length + 2, '', true)
792
+ const sel = $getSelection()
793
+ if (!$isRangeSelection(sel)) return
794
+ sel.insertNodes([$createWikiLinkNode(effect.target, null), $createTextNode(' ')])
795
+ })
549
796
  },
550
- onEffect: (effect) => opts.onNavigate?.(effect.link),
797
+ view: ({ state, send }) =>
798
+ overlayRoot({
799
+ open: state.at('searchOpen'),
800
+ x: state.at('searchX'),
801
+ y: state.at('searchY'),
802
+ zIndex: OVERLAY_Z.typeahead,
803
+ attrs: { 'data-scope': 'md-wikilink', 'data-part': 'panel-root' },
804
+ children: () => [
805
+ div({ 'data-scope': 'md-wikilink', 'data-part': 'panel' }, [
806
+ div({ 'data-scope': 'md-wikilink', 'data-part': 'results', role: 'listbox' }, [
807
+ each(state.at('searchItems') as Signal<DocRow[]>, {
808
+ key: (r) => r.target,
809
+ render: (item, index) => [
810
+ div(
811
+ {
812
+ 'data-scope': 'md-wikilink',
813
+ 'data-part': 'result',
814
+ role: 'option',
815
+ 'data-active': item.map((r) => (r.active ? '' : undefined)),
816
+ onMouseDown: (e: MouseEvent) => {
817
+ e.preventDefault()
818
+ send({ type: 'searchClick', index: index.peek() })
819
+ },
820
+ onMouseEnter: () => send({ type: 'searchHover', index: index.peek() }),
821
+ },
822
+ [
823
+ div({ 'data-scope': 'md-wikilink', 'data-part': 'result-title' }, [
824
+ text(item.map((r) => r.title)),
825
+ ]),
826
+ div({ 'data-scope': 'md-wikilink', 'data-part': 'result-snippet' }, [
827
+ text(item.map((r) => r.snippet)),
828
+ ]),
829
+ ],
830
+ ),
831
+ ],
832
+ }),
833
+ ]),
834
+ // The reference pane: the active candidate's content preview.
835
+ div({ 'data-scope': 'md-wikilink', 'data-part': 'preview' }, [
836
+ text(state.map((s) => s.searchItems[s.searchIndex]?.preview ?? '')),
837
+ ]),
838
+ ]),
839
+ ],
840
+ }),
551
841
  }),
552
842
  }
553
843
  }
@@ -61,3 +61,58 @@
61
61
  background-color 120ms ease;
62
62
  }
63
63
  }
64
+
65
+ /* Block-actions menu (grip click / right-click). Positioned inline by the plugin;
66
+ * appearance only here. */
67
+ [data-scope='md-block-drag'][data-part='menu'] {
68
+ min-width: 160px;
69
+ padding: 4px;
70
+ border: 1px solid var(--md-border, rgba(127, 127, 127, 0.2));
71
+ border-radius: var(--md-radius, 8px);
72
+ background: var(--md-surface, #fff);
73
+ color: var(--md-text, #1f2328);
74
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
75
+ font-size: 13px;
76
+ display: flex;
77
+ flex-direction: column;
78
+ gap: 1px;
79
+ }
80
+
81
+ [data-scope='md-block-drag'][data-part='menu-label'] {
82
+ padding: 4px 8px 2px;
83
+ font-size: 11px;
84
+ font-weight: 600;
85
+ text-transform: uppercase;
86
+ letter-spacing: 0.04em;
87
+ color: var(--md-muted, #9aa0a6);
88
+ }
89
+
90
+ [data-scope='md-block-drag'][data-part='menu-item'] {
91
+ display: flex;
92
+ align-items: center;
93
+ width: 100%;
94
+ padding: 5px 8px;
95
+ border: 0;
96
+ border-radius: 5px;
97
+ background: transparent;
98
+ color: inherit;
99
+ font: inherit;
100
+ text-align: left;
101
+ cursor: pointer;
102
+ }
103
+
104
+ [data-scope='md-block-drag'][data-part='menu-item']:hover,
105
+ [data-scope='md-block-drag'][data-part='menu-item']:focus-visible {
106
+ background: var(--md-hover, rgba(127, 127, 127, 0.14));
107
+ outline: none;
108
+ }
109
+
110
+ [data-scope='md-block-drag'][data-part='menu-item']:focus-visible {
111
+ box-shadow: inset 0 0 0 2px var(--md-accent, #3b82f6);
112
+ }
113
+
114
+ [data-scope='md-block-drag'][data-part='menu-sep'] {
115
+ height: 1px;
116
+ margin: 4px 0;
117
+ background: var(--md-border, rgba(127, 127, 127, 0.2));
118
+ }
@@ -485,6 +485,54 @@
485
485
  background: var(--md-primary-soft);
486
486
  }
487
487
 
488
+ /* ── Document-link (wikilink) search + reference panel ────────────────── */
489
+ [data-scope='md-wikilink'][data-part='panel'] {
490
+ display: flex;
491
+ min-width: 22rem;
492
+ max-width: 34rem;
493
+ background: var(--md-surface);
494
+ border: 1px solid var(--md-border);
495
+ border-radius: 8px;
496
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.5);
497
+ overflow: hidden;
498
+ }
499
+ [data-scope='md-wikilink'][data-part='results'] {
500
+ flex: 0 0 14rem;
501
+ max-height: 18rem;
502
+ overflow: auto;
503
+ padding: 0.25rem;
504
+ border-right: 1px solid var(--md-border);
505
+ }
506
+ [data-scope='md-wikilink'][data-part='result'] {
507
+ padding: 0.4rem 0.6rem;
508
+ border-radius: 5px;
509
+ cursor: pointer;
510
+ }
511
+ [data-scope='md-wikilink'][data-part='result'][data-active] {
512
+ background: var(--md-primary-soft);
513
+ }
514
+ [data-scope='md-wikilink'][data-part='result-title'] {
515
+ font-size: 0.88rem;
516
+ color: var(--md-text);
517
+ }
518
+ [data-scope='md-wikilink'][data-part='result-snippet'] {
519
+ font-size: 0.78rem;
520
+ color: var(--md-muted);
521
+ white-space: nowrap;
522
+ overflow: hidden;
523
+ text-overflow: ellipsis;
524
+ }
525
+ [data-scope='md-wikilink'][data-part='preview'] {
526
+ flex: 1 1 auto;
527
+ max-height: 18rem;
528
+ overflow: auto;
529
+ padding: 0.6rem 0.75rem;
530
+ font-size: 0.82rem;
531
+ line-height: 1.5;
532
+ color: var(--md-text-soft, var(--md-muted));
533
+ white-space: pre-wrap;
534
+ }
535
+
488
536
  /* ── Context menu ─────────────────────────────────────────────────────── */
489
537
  [data-scope='md-context'][data-part='backdrop'] {
490
538
  position: fixed;