@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 +45 -0
- package/dist/index.d.mts +14 -1
- package/dist/index.d.ts +14 -1
- package/dist/index.js +118 -11
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +118 -11
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
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(
|
|
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
|
-
|
|
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) => {
|