@relevaince/mentions 0.5.0 → 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/README.md CHANGED
@@ -31,6 +31,7 @@ This is **not** a simple `@mention` dropdown. It is a resource-addressing langua
31
31
  - **Portal support** — render the dropdown into a custom container
32
32
  - **Tab to complete** — Tab selects the active suggestion
33
33
  - **Trigger gating** — `allowTrigger` to conditionally suppress the dropdown
34
+ - **Streaming support** — stream AI-generated text into the editor with automatic mention parsing
34
35
  - **Multi-instance** — unique ARIA IDs per component instance
35
36
 
36
37
  ## Install
@@ -192,6 +193,8 @@ type MentionsOutput = {
192
193
  | `allowTrigger` | `(trigger, { textBefore }) => boolean` | — | Conditionally suppress the dropdown |
193
194
  | `validateMention` | `(token) => boolean \| Promise<boolean>` | — | Validate mentions; invalid ones get `data-mention-invalid` |
194
195
  | `portalContainer` | `HTMLElement` | — | Render dropdown into a custom DOM node |
196
+ | `streaming` | `boolean` | `false` | Signals the editor is receiving streamed content (suppresses triggers, blocks user input, throttles onChange) |
197
+ | `onStreamingComplete` | `(output: MentionsOutput) => void` | — | Fires once when `streaming` transitions from `true` to `false` with the final output |
195
198
 
196
199
  ## Imperative ref API
197
200
 
@@ -228,9 +231,51 @@ function Chat() {
228
231
  |--------|-----------|-------------|
229
232
  | `clear` | `() => void` | Clears all editor content |
230
233
  | `setContent` | `(markdown: string) => void` | Replaces content with a markdown string (mention tokens are parsed) |
234
+ | `appendText` | `(text: string) => void` | Appends plain text at the end (no mention parsing — use for plain-text streaming) |
231
235
  | `focus` | `() => void` | Focuses the editor and places the cursor at the end |
232
236
  | `getOutput` | `() => MentionsOutput \| null` | Reads the current structured output without waiting for onChange |
233
237
 
238
+ ## Streaming
239
+
240
+ Stream AI-generated text into the editor while maintaining mention state. Set `streaming={true}` to enter streaming mode: the suggestion dropdown is suppressed, user keyboard/paste input is blocked, and `onChange` is throttled (~150 ms). Call `ref.current.setContent(accumulated)` on each chunk — completed mention tokens are parsed into chips automatically.
241
+
242
+ ```tsx
243
+ const ref = useRef<MentionsInputHandle>(null);
244
+ const [isStreaming, setIsStreaming] = useState(false);
245
+
246
+ async function enhancePrompt() {
247
+ setIsStreaming(true);
248
+ let accumulated = "";
249
+
250
+ const stream = await fetchEnhancedPrompt(currentPrompt);
251
+ for await (const chunk of stream) {
252
+ accumulated += chunk;
253
+ ref.current?.setContent(accumulated);
254
+ }
255
+
256
+ setIsStreaming(false);
257
+ }
258
+
259
+ <MentionsInput
260
+ ref={ref}
261
+ streaming={isStreaming}
262
+ providers={providers}
263
+ onChange={handleChange}
264
+ onStreamingComplete={(output) => {
265
+ console.log("Final tokens:", output.tokens);
266
+ }}
267
+ />
268
+ ```
269
+
270
+ **How it works:**
271
+
272
+ 1. Set `streaming={true}` — the editor enters streaming mode
273
+ 2. On each chunk, accumulate the full text and call `ref.current.setContent(accumulated)`
274
+ 3. Incomplete mention tokens (e.g. `@[NDA`) render as plain text until the full `@[label](id)` syntax is received, then snap into mention chips
275
+ 4. Set `streaming={false}` — the editor exits streaming mode, fires a final `onChange` and `onStreamingComplete`
276
+
277
+ For plain-text-only streaming (no mention syntax in chunks), use `ref.current.appendText(chunk)` instead for better performance.
278
+
234
279
  ## Keyboard shortcuts
235
280
 
236
281
  | Key | Context | Action |
package/dist/index.d.mts CHANGED
@@ -150,6 +150,17 @@ type MentionsInputProps = {
150
150
  validateMention?: (token: MentionToken) => boolean | Promise<boolean>;
151
151
  /** DOM element to portal the suggestion dropdown into. */
152
152
  portalContainer?: HTMLElement;
153
+ /**
154
+ * Signals the editor is receiving streamed content (e.g. from an AI).
155
+ * When `true`: suggestion triggers are suppressed, user keyboard/paste
156
+ * input is blocked, and `onChange` is throttled (~150 ms).
157
+ */
158
+ streaming?: boolean;
159
+ /**
160
+ * Fires once when `streaming` transitions from `true` to `false`
161
+ * with the final structured output.
162
+ */
163
+ onStreamingComplete?: (output: MentionsOutput) => void;
153
164
  };
154
165
  /**
155
166
  * Imperative handle exposed via `ref` on `<MentionsInput>`.
@@ -167,8 +178,10 @@ type MentionsInputProps = {
167
178
  type MentionsInputHandle = {
168
179
  /** Clear all editor content. */
169
180
  clear: () => void;
170
- /** Replace editor content with a markdown string. */
181
+ /** Replace editor content with a markdown string. Mention tokens are parsed. */
171
182
  setContent: (markdown: string) => void;
183
+ /** Append plain text at the end of the document (no mention parsing). */
184
+ appendText: (text: string) => void;
172
185
  /** Focus the editor. */
173
186
  focus: () => void;
174
187
  /** Read the current structured output without waiting for onChange. */
package/dist/index.d.ts CHANGED
@@ -150,6 +150,17 @@ type MentionsInputProps = {
150
150
  validateMention?: (token: MentionToken) => boolean | Promise<boolean>;
151
151
  /** DOM element to portal the suggestion dropdown into. */
152
152
  portalContainer?: HTMLElement;
153
+ /**
154
+ * Signals the editor is receiving streamed content (e.g. from an AI).
155
+ * When `true`: suggestion triggers are suppressed, user keyboard/paste
156
+ * input is blocked, and `onChange` is throttled (~150 ms).
157
+ */
158
+ streaming?: boolean;
159
+ /**
160
+ * Fires once when `streaming` transitions from `true` to `false`
161
+ * with the final structured output.
162
+ */
163
+ onStreamingComplete?: (output: MentionsOutput) => void;
153
164
  };
154
165
  /**
155
166
  * Imperative handle exposed via `ref` on `<MentionsInput>`.
@@ -167,8 +178,10 @@ type MentionsInputProps = {
167
178
  type MentionsInputHandle = {
168
179
  /** Clear all editor content. */
169
180
  clear: () => void;
170
- /** Replace editor content with a markdown string. */
181
+ /** Replace editor content with a markdown string. Mention tokens are parsed. */
171
182
  setContent: (markdown: string) => void;
183
+ /** Append plain text at the end of the document (no mention parsing). */
184
+ appendText: (text: string) => void;
172
185
  /** Focus the editor. */
173
186
  focus: () => void;
174
187
  /** Read the current structured output without waiting for onChange. */
package/dist/index.js CHANGED
@@ -222,7 +222,7 @@ function detectTrigger(text, cursorPos, docStartPos, triggers) {
222
222
  return null;
223
223
  }
224
224
  var suggestionPluginKey = new import_state.PluginKey("mentionSuggestion");
225
- function createSuggestionExtension(triggers, callbacksRef, allowTriggerRef) {
225
+ function createSuggestionExtension(triggers, callbacksRef, allowTriggerRef, streamingRef) {
226
226
  return import_core2.Extension.create({
227
227
  name: "mentionSuggestion",
228
228
  priority: 200,
@@ -273,6 +273,15 @@ function createSuggestionExtension(triggers, callbacksRef, allowTriggerRef) {
273
273
  view() {
274
274
  return {
275
275
  update(view, _prevState) {
276
+ if (streamingRef?.current) {
277
+ if (active) {
278
+ active = false;
279
+ lastQuery = null;
280
+ lastTrigger = null;
281
+ callbacksRef.current.onExit();
282
+ }
283
+ return;
284
+ }
276
285
  const { state } = view;
277
286
  const { selection } = state;
278
287
  if (!selection.empty) {
@@ -587,6 +596,34 @@ function createMentionRemoveExtension(onMentionRemoveRef) {
587
596
  }
588
597
  });
589
598
  }
599
+ var streamingBlockPluginKey = new import_state2.PluginKey("streamingBlock");
600
+ function createStreamingBlockExtension(streamingRef) {
601
+ return import_core3.Extension.create({
602
+ name: "streamingBlock",
603
+ priority: 200,
604
+ addProseMirrorPlugins() {
605
+ return [
606
+ new import_state2.Plugin({
607
+ key: streamingBlockPluginKey,
608
+ props: {
609
+ handleKeyDown() {
610
+ return streamingRef.current;
611
+ },
612
+ handleKeyPress() {
613
+ return streamingRef.current;
614
+ },
615
+ handlePaste() {
616
+ return streamingRef.current;
617
+ },
618
+ handleDrop() {
619
+ return streamingRef.current;
620
+ }
621
+ }
622
+ })
623
+ ];
624
+ }
625
+ });
626
+ }
590
627
  function useMentionsEditor({
591
628
  providers,
592
629
  value,
@@ -604,7 +641,9 @@ function useMentionsEditor({
604
641
  onMentionClick,
605
642
  onMentionHover,
606
643
  allowTrigger,
607
- validateMention
644
+ validateMention,
645
+ streaming = false,
646
+ onStreamingComplete
608
647
  }) {
609
648
  const onChangeRef = (0, import_react.useRef)(onChange);
610
649
  onChangeRef.current = onChange;
@@ -628,6 +667,13 @@ function useMentionsEditor({
628
667
  allowTriggerRef.current = allowTrigger;
629
668
  const validateMentionRef = (0, import_react.useRef)(validateMention);
630
669
  validateMentionRef.current = validateMention;
670
+ const onStreamingCompleteRef = (0, import_react.useRef)(onStreamingComplete);
671
+ onStreamingCompleteRef.current = onStreamingComplete;
672
+ const streamingRef = (0, import_react.useRef)(streaming);
673
+ streamingRef.current = streaming;
674
+ const prevStreamingRef = (0, import_react.useRef)(streaming);
675
+ const throttleTimerRef = (0, import_react.useRef)(null);
676
+ const pendingOutputRef = (0, import_react.useRef)(null);
631
677
  const internalMarkdownRef = (0, import_react.useRef)(null);
632
678
  const initialContent = (0, import_react.useMemo)(() => {
633
679
  if (!value) return void 0;
@@ -640,7 +686,12 @@ function useMentionsEditor({
640
686
  [triggersKey]
641
687
  );
642
688
  const suggestionExtension = (0, import_react.useMemo)(
643
- () => createSuggestionExtension(triggers, callbacksRef, allowTriggerRef),
689
+ () => createSuggestionExtension(
690
+ triggers,
691
+ callbacksRef,
692
+ allowTriggerRef,
693
+ streamingRef
694
+ ),
644
695
  // eslint-disable-next-line react-hooks/exhaustive-deps
645
696
  [triggersKey]
646
697
  );
@@ -656,6 +707,10 @@ function useMentionsEditor({
656
707
  () => createMentionRemoveExtension(onMentionRemoveRef),
657
708
  []
658
709
  );
710
+ const streamingBlockExt = (0, import_react.useMemo)(
711
+ () => createStreamingBlockExtension(streamingRef),
712
+ []
713
+ );
659
714
  const mentionNodeExt = (0, import_react.useMemo)(
660
715
  () => MentionNode.configure({
661
716
  onClickRef: onMentionClickRef,
@@ -683,7 +738,8 @@ function useMentionsEditor({
683
738
  suggestionExtension,
684
739
  submitExt,
685
740
  enterExt,
686
- mentionRemoveExt
741
+ mentionRemoveExt,
742
+ streamingBlockExt
687
743
  ],
688
744
  content: initialContent,
689
745
  autofocus: autoFocus ? "end" : false,
@@ -696,7 +752,20 @@ function useMentionsEditor({
696
752
  onUpdate: ({ editor: editor2 }) => {
697
753
  const output = buildOutput(editor2);
698
754
  internalMarkdownRef.current = output.markdown;
699
- onChangeRef.current?.(output);
755
+ if (streamingRef.current) {
756
+ pendingOutputRef.current = output;
757
+ if (!throttleTimerRef.current) {
758
+ throttleTimerRef.current = setTimeout(() => {
759
+ throttleTimerRef.current = null;
760
+ if (pendingOutputRef.current) {
761
+ onChangeRef.current?.(pendingOutputRef.current);
762
+ pendingOutputRef.current = null;
763
+ }
764
+ }, 150);
765
+ }
766
+ } else {
767
+ onChangeRef.current?.(output);
768
+ }
700
769
  },
701
770
  onFocus: () => {
702
771
  onFocusRef.current?.();
@@ -705,6 +774,26 @@ function useMentionsEditor({
705
774
  onBlurRef.current?.();
706
775
  }
707
776
  });
777
+ (0, import_react.useEffect)(() => {
778
+ if (prevStreamingRef.current && !streaming && editor) {
779
+ if (throttleTimerRef.current) {
780
+ clearTimeout(throttleTimerRef.current);
781
+ throttleTimerRef.current = null;
782
+ }
783
+ const output = buildOutput(editor);
784
+ onChangeRef.current?.(output);
785
+ onStreamingCompleteRef.current?.(output);
786
+ pendingOutputRef.current = null;
787
+ }
788
+ prevStreamingRef.current = streaming;
789
+ }, [streaming, editor]);
790
+ (0, import_react.useEffect)(() => {
791
+ return () => {
792
+ if (throttleTimerRef.current) {
793
+ clearTimeout(throttleTimerRef.current);
794
+ }
795
+ };
796
+ }, []);
708
797
  (0, import_react.useEffect)(() => {
709
798
  if (editor && editor.isEditable !== editable) {
710
799
  editor.setEditable(editable);
@@ -749,6 +838,20 @@ function useMentionsEditor({
749
838
  const doc = parseFromMarkdown(markdown);
750
839
  editor.commands.setContent(doc);
751
840
  internalMarkdownRef.current = markdown;
841
+ if (streamingRef.current) {
842
+ editor.commands.focus("end");
843
+ }
844
+ },
845
+ [editor]
846
+ );
847
+ const appendText = (0, import_react.useCallback)(
848
+ (text) => {
849
+ if (!editor) return;
850
+ const endPos = editor.state.doc.content.size - 1;
851
+ editor.commands.insertContentAt(endPos, text);
852
+ if (streamingRef.current) {
853
+ editor.commands.focus("end");
854
+ }
752
855
  },
753
856
  [editor]
754
857
  );
@@ -759,7 +862,7 @@ function useMentionsEditor({
759
862
  if (!editor) return null;
760
863
  return buildOutput(editor);
761
864
  }, [editor]);
762
- return { editor, getOutput, clear, setContent, focus };
865
+ return { editor, getOutput, clear, setContent, appendText, focus };
763
866
  }
764
867
 
765
868
  // src/hooks/useSuggestion.ts
@@ -1429,14 +1532,16 @@ var MentionsInput = (0, import_react5.forwardRef)(
1429
1532
  submitKey = "enter",
1430
1533
  allowTrigger,
1431
1534
  validateMention,
1432
- portalContainer
1535
+ portalContainer,
1536
+ streaming,
1537
+ onStreamingComplete
1433
1538
  }, ref) {
1434
1539
  const instanceId = (0, import_react5.useId)();
1435
1540
  const listboxId = `mentions-listbox-${instanceId}`;
1436
1541
  const { uiState, actions, callbacksRef } = useSuggestion(providers, {
1437
1542
  onMentionAdd
1438
1543
  });
1439
- const { editor, getOutput, clear, setContent, focus } = useMentionsEditor({
1544
+ const { editor, getOutput, clear, setContent, appendText, focus } = useMentionsEditor({
1440
1545
  providers,
1441
1546
  value,
1442
1547
  onChange,
@@ -1453,12 +1558,14 @@ var MentionsInput = (0, import_react5.forwardRef)(
1453
1558
  onMentionClick,
1454
1559
  onMentionHover,
1455
1560
  allowTrigger,
1456
- validateMention
1561
+ validateMention,
1562
+ streaming,
1563
+ onStreamingComplete
1457
1564
  });
1458
1565
  (0, import_react5.useImperativeHandle)(
1459
1566
  ref,
1460
- () => ({ clear, setContent, focus, getOutput }),
1461
- [clear, setContent, focus, getOutput]
1567
+ () => ({ clear, setContent, appendText, focus, getOutput }),
1568
+ [clear, setContent, appendText, focus, getOutput]
1462
1569
  );
1463
1570
  const isExpanded = uiState.state !== "idle";
1464
1571
  const handleHover = (0, import_react5.useCallback)((index) => {