@carlonicora/nextjs-jsonapi 1.106.2 → 1.107.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.
Files changed (30) hide show
  1. package/dist/{BlockNoteEditor-54ZSYWYM.js → BlockNoteEditor-EAIEASEE.js} +285 -22
  2. package/dist/BlockNoteEditor-EAIEASEE.js.map +1 -0
  3. package/dist/{BlockNoteEditor-Z3LF4LFQ.mjs → BlockNoteEditor-U4MWBUCU.mjs} +278 -15
  4. package/dist/BlockNoteEditor-U4MWBUCU.mjs.map +1 -0
  5. package/dist/billing/index.js +299 -299
  6. package/dist/billing/index.mjs +1 -1
  7. package/dist/{chunk-THZ4W7TG.mjs → chunk-3ER4NXVY.mjs} +31 -4
  8. package/dist/chunk-3ER4NXVY.mjs.map +1 -0
  9. package/dist/{chunk-UB7VGH2D.js → chunk-YC2JK36B.js} +131 -104
  10. package/dist/chunk-YC2JK36B.js.map +1 -0
  11. package/dist/client/index.js +2 -2
  12. package/dist/client/index.mjs +1 -1
  13. package/dist/components/index.d.mts +15 -2
  14. package/dist/components/index.d.ts +15 -2
  15. package/dist/components/index.js +4 -2
  16. package/dist/components/index.js.map +1 -1
  17. package/dist/components/index.mjs +3 -1
  18. package/dist/contexts/index.js +2 -2
  19. package/dist/contexts/index.mjs +1 -1
  20. package/package.json +3 -1
  21. package/src/components/editors/BlockNoteEditor.tsx +361 -5
  22. package/src/components/editors/BlockNoteEditorFormattingToolbar.tsx +4 -1
  23. package/src/components/editors/BlockNoteEditorMentionInlineContent.tsx +27 -0
  24. package/src/components/editors/__tests__/BlockNoteEditorMentionInlineContent.test.ts +97 -0
  25. package/src/components/editors/index.ts +1 -0
  26. package/src/components/forms/FormBlockNote.tsx +4 -0
  27. package/dist/BlockNoteEditor-54ZSYWYM.js.map +0 -1
  28. package/dist/BlockNoteEditor-Z3LF4LFQ.mjs.map +0 -1
  29. package/dist/chunk-THZ4W7TG.mjs.map +0 -1
  30. package/dist/chunk-UB7VGH2D.js.map +0 -1
@@ -1,21 +1,49 @@
1
1
  "use client";
2
2
 
3
- import { BlockNoteSchema, defaultInlineContentSpecs, PartialBlock } from "@blocknote/core";
3
+ import { BlockNoteSchema, defaultInlineContentSpecs, filterSuggestionItems, PartialBlock } from "@blocknote/core";
4
+ import { en as coreEn } from "@blocknote/core/locales";
4
5
  import {
5
6
  createReactInlineContentSpec,
6
7
  DefaultReactSuggestionItem,
8
+ getDefaultReactSlashMenuItems,
9
+ SuggestionMenuController,
7
10
  SuggestionMenuProps,
11
+ useBlockNoteEditor,
8
12
  useCreateBlockNote,
13
+ useExtension,
14
+ useExtensionState,
9
15
  } from "@blocknote/react";
10
16
  import { BlockNoteView } from "@blocknote/shadcn";
11
17
  import "@blocknote/shadcn/style.css";
12
- import { CheckIcon, XIcon } from "lucide-react";
18
+ import {
19
+ AIExtension,
20
+ AIMenuController,
21
+ getAISlashMenuItems,
22
+ getDefaultAIMenuItems,
23
+ PromptSuggestionMenu,
24
+ useAIDictionary,
25
+ } from "@blocknote/xl-ai";
26
+ import { en as aiEn } from "@blocknote/xl-ai/locales";
27
+ import "@blocknote/xl-ai/style.css";
28
+ import { DefaultChatTransport } from "ai";
29
+ import {
30
+ CheckIcon,
31
+ LanguagesIcon,
32
+ LayoutTemplateIcon,
33
+ SparklesIcon,
34
+ TypeIcon,
35
+ WandSparklesIcon,
36
+ XIcon,
37
+ } from "lucide-react";
13
38
  import { useTranslations } from "next-intl";
