testomatio-editor-blocks 0.4.77 → 0.4.78
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/package/editor/MentionAutocomplete.d.ts +38 -0
- package/package/editor/MentionAutocomplete.js +312 -0
- package/package/editor/MentionMenu.d.ts +17 -0
- package/package/editor/MentionMenu.js +35 -0
- package/package/editor/mentionAutocomplete.d.ts +152 -0
- package/package/editor/mentionAutocomplete.js +218 -0
- package/package/index.d.ts +3 -0
- package/package/index.js +3 -0
- package/package/styles.css +75 -0
- package/package.json +1 -1
- package/src/App.tsx +77 -0
- package/src/editor/MentionAutocomplete.tsx +463 -0
- package/src/editor/MentionMenu.tsx +46 -0
- package/src/editor/mentionAutocomplete.test.ts +267 -0
- package/src/editor/mentionAutocomplete.ts +348 -0
- package/src/editor/styles.css +75 -0
- package/src/index.ts +31 -0
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
/* eslint-disable react-refresh/only-export-components -- the hook colocates
|
|
2
|
+
with its private popup component; they are one cohesive module. */
|
|
3
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
4
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
5
|
+
import { createPortal } from "react-dom";
|
|
6
|
+
import {
|
|
7
|
+
applyMention,
|
|
8
|
+
buildMentionInsertText,
|
|
9
|
+
filterMentionItems,
|
|
10
|
+
getMentionSources,
|
|
11
|
+
normalizeMentionItems,
|
|
12
|
+
parseActiveMention,
|
|
13
|
+
type ActiveMention,
|
|
14
|
+
type MentionItem,
|
|
15
|
+
type MentionSource,
|
|
16
|
+
} from "./mentionAutocomplete";
|
|
17
|
+
|
|
18
|
+
export type UseMentionAutocompleteOptions = {
|
|
19
|
+
/** The textarea the mentions are typed into (e.g. an OverType instance's textarea). */
|
|
20
|
+
textarea: HTMLTextAreaElement | null;
|
|
21
|
+
/** Reads the current full text. Defaults to `textarea.value`. */
|
|
22
|
+
getText?: () => string;
|
|
23
|
+
/**
|
|
24
|
+
* Writes the full text back and positions the caret. Defaults to mutating
|
|
25
|
+
* `textarea.value` directly; pass a custom writer when a wrapper (OverType,
|
|
26
|
+
* a controlled component) owns the value.
|
|
27
|
+
*/
|
|
28
|
+
setText?: (text: string, caret: number) => void;
|
|
29
|
+
/** Sources to use. Falls back to the global registry (`setMentionSources`). */
|
|
30
|
+
sources?: MentionSource[];
|
|
31
|
+
/** Master switch; when false the popup never opens. Default true. */
|
|
32
|
+
enabled?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* When true (default) the hook attaches its own capture-phase keydown listener
|
|
35
|
+
* to the textarea. Set false to drive navigation yourself via the returned
|
|
36
|
+
* `onKeyDown` (useful when another handler must take priority ordering).
|
|
37
|
+
*/
|
|
38
|
+
attachKeyDown?: boolean;
|
|
39
|
+
/** Debounce (ms) before an async `search` runs. Default 150. */
|
|
40
|
+
searchDebounceMs?: number;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type UseMentionAutocompleteResult = {
|
|
44
|
+
/** Render this somewhere in the tree; it portals the popup to `document.body`. */
|
|
45
|
+
overlay: ReactNode;
|
|
46
|
+
/**
|
|
47
|
+
* Feed keydown events here when `attachKeyDown` is false. Returns true when the
|
|
48
|
+
* event was consumed (it also calls preventDefault / stopImmediatePropagation).
|
|
49
|
+
*/
|
|
50
|
+
onKeyDown: (event: KeyboardEvent) => boolean;
|
|
51
|
+
isOpen: boolean;
|
|
52
|
+
close: () => void;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
type CaretRect = { left: number; top: number; bottom: number; height: number };
|
|
56
|
+
|
|
57
|
+
const NAV_KEYS = new Set(["ArrowDown", "ArrowUp", "Enter", "Tab", "Escape"]);
|
|
58
|
+
|
|
59
|
+
export function useMentionAutocomplete(
|
|
60
|
+
options: UseMentionAutocompleteOptions,
|
|
61
|
+
): UseMentionAutocompleteResult {
|
|
62
|
+
const {
|
|
63
|
+
textarea,
|
|
64
|
+
sources: sourcesProp,
|
|
65
|
+
enabled = true,
|
|
66
|
+
attachKeyDown = true,
|
|
67
|
+
searchDebounceMs = 150,
|
|
68
|
+
} = options;
|
|
69
|
+
|
|
70
|
+
const getText = useCallback(
|
|
71
|
+
() => (options.getText ? options.getText() : textarea?.value ?? ""),
|
|
72
|
+
[options, textarea],
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const setText = useCallback(
|
|
76
|
+
(text: string, caret: number) => {
|
|
77
|
+
if (options.setText) {
|
|
78
|
+
options.setText(text, caret);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (!textarea) return;
|
|
82
|
+
textarea.value = text;
|
|
83
|
+
textarea.setSelectionRange(caret, caret);
|
|
84
|
+
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
|
85
|
+
},
|
|
86
|
+
[options, textarea],
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const [active, setActive] = useState<ActiveMention | null>(null);
|
|
90
|
+
const [items, setItems] = useState<MentionItem[]>([]);
|
|
91
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
92
|
+
const [loading, setLoading] = useState(false);
|
|
93
|
+
const [caretRect, setCaretRect] = useState<CaretRect | null>(null);
|
|
94
|
+
|
|
95
|
+
// Latest committed active mention, for cheap change-detection in refresh().
|
|
96
|
+
const activeRef = useRef<ActiveMention | null>(null);
|
|
97
|
+
// Cache resolved static item lists per source so provider functions run once.
|
|
98
|
+
const staticCacheRef = useRef(new WeakMap<MentionSource, MentionItem[]>());
|
|
99
|
+
// Cache async search results per `${prefix}::${query}`.
|
|
100
|
+
const searchCacheRef = useRef(new Map<string, MentionItem[]>());
|
|
101
|
+
|
|
102
|
+
const sources = sourcesProp ?? getMentionSources();
|
|
103
|
+
const isOpen = enabled && active !== null;
|
|
104
|
+
|
|
105
|
+
const close = useCallback(() => {
|
|
106
|
+
activeRef.current = null;
|
|
107
|
+
setActive(null);
|
|
108
|
+
setItems([]);
|
|
109
|
+
setActiveIndex(0);
|
|
110
|
+
setLoading(false);
|
|
111
|
+
}, []);
|
|
112
|
+
|
|
113
|
+
/** Re-read the caret and (re)detect the active mention, ignoring no-op changes. */
|
|
114
|
+
const refresh = useCallback(() => {
|
|
115
|
+
let next: ActiveMention | null = null;
|
|
116
|
+
if (enabled && textarea && textarea.selectionStart === textarea.selectionEnd) {
|
|
117
|
+
next = parseActiveMention(getText(), textarea.selectionStart ?? 0, sources);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const prev = activeRef.current;
|
|
121
|
+
const unchanged =
|
|
122
|
+
(!next && !prev) ||
|
|
123
|
+
(!!next &&
|
|
124
|
+
!!prev &&
|
|
125
|
+
next.source === prev.source &&
|
|
126
|
+
next.token === prev.token &&
|
|
127
|
+
next.start === prev.start &&
|
|
128
|
+
next.end === prev.end);
|
|
129
|
+
if (unchanged) return;
|
|
130
|
+
|
|
131
|
+
activeRef.current = next;
|
|
132
|
+
setActive(next);
|
|
133
|
+
if (next && textarea) {
|
|
134
|
+
setCaretRect(caretCoordinates(textarea, next.start));
|
|
135
|
+
}
|
|
136
|
+
}, [enabled, textarea, getText, sources]);
|
|
137
|
+
|
|
138
|
+
// Track input + caret movement.
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
if (!textarea || !enabled) return;
|
|
141
|
+
const onInput = () => refresh();
|
|
142
|
+
const onSelectionChange = () => {
|
|
143
|
+
if (document.activeElement === textarea) refresh();
|
|
144
|
+
};
|
|
145
|
+
const onBlur = () => close();
|
|
146
|
+
textarea.addEventListener("input", onInput);
|
|
147
|
+
textarea.addEventListener("blur", onBlur);
|
|
148
|
+
document.addEventListener("selectionchange", onSelectionChange);
|
|
149
|
+
return () => {
|
|
150
|
+
textarea.removeEventListener("input", onInput);
|
|
151
|
+
textarea.removeEventListener("blur", onBlur);
|
|
152
|
+
document.removeEventListener("selectionchange", onSelectionChange);
|
|
153
|
+
};
|
|
154
|
+
}, [textarea, enabled, refresh, close]);
|
|
155
|
+
|
|
156
|
+
// Resolve items for the active mention (static filter or async search).
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
if (!active) return;
|
|
159
|
+
const { source, query } = active;
|
|
160
|
+
setActiveIndex(0);
|
|
161
|
+
const limit = source.limit ?? 8;
|
|
162
|
+
|
|
163
|
+
// Async, server-backed source.
|
|
164
|
+
if (typeof source.search === "function") {
|
|
165
|
+
const minChars = source.minChars ?? 0;
|
|
166
|
+
if (query.length < minChars) {
|
|
167
|
+
setItems([]);
|
|
168
|
+
setLoading(false);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const key = `${source.prefix}::${query}`;
|
|
172
|
+
const cached = searchCacheRef.current.get(key);
|
|
173
|
+
if (cached) {
|
|
174
|
+
setItems(cached);
|
|
175
|
+
setLoading(false);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
let cancelled = false;
|
|
179
|
+
setLoading(true);
|
|
180
|
+
const timer = setTimeout(() => {
|
|
181
|
+
Promise.resolve(source.search!(query))
|
|
182
|
+
.then((result) => normalizeMentionItems(result).slice(0, limit))
|
|
183
|
+
.then((resolved) => {
|
|
184
|
+
searchCacheRef.current.set(key, resolved);
|
|
185
|
+
if (!cancelled) {
|
|
186
|
+
setItems(resolved);
|
|
187
|
+
setLoading(false);
|
|
188
|
+
}
|
|
189
|
+
})
|
|
190
|
+
.catch((error) => {
|
|
191
|
+
console.error("Mention search failed", error);
|
|
192
|
+
if (!cancelled) {
|
|
193
|
+
setItems([]);
|
|
194
|
+
setLoading(false);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
}, searchDebounceMs);
|
|
198
|
+
return () => {
|
|
199
|
+
cancelled = true;
|
|
200
|
+
clearTimeout(timer);
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Static, in-memory source.
|
|
205
|
+
const resolveStatic = (): Promise<MentionItem[]> => {
|
|
206
|
+
const cached = staticCacheRef.current.get(source);
|
|
207
|
+
if (cached) return Promise.resolve(cached);
|
|
208
|
+
const input = source.items;
|
|
209
|
+
const raw = typeof input === "function" ? input() : input ?? [];
|
|
210
|
+
return Promise.resolve(raw).then((list) => {
|
|
211
|
+
const normalized = normalizeMentionItems(list);
|
|
212
|
+
staticCacheRef.current.set(source, normalized);
|
|
213
|
+
return normalized;
|
|
214
|
+
});
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
let cancelled = false;
|
|
218
|
+
resolveStatic().then((list) => {
|
|
219
|
+
if (!cancelled) setItems(filterMentionItems(list, query, limit));
|
|
220
|
+
});
|
|
221
|
+
return () => {
|
|
222
|
+
cancelled = true;
|
|
223
|
+
};
|
|
224
|
+
}, [active, searchDebounceMs]);
|
|
225
|
+
|
|
226
|
+
const apply = useCallback(
|
|
227
|
+
(item: MentionItem) => {
|
|
228
|
+
if (!active) return;
|
|
229
|
+
const insertText = buildMentionInsertText(active.source, item);
|
|
230
|
+
const result = applyMention(getText(), active, insertText);
|
|
231
|
+
close();
|
|
232
|
+
setText(result.text, result.caret);
|
|
233
|
+
// Return focus to the textarea after the value write settles.
|
|
234
|
+
requestAnimationFrame(() => {
|
|
235
|
+
textarea?.focus();
|
|
236
|
+
textarea?.setSelectionRange(result.caret, result.caret);
|
|
237
|
+
});
|
|
238
|
+
},
|
|
239
|
+
[active, getText, setText, close, textarea],
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
const onKeyDown = useCallback(
|
|
243
|
+
(event: KeyboardEvent): boolean => {
|
|
244
|
+
if (!isOpen || !NAV_KEYS.has(event.key)) return false;
|
|
245
|
+
if (event.key === "Escape") {
|
|
246
|
+
event.preventDefault();
|
|
247
|
+
event.stopImmediatePropagation();
|
|
248
|
+
close();
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
if (items.length === 0) {
|
|
252
|
+
// Swallow nav keys while open with no results only for Escape (handled).
|
|
253
|
+
// Let other keys through so typing/navigation still works.
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
if (event.key === "ArrowDown") {
|
|
257
|
+
event.preventDefault();
|
|
258
|
+
event.stopImmediatePropagation();
|
|
259
|
+
setActiveIndex((prev) => (prev + 1 >= items.length ? 0 : prev + 1));
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
if (event.key === "ArrowUp") {
|
|
263
|
+
event.preventDefault();
|
|
264
|
+
event.stopImmediatePropagation();
|
|
265
|
+
setActiveIndex((prev) => (prev - 1 < 0 ? items.length - 1 : prev - 1));
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
if (event.key === "Enter" || event.key === "Tab") {
|
|
269
|
+
event.preventDefault();
|
|
270
|
+
event.stopImmediatePropagation();
|
|
271
|
+
apply(items[activeIndex] ?? items[0]);
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
return false;
|
|
275
|
+
},
|
|
276
|
+
[isOpen, items, activeIndex, apply, close],
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
// Keep a fresh handler for the (optional) self-attached listener.
|
|
280
|
+
const handlerRef = useRef(onKeyDown);
|
|
281
|
+
handlerRef.current = onKeyDown;
|
|
282
|
+
|
|
283
|
+
useEffect(() => {
|
|
284
|
+
if (!textarea || !attachKeyDown || !enabled) return;
|
|
285
|
+
const listener = (event: KeyboardEvent) => {
|
|
286
|
+
handlerRef.current(event);
|
|
287
|
+
};
|
|
288
|
+
const opts: AddEventListenerOptions = { capture: true };
|
|
289
|
+
textarea.addEventListener("keydown", listener, opts);
|
|
290
|
+
return () => textarea.removeEventListener("keydown", listener, opts);
|
|
291
|
+
}, [textarea, attachKeyDown, enabled]);
|
|
292
|
+
|
|
293
|
+
const overlay = isOpen ? (
|
|
294
|
+
<MentionPopup
|
|
295
|
+
active={active}
|
|
296
|
+
items={items}
|
|
297
|
+
activeIndex={activeIndex}
|
|
298
|
+
loading={loading}
|
|
299
|
+
caretRect={caretRect}
|
|
300
|
+
onHover={setActiveIndex}
|
|
301
|
+
onSelect={apply}
|
|
302
|
+
/>
|
|
303
|
+
) : null;
|
|
304
|
+
|
|
305
|
+
return { overlay, onKeyDown, isOpen, close };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
type MentionPopupProps = {
|
|
309
|
+
active: ActiveMention;
|
|
310
|
+
items: MentionItem[];
|
|
311
|
+
activeIndex: number;
|
|
312
|
+
loading: boolean;
|
|
313
|
+
caretRect: CaretRect | null;
|
|
314
|
+
onHover: (index: number) => void;
|
|
315
|
+
onSelect: (item: MentionItem) => void;
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
function MentionPopup({
|
|
319
|
+
active,
|
|
320
|
+
items,
|
|
321
|
+
activeIndex,
|
|
322
|
+
loading,
|
|
323
|
+
caretRect,
|
|
324
|
+
onHover,
|
|
325
|
+
onSelect,
|
|
326
|
+
}: MentionPopupProps) {
|
|
327
|
+
const listRef = useRef<HTMLDivElement | null>(null);
|
|
328
|
+
|
|
329
|
+
// Keep the highlighted item scrolled into view.
|
|
330
|
+
useLayoutEffect(() => {
|
|
331
|
+
const el = listRef.current?.querySelector<HTMLElement>('[data-active="true"]');
|
|
332
|
+
el?.scrollIntoView({ block: "nearest" });
|
|
333
|
+
}, [activeIndex, items]);
|
|
334
|
+
|
|
335
|
+
if (typeof document === "undefined") return null;
|
|
336
|
+
|
|
337
|
+
const style: CSSProperties = caretRect
|
|
338
|
+
? { position: "fixed", left: caretRect.left, top: caretRect.bottom + 4 }
|
|
339
|
+
: { position: "fixed", left: 16, top: 16 };
|
|
340
|
+
|
|
341
|
+
const source = active.source;
|
|
342
|
+
const minChars = source.minChars ?? 0;
|
|
343
|
+
const belowMin = typeof source.search === "function" && active.query.length < minChars;
|
|
344
|
+
|
|
345
|
+
return createPortal(
|
|
346
|
+
<div className="bn-mention-popup" role="listbox" style={style} ref={listRef}>
|
|
347
|
+
{source.label && (
|
|
348
|
+
<div className="bn-mention-popup__header">
|
|
349
|
+
<span className="bn-mention-popup__prefix">@{source.prefix}</span>
|
|
350
|
+
<span className="bn-mention-popup__label">{source.label}</span>
|
|
351
|
+
</div>
|
|
352
|
+
)}
|
|
353
|
+
{belowMin ? (
|
|
354
|
+
<div className="bn-mention-popup__status">Keep typing to search…</div>
|
|
355
|
+
) : loading ? (
|
|
356
|
+
<div className="bn-mention-popup__status">Searching…</div>
|
|
357
|
+
) : items.length === 0 ? (
|
|
358
|
+
<div className="bn-mention-popup__status">No matches</div>
|
|
359
|
+
) : (
|
|
360
|
+
items.map((item, index) => (
|
|
361
|
+
<button
|
|
362
|
+
type="button"
|
|
363
|
+
key={item.id}
|
|
364
|
+
role="option"
|
|
365
|
+
data-active={index === activeIndex}
|
|
366
|
+
aria-selected={index === activeIndex}
|
|
367
|
+
className={
|
|
368
|
+
index === activeIndex
|
|
369
|
+
? "bn-mention-item bn-mention-item--active"
|
|
370
|
+
: "bn-mention-item"
|
|
371
|
+
}
|
|
372
|
+
// onMouseDown (not onClick) + preventDefault keeps the textarea focused.
|
|
373
|
+
onMouseDown={(event) => {
|
|
374
|
+
event.preventDefault();
|
|
375
|
+
onSelect(item);
|
|
376
|
+
}}
|
|
377
|
+
onMouseEnter={() => onHover(index)}
|
|
378
|
+
tabIndex={-1}
|
|
379
|
+
>
|
|
380
|
+
<span className="bn-mention-item__label">{item.label}</span>
|
|
381
|
+
{item.detail && <span className="bn-mention-item__detail">{item.detail}</span>}
|
|
382
|
+
</button>
|
|
383
|
+
))
|
|
384
|
+
)}
|
|
385
|
+
</div>,
|
|
386
|
+
document.body,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/* ------------------------------------------------------------------ *
|
|
391
|
+
* Caret coordinates via the mirror-div technique. Works for any plain
|
|
392
|
+
* <textarea>; returns viewport (fixed) coordinates of the given index.
|
|
393
|
+
* ------------------------------------------------------------------ */
|
|
394
|
+
|
|
395
|
+
const MIRROR_PROPS = [
|
|
396
|
+
"boxSizing",
|
|
397
|
+
"width",
|
|
398
|
+
"borderTopWidth",
|
|
399
|
+
"borderRightWidth",
|
|
400
|
+
"borderBottomWidth",
|
|
401
|
+
"borderLeftWidth",
|
|
402
|
+
"paddingTop",
|
|
403
|
+
"paddingRight",
|
|
404
|
+
"paddingBottom",
|
|
405
|
+
"paddingLeft",
|
|
406
|
+
"fontStyle",
|
|
407
|
+
"fontVariant",
|
|
408
|
+
"fontWeight",
|
|
409
|
+
"fontStretch",
|
|
410
|
+
"fontSize",
|
|
411
|
+
"fontSizeAdjust",
|
|
412
|
+
"lineHeight",
|
|
413
|
+
"fontFamily",
|
|
414
|
+
"textAlign",
|
|
415
|
+
"textTransform",
|
|
416
|
+
"textIndent",
|
|
417
|
+
"textDecoration",
|
|
418
|
+
"letterSpacing",
|
|
419
|
+
"wordSpacing",
|
|
420
|
+
"tabSize",
|
|
421
|
+
"whiteSpace",
|
|
422
|
+
"wordWrap",
|
|
423
|
+
"wordBreak",
|
|
424
|
+
] as const;
|
|
425
|
+
|
|
426
|
+
function caretCoordinates(textarea: HTMLTextAreaElement, position: number): CaretRect {
|
|
427
|
+
const rect = textarea.getBoundingClientRect();
|
|
428
|
+
const fallback: CaretRect = {
|
|
429
|
+
left: rect.left,
|
|
430
|
+
top: rect.top,
|
|
431
|
+
bottom: rect.bottom,
|
|
432
|
+
height: rect.height,
|
|
433
|
+
};
|
|
434
|
+
if (typeof document === "undefined") return fallback;
|
|
435
|
+
|
|
436
|
+
const style = window.getComputedStyle(textarea);
|
|
437
|
+
const mirror = document.createElement("div");
|
|
438
|
+
const cs = mirror.style;
|
|
439
|
+
cs.position = "absolute";
|
|
440
|
+
cs.visibility = "hidden";
|
|
441
|
+
cs.whiteSpace = "pre-wrap";
|
|
442
|
+
cs.overflow = "hidden";
|
|
443
|
+
const sourceStyle = style as unknown as Record<string, string>;
|
|
444
|
+
const mirrorStyle = cs as unknown as Record<string, string>;
|
|
445
|
+
for (const prop of MIRROR_PROPS) {
|
|
446
|
+
mirrorStyle[prop] = sourceStyle[prop];
|
|
447
|
+
}
|
|
448
|
+
cs.width = `${textarea.clientWidth}px`;
|
|
449
|
+
|
|
450
|
+
const value = textarea.value;
|
|
451
|
+
mirror.textContent = value.slice(0, position);
|
|
452
|
+
const marker = document.createElement("span");
|
|
453
|
+
marker.textContent = value.slice(position) || ".";
|
|
454
|
+
mirror.appendChild(marker);
|
|
455
|
+
document.body.appendChild(mirror);
|
|
456
|
+
|
|
457
|
+
const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.2;
|
|
458
|
+
const left = rect.left + marker.offsetLeft - textarea.scrollLeft;
|
|
459
|
+
const top = rect.top + marker.offsetTop - textarea.scrollTop;
|
|
460
|
+
document.body.removeChild(mirror);
|
|
461
|
+
|
|
462
|
+
return { left, top, bottom: top + lineHeight, height: lineHeight };
|
|
463
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { SuggestionMenuController, useBlockNoteEditor } from "@blocknote/react";
|
|
2
|
+
import type { DefaultReactSuggestionItem } from "@blocknote/react";
|
|
3
|
+
import {
|
|
4
|
+
buildMentionInsertText,
|
|
5
|
+
getMentionSources,
|
|
6
|
+
resolveMentionQuery,
|
|
7
|
+
type MentionSource,
|
|
8
|
+
} from "./mentionAutocomplete";
|
|
9
|
+
|
|
10
|
+
export type MentionMenuProps = {
|
|
11
|
+
/** Sources to use. Falls back to the global registry (`setMentionSources`). */
|
|
12
|
+
sources?: MentionSource[];
|
|
13
|
+
/** Character that opens the menu. Default `@`. */
|
|
14
|
+
triggerCharacter?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Universal `@`-mention menu for the main BlockNote editor. Render it as a child
|
|
19
|
+
* of `<BlockNoteView>`. On selection it inserts the source's token (e.g.
|
|
20
|
+
* `@T123456`, `@S98765`, `@username`) as inline text at the cursor.
|
|
21
|
+
*
|
|
22
|
+
* <BlockNoteView editor={editor}>
|
|
23
|
+
* <MentionMenu />
|
|
24
|
+
* </BlockNoteView>
|
|
25
|
+
*/
|
|
26
|
+
export function MentionMenu({ sources: sourcesProp, triggerCharacter = "@" }: MentionMenuProps) {
|
|
27
|
+
const editor = useBlockNoteEditor();
|
|
28
|
+
|
|
29
|
+
const getItems = async (query: string): Promise<DefaultReactSuggestionItem[]> => {
|
|
30
|
+
const sources = sourcesProp ?? getMentionSources();
|
|
31
|
+
const resolved = await resolveMentionQuery(query, sources);
|
|
32
|
+
if (!resolved) return [];
|
|
33
|
+
const { source, items } = resolved;
|
|
34
|
+
return items.map((item) => ({
|
|
35
|
+
title: item.label,
|
|
36
|
+
subtext: item.detail ?? undefined,
|
|
37
|
+
onItemClick: () => {
|
|
38
|
+
// The controller has already removed the trigger + query text; we insert
|
|
39
|
+
// the token plus a trailing space so the caret is ready for more typing.
|
|
40
|
+
editor.insertInlineContent([buildMentionInsertText(source, item), " "]);
|
|
41
|
+
},
|
|
42
|
+
}));
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
return <SuggestionMenuController triggerCharacter={triggerCharacter} getItems={getItems} />;
|
|
46
|
+
}
|