@relevaince/mentions 0.5.0 → 0.6.1

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
@@ -13,6 +13,7 @@ This is **not** a simple `@mention` dropdown. It is a resource-addressing langua
13
13
  - **Async providers** — fetch suggestions from any API
14
14
  - **Debounced fetching** — per-provider `debounceMs` prevents API floods
15
15
  - **Structured output** — returns `{ markdown, tokens, plainText }` on every change
16
+ - **`useMentionsContent`** — hook that pairs `onChange` with reactive `output` and `hasContent` (e.g. disable submit until there is text)
16
17
  - **Markdown serialization** — `@[label](id)` token syntax for storage and LLM context
17
18
  - **Markdown parsing** — `extractFromMarkdown()` returns tokens + plain text from a markdown string
18
19
  - **Headless styling** — zero bundled CSS, style via `data-*` attributes with Tailwind or plain CSS
@@ -31,6 +32,7 @@ This is **not** a simple `@mention` dropdown. It is a resource-addressing langua
31
32
  - **Portal support** — render the dropdown into a custom container
32
33
  - **Tab to complete** — Tab selects the active suggestion
33
34
  - **Trigger gating** — `allowTrigger` to conditionally suppress the dropdown
35
+ - **Streaming support** — stream AI-generated text into the editor with automatic mention parsing
34
36
  - **Multi-instance** — unique ARIA IDs per component instance
35
37
 
36
38
  ## Install
