@carlonicora/nextjs-jsonapi 1.106.2 → 1.107.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/dist/{BlockNoteEditor-Z3LF4LFQ.mjs → BlockNoteEditor-7TSK7PNG.mjs} +278 -15
- package/dist/BlockNoteEditor-7TSK7PNG.mjs.map +1 -0
- package/dist/{BlockNoteEditor-54ZSYWYM.js → BlockNoteEditor-RWRVIEZC.js} +285 -22
- package/dist/BlockNoteEditor-RWRVIEZC.js.map +1 -0
- package/dist/billing/index.js +299 -299
- package/dist/billing/index.mjs +1 -1
- package/dist/{chunk-UB7VGH2D.js → chunk-2IRWQVG4.js} +131 -104
- package/dist/chunk-2IRWQVG4.js.map +1 -0
- package/dist/{chunk-THZ4W7TG.mjs → chunk-WSOPEIRP.mjs} +31 -4
- package/dist/chunk-WSOPEIRP.mjs.map +1 -0
- package/dist/client/index.js +2 -2
- package/dist/client/index.mjs +1 -1
- package/dist/components/index.d.mts +15 -2
- package/dist/components/index.d.ts +15 -2
- package/dist/components/index.js +4 -2
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +3 -1
- package/dist/contexts/index.js +2 -2
- package/dist/contexts/index.mjs +1 -1
- package/package.json +3 -1
- package/src/components/editors/BlockNoteEditor.tsx +356 -5
- package/src/components/editors/BlockNoteEditorFormattingToolbar.tsx +4 -1
- package/src/components/editors/BlockNoteEditorMentionInlineContent.tsx +27 -0
- package/src/components/editors/__tests__/BlockNoteEditorMentionInlineContent.test.ts +97 -0
- package/src/components/editors/index.ts +1 -0
- package/src/components/forms/FormBlockNote.tsx +4 -0
- package/dist/BlockNoteEditor-54ZSYWYM.js.map +0 -1
- package/dist/BlockNoteEditor-Z3LF4LFQ.mjs.map +0 -1
- package/dist/chunk-THZ4W7TG.mjs.map +0 -1
- 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 {
|
|
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,222 @@ 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 (status === "ai-writing" || status === "user-reviewing" || status === "error" || status === "closed") {
|
|
207
|
+
setPrompt("");
|
|
208
|
+
pendingTypeRef.current = null;
|
|
209
|
+
}
|
|
210
|
+
}, [status]);
|
|
211
|
+
|
|
212
|
+
// Selection-edit ops operate per-block. If the user's selection only
|
|
213
|
+
// covers part of a block (cursor mid-paragraph dragged to mid-next), the
|
|
214
|
+
// rewrite would silently replace the WHOLE containing blocks — beyond
|
|
215
|
+
// what the user highlighted. Expand the selection to whole-block bounds
|
|
216
|
+
// BEFORE invokeAI so the user sees exactly what will be rewritten.
|
|
217
|
+
const expandSelectionToBlocks = useCallback(() => {
|
|
218
|
+
const sel = editor.getSelection?.();
|
|
219
|
+
const blocks = (sel as any)?.blocks;
|
|
220
|
+
if (!Array.isArray(blocks) || blocks.length === 0) return;
|
|
221
|
+
const first = blocks[0];
|
|
222
|
+
const last = blocks[blocks.length - 1];
|
|
223
|
+
try {
|
|
224
|
+
(editor as any).setSelection?.(first, last);
|
|
225
|
+
} catch {
|
|
226
|
+
// If BlockNote's setSelection signature changes, fail silently — the
|
|
227
|
+
// backend still operates per-block, so we just lose the visual hint.
|
|
228
|
+
}
|
|
229
|
+
}, [editor]);
|
|
230
|
+
|
|
231
|
+
const items = useMemo(() => {
|
|
232
|
+
// Outside user-input (reviewing / error), use BlockNote's default
|
|
233
|
+
// review buttons (accept/revert/retry/cancel). Need to wrap onItemClick
|
|
234
|
+
// because the default items expect a setPrompt argument.
|
|
235
|
+
if (status !== "user-input") {
|
|
236
|
+
return getDefaultAIMenuItems(editor, status).map((item) => ({
|
|
237
|
+
...item,
|
|
238
|
+
onItemClick: () => item.onItemClick(setPrompt),
|
|
239
|
+
}));
|
|
240
|
+
}
|
|
241
|
+
const hasSelection = editor.getSelection() !== undefined;
|
|
242
|
+
const hasTyped = prompt.trim().length > 0;
|
|
243
|
+
// Once the user starts typing, hide all items so Enter submits the
|
|
244
|
+
// free-form (or pending-type) prompt instead of being hijacked.
|
|
245
|
+
if (hasTyped) return [];
|
|
246
|
+
|
|
247
|
+
// Generate from Template is shown in BOTH contexts (with and without
|
|
248
|
+
// selection) because it operates on the whole document — it ignores any
|
|
249
|
+
// active selection and runs the per-section template-fill flow. Listed
|
|
250
|
+
// first so it's the default-highlighted item.
|
|
251
|
+
const generateFromTemplate = {
|
|
252
|
+
key: "generate_from_template",
|
|
253
|
+
title: "Generate from Template",
|
|
254
|
+
aliases: ["generate", "template", "fill"],
|
|
255
|
+
icon: <LayoutTemplateIcon size={18} />,
|
|
256
|
+
size: "small" as const,
|
|
257
|
+
onItemClick: () => {
|
|
258
|
+
void ai.invokeAI({
|
|
259
|
+
userPrompt: "fill-template",
|
|
260
|
+
useSelection: false,
|
|
261
|
+
chatRequestOptions: { body: { type: "fill-template" } },
|
|
262
|
+
});
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
if (hasSelection) {
|
|
267
|
+
// Selection-edit items + the always-available Generate from Template.
|
|
268
|
+
// Each selection item invokes ai.invokeAI with chatRequestOptions
|
|
269
|
+
// carrying the `type` body field. The userPrompt is a short tag — the
|
|
270
|
+
// backend ignores it and uses the canonical prompt for the type instead.
|
|
271
|
+
return [
|
|
272
|
+
generateFromTemplate,
|
|
273
|
+
{
|
|
274
|
+
key: "improve_writing",
|
|
275
|
+
title: "Improve Writing",
|
|
276
|
+
aliases: ["improve", "rewrite", "polish"],
|
|
277
|
+
icon: <SparklesIcon size={18} />,
|
|
278
|
+
size: "small" as const,
|
|
279
|
+
onItemClick: () => {
|
|
280
|
+
expandSelectionToBlocks();
|
|
281
|
+
void ai.invokeAI({
|
|
282
|
+
userPrompt: "improve-writing",
|
|
283
|
+
useSelection: true,
|
|
284
|
+
chatRequestOptions: { body: { type: "improve-writing" } },
|
|
285
|
+
});
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
key: "fix_spelling",
|
|
290
|
+
title: "Fix Spelling",
|
|
291
|
+
aliases: ["spelling", "grammar", "typo"],
|
|
292
|
+
icon: <TypeIcon size={18} />,
|
|
293
|
+
size: "small" as const,
|
|
294
|
+
onItemClick: () => {
|
|
295
|
+
expandSelectionToBlocks();
|
|
296
|
+
void ai.invokeAI({
|
|
297
|
+
userPrompt: "fix-spelling",
|
|
298
|
+
useSelection: true,
|
|
299
|
+
chatRequestOptions: { body: { type: "fix-spelling" } },
|
|
300
|
+
});
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
key: "translate",
|
|
305
|
+
title: "Translate…",
|
|
306
|
+
aliases: ["translate", "language"],
|
|
307
|
+
icon: <LanguagesIcon size={18} />,
|
|
308
|
+
size: "small" as const,
|
|
309
|
+
// Pre-fills the input with a placeholder. User appends/replaces
|
|
310
|
+
// with the target language and submits via Enter. handleSubmit
|
|
311
|
+
// reads pendingTypeRef and forwards `type: "translate"`. We
|
|
312
|
+
// expand the selection now so the user sees the scope before
|
|
313
|
+
// typing the language — handleSubmit doesn't re-expand.
|
|
314
|
+
onItemClick: () => {
|
|
315
|
+
expandSelectionToBlocks();
|
|
316
|
+
pendingTypeRef.current = "translate";
|
|
317
|
+
setPrompt("Translate to ");
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
key: "simplify",
|
|
322
|
+
title: "Simplify",
|
|
323
|
+
aliases: ["simplify", "easier", "plain"],
|
|
324
|
+
icon: <WandSparklesIcon size={18} />,
|
|
325
|
+
size: "small" as const,
|
|
326
|
+
onItemClick: () => {
|
|
327
|
+
expandSelectionToBlocks();
|
|
328
|
+
void ai.invokeAI({
|
|
329
|
+
userPrompt: "simplify",
|
|
330
|
+
useSelection: true,
|
|
331
|
+
chatRequestOptions: { body: { type: "simplify" } },
|
|
332
|
+
});
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// No selection (the /ai slash menu path): just Generate from Template.
|
|
339
|
+
// Free-form typing still works — once the user types, items hide and
|
|
340
|
+
// Enter submits with no type (backend defaults to fill-template).
|
|
341
|
+
return [generateFromTemplate];
|
|
342
|
+
}, [editor, status, prompt, ai, expandSelectionToBlocks]);
|
|
343
|
+
|
|
344
|
+
const handleSubmit = useCallback(
|
|
345
|
+
async (userPrompt: string) => {
|
|
346
|
+
if (!userPrompt.trim()) return;
|
|
347
|
+
const pendingType = pendingTypeRef.current;
|
|
348
|
+
pendingTypeRef.current = null;
|
|
349
|
+
const body = pendingType ? { type: pendingType } : undefined;
|
|
350
|
+
await ai.invokeAI({
|
|
351
|
+
userPrompt,
|
|
352
|
+
useSelection: editor.getSelection() !== undefined,
|
|
353
|
+
...(body ? { chatRequestOptions: { body } } : {}),
|
|
354
|
+
});
|
|
355
|
+
},
|
|
356
|
+
[ai, editor],
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
const placeholder =
|
|
360
|
+
status === "thinking"
|
|
361
|
+
? dict.ai_menu.status.thinking
|
|
362
|
+
: status === "ai-writing"
|
|
363
|
+
? dict.ai_menu.status.editing
|
|
364
|
+
: status === "error"
|
|
365
|
+
? dict.ai_menu.status.error
|
|
366
|
+
: dict.ai_menu.input_placeholder;
|
|
367
|
+
|
|
368
|
+
const disabled = status === "thinking" || status === "ai-writing";
|
|
369
|
+
|
|
370
|
+
return (
|
|
371
|
+
<PromptSuggestionMenu
|
|
372
|
+
items={items}
|
|
373
|
+
onManualPromptSubmit={handleSubmit}
|
|
374
|
+
promptText={prompt}
|
|
375
|
+
onPromptTextChange={setPrompt}
|
|
376
|
+
placeholder={placeholder}
|
|
377
|
+
disabled={disabled}
|
|
378
|
+
icon={
|
|
379
|
+
<div className="bn-combobox-icon">
|
|
380
|
+
<SparklesIcon size={16} />
|
|
381
|
+
</div>
|
|
382
|
+
}
|
|
383
|
+
/>
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
131
387
|
export default function BlockNoteEditor({
|
|
132
388
|
id,
|
|
133
389
|
type,
|
|
@@ -149,8 +405,11 @@ export default function BlockNoteEditor({
|
|
|
149
405
|
suggestionMenuComponent,
|
|
150
406
|
mentionNameResolver,
|
|
151
407
|
onWarmMentions,
|
|
408
|
+
aiConfig,
|
|
409
|
+
stretch,
|
|
152
410
|
}: BlockNoteEditorProps): React.JSX.Element {
|
|
153
411
|
const t = useTranslations();
|
|
412
|
+
const locale = useI18nLocale();
|
|
154
413
|
const { company } = useCurrentUserContext<UserInterface>();
|
|
155
414
|
|
|
156
415
|
const [acceptedChanges, setAcceptedChanges] = useState<Set<string>>(new Set());
|
|
@@ -214,6 +473,57 @@ export default function BlockNoteEditor({
|
|
|
214
473
|
[DiffActionsInlineContent, mentionSpec, inlineContentSpecs],
|
|
215
474
|
);
|
|
216
475
|
|
|
476
|
+
const docRef = useRef<{ getDoc: () => any[] }>({ getDoc: () => [] });
|
|
477
|
+
// Selection getter used by the AI transport to attach `selectionBlocks` to
|
|
478
|
+
// the outgoing request metadata. Populated in a useEffect once the editor
|
|
479
|
+
// instance exists; reads the current BlockNote selection on every send.
|
|
480
|
+
const selectionRef = useRef<{ getSelectedBlocks: () => any[] }>({ getSelectedBlocks: () => [] });
|
|
481
|
+
|
|
482
|
+
const companyId = company?.id;
|
|
483
|
+
const aiExtension = useMemo(() => {
|
|
484
|
+
if (!aiConfig) return undefined;
|
|
485
|
+
const base = getPublicApiUrl();
|
|
486
|
+
const url = new URL(aiConfig.endpoint, base.endsWith("/") ? base : base + "/").toString();
|
|
487
|
+
return AIExtension({
|
|
488
|
+
transport: new DefaultChatTransport({
|
|
489
|
+
api: url,
|
|
490
|
+
credentials: "include",
|
|
491
|
+
headers: async () => {
|
|
492
|
+
const headers: Record<string, string> = { "x-language": locale };
|
|
493
|
+
const token = await getClientToken();
|
|
494
|
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
495
|
+
if (companyId) headers["x-companyid"] = companyId;
|
|
496
|
+
return headers;
|
|
497
|
+
},
|
|
498
|
+
prepareSendMessagesRequest: ({ messages, body }: any) => {
|
|
499
|
+
let lastUserIdx = -1;
|
|
500
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
501
|
+
if (messages[i]?.role === "user") {
|
|
502
|
+
lastUserIdx = i;
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
const selectedBlocks = selectionRef.current.getSelectedBlocks();
|
|
507
|
+
const augmented = messages.map((m: any, i: number) =>
|
|
508
|
+
i === lastUserIdx
|
|
509
|
+
? {
|
|
510
|
+
...m,
|
|
511
|
+
metadata: {
|
|
512
|
+
...(m.metadata ?? {}),
|
|
513
|
+
entityType: aiConfig.entityType,
|
|
514
|
+
entityId: aiConfig.entityId,
|
|
515
|
+
blocks: docRef.current.getDoc(),
|
|
516
|
+
selectionBlocks: selectedBlocks,
|
|
517
|
+
},
|
|
518
|
+
}
|
|
519
|
+
: m,
|
|
520
|
+
);
|
|
521
|
+
return { body: { ...(body ?? {}), messages: augmented } };
|
|
522
|
+
},
|
|
523
|
+
}),
|
|
524
|
+
});
|
|
525
|
+
}, [aiConfig, companyId, locale]);
|
|
526
|
+
|
|
217
527
|
const uploadImage = useCallback(
|
|
218
528
|
async (file: File): Promise<string> => {
|
|
219
529
|
if (!company) {
|
|
@@ -341,8 +651,10 @@ export default function BlockNoteEditor({
|
|
|
341
651
|
schema,
|
|
342
652
|
initialContent: validatedInitialContent,
|
|
343
653
|
uploadFile: uploadImage,
|
|
654
|
+
extensions: aiExtension ? [aiExtension] : undefined,
|
|
655
|
+
dictionary: aiExtension ? { ...coreEn, ai: aiEn } : undefined,
|
|
344
656
|
}),
|
|
345
|
-
[placeholder, t, schema, validatedInitialContent, uploadImage],
|
|
657
|
+
[placeholder, t, schema, validatedInitialContent, uploadImage, aiExtension],
|
|
346
658
|
),
|
|
347
659
|
);
|
|
348
660
|
|
|
@@ -461,6 +773,15 @@ export default function BlockNoteEditor({
|
|
|
461
773
|
onWarmMentions(initialContent);
|
|
462
774
|
}, [onWarmMentions, initialContent]);
|
|
463
775
|
|
|
776
|
+
useEffect(() => {
|
|
777
|
+
docRef.current.getDoc = () => editor?.document ?? [];
|
|
778
|
+
selectionRef.current.getSelectedBlocks = () => {
|
|
779
|
+
const sel = editor?.getSelection?.();
|
|
780
|
+
const blocks = (sel as any)?.blocks;
|
|
781
|
+
return Array.isArray(blocks) ? blocks : [];
|
|
782
|
+
};
|
|
783
|
+
}, [editor]);
|
|
784
|
+
|
|
464
785
|
// Handle audio received from whisper transcription
|
|
465
786
|
const _handleAudioReceived = useCallback(
|
|
466
787
|
(message: string) => {
|
|
@@ -497,6 +818,12 @@ export default function BlockNoteEditor({
|
|
|
497
818
|
className={cn(
|
|
498
819
|
bordered ? "rounded-md border border-input bg-input/20 dark:bg-input/30" : "",
|
|
499
820
|
"flex flex-col w-full",
|
|
821
|
+
// Pin BlockNote's font-size so it doesn't jump from 14→16px when the
|
|
822
|
+
// xl-ai AIMenu mounts. The shadcn theme sets `.bn-default-styles {
|
|
823
|
+
// font-size: 16px }` explicitly; outside AI mode the form's text-sm
|
|
824
|
+
// wins via cascade, but ForkYDocExtension re-evaluates the style
|
|
825
|
+
// context on AI activation and the explicit 16px takes over.
|
|
826
|
+
"[&_.bn-default-styles]:!text-sm",
|
|
500
827
|
className,
|
|
501
828
|
)}
|
|
502
829
|
>
|
|
@@ -505,10 +832,25 @@ export default function BlockNoteEditor({
|
|
|
505
832
|
onChange={handleChange}
|
|
506
833
|
editable={onChange !== undefined}
|
|
507
834
|
formattingToolbar={false}
|
|
835
|
+
slashMenu={!aiConfig}
|
|
508
836
|
theme="light"
|
|
509
|
-
className
|
|
837
|
+
// `className` is applied by BlockNote to both the main `.bn-container`
|
|
838
|
+
// AND `editor.portalElement` (the floating-UI portal root). Gate `p-4`
|
|
839
|
+
// on `.bn-container` so it doesn't add padding to the empty portal
|
|
840
|
+
// element and produce a phantom scrollbar on the wrapper.
|
|
841
|
+
className={cn(
|
|
842
|
+
"BlockNoteView flex-1",
|
|
843
|
+
onChange && "[&.bn-container]:p-4",
|
|
844
|
+
// In stretch mode the parent chain caps our height via flex; without
|
|
845
|
+
// these two classes the `.bn-container` keeps `min-height: auto`
|
|
846
|
+
// (its content's intrinsic height) and pushes the bordered wrapper
|
|
847
|
+
// — and the surrounding EditorSheet form — to scroll instead of
|
|
848
|
+
// scrolling internally.
|
|
849
|
+
onChange && stretch && "[&.bn-container]:min-h-0 [&.bn-container]:overflow-y-auto",
|
|
850
|
+
size === "sm" && "small",
|
|
851
|
+
)}
|
|
510
852
|
>
|
|
511
|
-
<BlockNoteEditorFormattingToolbar />
|
|
853
|
+
<BlockNoteEditorFormattingToolbar showAI={!!aiConfig} />
|
|
512
854
|
{enableMentions && mentionSearchFn && (
|
|
513
855
|
<BlockNoteEditorMentionSuggestionMenu
|
|
514
856
|
editor={editor}
|
|
@@ -521,6 +863,15 @@ export default function BlockNoteEditor({
|
|
|
521
863
|
{enableMentions && mentionResolveFn && (
|
|
522
864
|
<BlockNoteEditorMentionHoverCard containerRef={editorRef} mentionResolveFn={mentionResolveFn} />
|
|
523
865
|
)}
|
|
866
|
+
{aiConfig && (
|
|
867
|
+
<SuggestionMenuController
|
|
868
|
+
triggerCharacter="/"
|
|
869
|
+
getItems={async (query: string) =>
|
|
870
|
+
filterSuggestionItems([...getDefaultReactSlashMenuItems(editor), ...getAISlashMenuItems(editor)], query)
|
|
871
|
+
}
|
|
872
|
+
/>
|
|
873
|
+
)}
|
|
874
|
+
{aiConfig && <AIMenuController aiMenu={() => <NarrAIMenu />} />}
|
|
524
875
|
</BlockNoteView>
|
|
525
876
|
</div>
|
|
526
877
|
);
|
|
@@ -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
|
);
|