14
39
  import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
40
+ import { getPublicApiUrl } from "../../client/config";
41
+ import { getClientToken } from "../../client/token";
15
42
  import { useCurrentUserContext } from "../../contexts";
16
43
  import { S3Interface } from "../../features/s3/data";
17
44
  import { S3Service } from "../../features/s3/data/s3.service";
18
45
  import { UserInterface } from "../../features/user/data";
46
+ import { useI18nLocale } from "../../i18n/config";
19
47
  import { Button } from "../../shadcnui";
20
48
  import { BlockNoteDiffUtil, BlockNoteWordDiffRendererUtil, cn } from "../../utils";
21
49
  import { errorToast } from "../errors";
@@ -28,6 +56,12 @@ import {
28
56
  } from "./BlockNoteEditorMentionInlineContent";
29
57
  import { BlockNoteEditorMentionSuggestionMenu } from "./BlockNoteEditorSuggestionMenuController";
30
58
 
59
+ export type BlockNoteAiConfig = {
60
+ endpoint: string;
61
+ entityType: string;
62
+ entityId?: string;
63
+ };
64
+
31
65
  export type BlockNoteEditorProps = {
32
66
  id: string;
33
67
  type: string;
@@ -52,6 +86,12 @@ export type BlockNoteEditorProps = {
52
86
  suggestionMenuComponent?: React.FC<SuggestionMenuProps<DefaultReactSuggestionItem>>;
53
87
  mentionNameResolver?: MentionNameResolver;
54
88
  onWarmMentions?: (blocks: PartialBlock[]) => void;
89
+ aiConfig?: BlockNoteAiConfig;
90
+ // When the editor is inside a bounded flex column (parent gives it a real
91
+ // height via flex-1+min-h-0), set this so `.bn-container` shrinks to that
92
+ // height and scrolls internally. Without it the editor grows to fit its
93
+ // content and pushes the surrounding form to scroll instead.
94
+ stretch?: boolean;
55
95
  };
56
96
 
57
97
  function isBlockEmpty(block: any): boolean {
@@ -128,6 +168,227 @@ const createDiffActionsInlineContentSpec = (
128
168
  );
129
169
  };
130
170
 
171
+ /**
172
+ * Custom AI menu wrapper. Surfaces our backend-driven action items
173
+ * (Improve Writing, Fix Spelling) above the free-form prompt input.
174
+ *
175
+ * Why a custom wrapper instead of `<AIMenu items={…}>` with the BlockNote
176
+ * defaults: BlockNote's `PromptSuggestionMenu` hijacks Enter to pick the
177
+ * highlighted item whenever `items.length > 0`. We gate items on
178
+ * - status === "user-input"
179
+ * - editor has a selection
180
+ * - prompt input is empty
181
+ * so the moment the user types, items disappear and Enter falls back to
182
+ * free-form submission. Outside `user-input` (review / error states) we
183
+ * keep BlockNote's default review buttons (accept/revert/retry/cancel).
184
+ *
185
+ * Each custom item passes a `type` discriminator on `chatRequestOptions.body`.
186
+ * That field flows through `chat.sendMessage(msg, opts)` to the transport's
187
+ * `prepareSendMessagesRequest({messages, body})` (see types.ts in
188
+ * `@blocknote/xl-ai` — `ChatRequestOptions = Parameters<Chat["sendMessage"]>[1]`).
189
+ * The backend dispatcher reads `body.type` and routes to a per-type handler
190
+ * with its own canonical prompt. NO prompt text lives in this file.
191
+ */
192
+ function NarrAIMenu() {
193
+ const editor = useBlockNoteEditor();
194
+ const ai = useExtension(AIExtension);
195
+ const dict = useAIDictionary();
196
+ const status = useExtensionState(AIExtension, {
197
+ selector: (s) => (s.aiMenuState !== "closed" ? s.aiMenuState.status : "closed"),
198
+ });
199
+ const [prompt, setPrompt] = useState("");
200
+ // Set by items that pre-fill the input (Translate) so handleSubmit knows
201
+ // which `type` to attach when the user submits the captured text. Cleared
202
+ // after submit or on status change.
203
+ const pendingTypeRef = useRef<string | null>(null);
204
+
205
+ useEffect(() => {
206
+ if (
207
+ status === "ai-writing" ||
208
+ status === "user-reviewing" ||
209
+ status === "error" ||
210
+ status === "closed"
211
+ ) {
212
+ setPrompt("");
213
+ pendingTypeRef.current = null;
214
+ }
215
+ }, [status]);
216
+
217
+ // Selection-edit ops operate per-block. If the user's selection only
218
+ // covers part of a block (cursor mid-paragraph dragged to mid-next), the
219
+ // rewrite would silently replace the WHOLE containing blocks — beyond
220
+ // what the user highlighted. Expand the selection to whole-block bounds
221
+ // BEFORE invokeAI so the user sees exactly what will be rewritten.
222
+ const expandSelectionToBlocks = useCallback(() => {
223
+ const sel = editor.getSelection?.();
224
+ const blocks = (sel as any)?.blocks;
225
+ if (!Array.isArray(blocks) || blocks.length === 0) return;
226
+ const first = blocks[0];
227
+ const last = blocks[blocks.length - 1];
228
+ try {
229
+ (editor as any).setSelection?.(first, last);
230
+ } catch {
231
+ // If BlockNote's setSelection signature changes, fail silently — the
232
+ // backend still operates per-block, so we just lose the visual hint.
233
+ }
234
+ }, [editor]);
235
+
236
+ const items = useMemo(() => {
237
+ // Outside user-input (reviewing / error), use BlockNote's default
238
+ // review buttons (accept/revert/retry/cancel). Need to wrap onItemClick
239
+ // because the default items expect a setPrompt argument.
240
+ if (status !== "user-input") {
241
+ return getDefaultAIMenuItems(editor, status).map((item) => ({
242
+ ...item,
243
+ onItemClick: () => item.onItemClick(setPrompt),
244
+ }));
245
+ }
246
+ const hasSelection = editor.getSelection() !== undefined;
247
+ const hasTyped = prompt.trim().length > 0;
248
+ // Once the user starts typing, hide all items so Enter submits the
249
+ // free-form (or pending-type) prompt instead of being hijacked.
250
+ if (hasTyped) return [];
251
+
252
+ // Generate from Template is shown in BOTH contexts (with and without
253
+ // selection) because it operates on the whole document — it ignores any
254
+ // active selection and runs the per-section template-fill flow. Listed
255
+ // first so it's the default-highlighted item.
256
+ const generateFromTemplate = {
257
+ key: "generate_from_template",
258
+ title: "Generate from Template",
259
+ aliases: ["generate", "template", "fill"],
260
+ icon: <LayoutTemplateIcon size={18} />,
261
+ size: "small" as const,
262
+ onItemClick: () => {
263
+ void ai.invokeAI({
264
+ userPrompt: "fill-template",
265
+ useSelection: false,
266
+ chatRequestOptions: { body: { type: "fill-template" } },
267
+ });
268
+ },
269
+ };
270
+
271
+ if (hasSelection) {
272
+ // Selection-edit items + the always-available Generate from Template.
273
+ // Each selection item invokes ai.invokeAI with chatRequestOptions
274
+ // carrying the `type` body field. The userPrompt is a short tag — the
275
+ // backend ignores it and uses the canonical prompt for the type instead.
276
+ return [
277
+ generateFromTemplate,
278
+ {
279
+ key: "improve_writing",
280
+ title: "Improve Writing",
281
+ aliases: ["improve", "rewrite", "polish"],
282
+ icon: <SparklesIcon size={18} />,
283
+ size: "small" as const,
284
+ onItemClick: () => {
285
+ expandSelectionToBlocks();
286
+ void ai.invokeAI({
287
+ userPrompt: "improve-writing",
288
+ useSelection: true,
289
+ chatRequestOptions: { body: { type: "improve-writing" } },
290
+ });
291
+ },
292
+ },
293
+ {
294
+ key: "fix_spelling",
295
+ title: "Fix Spelling",
296
+ aliases: ["spelling", "grammar", "typo"],
297
+ icon: <TypeIcon size={18} />,
298
+ size: "small" as const,
299
+ onItemClick: () => {
300
+ expandSelectionToBlocks();
301
+ void ai.invokeAI({
302
+ userPrompt: "fix-spelling",
303
+ useSelection: true,
304
+ chatRequestOptions: { body: { type: "fix-spelling" } },
305
+ });
306
+ },
307
+ },
308
+ {
309
+ key: "translate",
310
+ title: "Translate…",
311
+ aliases: ["translate", "language"],
312
+ icon: <LanguagesIcon size={18} />,
313
+ size: "small" as const,
314
+ // Pre-fills the input with a placeholder. User appends/replaces
315
+ // with the target language and submits via Enter. handleSubmit
316
+ // reads pendingTypeRef and forwards `type: "translate"`. We
317
+ // expand the selection now so the user sees the scope before
318
+ // typing the language — handleSubmit doesn't re-expand.
319
+ onItemClick: () => {
320
+ expandSelectionToBlocks();
321
+ pendingTypeRef.current = "translate";
322
+ setPrompt("Translate to ");
323
+ },
324
+ },
325
+ {
326
+ key: "simplify",
327
+ title: "Simplify",
328
+ aliases: ["simplify", "easier", "plain"],
329
+ icon: <WandSparklesIcon size={18} />,
330
+ size: "small" as const,
331
+ onItemClick: () => {
332
+ expandSelectionToBlocks();
333
+ void ai.invokeAI({
334
+ userPrompt: "simplify",
335
+ useSelection: true,
336
+ chatRequestOptions: { body: { type: "simplify" } },
337
+ });
338
+ },
339
+ },
340
+ ];
341
+ }
342
+
343
+ // No selection (the /ai slash menu path): just Generate from Template.
344
+ // Free-form typing still works — once the user types, items hide and
345
+ // Enter submits with no type (backend defaults to fill-template).
346
+ return [generateFromTemplate];
347
+ }, [editor, status, prompt, ai, expandSelectionToBlocks]);
348
+
349
+ const handleSubmit = useCallback(
350
+ async (userPrompt: string) => {
351
+ if (!userPrompt.trim()) return;
352
+ const pendingType = pendingTypeRef.current;
353
+ pendingTypeRef.current = null;
354
+ const body = pendingType ? { type: pendingType } : undefined;
355
+ await ai.invokeAI({
356
+ userPrompt,
357
+ useSelection: editor.getSelection() !== undefined,
358
+ ...(body ? { chatRequestOptions: { body } } : {}),
359
+ });
360
+ },
361
+ [ai, editor],
362
+ );
363
+
364
+ const placeholder =
365
+ status === "thinking"
366
+ ? dict.ai_menu.status.thinking
367
+ : status === "ai-writing"
368
+ ? dict.ai_menu.status.editing
369
+ : status === "error"
370
+ ? dict.ai_menu.status.error
371
+ : dict.ai_menu.input_placeholder;
372
+
373
+ const disabled = status === "thinking" || status === "ai-writing";
374
+
375
+ return (
376
+ <PromptSuggestionMenu
377
+ items={items}
378
+ onManualPromptSubmit={handleSubmit}
379
+ promptText={prompt}
380
+ onPromptTextChange={setPrompt}
381
+ placeholder={placeholder}
382
+ disabled={disabled}
383
+ icon={
384
+ <div className="bn-combobox-icon">
385
+ <SparklesIcon size={16} />
386
+ </div>
387
+ }
388
+ />
389
+ );
390
+ }
391
+
131
392
  export default function BlockNoteEditor({
132
393
  id,
133
394
  type,
@@ -149,8 +410,11 @@ export default function BlockNoteEditor({
149
410
  suggestionMenuComponent,
150
411
  mentionNameResolver,
151
412
  onWarmMentions,
413
+ aiConfig,
414
+ stretch,
152
415
  }: BlockNoteEditorProps): React.JSX.Element {
153
416
  const t = useTranslations();
417
+ const locale = useI18nLocale();
154
418
  const { company } = useCurrentUserContext<UserInterface>();
155
419
 
156
420
  const [acceptedChanges, setAcceptedChanges] = useState<Set<string>>(new Set());
@@ -214,6 +478,57 @@ export default function BlockNoteEditor({
214
478
  [DiffActionsInlineContent, mentionSpec, inlineContentSpecs],
215
479
  );
216
480
 
481
+ const docRef = useRef<{ getDoc: () => any[] }>({ getDoc: () => [] });
482
+ // Selection getter used by the AI transport to attach `selectionBlocks` to
483
+ // the outgoing request metadata. Populated in a useEffect once the editor
484
+ // instance exists; reads the current BlockNote selection on every send.
485
+ const selectionRef = useRef<{ getSelectedBlocks: () => any[] }>({ getSelectedBlocks: () => [] });
486
+
487
+ const companyId = company?.id;
488
+ const aiExtension = useMemo(() => {
489
+ if (!aiConfig) return undefined;
490
+ const base = getPublicApiUrl();
491
+ const url = new URL(aiConfig.endpoint, base.endsWith("/") ? base : base + "/").toString();
492
+ return AIExtension({
493
+ transport: new DefaultChatTransport({
494
+ api: url,
495
+ credentials: "include",
496
+ headers: async () => {
497
+ const headers: Record<string, string> = { "x-language": locale };
498
+ const token = await getClientToken();
499
+ if (token) headers["Authorization"] = `Bearer ${token}`;
500
+ if (companyId) headers["x-companyid"] = companyId;
501
+ return headers;
502
+ },
503
+ prepareSendMessagesRequest: ({ messages, body }: any) => {
504
+ let lastUserIdx = -1;
505
+ for (let i = messages.length - 1; i >= 0; i--) {
506
+ if (messages[i]?.role === "user") {
507
+ lastUserIdx = i;
508
+ break;
509
+ }
510
+ }
511
+ const selectedBlocks = selectionRef.current.getSelectedBlocks();
512
+ const augmented = messages.map((m: any, i: number) =>
513
+ i === lastUserIdx
514
+ ? {
515
+ ...m,
516
+ metadata: {
517
+ ...(m.metadata ?? {}),
518
+ entityType: aiConfig.entityType,
519
+ entityId: aiConfig.entityId,
520
+ blocks: docRef.current.getDoc(),
521
+ selectionBlocks: selectedBlocks,
522
+ },
523
+ }
524
+ : m,
525
+ );
526
+ return { body: { ...(body ?? {}), messages: augmented } };
527
+ },
528
+ }),
529
+ });
530
+ }, [aiConfig, companyId, locale]);
531
+
217
532
  const uploadImage = useCallback(
218
533
  async (file: File): Promise<string> => {
219
534
  if (!company) {
@@ -341,8 +656,10 @@ export default function BlockNoteEditor({
341
656
  schema,
342
657
  initialContent: validatedInitialContent,
343
658
  uploadFile: uploadImage,
659
+ extensions: aiExtension ? [aiExtension] : undefined,
660
+ dictionary: aiExtension ? { ...coreEn, ai: aiEn } : undefined,
344
661
  }),
345
- [placeholder, t, schema, validatedInitialContent, uploadImage],
662
+ [placeholder, t, schema, validatedInitialContent, uploadImage, aiExtension],
346
663
  ),
347
664
  );
348
665
 
@@ -461,6 +778,15 @@ export default function BlockNoteEditor({
461
778
  onWarmMentions(initialContent);
462
779
  }, [onWarmMentions, initialContent]);
463
780
 
781
+ useEffect(() => {
782
+ docRef.current.getDoc = () => editor?.document ?? [];
783
+ selectionRef.current.getSelectedBlocks = () => {
784
+ const sel = editor?.getSelection?.();
785
+ const blocks = (sel as any)?.blocks;
786
+ return Array.isArray(blocks) ? blocks : [];
787
+ };
788
+ }, [editor]);
789
+
464
790
  // Handle audio received from whisper transcription
465
791
  const _handleAudioReceived = useCallback(
466
792
  (message: string) => {
@@ -497,6 +823,12 @@ export default function BlockNoteEditor({
497
823
  className={cn(
498
824
  bordered ? "rounded-md border border-input bg-input/20 dark:bg-input/30" : "",
499
825
  "flex flex-col w-full",
826
+ // Pin BlockNote's font-size so it doesn't jump from 14→16px when the
827
+ // xl-ai AIMenu mounts. The shadcn theme sets `.bn-default-styles {
828
+ // font-size: 16px }` explicitly; outside AI mode the form's text-sm
829
+ // wins via cascade, but ForkYDocExtension re-evaluates the style
830
+ // context on AI activation and the explicit 16px takes over.
831
+ "[&_.bn-default-styles]:!text-sm",
500
832
  className,
501
833
  )}
502
834
  >
@@ -505,10 +837,25 @@ export default function BlockNoteEditor({
505
837
  onChange={handleChange}
506
838
  editable={onChange !== undefined}
507
839
  formattingToolbar={false}
840
+ slashMenu={!aiConfig}
508
841
  theme="light"
509
- className={cn(`BlockNoteView flex-1 ${onChange ? "p-4" : ""}`, size === "sm" && "small")}
842
+ // `className` is applied by BlockNote to both the main `.bn-container`
843
+ // AND `editor.portalElement` (the floating-UI portal root). Gate `p-4`
844
+ // on `.bn-container` so it doesn't add padding to the empty portal
845
+ // element and produce a phantom scrollbar on the wrapper.
846
+ className={cn(
847
+ "BlockNoteView flex-1",
848
+ onChange && "[&.bn-container]:p-4",
849
+ // In stretch mode the parent chain caps our height via flex; without
850
+ // these two classes the `.bn-container` keeps `min-height: auto`
851
+ // (its content's intrinsic height) and pushes the bordered wrapper
852
+ // — and the surrounding EditorSheet form — to scroll instead of
853
+ // scrolling internally.
854
+ onChange && stretch && "[&.bn-container]:min-h-0 [&.bn-container]:overflow-y-auto",
855
+ size === "sm" && "small",
856
+ )}
510
857
  >
511
- <BlockNoteEditorFormattingToolbar />
858
+ <BlockNoteEditorFormattingToolbar showAI={!!aiConfig} />
512
859
  {enableMentions && mentionSearchFn && (
513
860
  <BlockNoteEditorMentionSuggestionMenu
514
861
  editor={editor}
@@ -521,6 +868,15 @@ export default function BlockNoteEditor({
521
868
  {enableMentions && mentionResolveFn && (
522
869
  <BlockNoteEditorMentionHoverCard containerRef={editorRef} mentionResolveFn={mentionResolveFn} />
523
870
  )}
871
+ {aiConfig && (
872
+ <SuggestionMenuController
873
+ triggerCharacter="/"
874
+ getItems={async (query: string) =>
875
+ filterSuggestionItems([...getDefaultReactSlashMenuItems(editor), ...getAISlashMenuItems(editor)], query)
876
+ }
877
+ />
878
+ )}
879
+ {aiConfig && <AIMenuController aiMenu={() => <NarrAIMenu />} />}
524
880
  </BlockNoteView>
525
881
  </div>
526
882
  );
@@ -10,8 +10,9 @@ import {
10
10
  FormattingToolbarController,
11
11
  TextAlignButton,
12
12
  } from "@blocknote/react";
13
+ import { AIToolbarButton } from "@blocknote/xl-ai";
13
14
 
14
- export function BlockNoteEditorFormattingToolbar() {
15
+ export function BlockNoteEditorFormattingToolbar({ showAI = false }: { showAI?: boolean }) {
15
16
  return (
16
17
  <FormattingToolbarController
17
18
  formattingToolbar={() => (
@@ -31,6 +32,8 @@ export function BlockNoteEditorFormattingToolbar() {
31
32
  <TextAlignButton textAlignment={"right"} key={"textAlignRightButton"} />
32
33
 
33
34
  <CreateLinkButton key={"createLinkButton"} />
35
+
36
+ {showAI ? <AIToolbarButton key={"aiToolbarButton"} /> : null}
34
37
  </FormattingToolbar>
35
38
  )}
36
39
  />
@@ -36,11 +36,30 @@ export const mentionDataAttrs = (p: MentionRenderProps) => ({
36
36
  "data-mention-alias": p.alias,
37
37
  });
38
38
 
39
+ export const parseMentionElement = (
40
+ element: HTMLElement,
41
+ ): { id: string; entityType: string; alias: string } | undefined => {
42
+ const id = element.getAttribute("data-mention-id");
43
+ const entityType = element.getAttribute("data-mention-type");
44
+ const alias = element.getAttribute("data-mention-alias");
45
+ if (!id || !entityType || !alias) return undefined;
46
+ return { id, entityType, alias };
47
+ };
48
+
39
49
  export const createMentionInlineContentSpec = (
40
50
  resolveFn?: MentionResolveFn,
41
51
  disableMention?: boolean,
42
52
  nameResolver?: MentionNameResolver,
43
53
  ) => {
54
+ const MentionExternalHTML = (props: MentionRenderProps) => {
55
+ const displayName = nameResolver?.(props.id, props.entityType, props.alias) ?? props.alias;
56
+ return (
57
+ <span data-mention-id={props.id} data-mention-type={props.entityType} data-mention-alias={props.alias}>
58
+ @{displayName}
59
+ </span>
60
+ );
61
+ };
62
+
44
63
  const Mention = React.memo(function Mention(props: MentionRenderProps) {
45
64
  const displayName = nameResolver?.(props.id, props.entityType, props.alias) ?? props.alias;
46
65
 
@@ -93,6 +112,14 @@ export const createMentionInlineContentSpec = (
93
112
  alias={props.inlineContent.props.alias}
94
113
  />
95
114
  ),
115
+ toExternalHTML: (props) => (
116
+ <MentionExternalHTML
117
+ id={props.inlineContent.props.id}
118
+ entityType={props.inlineContent.props.entityType}
119
+ alias={props.inlineContent.props.alias}
120
+ />
121
+ ),
122
+ parse: (element) => parseMentionElement(element),
96
123
  },
97
124
  );
98
125
  };
@@ -0,0 +1,97 @@
1
+ import { describe, it, expect } from "vitest";
2
+
3
+ import { parseMentionElement } from "../BlockNoteEditorMentionInlineContent";
4
+
5
+ describe("parseMentionElement", () => {
6
+ const makeSpan = (attrs: Record<string, string>): HTMLElement => {
7
+ const span = document.createElement("span");
8
+ Object.entries(attrs).forEach(([k, v]) => span.setAttribute(k, v));
9
+ return span;
10
+ };
11
+
12
+ it("returns the mention props when all three data attributes are present and non-empty", () => {
13
+ const el = makeSpan({
14
+ "data-mention-id": "id-1",
15
+ "data-mention-type": "type-x",
16
+ "data-mention-alias": "Alias One",
17
+ });
18
+
19
+ expect(parseMentionElement(el)).toEqual({
20
+ id: "id-1",
21
+ entityType: "type-x",
22
+ alias: "Alias One",
23
+ });
24
+ });
25
+
26
+ it("returns undefined when data-mention-id is missing", () => {
27
+ const el = makeSpan({
28
+ "data-mention-type": "type-x",
29
+ "data-mention-alias": "Alias One",
30
+ });
31
+
32
+ expect(parseMentionElement(el)).toBeUndefined();
33
+ });
34
+
35
+ it("returns undefined when data-mention-type is missing", () => {
36
+ const el = makeSpan({
37
+ "data-mention-id": "id-1",
38
+ "data-mention-alias": "Alias One",
39
+ });
40
+
41
+ expect(parseMentionElement(el)).toBeUndefined();
42
+ });
43
+
44
+ it("returns undefined when data-mention-alias is missing", () => {
45
+ const el = makeSpan({
46
+ "data-mention-id": "id-1",
47
+ "data-mention-type": "type-x",
48
+ });
49
+
50
+ expect(parseMentionElement(el)).toBeUndefined();
51
+ });
52
+
53
+ it("returns undefined when data-mention-id is an empty string", () => {
54
+ const el = makeSpan({
55
+ "data-mention-id": "",
56
+ "data-mention-type": "type-x",
57
+ "data-mention-alias": "Alias One",
58
+ });
59
+
60
+ expect(parseMentionElement(el)).toBeUndefined();
61
+ });
62
+
63
+ it("returns undefined when data-mention-type is an empty string", () => {
64
+ const el = makeSpan({
65
+ "data-mention-id": "id-1",
66
+ "data-mention-type": "",
67
+ "data-mention-alias": "Alias One",
68
+ });
69
+
70
+ expect(parseMentionElement(el)).toBeUndefined();
71
+ });
72
+
73
+ it("returns undefined when data-mention-alias is an empty string", () => {
74
+ const el = makeSpan({
75
+ "data-mention-id": "id-1",
76
+ "data-mention-type": "type-x",
77
+ "data-mention-alias": "",
78
+ });
79
+
80
+ expect(parseMentionElement(el)).toBeUndefined();
81
+ });
82
+
83
+ it("ignores unrelated data-* attributes", () => {
84
+ const el = makeSpan({
85
+ "data-mention-id": "id-1",
86
+ "data-mention-type": "type-x",
87
+ "data-mention-alias": "Alias One",
88
+ "data-foo": "bar",
89
+ });
90
+
91
+ expect(parseMentionElement(el)).toEqual({
92
+ id: "id-1",
93
+ entityType: "type-x",
94
+ alias: "Alias One",
95
+ });
96
+ });
97
+ });
@@ -2,3 +2,4 @@ export * from "./BlockNoteEditorContainer";
2
2
  export * from "./BlockNoteEditorMentionHoverCard";
3
3
  export * from "./BlockNoteEditorMentionInlineContent";
4
4
  export * from "./BlockNoteEditorSuggestionMenuController";
5
+ export type { BlockNoteAiConfig, BlockNoteEditorProps } from "./BlockNoteEditor";
@@ -28,6 +28,7 @@ export function FormBlockNote({
28
28
  suggestionMenuComponent,
29
29
  mentionNameResolver,
30
30
  onWarmMentions,
31
+ aiConfig,
31
32
  }: {
32
33
  form: any;
33
34
  id: string;
@@ -56,6 +57,7 @@ export function FormBlockNote({
56
57
  suggestionMenuComponent?: React.FC<SuggestionMenuProps<DefaultReactSuggestionItem>>;
57
58
  mentionNameResolver?: MentionNameResolver;
58
59
  onWarmMentions?: (blocks: any[]) => void;
60
+ aiConfig?: import("../editors/BlockNoteEditor").BlockNoteAiConfig;
59
61
  }) {
60
62
  const initialContentRef = useRef<any>(null);
61
63
  const lastEditorContentRef = useRef<any>(undefined);
@@ -105,6 +107,8 @@ export function FormBlockNote({
105
107
  suggestionMenuComponent={suggestionMenuComponent}
106
108
  mentionNameResolver={mentionNameResolver}
107
109
  onWarmMentions={onWarmMentions}
110
+ aiConfig={aiConfig}
111
+ stretch={stretch}
108
112
  className={cn(stretch && "min-h-0 flex-1")}
109
113
  />
110
114
  );