@relevaince/mentions 0.3.3 → 0.6.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/index.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  // src/components/MentionsInput.tsx
2
- import { forwardRef, useCallback as useCallback3, useImperativeHandle } from "react";
2
+ import { forwardRef, useCallback as useCallback3, useId, useImperativeHandle } from "react";
3
+ import { createPortal } from "react-dom";
3
4
  import { EditorContent } from "@tiptap/react";
4
5
 
5
6
  // src/hooks/useMentionsEditor.ts
@@ -25,6 +26,12 @@ var MentionNode = Node.create({
25
26
  atom: true,
26
27
  selectable: true,
27
28
  draggable: false,
29
+ addOptions() {
30
+ return {
31
+ onClickRef: void 0,
32
+ onHoverRef: void 0
33
+ };
34
+ },
28
35
  addAttributes() {
29
36
  return {
30
37
  id: {
@@ -57,11 +64,17 @@ var MentionNode = Node.create({
57
64
  const label = node.attrs.label;
58
65
  const prefix = DEFAULT_PREFIXES[entityType] ?? "@";
59
66
  const display = `${prefix}${label}`;
67
+ const hasClick = !!this.options.onClickRef?.current;
68
+ const extraAttrs = {};
69
+ if (hasClick) {
70
+ extraAttrs["data-mention-clickable"] = "";
71
+ }
60
72
  return [
61
73
  "span",
62
74
  mergeAttributes(HTMLAttributes, {
63
75
  "data-mention": "",
64
- class: "mention-chip"
76
+ class: "mention-chip",
77
+ ...extraAttrs
65
78
  }),
66
79
  display
67
80
  ];
@@ -72,6 +85,58 @@ var MentionNode = Node.create({
72
85
  const prefix = DEFAULT_PREFIXES[entityType] ?? "@";
73
86
  return `${prefix}${label}`;
74
87
  },
88
+ addNodeView() {
89
+ const options = this.options;
90
+ return ({ node, HTMLAttributes }) => {
91
+ const entityType = node.attrs.entityType;
92
+ const label = node.attrs.label;
93
+ const id = node.attrs.id;
94
+ const prefix = DEFAULT_PREFIXES[entityType] ?? "@";
95
+ const dom = document.createElement("span");
96
+ Object.entries(
97
+ mergeAttributes(HTMLAttributes, {
98
+ "data-mention": "",
99
+ "data-type": entityType,
100
+ "data-id": id,
101
+ class: "mention-chip"
102
+ })
103
+ ).forEach(([key, val]) => {
104
+ if (val != null && val !== false) dom.setAttribute(key, String(val));
105
+ });
106
+ dom.textContent = `${prefix}${label}`;
107
+ if (options.onClickRef?.current) {
108
+ dom.setAttribute("data-mention-clickable", "");
109
+ dom.style.cursor = "pointer";
110
+ }
111
+ dom.addEventListener("click", (event) => {
112
+ const handler = options.onClickRef?.current;
113
+ if (handler) {
114
+ event.preventDefault();
115
+ event.stopPropagation();
116
+ handler({ id, type: entityType, label }, event);
117
+ }
118
+ });
119
+ let tooltip = null;
120
+ dom.addEventListener("mouseenter", () => {
121
+ const hoverFn = options.onHoverRef?.current;
122
+ if (!hoverFn) return;
123
+ const content = hoverFn({ id, type: entityType, label });
124
+ if (!content) return;
125
+ tooltip = document.createElement("div");
126
+ tooltip.setAttribute("data-mention-tooltip", "");
127
+ tooltip.textContent = typeof content === "string" ? content : "";
128
+ dom.style.position = "relative";
129
+ dom.appendChild(tooltip);
130
+ });
131
+ dom.addEventListener("mouseleave", () => {
132
+ if (tooltip && tooltip.parentNode) {
133
+ tooltip.parentNode.removeChild(tooltip);
134
+ tooltip = null;
135
+ }
136
+ });
137
+ return { dom };
138
+ };
139
+ },
75
140
  addKeyboardShortcuts() {
76
141
  return {
77
142
  Backspace: () => this.editor.commands.command(({ tr, state }) => {
@@ -104,7 +169,13 @@ function detectTrigger(text, cursorPos, docStartPos, triggers) {
104
169
  if (before.substring(i, i + trigger.length) === trigger) {
105
170
  if (i === 0 || /\s/.test(before[i - 1])) {
106
171
  const query = before.slice(i + trigger.length);
107
- return { trigger, query, from: docStartPos + i, to: cursorPos };
172
+ return {
173
+ trigger,
174
+ query,
175
+ from: docStartPos + i,
176
+ to: cursorPos,
177
+ textBefore: before.slice(0, i)
178
+ };
108
179
  }
109
180
  }
110
181
  }
@@ -112,7 +183,7 @@ function detectTrigger(text, cursorPos, docStartPos, triggers) {
112
183
  return null;
113
184
  }
114
185
  var suggestionPluginKey = new PluginKey("mentionSuggestion");
115
- function createSuggestionExtension(triggers, callbacksRef) {
186
+ function createSuggestionExtension(triggers, callbacksRef, allowTriggerRef, streamingRef) {
116
187
  return Extension.create({
117
188
  name: "mentionSuggestion",
118
189
  priority: 200,
@@ -163,6 +234,15 @@ function createSuggestionExtension(triggers, callbacksRef) {
163
234
  view() {
164
235
  return {
165
236
  update(view, _prevState) {
237
+ if (streamingRef?.current) {
238
+ if (active) {
239
+ active = false;
240
+ lastQuery = null;
241
+ lastTrigger = null;
242
+ callbacksRef.current.onExit();
243
+ }
244
+ return;
245
+ }
166
246
  const { state } = view;
167
247
  const { selection } = state;
168
248
  if (!selection.empty) {
@@ -190,6 +270,20 @@ function createSuggestionExtension(triggers, callbacksRef) {
190
270
  const cursorPos = $pos.pos;
191
271
  const match = detectTrigger(blockText, cursorPos, blockStart, triggers);
192
272
  if (match) {
273
+ if (allowTriggerRef?.current) {
274
+ const allowed = allowTriggerRef.current(match.trigger, {
275
+ textBefore: match.textBefore
276
+ });
277
+ if (!allowed) {
278
+ if (active) {
279
+ active = false;
280
+ lastQuery = null;
281
+ lastTrigger = null;
282
+ callbacksRef.current.onExit();
283
+ }
284
+ return;
285
+ }
286
+ }
193
287
  const range = { from: match.from, to: match.to };
194
288
  const props = {
195
289
  query: match.query,
@@ -359,27 +453,48 @@ function buildOutput(editor) {
359
453
  plainText: extractPlainText(json)
360
454
  };
361
455
  }
362
- function createSubmitExtension(onSubmitRef, clearOnSubmitRef) {
456
+ function collectMentionTokens(doc) {
457
+ const tokens = [];
458
+ function walk(node) {
459
+ if (node.type === "mention" && node.attrs) {
460
+ tokens.push({
461
+ id: node.attrs.id,
462
+ type: node.attrs.entityType ?? node.attrs.type,
463
+ label: node.attrs.label
464
+ });
465
+ }
466
+ if (node.content) {
467
+ for (const child of node.content) walk(child);
468
+ }
469
+ }
470
+ walk(doc);
471
+ return tokens;
472
+ }
473
+ function createSubmitExtension(onSubmitRef, clearOnSubmitRef, submitKeyRef) {
363
474
  return Extension2.create({
364
475
  name: "submitShortcut",
365
476
  priority: 150,
366
477
  addKeyboardShortcuts() {
367
478
  return {
368
479
  "Mod-Enter": () => {
369
- if (onSubmitRef.current) {
370
- onSubmitRef.current(buildOutput(this.editor));
371
- if (clearOnSubmitRef.current) {
372
- this.editor.commands.clearContent(true);
480
+ const key = submitKeyRef.current;
481
+ if (key === "mod+enter" || key === "enter") {
482
+ if (onSubmitRef.current) {
483
+ onSubmitRef.current(buildOutput(this.editor));
484
+ if (clearOnSubmitRef.current) {
485
+ this.editor.commands.clearContent(true);
486
+ }
373
487
  }
488
+ return true;
374
489
  }
375
- return true;
490
+ return false;
376
491
  }
377
492
  };
378
493
  }
379
494
  });
380
495
  }
381
496
  var enterSubmitPluginKey = new PluginKey2("enterSubmit");
382
- function createEnterExtension(onSubmitRef, clearOnSubmitRef) {
497
+ function createEnterExtension(onSubmitRef, clearOnSubmitRef, submitKeyRef) {
383
498
  return Extension2.create({
384
499
  name: "enterSubmit",
385
500
  priority: 150,
@@ -391,6 +506,11 @@ function createEnterExtension(onSubmitRef, clearOnSubmitRef) {
391
506
  props: {
392
507
  handleKeyDown(_view, event) {
393
508
  if (event.key !== "Enter") return false;
509
+ const key = submitKeyRef.current;
510
+ if (key === "none") return false;
511
+ if (key === "mod+enter") {
512
+ return false;
513
+ }
394
514
  if (event.shiftKey) {
395
515
  editor.commands.splitBlock();
396
516
  return true;
@@ -410,6 +530,61 @@ function createEnterExtension(onSubmitRef, clearOnSubmitRef) {
410
530
  }
411
531
  });
412
532
  }
533
+ var mentionRemovePluginKey = new PluginKey2("mentionRemove");
534
+ function createMentionRemoveExtension(onMentionRemoveRef) {
535
+ return Extension2.create({
536
+ name: "mentionRemoveDetector",
537
+ priority: 100,
538
+ addProseMirrorPlugins() {
539
+ return [
540
+ new Plugin2({
541
+ key: mentionRemovePluginKey,
542
+ appendTransaction(transactions, oldState, newState) {
543
+ if (!onMentionRemoveRef.current) return null;
544
+ const oldMentions = collectMentionTokens(oldState.doc.toJSON());
545
+ const newMentions = collectMentionTokens(newState.doc.toJSON());
546
+ if (oldMentions.length <= newMentions.length) return null;
547
+ const newIds = new Set(newMentions.map((m) => m.id));
548
+ for (const m of oldMentions) {
549
+ if (!newIds.has(m.id)) {
550
+ onMentionRemoveRef.current(m);
551
+ }
552
+ }
553
+ return null;
554
+ }
555
+ })
556
+ ];
557
+ }
558
+ });
559
+ }
560
+ var streamingBlockPluginKey = new PluginKey2("streamingBlock");
561
+ function createStreamingBlockExtension(streamingRef) {
562
+ return Extension2.create({
563
+ name: "streamingBlock",
564
+ priority: 200,
565
+ addProseMirrorPlugins() {
566
+ return [
567
+ new Plugin2({
568
+ key: streamingBlockPluginKey,
569
+ props: {
570
+ handleKeyDown() {
571
+ return streamingRef.current;
572
+ },
573
+ handleKeyPress() {
574
+ return streamingRef.current;
575
+ },
576
+ handlePaste() {
577
+ return streamingRef.current;
578
+ },
579
+ handleDrop() {
580
+ return streamingRef.current;
581
+ }
582
+ }
583
+ })
584
+ ];
585
+ }
586
+ });
587
+ }
413
588
  function useMentionsEditor({
414
589
  providers,
415
590
  value,
@@ -419,7 +594,17 @@ function useMentionsEditor({
419
594
  placeholder,
420
595
  autoFocus = false,
421
596
  editable = true,
422
- callbacksRef
597
+ callbacksRef,
598
+ onFocus,
599
+ onBlur,
600
+ submitKey = "enter",
601
+ onMentionRemove,
602
+ onMentionClick,
603
+ onMentionHover,
604
+ allowTrigger,
605
+ validateMention,
606
+ streaming = false,
607
+ onStreamingComplete
423
608
  }) {
424
609
  const onChangeRef = useRef(onChange);
425
610
  onChangeRef.current = onChange;
@@ -427,6 +612,30 @@ function useMentionsEditor({
427
612
  onSubmitRef.current = onSubmit;
428
613
  const clearOnSubmitRef = useRef(clearOnSubmit);
429
614
  clearOnSubmitRef.current = clearOnSubmit;
615
+ const onFocusRef = useRef(onFocus);
616
+ onFocusRef.current = onFocus;
617
+ const onBlurRef = useRef(onBlur);
618
+ onBlurRef.current = onBlur;
619
+ const submitKeyRef = useRef(submitKey);
620
+ submitKeyRef.current = submitKey;
621
+ const onMentionRemoveRef = useRef(onMentionRemove);
622
+ onMentionRemoveRef.current = onMentionRemove;
623
+ const onMentionClickRef = useRef(onMentionClick);
624
+ onMentionClickRef.current = onMentionClick;
625
+ const onMentionHoverRef = useRef(onMentionHover);
626
+ onMentionHoverRef.current = onMentionHover;
627
+ const allowTriggerRef = useRef(allowTrigger);
628
+ allowTriggerRef.current = allowTrigger;
629
+ const validateMentionRef = useRef(validateMention);
630
+ validateMentionRef.current = validateMention;
631
+ const onStreamingCompleteRef = useRef(onStreamingComplete);
632
+ onStreamingCompleteRef.current = onStreamingComplete;
633
+ const streamingRef = useRef(streaming);
634
+ streamingRef.current = streaming;
635
+ const prevStreamingRef = useRef(streaming);
636
+ const throttleTimerRef = useRef(null);
637
+ const pendingOutputRef = useRef(null);
638
+ const internalMarkdownRef = useRef(null);
430
639
  const initialContent = useMemo(() => {
431
640
  if (!value) return void 0;
432
641
  return parseFromMarkdown(value);
@@ -438,16 +647,36 @@ function useMentionsEditor({
438
647
  [triggersKey]
439
648
  );
440
649
  const suggestionExtension = useMemo(
441
- () => createSuggestionExtension(triggers, callbacksRef),
650
+ () => createSuggestionExtension(
651
+ triggers,
652
+ callbacksRef,
653
+ allowTriggerRef,
654
+ streamingRef
655
+ ),
442
656
  // eslint-disable-next-line react-hooks/exhaustive-deps
443
657
  [triggersKey]
444
658
  );
445
659
  const submitExt = useMemo(
446
- () => createSubmitExtension(onSubmitRef, clearOnSubmitRef),
660
+ () => createSubmitExtension(onSubmitRef, clearOnSubmitRef, submitKeyRef),
447
661
  []
448
662
  );
449
663
  const enterExt = useMemo(
450
- () => createEnterExtension(onSubmitRef, clearOnSubmitRef),
664
+ () => createEnterExtension(onSubmitRef, clearOnSubmitRef, submitKeyRef),
665
+ []
666
+ );
667
+ const mentionRemoveExt = useMemo(
668
+ () => createMentionRemoveExtension(onMentionRemoveRef),
669
+ []
670
+ );
671
+ const streamingBlockExt = useMemo(
672
+ () => createStreamingBlockExtension(streamingRef),
673
+ []
674
+ );
675
+ const mentionNodeExt = useMemo(
676
+ () => MentionNode.configure({
677
+ onClickRef: onMentionClickRef,
678
+ onHoverRef: onMentionHoverRef
679
+ }),
451
680
  []
452
681
  );
453
682
  const editor = useEditor({
@@ -466,10 +695,12 @@ function useMentionsEditor({
466
695
  placeholder: ({ editor: editor2 }) => editor2.isEmpty ? placeholder ?? "Type a message..." : "",
467
696
  showOnlyCurrent: true
468
697
  }),
469
- MentionNode,
698
+ mentionNodeExt,
470
699
  suggestionExtension,
471
700
  submitExt,
472
- enterExt
701
+ enterExt,
702
+ mentionRemoveExt,
703
+ streamingBlockExt
473
704
  ],
474
705
  content: initialContent,
475
706
  autofocus: autoFocus ? "end" : false,
@@ -480,14 +711,85 @@ function useMentionsEditor({
480
711
  }
481
712
  },
482
713
  onUpdate: ({ editor: editor2 }) => {
483
- onChangeRef.current?.(buildOutput(editor2));
714
+ const output = buildOutput(editor2);
715
+ internalMarkdownRef.current = output.markdown;
716
+ if (streamingRef.current) {
717
+ pendingOutputRef.current = output;
718
+ if (!throttleTimerRef.current) {
719
+ throttleTimerRef.current = setTimeout(() => {
720
+ throttleTimerRef.current = null;
721
+ if (pendingOutputRef.current) {
722
+ onChangeRef.current?.(pendingOutputRef.current);
723
+ pendingOutputRef.current = null;
724
+ }
725
+ }, 150);
726
+ }
727
+ } else {
728
+ onChangeRef.current?.(output);
729
+ }
730
+ },
731
+ onFocus: () => {
732
+ onFocusRef.current?.();
733
+ },
734
+ onBlur: () => {
735
+ onBlurRef.current?.();
484
736
  }
485
737
  });
738
+ useEffect(() => {
739
+ if (prevStreamingRef.current && !streaming && editor) {
740
+ if (throttleTimerRef.current) {
741
+ clearTimeout(throttleTimerRef.current);
742
+ throttleTimerRef.current = null;
743
+ }
744
+ const output = buildOutput(editor);
745
+ onChangeRef.current?.(output);
746
+ onStreamingCompleteRef.current?.(output);
747
+ pendingOutputRef.current = null;
748
+ }
749
+ prevStreamingRef.current = streaming;
750
+ }, [streaming, editor]);
751
+ useEffect(() => {
752
+ return () => {
753
+ if (throttleTimerRef.current) {
754
+ clearTimeout(throttleTimerRef.current);
755
+ }
756
+ };
757
+ }, []);
486
758
  useEffect(() => {
487
759
  if (editor && editor.isEditable !== editable) {
488
760
  editor.setEditable(editable);
489
761
  }
490
762
  }, [editor, editable]);
763
+ useEffect(() => {
764
+ if (!editor || value === void 0) return;
765
+ if (value === internalMarkdownRef.current) return;
766
+ const doc = parseFromMarkdown(value);
767
+ editor.commands.setContent(doc);
768
+ internalMarkdownRef.current = value;
769
+ }, [editor, value]);
770
+ useEffect(() => {
771
+ if (!editor || !validateMention) return;
772
+ const runValidation = async () => {
773
+ const doc = editor.getJSON();
774
+ const tokens = collectMentionTokens(doc);
775
+ const invalidIds = /* @__PURE__ */ new Set();
776
+ await Promise.all(
777
+ tokens.map(async (token) => {
778
+ const valid = await validateMention(token);
779
+ if (!valid) invalidIds.add(token.id);
780
+ })
781
+ );
782
+ editor.view.dom.querySelectorAll("[data-mention]").forEach((el) => {
783
+ const id = el.getAttribute("data-id");
784
+ if (id && invalidIds.has(id)) {
785
+ el.setAttribute("data-mention-invalid", "");
786
+ } else {
787
+ el.removeAttribute("data-mention-invalid");
788
+ }
789
+ });
790
+ };
791
+ runValidation();
792
+ }, [editor, validateMention]);
491
793
  const clear = useCallback(() => {
492
794
  editor?.commands.clearContent(true);
493
795
  }, [editor]);
@@ -496,6 +798,21 @@ function useMentionsEditor({
496
798
  if (!editor) return;
497
799
  const doc = parseFromMarkdown(markdown);
498
800
  editor.commands.setContent(doc);
801
+ internalMarkdownRef.current = markdown;
802
+ if (streamingRef.current) {
803
+ editor.commands.focus("end");
804
+ }
805
+ },
806
+ [editor]
807
+ );
808
+ const appendText = useCallback(
809
+ (text) => {
810
+ if (!editor) return;
811
+ const endPos = editor.state.doc.content.size - 1;
812
+ editor.commands.insertContentAt(endPos, text);
813
+ if (streamingRef.current) {
814
+ editor.commands.focus("end");
815
+ }
499
816
  },
500
817
  [editor]
501
818
  );
@@ -506,11 +823,32 @@ function useMentionsEditor({
506
823
  if (!editor) return null;
507
824
  return buildOutput(editor);
508
825
  }, [editor]);
509
- return { editor, getOutput, clear, setContent, focus };
826
+ return { editor, getOutput, clear, setContent, appendText, focus };
827
+ }
828
+
829
+ // src/hooks/useSuggestion.ts
830
+ import { useCallback as useCallback2, useEffect as useEffect2, useRef as useRef2, useState } from "react";
831
+
832
+ // src/utils/debounce.ts
833
+ function debounce(fn, ms) {
834
+ let timer = null;
835
+ const debounced = ((...args) => {
836
+ if (timer != null) clearTimeout(timer);
837
+ timer = setTimeout(() => {
838
+ timer = null;
839
+ fn(...args);
840
+ }, ms);
841
+ });
842
+ debounced.cancel = () => {
843
+ if (timer != null) {
844
+ clearTimeout(timer);
845
+ timer = null;
846
+ }
847
+ };
848
+ return debounced;
510
849
  }
511
850
 
512
851
  // src/hooks/useSuggestion.ts
513
- import { useCallback as useCallback2, useRef as useRef2, useState } from "react";
514
852
  var IDLE_STATE = {
515
853
  state: "idle",
516
854
  items: [],
@@ -521,16 +859,24 @@ var IDLE_STATE = {
521
859
  trigger: null,
522
860
  query: ""
523
861
  };
524
- function useSuggestion(providers) {
862
+ function useSuggestion(providers, options = {}) {
525
863
  const [uiState, setUIState] = useState(IDLE_STATE);
526
864
  const stateRef = useRef2(uiState);
527
865
  stateRef.current = uiState;
528
866
  const providersRef = useRef2(providers);
529
867
  providersRef.current = providers;
868
+ const onMentionAddRef = useRef2(options.onMentionAdd);
869
+ onMentionAddRef.current = options.onMentionAdd;
530
870
  const commandRef = useRef2(
531
871
  null
532
872
  );
533
873
  const providerRef = useRef2(null);
874
+ const debouncedFetchRef = useRef2(null);
875
+ useEffect2(() => {
876
+ return () => {
877
+ debouncedFetchRef.current?.cancel();
878
+ };
879
+ }, []);
534
880
  const fetchItems = useCallback2(
535
881
  async (provider, query, parent, useSearchAll) => {
536
882
  setUIState((prev) => ({ ...prev, loading: true, state: "loading" }));
@@ -561,6 +907,23 @@ function useSuggestion(providers) {
561
907
  },
562
908
  []
563
909
  );
910
+ const scheduleFetch = useCallback2(
911
+ (provider, query, parent, useSearchAll) => {
912
+ debouncedFetchRef.current?.cancel();
913
+ const ms = provider.debounceMs;
914
+ if (ms && ms > 0) {
915
+ setUIState((prev) => ({ ...prev, loading: true }));
916
+ const debouncedFn = debounce(() => {
917
+ fetchItems(provider, query, parent, useSearchAll);
918
+ }, ms);
919
+ debouncedFetchRef.current = debouncedFn;
920
+ debouncedFn();
921
+ } else {
922
+ fetchItems(provider, query, parent, useSearchAll);
923
+ }
924
+ },
925
+ [fetchItems]
926
+ );
564
927
  const onStart = useCallback2(
565
928
  (props) => {
566
929
  const provider = providersRef.current.find(
@@ -579,13 +942,31 @@ function useSuggestion(providers) {
579
942
  trigger: props.trigger,
580
943
  query: props.query
581
944
  });
945
+ if (!props.query.trim() && provider.getRecentItems) {
946
+ provider.getRecentItems().then((recentItems) => {
947
+ const tagged = recentItems.map((item) => ({
948
+ ...item,
949
+ group: item.group ?? "Recent"
950
+ }));
951
+ setUIState((prev) => ({
952
+ ...prev,
953
+ items: tagged,
954
+ loading: false,
955
+ state: "showing",
956
+ activeIndex: 0
957
+ }));
958
+ }).catch(() => {
959
+ scheduleFetch(provider, props.query);
960
+ });
961
+ return;
962
+ }
582
963
  if (props.query.trim() && provider.searchAll) {
583
- fetchItems(provider, props.query, void 0, true);
964
+ scheduleFetch(provider, props.query, void 0, true);
584
965
  } else {
585
- fetchItems(provider, props.query);
966
+ scheduleFetch(provider, props.query);
586
967
  }
587
968
  },
588
- [fetchItems]
969
+ [scheduleFetch]
589
970
  );
590
971
  const onUpdate = useCallback2(
591
972
  (props) => {
@@ -609,14 +990,31 @@ function useSuggestion(providers) {
609
990
  }));
610
991
  }
611
992
  if (props.query.trim() && provider.searchAll) {
612
- fetchItems(provider, props.query, void 0, true);
993
+ scheduleFetch(provider, props.query, void 0, true);
994
+ } else if (!props.query.trim() && provider.getRecentItems) {
995
+ provider.getRecentItems().then((recentItems) => {
996
+ const tagged = recentItems.map((item) => ({
997
+ ...item,
998
+ group: item.group ?? "Recent"
999
+ }));
1000
+ setUIState((prev) => ({
1001
+ ...prev,
1002
+ items: tagged,
1003
+ loading: false,
1004
+ state: "showing",
1005
+ activeIndex: 0
1006
+ }));
1007
+ }).catch(() => {
1008
+ scheduleFetch(provider, props.query);
1009
+ });
613
1010
  } else {
614
- fetchItems(provider, props.query);
1011
+ scheduleFetch(provider, props.query);
615
1012
  }
616
1013
  },
617
- [fetchItems]
1014
+ [scheduleFetch]
618
1015
  );
619
1016
  const onExit = useCallback2(() => {
1017
+ debouncedFetchRef.current?.cancel();
620
1018
  providerRef.current = null;
621
1019
  commandRef.current = null;
622
1020
  setUIState(IDLE_STATE);
@@ -651,8 +1049,8 @@ function useSuggestion(providers) {
651
1049
  fetchItems(provider, "", selected);
652
1050
  return;
653
1051
  }
1052
+ const rootLabel = current.breadcrumbs.length > 0 ? current.breadcrumbs[0].label : selected.rootLabel ?? null;
654
1053
  if (commandRef.current) {
655
- const rootLabel = current.breadcrumbs.length > 0 ? current.breadcrumbs[0].label : selected.rootLabel ?? null;
656
1054
  commandRef.current({
657
1055
  id: selected.id,
658
1056
  label: selected.label,
@@ -660,6 +1058,11 @@ function useSuggestion(providers) {
660
1058
  rootLabel
661
1059
  });
662
1060
  }
1061
+ onMentionAddRef.current?.({
1062
+ id: selected.id,
1063
+ type: selected.type,
1064
+ label: selected.label
1065
+ });
663
1066
  },
664
1067
  [fetchItems]
665
1068
  );
@@ -684,6 +1087,7 @@ function useSuggestion(providers) {
684
1087
  });
685
1088
  }, [fetchItems]);
686
1089
  const close = useCallback2(() => {
1090
+ debouncedFetchRef.current?.cancel();
687
1091
  setUIState(IDLE_STATE);
688
1092
  }, []);
689
1093
  const searchNested = useCallback2(
@@ -693,10 +1097,10 @@ function useSuggestion(providers) {
693
1097
  const current = stateRef.current;
694
1098
  const parent = current.breadcrumbs[current.breadcrumbs.length - 1];
695
1099
  if (parent) {
696
- fetchItems(provider, query, parent);
1100
+ scheduleFetch(provider, query, parent);
697
1101
  }
698
1102
  },
699
- [fetchItems]
1103
+ [scheduleFetch]
700
1104
  );
701
1105
  const onKeyDown = useCallback2(
702
1106
  ({ event }) => {
@@ -719,6 +1123,14 @@ function useSuggestion(providers) {
719
1123
  }
720
1124
  return true;
721
1125
  }
1126
+ case "Tab": {
1127
+ event.preventDefault();
1128
+ const selectedItem = current.items[current.activeIndex];
1129
+ if (selectedItem) {
1130
+ select(selectedItem);
1131
+ }
1132
+ return true;
1133
+ }
722
1134
  case "ArrowRight": {
723
1135
  const activeItem = current.items[current.activeIndex];
724
1136
  if (activeItem?.hasChildren) {
@@ -764,7 +1176,7 @@ function useSuggestion(providers) {
764
1176
  }
765
1177
 
766
1178
  // src/components/SuggestionList.tsx
767
- import { useEffect as useEffect2, useRef as useRef3, useState as useState2 } from "react";
1179
+ import { useEffect as useEffect3, useRef as useRef3, useState as useState2 } from "react";
768
1180
 
769
1181
  // src/utils/ariaHelpers.ts
770
1182
  function comboboxAttrs(expanded, listboxId) {
@@ -793,13 +1205,13 @@ function optionAttrs(id, selected, index) {
793
1205
 
794
1206
  // src/components/SuggestionList.tsx
795
1207
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
796
- var LISTBOX_ID = "mentions-suggestion-listbox";
797
1208
  function SuggestionList({
798
1209
  items,
799
1210
  activeIndex,
800
1211
  breadcrumbs,
801
1212
  loading,
802
1213
  trigger,
1214
+ query,
803
1215
  clientRect,
804
1216
  onSelect,
805
1217
  onHover,
@@ -808,7 +1220,12 @@ function SuggestionList({
808
1220
  onNavigateUp,
809
1221
  onNavigateDown,
810
1222
  onClose,
811
- renderItem
1223
+ onFocusEditor,
1224
+ renderItem,
1225
+ renderEmpty,
1226
+ renderLoading,
1227
+ renderGroupHeader,
1228
+ listboxId
812
1229
  }) {
813
1230
  const listRef = useRef3(null);
814
1231
  const searchInputRef = useRef3(null);
@@ -816,24 +1233,30 @@ function SuggestionList({
816
1233
  const [nestedQuery, setNestedQuery] = useState2("");
817
1234
  const breadcrumbKey = breadcrumbs.map((b) => b.id).join("/");
818
1235
  const prevBreadcrumbKey = useRef3(breadcrumbKey);
819
- useEffect2(() => {
1236
+ useEffect3(() => {
820
1237
  if (prevBreadcrumbKey.current !== breadcrumbKey) {
821
1238
  setNestedQuery("");
822
1239
  prevBreadcrumbKey.current = breadcrumbKey;
823
1240
  }
824
1241
  }, [breadcrumbKey]);
825
- useEffect2(() => {
826
- if (breadcrumbs.length > 0 && searchInputRef.current) {
1242
+ const prevBreadcrumbsLen = useRef3(breadcrumbs.length);
1243
+ useEffect3(() => {
1244
+ if (prevBreadcrumbsLen.current > 0 && breadcrumbs.length === 0) {
1245
+ onFocusEditor?.();
1246
+ } else if (breadcrumbs.length > 0 && searchInputRef.current) {
827
1247
  requestAnimationFrame(() => searchInputRef.current?.focus());
828
1248
  }
829
- }, [breadcrumbKey, breadcrumbs.length]);
830
- useEffect2(() => {
1249
+ prevBreadcrumbsLen.current = breadcrumbs.length;
1250
+ }, [breadcrumbKey, breadcrumbs.length, onFocusEditor]);
1251
+ useEffect3(() => {
831
1252
  if (!listRef.current) return;
832
1253
  const active = listRef.current.querySelector('[aria-selected="true"]');
833
1254
  active?.scrollIntoView({ block: "nearest" });
834
1255
  }, [activeIndex]);
835
- const style = usePopoverPosition(clientRect);
836
- if (items.length === 0 && !loading) return null;
1256
+ const { style, position } = usePopoverPosition(clientRect);
1257
+ const activeQuery = breadcrumbs.length > 0 ? nestedQuery : query;
1258
+ const showEmpty = !loading && items.length === 0 && activeQuery.trim().length > 0;
1259
+ if (items.length === 0 && !loading && !showEmpty) return null;
837
1260
  const handleSearchKeyDown = (e) => {
838
1261
  switch (e.key) {
839
1262
  case "ArrowDown":
@@ -853,8 +1276,23 @@ function SuggestionList({
853
1276
  }
854
1277
  case "Escape":
855
1278
  e.preventDefault();
1279
+ onFocusEditor?.();
856
1280
  onClose?.();
857
1281
  break;
1282
+ case "ArrowLeft":
1283
+ if (nestedQuery === "" || e.currentTarget.selectionStart === 0) {
1284
+ e.preventDefault();
1285
+ onGoBack();
1286
+ }
1287
+ break;
1288
+ case "ArrowRight": {
1289
+ const item = items[activeIndex];
1290
+ if (item?.hasChildren) {
1291
+ e.preventDefault();
1292
+ onSelect(item);
1293
+ }
1294
+ break;
1295
+ }
858
1296
  case "Backspace":
859
1297
  if (nestedQuery === "") {
860
1298
  e.preventDefault();
@@ -863,11 +1301,13 @@ function SuggestionList({
863
1301
  break;
864
1302
  }
865
1303
  };
1304
+ const hasGroups = items.some((item) => item.group);
866
1305
  return /* @__PURE__ */ jsxs(
867
1306
  "div",
868
1307
  {
869
1308
  "data-suggestions": "",
870
1309
  "data-trigger": trigger,
1310
+ "data-suggestions-position": position,
871
1311
  style,
872
1312
  ref: listRef,
873
1313
  children: [
@@ -905,28 +1345,76 @@ function SuggestionList({
905
1345
  spellCheck: false
906
1346
  }
907
1347
  ) }),
908
- loading && /* @__PURE__ */ jsx("div", { "data-suggestion-loading": "", children: "Loading..." }),
909
- !loading && /* @__PURE__ */ jsx("div", { ...listboxAttrs(LISTBOX_ID, `${trigger ?? ""} suggestions`), children: items.map((item, index) => {
910
- const isActive = index === activeIndex;
911
- const itemId = `mention-option-${item.id}`;
912
- return /* @__PURE__ */ jsx(
913
- "div",
914
- {
915
- ...optionAttrs(itemId, isActive, index),
916
- "data-suggestion-item": "",
917
- "data-suggestion-item-active": isActive ? "" : void 0,
918
- "data-has-children": item.hasChildren ? "" : void 0,
919
- onMouseEnter: () => onHover(index),
920
- onClick: () => onSelect(item),
921
- children: renderItem ? renderItem(item, depth) : /* @__PURE__ */ jsx(DefaultSuggestionItem, { item })
922
- },
923
- item.id
924
- );
925
- }) })
1348
+ loading && /* @__PURE__ */ jsx("div", { "data-suggestion-loading": "", children: renderLoading ? renderLoading() : "Loading..." }),
1349
+ showEmpty && /* @__PURE__ */ jsx("div", { "data-suggestion-empty": "", children: renderEmpty ? renderEmpty(activeQuery) : "No results" }),
1350
+ !loading && items.length > 0 && /* @__PURE__ */ jsx("div", { ...listboxAttrs(listboxId, `${trigger ?? ""} suggestions`), children: hasGroups ? renderGroupedItems(items, activeIndex, depth, onSelect, onHover, renderItem, renderGroupHeader) : items.map((item, index) => /* @__PURE__ */ jsx(
1351
+ SuggestionItem,
1352
+ {
1353
+ item,
1354
+ index,
1355
+ isActive: index === activeIndex,
1356
+ depth,
1357
+ onSelect,
1358
+ onHover,
1359
+ renderItem
1360
+ },
1361
+ item.id
1362
+ )) })
926
1363
  ]
927
1364
  }
928
1365
  );
929
1366
  }
1367
+ function renderGroupedItems(items, activeIndex, depth, onSelect, onHover, renderItem, renderGroupHeader) {
1368
+ const elements = [];
1369
+ let lastGroup;
1370
+ items.forEach((item, index) => {
1371
+ if (item.group && item.group !== lastGroup) {
1372
+ lastGroup = item.group;
1373
+ elements.push(
1374
+ /* @__PURE__ */ jsx("div", { "data-suggestion-group-header": "", children: renderGroupHeader ? renderGroupHeader(item.group) : item.group }, `group-${item.group}`)
1375
+ );
1376
+ }
1377
+ elements.push(
1378
+ /* @__PURE__ */ jsx(
1379
+ SuggestionItem,
1380
+ {
1381
+ item,
1382
+ index,
1383
+ isActive: index === activeIndex,
1384
+ depth,
1385
+ onSelect,
1386
+ onHover,
1387
+ renderItem
1388
+ },
1389
+ item.id
1390
+ )
1391
+ );
1392
+ });
1393
+ return elements;
1394
+ }
1395
+ function SuggestionItem({
1396
+ item,
1397
+ index,
1398
+ isActive,
1399
+ depth,
1400
+ onSelect,
1401
+ onHover,
1402
+ renderItem
1403
+ }) {
1404
+ const itemId = `mention-option-${item.id}`;
1405
+ return /* @__PURE__ */ jsx(
1406
+ "div",
1407
+ {
1408
+ ...optionAttrs(itemId, isActive, index),
1409
+ "data-suggestion-item": "",
1410
+ "data-suggestion-item-active": isActive ? "" : void 0,
1411
+ "data-has-children": item.hasChildren ? "" : void 0,
1412
+ onMouseEnter: () => onHover(index),
1413
+ onClick: () => onSelect(item),
1414
+ children: renderItem ? renderItem(item, depth) : /* @__PURE__ */ jsx(DefaultSuggestionItem, { item })
1415
+ }
1416
+ );
1417
+ }
930
1418
  function DefaultSuggestionItem({ item }) {
931
1419
  return /* @__PURE__ */ jsxs(Fragment, { children: [
932
1420
  item.icon && /* @__PURE__ */ jsx("span", { "data-suggestion-item-icon": "", children: item.icon }),
@@ -935,25 +1423,48 @@ function DefaultSuggestionItem({ item }) {
935
1423
  item.hasChildren && /* @__PURE__ */ jsx("span", { "data-suggestion-item-chevron": "", "aria-hidden": "true", children: "\u203A" })
936
1424
  ] });
937
1425
  }
1426
+ var POPOVER_HEIGHT_ESTIMATE = 280;
1427
+ var POPOVER_WIDTH_ESTIMATE = 360;
938
1428
  function usePopoverPosition(clientRect) {
939
1429
  if (!clientRect) {
940
- return { display: "none" };
1430
+ return { style: { display: "none" }, position: "below" };
941
1431
  }
942
1432
  const rect = clientRect();
943
1433
  if (!rect) {
944
- return { display: "none" };
1434
+ return { style: { display: "none" }, position: "below" };
1435
+ }
1436
+ const viewportH = typeof window !== "undefined" ? window.innerHeight : 800;
1437
+ const viewportW = typeof window !== "undefined" ? window.innerWidth : 1200;
1438
+ const spaceBelow = viewportH - rect.bottom;
1439
+ const shouldFlip = spaceBelow < POPOVER_HEIGHT_ESTIMATE && rect.top > spaceBelow;
1440
+ let left = rect.left;
1441
+ if (left + POPOVER_WIDTH_ESTIMATE > viewportW) {
1442
+ left = Math.max(0, viewportW - POPOVER_WIDTH_ESTIMATE);
1443
+ }
1444
+ if (shouldFlip) {
1445
+ return {
1446
+ style: {
1447
+ position: "fixed",
1448
+ left: `${left}px`,
1449
+ bottom: `${viewportH - rect.top + 4}px`,
1450
+ zIndex: 50
1451
+ },
1452
+ position: "above"
1453
+ };
945
1454
  }
946
1455
  return {
947
- position: "fixed",
948
- left: `${rect.left}px`,
949
- top: `${rect.bottom + 4}px`,
950
- zIndex: 50
1456
+ style: {
1457
+ position: "fixed",
1458
+ left: `${left}px`,
1459
+ top: `${rect.bottom + 4}px`,
1460
+ zIndex: 50
1461
+ },
1462
+ position: "below"
951
1463
  };
952
1464
  }
953
1465
 
954
1466
  // src/components/MentionsInput.tsx
955
1467
  import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
956
- var LISTBOX_ID2 = "mentions-suggestion-listbox";
957
1468
  var MentionsInput = forwardRef(
958
1469
  function MentionsInput2({
959
1470
  value,
@@ -967,10 +1478,31 @@ var MentionsInput = forwardRef(
967
1478
  clearOnSubmit = true,
968
1479
  maxLength,
969
1480
  renderItem,
970
- renderChip
1481
+ renderChip,
1482
+ renderEmpty,
1483
+ renderLoading,
1484
+ renderGroupHeader,
1485
+ onFocus,
1486
+ onBlur,
1487
+ onMentionAdd,
1488
+ onMentionRemove,
1489
+ onMentionClick,
1490
+ onMentionHover,
1491
+ minHeight,
1492
+ maxHeight,
1493
+ submitKey = "enter",
1494
+ allowTrigger,
1495
+ validateMention,
1496
+ portalContainer,
1497
+ streaming,
1498
+ onStreamingComplete
971
1499
  }, ref) {
972
- const { uiState, actions, callbacksRef } = useSuggestion(providers);
973
- const { editor, clear, setContent, focus } = useMentionsEditor({
1500
+ const instanceId = useId();
1501
+ const listboxId = `mentions-listbox-${instanceId}`;
1502
+ const { uiState, actions, callbacksRef } = useSuggestion(providers, {
1503
+ onMentionAdd
1504
+ });
1505
+ const { editor, getOutput, clear, setContent, appendText, focus } = useMentionsEditor({
974
1506
  providers,
975
1507
  value,
976
1508
  onChange,
@@ -979,46 +1511,72 @@ var MentionsInput = forwardRef(
979
1511
  placeholder,
980
1512
  autoFocus,
981
1513
  editable: !disabled,
982
- callbacksRef
1514
+ callbacksRef,
1515
+ onFocus,
1516
+ onBlur,
1517
+ submitKey,
1518
+ onMentionRemove,
1519
+ onMentionClick,
1520
+ onMentionHover,
1521
+ allowTrigger,
1522
+ validateMention,
1523
+ streaming,
1524
+ onStreamingComplete
983
1525
  });
984
1526
  useImperativeHandle(
985
1527
  ref,
986
- () => ({ clear, setContent, focus }),
987
- [clear, setContent, focus]
1528
+ () => ({ clear, setContent, appendText, focus, getOutput }),
1529
+ [clear, setContent, appendText, focus, getOutput]
988
1530
  );
989
1531
  const isExpanded = uiState.state !== "idle";
990
1532
  const handleHover = useCallback3((index) => {
991
1533
  void index;
992
1534
  }, []);
1535
+ const handleFocusEditor = useCallback3(() => {
1536
+ editor?.commands.focus();
1537
+ }, [editor]);
1538
+ const editorStyle = {};
1539
+ if (minHeight != null) editorStyle.minHeight = `${minHeight}px`;
1540
+ if (maxHeight != null) {
1541
+ editorStyle.maxHeight = `${maxHeight}px`;
1542
+ editorStyle.overflowY = "auto";
1543
+ }
1544
+ const suggestionList = isExpanded ? /* @__PURE__ */ jsx2(
1545
+ SuggestionList,
1546
+ {
1547
+ items: uiState.items,
1548
+ activeIndex: uiState.activeIndex,
1549
+ breadcrumbs: uiState.breadcrumbs,
1550
+ loading: uiState.loading,
1551
+ trigger: uiState.trigger,
1552
+ query: uiState.query,
1553
+ clientRect: uiState.clientRect,
1554
+ onSelect: (item) => actions.select(item),
1555
+ onHover: handleHover,
1556
+ onGoBack: actions.goBack,
1557
+ onSearchNested: actions.searchNested,
1558
+ onNavigateUp: actions.navigateUp,
1559
+ onNavigateDown: actions.navigateDown,
1560
+ onClose: actions.close,
1561
+ onFocusEditor: handleFocusEditor,
1562
+ renderItem,
1563
+ renderEmpty,
1564
+ renderLoading,
1565
+ renderGroupHeader,
1566
+ listboxId
1567
+ }
1568
+ ) : null;
993
1569
  return /* @__PURE__ */ jsxs2(
994
1570
  "div",
995
1571
  {
996
1572
  className,
997
1573
  "data-mentions-input": "",
998
1574
  "data-disabled": disabled ? "" : void 0,
999
- ...comboboxAttrs(isExpanded, LISTBOX_ID2),
1575
+ ...comboboxAttrs(isExpanded, listboxId),
1000
1576
  "aria-activedescendant": isExpanded && uiState.items[uiState.activeIndex] ? `mention-option-${uiState.items[uiState.activeIndex].id}` : void 0,
1001
1577
  children: [
1002
- /* @__PURE__ */ jsx2(EditorContent, { editor }),
1003
- isExpanded && /* @__PURE__ */ jsx2(
1004
- SuggestionList,
1005
- {
1006
- items: uiState.items,
1007
- activeIndex: uiState.activeIndex,
1008
- breadcrumbs: uiState.breadcrumbs,
1009
- loading: uiState.loading,
1010
- trigger: uiState.trigger,
1011
- clientRect: uiState.clientRect,
1012
- onSelect: (item) => actions.select(item),
1013
- onHover: handleHover,
1014
- onGoBack: actions.goBack,
1015
- onSearchNested: actions.searchNested,
1016
- onNavigateUp: actions.navigateUp,
1017
- onNavigateDown: actions.navigateDown,
1018
- onClose: actions.close,
1019
- renderItem
1020
- }
1021
- )
1578
+ /* @__PURE__ */ jsx2("div", { style: editorStyle, children: /* @__PURE__ */ jsx2(EditorContent, { editor }) }),
1579
+ portalContainer ? suggestionList && createPortal(suggestionList, portalContainer) : suggestionList
1022
1580
  ]
1023
1581
  }
1024
1582
  );