@@ -192,6 +194,8 @@ type MentionsOutput = {
192
194
  | `allowTrigger` | `(trigger, { textBefore }) => boolean` | — | Conditionally suppress the dropdown |
193
195
  | `validateMention` | `(token) => boolean \| Promise<boolean>` | — | Validate mentions; invalid ones get `data-mention-invalid` |
194
196
  | `portalContainer` | `HTMLElement` | — | Render dropdown into a custom DOM node |
197
+ | `streaming` | `boolean` | `false` | Signals the editor is receiving streamed content (suppresses triggers, blocks user input, throttles onChange) |
198
+ | `onStreamingComplete` | `(output: MentionsOutput) => void` | — | Fires once when `streaming` transitions from `true` to `false` with the final output |
195
199
 
196
200
  ## Imperative ref API
197
201
 
@@ -228,9 +232,100 @@ function Chat() {
228
232
  |--------|-----------|-------------|
229
233
  | `clear` | `() => void` | Clears all editor content |
230
234
  | `setContent` | `(markdown: string) => void` | Replaces content with a markdown string (mention tokens are parsed) |
235
+ | `appendText` | `(text: string) => void` | Appends plain text at the end (no mention parsing — use for plain-text streaming) |
231
236
  | `focus` | `() => void` | Focuses the editor and places the cursor at the end |
232
237
  | `getOutput` | `() => MentionsOutput \| null` | Reads the current structured output without waiting for onChange |
233
238
 
239
+ ## Hooks
240
+
241
+ ### `useMentionsContent`
242
+
243
+ Keeps the latest `MentionsOutput` in React state so UI can react to editor content. `ref.current.getOutput()` does not trigger re-renders; this hook wires `onChange` for you and exposes `hasContent` from trimmed `plainText`.
244
+
245
+ ```ts
246
+ function useMentionsContent(): {
247
+ output: MentionsOutput | null;
248
+ onChange: (output: MentionsOutput) => void;
249
+ hasContent: boolean;
250
+ };
251
+ ```
252
+
253
+ **Submit button disabled when empty:**
254
+
255
+ ```tsx
256
+ import { useRef } from "react";
257
+ import {
258
+ MentionsInput,
259
+ useMentionsContent,
260
+ type MentionsInputHandle,
261
+ } from "@relevaince/mentions";
262
+
263
+ function Composer() {
264
+ const textareaRef = useRef<MentionsInputHandle>(null);
265
+ const { output, onChange, hasContent } = useMentionsContent();
266
+
267
+ function onSubmit() {
268
+ if (!output) return;
269
+ sendMessage(output);
270
+ }
271
+
272
+ return (
273
+ <>
274
+ <MentionsInput
275
+ ref={textareaRef}
276
+ providers={providers}
277
+ onChange={onChange}
278
+ onSubmit={onSubmit}
279
+ />
280
+ <button type="button" disabled={!hasContent} onClick={onSubmit}>
281
+ Send
282
+ </button>
283
+ </>
284
+ );
285
+ }
286
+ ```
287
+
288
+ ## Streaming
289
+
290
+ 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.
291
+
292
+ ```tsx
293
+ const ref = useRef<MentionsInputHandle>(null);
294
+ const [isStreaming, setIsStreaming] = useState(false);
295
+
296
+ async function enhancePrompt() {
297
+ setIsStreaming(true);
298
+ let accumulated = "";
299
+
300
+ const stream = await fetchEnhancedPrompt(currentPrompt);
301
+ for await (const chunk of stream) {
302
+ accumulated += chunk;
303
+ ref.current?.setContent(accumulated);
304
+ }
305
+
306
+ setIsStreaming(false);
307
+ }
308
+
309
+ <MentionsInput
310
+ ref={ref}
311
+ streaming={isStreaming}
312
+ providers={providers}
313
+ onChange={handleChange}
314
+ onStreamingComplete={(output) => {
315
+ console.log("Final tokens:", output.tokens);
316
+ }}
317
+ />
318
+ ```
319
+
320
+ **How it works:**
321
+
322
+ 1. Set `streaming={true}` — the editor enters streaming mode
323
+ 2. On each chunk, accumulate the full text and call `ref.current.setContent(accumulated)`
324
+ 3. Incomplete mention tokens (e.g. `@[NDA`) render as plain text until the full `@[label](id)` syntax is received, then snap into mention chips
325
+ 4. Set `streaming={false}` — the editor exits streaming mode, fires a final `onChange` and `onStreamingComplete`
326
+
327
+ For plain-text-only streaming (no mention syntax in chunks), use `ref.current.appendText(chunk)` instead for better performance.
328
+
234
329
  ## Keyboard shortcuts
235
330
 
236
331
  | 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. */
@@ -184,6 +197,12 @@ type MentionsInputHandle = {
184
197
  */
185
198
  declare const MentionsInput: React.ForwardRefExoticComponent<MentionsInputProps & React.RefAttributes<MentionsInputHandle>>;
186
199
 
200
+ declare function useMentionsContent(): {
201
+ output: MentionsOutput | null;
202
+ onChange: (o: MentionsOutput) => void;
203
+ hasContent: boolean;
204
+ };
205
+
187
206
  /**
188
207
  * Serialize a Tiptap JSON document to a markdown string.
189
208
  *
@@ -217,4 +236,4 @@ declare function extractFromMarkdown(markdown: string): {
217
236
  plainText: string;
218
237
  };
219
238
 
220
- export { type MentionItem, type MentionProvider, type MentionToken, MentionsInput, type MentionsInputHandle, type MentionsInputProps, type MentionsOutput, extractFromMarkdown, parseFromMarkdown, serializeToMarkdown };
239
+ export { type MentionItem, type MentionProvider, type MentionToken, MentionsInput, type MentionsInputHandle, type MentionsInputProps, type MentionsOutput, extractFromMarkdown, parseFromMarkdown, serializeToMarkdown, useMentionsContent };
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. */
@@ -184,6 +197,12 @@ type MentionsInputHandle = {
184
197
  */
185
198
  declare const MentionsInput: React.ForwardRefExoticComponent<MentionsInputProps & React.RefAttributes<MentionsInputHandle>>;
186
199
 
200
+ declare function useMentionsContent(): {
201
+ output: MentionsOutput | null;
202
+ onChange: (o: MentionsOutput) => void;
203
+ hasContent: boolean;
204
+ };
205
+
187
206
  /**
188
207
  * Serialize a Tiptap JSON document to a markdown string.
189
208
  *
@@ -217,4 +236,4 @@ declare function extractFromMarkdown(markdown: string): {
217
236
  plainText: string;
218
237
  };
219
238
 
220
- export { type MentionItem, type MentionProvider, type MentionToken, MentionsInput, type MentionsInputHandle, type MentionsInputProps, type MentionsOutput, extractFromMarkdown, parseFromMarkdown, serializeToMarkdown };
239
+ export { type MentionItem, type MentionProvider, type MentionToken, MentionsInput, type MentionsInputHandle, type MentionsInputProps, type MentionsOutput, extractFromMarkdown, parseFromMarkdown, serializeToMarkdown, useMentionsContent };
package/dist/index.js CHANGED
@@ -33,7 +33,8 @@ __export(index_exports, {
33
33
  MentionsInput: () => MentionsInput,
34
34
  extractFromMarkdown: () => extractFromMarkdown,
35
35
  parseFromMarkdown: () => parseFromMarkdown,
36
- serializeToMarkdown: () => serializeToMarkdown
36
+ serializeToMarkdown: () => serializeToMarkdown,
37
+ useMentionsContent: () => useMentionsContent
37
38
  });
38
39
  module.exports = __toCommonJS(index_exports);
39
40
 
@@ -222,7 +223,7 @@ function detectTrigger(text, cursorPos, docStartPos, triggers) {
222
223
  return null;
223
224
  }
224
225
  var suggestionPluginKey = new import_state.PluginKey("mentionSuggestion");
225
- function createSuggestionExtension(triggers, callbacksRef, allowTriggerRef) {
226
+ function createSuggestionExtension(triggers, callbacksRef, allowTriggerRef, streamingRef) {
226
227
  return import_core2.Extension.create({
227
228
  name: "mentionSuggestion",
228
229
  priority: 200,
@@ -273,6 +274,15 @@ function createSuggestionExtension(triggers, callbacksRef, allowTriggerRef) {
273
274
  view() {
274
275
  return {
275
276
  update(view, _prevState) {
277
+ if (streamingRef?.current) {
278
+ if (active) {
279
+ active = false;
280
+ lastQuery = null;
281
+ lastTrigger = null;
282
+ callbacksRef.current.onExit();
283
+ }
284
+ return;
285
+ }
276
286
  const { state } = view;
277
287
  const { selection } = state;
278
288
  if (!selection.empty) {
@@ -587,6 +597,34 @@ function createMentionRemoveExtension(onMentionRemoveRef) {
587
597
  }
588
598
  });
589
599
  }
600
+ var streamingBlockPluginKey = new import_state2.PluginKey("streamingBlock");
601
+ function createStreamingBlockExtension(streamingRef) {
602
+ return import_core3.Extension.create({
603
+ name: "streamingBlock",
604
+ priority: 200,
605
+ addProseMirrorPlugins() {
606
+ return [
607
+ new import_state2.Plugin({
608
+ key: streamingBlockPluginKey,
609
+ props: {
610
+ handleKeyDown() {
611
+ return streamingRef.current;
612
+ },
613
+ handleKeyPress() {
614
+ return streamingRef.current;
615
+ },
616
+ handlePaste() {
617
+ return streamingRef.current;
618
+ },
619
+ handleDrop() {
620
+ return streamingRef.current;
621
+ }
622
+ }
623
+ })
624
+ ];
625
+ }
626
+ });
627
+ }
590
628
  function useMentionsEditor({
591
629
  providers,
592
630
  value,
@@ -604,7 +642,9 @@ function useMentionsEditor({
604
642
  onMentionClick,
605
643
  onMentionHover,
606
644
  allowTrigger,
607
- validateMention
645
+ validateMention,
646
+ streaming = false,
647
+ onStreamingComplete
608
648
  }) {
609
649
  const onChangeRef = (0, import_react.useRef)(onChange);
610
650
  onChangeRef.current = onChange;
@@ -628,6 +668,13 @@ function useMentionsEditor({
628
668
  allowTriggerRef.current = allowTrigger;
629
669
  const validateMentionRef = (0, import_react.useRef)(validateMention);
630
670
  validateMentionRef.current = validateMention;
671
+ const onStreamingCompleteRef = (0, import_react.useRef)(onStreamingComplete);
672
+ onStreamingCompleteRef.current = onStreamingComplete;
673
+ const streamingRef = (0, import_react.useRef)(streaming);
674
+ streamingRef.current = streaming;
675
+ const prevStreamingRef = (0, import_react.useRef)(streaming);
676
+ const throttleTimerRef = (0, import_react.useRef)(null);
677
+ const pendingOutputRef = (0, import_react.useRef)(null);
631
678
  const internalMarkdownRef = (0, import_react.useRef)(null);
632
679
  const initialContent = (0, import_react.useMemo)(() => {
633
680
  if (!value) return void 0;
@@ -640,7 +687,12 @@ function useMentionsEditor({
640
687
  [triggersKey]
641
688
  );
642
689
  const suggestionExtension = (0, import_react.useMemo)(
643
- () => createSuggestionExtension(triggers, callbacksRef, allowTriggerRef),
690
+ () => createSuggestionExtension(
691
+ triggers,
692
+ callbacksRef,
693
+ allowTriggerRef,
694
+ streamingRef
695
+ ),
644
696
  // eslint-disable-next-line react-hooks/exhaustive-deps
645
697
  [triggersKey]
646
698
  );
@@ -656,6 +708,10 @@ function useMentionsEditor({
656
708
  () => createMentionRemoveExtension(onMentionRemoveRef),
657
709
  []
658
710
  );
711
+ const streamingBlockExt = (0, import_react.useMemo)(
712
+ () => createStreamingBlockExtension(streamingRef),
713
+ []
714
+ );
659
715
  const mentionNodeExt = (0, import_react.useMemo)(
660
716
  () => MentionNode.configure({
661
717
  onClickRef: onMentionClickRef,
@@ -683,7 +739,8 @@ function useMentionsEditor({
683
739
  suggestionExtension,
684
740
  submitExt,
685
741
  enterExt,
686
- mentionRemoveExt
742
+ mentionRemoveExt,
743
+ streamingBlockExt
687
744
  ],
688
745
  content: initialContent,
689
746
  autofocus: autoFocus ? "end" : false,
@@ -696,7 +753,20 @@ function useMentionsEditor({
696
753
  onUpdate: ({ editor: editor2 }) => {
697
754
  const output = buildOutput(editor2);
698
755
  internalMarkdownRef.current = output.markdown;
699
- onChangeRef.current?.(output);
756
+ if (streamingRef.current) {
757
+ pendingOutputRef.current = output;
758
+ if (!throttleTimerRef.current) {
759
+ throttleTimerRef.current = setTimeout(() => {
760
+ throttleTimerRef.current = null;
761
+ if (pendingOutputRef.current) {
762
+ onChangeRef.current?.(pendingOutputRef.current);
763
+ pendingOutputRef.current = null;
764
+ }
765
+ }, 150);
766
+ }
767
+ } else {
768
+ onChangeRef.current?.(output);
769
+ }
700
770
  },
701
771
  onFocus: () => {
702
772
  onFocusRef.current?.();
@@ -705,6 +775,26 @@ function useMentionsEditor({
705
775
  onBlurRef.current?.();
706
776
  }
707
777
  });
778
+ (0, import_react.useEffect)(() => {
779
+ if (prevStreamingRef.current && !streaming && editor) {
780
+ if (throttleTimerRef.current) {
781
+ clearTimeout(throttleTimerRef.current);
782
+ throttleTimerRef.current = null;
783
+ }
784
+ const output = buildOutput(editor);
785
+ onChangeRef.current?.(output);
786
+ onStreamingCompleteRef.current?.(output);
787
+ pendingOutputRef.current = null;
788
+ }
789
+ prevStreamingRef.current = streaming;
790
+ }, [streaming, editor]);
791
+ (0, import_react.useEffect)(() => {
792
+ return () => {
793
+ if (throttleTimerRef.current) {
794
+ clearTimeout(throttleTimerRef.current);
795
+ }
796
+ };
797
+ }, []);
708
798
  (0, import_react.useEffect)(() => {
709
799
  if (editor && editor.isEditable !== editable) {
710
800
  editor.setEditable(editable);
@@ -749,6 +839,20 @@ function useMentionsEditor({
749
839
  const doc = parseFromMarkdown(markdown);
750
840
  editor.commands.setContent(doc);
751
841
  internalMarkdownRef.current = markdown;
842
+ if (streamingRef.current) {
843
+ editor.commands.focus("end");
844
+ }
845
+ },
846
+ [editor]
847
+ );
848
+ const appendText = (0, import_react.useCallback)(
849
+ (text) => {
850
+ if (!editor) return;
851
+ const endPos = editor.state.doc.content.size - 1;
852
+ editor.commands.insertContentAt(endPos, text);
853
+ if (streamingRef.current) {
854
+ editor.commands.focus("end");
855
+ }
752
856
  },
753
857
  [editor]
754
858
  );
@@ -759,7 +863,7 @@ function useMentionsEditor({
759
863
  if (!editor) return null;
760
864
  return buildOutput(editor);
761
865
  }, [editor]);
762
- return { editor, getOutput, clear, setContent, focus };
866
+ return { editor, getOutput, clear, setContent, appendText, focus };
763
867
  }
764
868
 
765
869
  // src/hooks/useSuggestion.ts
@@ -1429,14 +1533,16 @@ var MentionsInput = (0, import_react5.forwardRef)(
1429
1533
  submitKey = "enter",
1430
1534
  allowTrigger,
1431
1535
  validateMention,
1432
- portalContainer
1536
+ portalContainer,
1537
+ streaming,
1538
+ onStreamingComplete
1433
1539
  }, ref) {
1434
1540
  const instanceId = (0, import_react5.useId)();
1435
1541
  const listboxId = `mentions-listbox-${instanceId}`;
1436
1542
  const { uiState, actions, callbacksRef } = useSuggestion(providers, {
1437
1543
  onMentionAdd
1438
1544
  });
1439
- const { editor, getOutput, clear, setContent, focus } = useMentionsEditor({
1545
+ const { editor, getOutput, clear, setContent, appendText, focus } = useMentionsEditor({
1440
1546
  providers,
1441
1547
  value,
1442
1548
  onChange,
@@ -1453,12 +1559,14 @@ var MentionsInput = (0, import_react5.forwardRef)(
1453
1559
  onMentionClick,
1454
1560
  onMentionHover,
1455
1561
  allowTrigger,
1456
- validateMention
1562
+ validateMention,
1563
+ streaming,
1564
+ onStreamingComplete
1457
1565
  });
1458
1566
  (0, import_react5.useImperativeHandle)(
1459
1567
  ref,
1460
- () => ({ clear, setContent, focus, getOutput }),
1461
- [clear, setContent, focus, getOutput]
1568
+ () => ({ clear, setContent, appendText, focus, getOutput }),
1569
+ [clear, setContent, appendText, focus, getOutput]
1462
1570
  );
1463
1571
  const isExpanded = uiState.state !== "idle";
1464
1572
  const handleHover = (0, import_react5.useCallback)((index) => {
@@ -1514,11 +1622,24 @@ var MentionsInput = (0, import_react5.forwardRef)(
1514
1622
  );
1515
1623
  }
1516
1624
  );
1625
+
1626
+ // src/hooks/useMentionsContent.ts
1627
+ var import_react7 = require("react");
1628
+ function useMentionsContent() {
1629
+ const [output, setOutput] = (0, import_react7.useState)(null);
1630
+ const onChange = (0, import_react7.useCallback)((o) => setOutput(o), []);
1631
+ return {
1632
+ output,
1633
+ onChange,
1634
+ hasContent: (output?.plainText ?? "").trim().length > 0
1635
+ };
1636
+ }
1517
1637
  // Annotate the CommonJS export names for ESM import in node:
1518
1638
  0 && (module.exports = {
1519
1639
  MentionsInput,
1520
1640
  extractFromMarkdown,
1521
1641
  parseFromMarkdown,
1522
- serializeToMarkdown
1642
+ serializeToMarkdown,
1643
+ useMentionsContent
1523
1644
  });
1524
1645
  //# sourceMappingURL=index.js.map