testomatio-editor-blocks 0.4.76 → 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/customMarkdownConverter.js +34 -3
- 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/customMarkdownConverter.test.ts +71 -0
- package/src/editor/customMarkdownConverter.ts +38 -4
- 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,38 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { type MentionSource } from "./mentionAutocomplete";
|
|
3
|
+
export type UseMentionAutocompleteOptions = {
|
|
4
|
+
/** The textarea the mentions are typed into (e.g. an OverType instance's textarea). */
|
|
5
|
+
textarea: HTMLTextAreaElement | null;
|
|
6
|
+
/** Reads the current full text. Defaults to `textarea.value`. */
|
|
7
|
+
getText?: () => string;
|
|
8
|
+
/**
|
|
9
|
+
* Writes the full text back and positions the caret. Defaults to mutating
|
|
10
|
+
* `textarea.value` directly; pass a custom writer when a wrapper (OverType,
|
|
11
|
+
* a controlled component) owns the value.
|
|
12
|
+
*/
|
|
13
|
+
setText?: (text: string, caret: number) => void;
|
|
14
|
+
/** Sources to use. Falls back to the global registry (`setMentionSources`). */
|
|
15
|
+
sources?: MentionSource[];
|
|
16
|
+
/** Master switch; when false the popup never opens. Default true. */
|
|
17
|
+
enabled?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* When true (default) the hook attaches its own capture-phase keydown listener
|
|
20
|
+
* to the textarea. Set false to drive navigation yourself via the returned
|
|
21
|
+
* `onKeyDown` (useful when another handler must take priority ordering).
|
|
22
|
+
*/
|
|
23
|
+
attachKeyDown?: boolean;
|
|
24
|
+
/** Debounce (ms) before an async `search` runs. Default 150. */
|
|
25
|
+
searchDebounceMs?: number;
|
|
26
|
+
};
|
|
27
|
+
export type UseMentionAutocompleteResult = {
|
|
28
|
+
/** Render this somewhere in the tree; it portals the popup to `document.body`. */
|
|
29
|
+
overlay: ReactNode;
|
|
30
|
+
/**
|
|
31
|
+
* Feed keydown events here when `attachKeyDown` is false. Returns true when the
|
|
32
|
+
* event was consumed (it also calls preventDefault / stopImmediatePropagation).
|
|
33
|
+
*/
|
|
34
|
+
onKeyDown: (event: KeyboardEvent) => boolean;
|
|
35
|
+
isOpen: boolean;
|
|
36
|
+
close: () => void;
|
|
37
|
+
};
|
|
38
|
+
export declare function useMentionAutocomplete(options: UseMentionAutocompleteOptions): UseMentionAutocompleteResult;
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/* eslint-disable react-refresh/only-export-components -- the hook colocates
|
|
3
|
+
with its private popup component; they are one cohesive module. */
|
|
4
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
5
|
+
import { createPortal } from "react-dom";
|
|
6
|
+
import { applyMention, buildMentionInsertText, filterMentionItems, getMentionSources, normalizeMentionItems, parseActiveMention, } from "./mentionAutocomplete";
|
|
7
|
+
const NAV_KEYS = new Set(["ArrowDown", "ArrowUp", "Enter", "Tab", "Escape"]);
|
|
8
|
+
export function useMentionAutocomplete(options) {
|
|
9
|
+
const { textarea, sources: sourcesProp, enabled = true, attachKeyDown = true, searchDebounceMs = 150, } = options;
|
|
10
|
+
const getText = useCallback(() => { var _a; return (options.getText ? options.getText() : (_a = textarea === null || textarea === void 0 ? void 0 : textarea.value) !== null && _a !== void 0 ? _a : ""); }, [options, textarea]);
|
|
11
|
+
const setText = useCallback((text, caret) => {
|
|
12
|
+
if (options.setText) {
|
|
13
|
+
options.setText(text, caret);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (!textarea)
|
|
17
|
+
return;
|
|
18
|
+
textarea.value = text;
|
|
19
|
+
textarea.setSelectionRange(caret, caret);
|
|
20
|
+
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
|
21
|
+
}, [options, textarea]);
|
|
22
|
+
const [active, setActive] = useState(null);
|
|
23
|
+
const [items, setItems] = useState([]);
|
|
24
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
25
|
+
const [loading, setLoading] = useState(false);
|
|
26
|
+
const [caretRect, setCaretRect] = useState(null);
|
|
27
|
+
// Latest committed active mention, for cheap change-detection in refresh().
|
|
28
|
+
const activeRef = useRef(null);
|
|
29
|
+
// Cache resolved static item lists per source so provider functions run once.
|
|
30
|
+
const staticCacheRef = useRef(new WeakMap());
|
|
31
|
+
// Cache async search results per `${prefix}::${query}`.
|
|
32
|
+
const searchCacheRef = useRef(new Map());
|
|
33
|
+
const sources = sourcesProp !== null && sourcesProp !== void 0 ? sourcesProp : getMentionSources();
|
|
34
|
+
const isOpen = enabled && active !== null;
|
|
35
|
+
const close = useCallback(() => {
|
|
36
|
+
activeRef.current = null;
|
|
37
|
+
setActive(null);
|
|
38
|
+
setItems([]);
|
|
39
|
+
setActiveIndex(0);
|
|
40
|
+
setLoading(false);
|
|
41
|
+
}, []);
|
|
42
|
+
/** Re-read the caret and (re)detect the active mention, ignoring no-op changes. */
|
|
43
|
+
const refresh = useCallback(() => {
|
|
44
|
+
var _a;
|
|
45
|
+
let next = null;
|
|
46
|
+
if (enabled && textarea && textarea.selectionStart === textarea.selectionEnd) {
|
|
47
|
+
next = parseActiveMention(getText(), (_a = textarea.selectionStart) !== null && _a !== void 0 ? _a : 0, sources);
|
|
48
|
+
}
|
|
49
|
+
const prev = activeRef.current;
|
|
50
|
+
const unchanged = (!next && !prev) ||
|
|
51
|
+
(!!next &&
|
|
52
|
+
!!prev &&
|
|
53
|
+
next.source === prev.source &&
|
|
54
|
+
next.token === prev.token &&
|
|
55
|
+
next.start === prev.start &&
|
|
56
|
+
next.end === prev.end);
|
|
57
|
+
if (unchanged)
|
|
58
|
+
return;
|
|
59
|
+
activeRef.current = next;
|
|
60
|
+
setActive(next);
|
|
61
|
+
if (next && textarea) {
|
|
62
|
+
setCaretRect(caretCoordinates(textarea, next.start));
|
|
63
|
+
}
|
|
64
|
+
}, [enabled, textarea, getText, sources]);
|
|
65
|
+
// Track input + caret movement.
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (!textarea || !enabled)
|
|
68
|
+
return;
|
|
69
|
+
const onInput = () => refresh();
|
|
70
|
+
const onSelectionChange = () => {
|
|
71
|
+
if (document.activeElement === textarea)
|
|
72
|
+
refresh();
|
|
73
|
+
};
|
|
74
|
+
const onBlur = () => close();
|
|
75
|
+
textarea.addEventListener("input", onInput);
|
|
76
|
+
textarea.addEventListener("blur", onBlur);
|
|
77
|
+
document.addEventListener("selectionchange", onSelectionChange);
|
|
78
|
+
return () => {
|
|
79
|
+
textarea.removeEventListener("input", onInput);
|
|
80
|
+
textarea.removeEventListener("blur", onBlur);
|
|
81
|
+
document.removeEventListener("selectionchange", onSelectionChange);
|
|
82
|
+
};
|
|
83
|
+
}, [textarea, enabled, refresh, close]);
|
|
84
|
+
// Resolve items for the active mention (static filter or async search).
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
var _a, _b;
|
|
87
|
+
if (!active)
|
|
88
|
+
return;
|
|
89
|
+
const { source, query } = active;
|
|
90
|
+
setActiveIndex(0);
|
|
91
|
+
const limit = (_a = source.limit) !== null && _a !== void 0 ? _a : 8;
|
|
92
|
+
// Async, server-backed source.
|
|
93
|
+
if (typeof source.search === "function") {
|
|
94
|
+
const minChars = (_b = source.minChars) !== null && _b !== void 0 ? _b : 0;
|
|
95
|
+
if (query.length < minChars) {
|
|
96
|
+
setItems([]);
|
|
97
|
+
setLoading(false);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const key = `${source.prefix}::${query}`;
|
|
101
|
+
const cached = searchCacheRef.current.get(key);
|
|
102
|
+
if (cached) {
|
|
103
|
+
setItems(cached);
|
|
104
|
+
setLoading(false);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
let cancelled = false;
|
|
108
|
+
setLoading(true);
|
|
109
|
+
const timer = setTimeout(() => {
|
|
110
|
+
Promise.resolve(source.search(query))
|
|
111
|
+
.then((result) => normalizeMentionItems(result).slice(0, limit))
|
|
112
|
+
.then((resolved) => {
|
|
113
|
+
searchCacheRef.current.set(key, resolved);
|
|
114
|
+
if (!cancelled) {
|
|
115
|
+
setItems(resolved);
|
|
116
|
+
setLoading(false);
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
.catch((error) => {
|
|
120
|
+
console.error("Mention search failed", error);
|
|
121
|
+
if (!cancelled) {
|
|
122
|
+
setItems([]);
|
|
123
|
+
setLoading(false);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}, searchDebounceMs);
|
|
127
|
+
return () => {
|
|
128
|
+
cancelled = true;
|
|
129
|
+
clearTimeout(timer);
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
// Static, in-memory source.
|
|
133
|
+
const resolveStatic = () => {
|
|
134
|
+
const cached = staticCacheRef.current.get(source);
|
|
135
|
+
if (cached)
|
|
136
|
+
return Promise.resolve(cached);
|
|
137
|
+
const input = source.items;
|
|
138
|
+
const raw = typeof input === "function" ? input() : input !== null && input !== void 0 ? input : [];
|
|
139
|
+
return Promise.resolve(raw).then((list) => {
|
|
140
|
+
const normalized = normalizeMentionItems(list);
|
|
141
|
+
staticCacheRef.current.set(source, normalized);
|
|
142
|
+
return normalized;
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
let cancelled = false;
|
|
146
|
+
resolveStatic().then((list) => {
|
|
147
|
+
if (!cancelled)
|
|
148
|
+
setItems(filterMentionItems(list, query, limit));
|
|
149
|
+
});
|
|
150
|
+
return () => {
|
|
151
|
+
cancelled = true;
|
|
152
|
+
};
|
|
153
|
+
}, [active, searchDebounceMs]);
|
|
154
|
+
const apply = useCallback((item) => {
|
|
155
|
+
if (!active)
|
|
156
|
+
return;
|
|
157
|
+
const insertText = buildMentionInsertText(active.source, item);
|
|
158
|
+
const result = applyMention(getText(), active, insertText);
|
|
159
|
+
close();
|
|
160
|
+
setText(result.text, result.caret);
|
|
161
|
+
// Return focus to the textarea after the value write settles.
|
|
162
|
+
requestAnimationFrame(() => {
|
|
163
|
+
textarea === null || textarea === void 0 ? void 0 : textarea.focus();
|
|
164
|
+
textarea === null || textarea === void 0 ? void 0 : textarea.setSelectionRange(result.caret, result.caret);
|
|
165
|
+
});
|
|
166
|
+
}, [active, getText, setText, close, textarea]);
|
|
167
|
+
const onKeyDown = useCallback((event) => {
|
|
168
|
+
var _a;
|
|
169
|
+
if (!isOpen || !NAV_KEYS.has(event.key))
|
|
170
|
+
return false;
|
|
171
|
+
if (event.key === "Escape") {
|
|
172
|
+
event.preventDefault();
|
|
173
|
+
event.stopImmediatePropagation();
|
|
174
|
+
close();
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
if (items.length === 0) {
|
|
178
|
+
// Swallow nav keys while open with no results only for Escape (handled).
|
|
179
|
+
// Let other keys through so typing/navigation still works.
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
if (event.key === "ArrowDown") {
|
|
183
|
+
event.preventDefault();
|
|
184
|
+
event.stopImmediatePropagation();
|
|
185
|
+
setActiveIndex((prev) => (prev + 1 >= items.length ? 0 : prev + 1));
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
if (event.key === "ArrowUp") {
|
|
189
|
+
event.preventDefault();
|
|
190
|
+
event.stopImmediatePropagation();
|
|
191
|
+
setActiveIndex((prev) => (prev - 1 < 0 ? items.length - 1 : prev - 1));
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
if (event.key === "Enter" || event.key === "Tab") {
|
|
195
|
+
event.preventDefault();
|
|
196
|
+
event.stopImmediatePropagation();
|
|
197
|
+
apply((_a = items[activeIndex]) !== null && _a !== void 0 ? _a : items[0]);
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
return false;
|
|
201
|
+
}, [isOpen, items, activeIndex, apply, close]);
|
|
202
|
+
// Keep a fresh handler for the (optional) self-attached listener.
|
|
203
|
+
const handlerRef = useRef(onKeyDown);
|
|
204
|
+
handlerRef.current = onKeyDown;
|
|
205
|
+
useEffect(() => {
|
|
206
|
+
if (!textarea || !attachKeyDown || !enabled)
|
|
207
|
+
return;
|
|
208
|
+
const listener = (event) => {
|
|
209
|
+
handlerRef.current(event);
|
|
210
|
+
};
|
|
211
|
+
const opts = { capture: true };
|
|
212
|
+
textarea.addEventListener("keydown", listener, opts);
|
|
213
|
+
return () => textarea.removeEventListener("keydown", listener, opts);
|
|
214
|
+
}, [textarea, attachKeyDown, enabled]);
|
|
215
|
+
const overlay = isOpen ? (_jsx(MentionPopup, { active: active, items: items, activeIndex: activeIndex, loading: loading, caretRect: caretRect, onHover: setActiveIndex, onSelect: apply })) : null;
|
|
216
|
+
return { overlay, onKeyDown, isOpen, close };
|
|
217
|
+
}
|
|
218
|
+
function MentionPopup({ active, items, activeIndex, loading, caretRect, onHover, onSelect, }) {
|
|
219
|
+
var _a;
|
|
220
|
+
const listRef = useRef(null);
|
|
221
|
+
// Keep the highlighted item scrolled into view.
|
|
222
|
+
useLayoutEffect(() => {
|
|
223
|
+
var _a;
|
|
224
|
+
const el = (_a = listRef.current) === null || _a === void 0 ? void 0 : _a.querySelector('[data-active="true"]');
|
|
225
|
+
el === null || el === void 0 ? void 0 : el.scrollIntoView({ block: "nearest" });
|
|
226
|
+
}, [activeIndex, items]);
|
|
227
|
+
if (typeof document === "undefined")
|
|
228
|
+
return null;
|
|
229
|
+
const style = caretRect
|
|
230
|
+
? { position: "fixed", left: caretRect.left, top: caretRect.bottom + 4 }
|
|
231
|
+
: { position: "fixed", left: 16, top: 16 };
|
|
232
|
+
const source = active.source;
|
|
233
|
+
const minChars = (_a = source.minChars) !== null && _a !== void 0 ? _a : 0;
|
|
234
|
+
const belowMin = typeof source.search === "function" && active.query.length < minChars;
|
|
235
|
+
return createPortal(_jsxs("div", { className: "bn-mention-popup", role: "listbox", style: style, ref: listRef, children: [source.label && (_jsxs("div", { className: "bn-mention-popup__header", children: [_jsxs("span", { className: "bn-mention-popup__prefix", children: ["@", source.prefix] }), _jsx("span", { className: "bn-mention-popup__label", children: source.label })] })), belowMin ? (_jsx("div", { className: "bn-mention-popup__status", children: "Keep typing to search\u2026" })) : loading ? (_jsx("div", { className: "bn-mention-popup__status", children: "Searching\u2026" })) : items.length === 0 ? (_jsx("div", { className: "bn-mention-popup__status", children: "No matches" })) : (items.map((item, index) => (_jsxs("button", { type: "button", role: "option", "data-active": index === activeIndex, "aria-selected": index === activeIndex, className: index === activeIndex
|
|
236
|
+
? "bn-mention-item bn-mention-item--active"
|
|
237
|
+
: "bn-mention-item",
|
|
238
|
+
// onMouseDown (not onClick) + preventDefault keeps the textarea focused.
|
|
239
|
+
onMouseDown: (event) => {
|
|
240
|
+
event.preventDefault();
|
|
241
|
+
onSelect(item);
|
|
242
|
+
}, onMouseEnter: () => onHover(index), tabIndex: -1, children: [_jsx("span", { className: "bn-mention-item__label", children: item.label }), item.detail && _jsx("span", { className: "bn-mention-item__detail", children: item.detail })] }, item.id))))] }), document.body);
|
|
243
|
+
}
|
|
244
|
+
/* ------------------------------------------------------------------ *
|
|
245
|
+
* Caret coordinates via the mirror-div technique. Works for any plain
|
|
246
|
+
* <textarea>; returns viewport (fixed) coordinates of the given index.
|
|
247
|
+
* ------------------------------------------------------------------ */
|
|
248
|
+
const MIRROR_PROPS = [
|
|
249
|
+
"boxSizing",
|
|
250
|
+
"width",
|
|
251
|
+
"borderTopWidth",
|
|
252
|
+
"borderRightWidth",
|
|
253
|
+
"borderBottomWidth",
|
|
254
|
+
"borderLeftWidth",
|
|
255
|
+
"paddingTop",
|
|
256
|
+
"paddingRight",
|
|
257
|
+
"paddingBottom",
|
|
258
|
+
"paddingLeft",
|
|
259
|
+
"fontStyle",
|
|
260
|
+
"fontVariant",
|
|
261
|
+
"fontWeight",
|
|
262
|
+
"fontStretch",
|
|
263
|
+
"fontSize",
|
|
264
|
+
"fontSizeAdjust",
|
|
265
|
+
"lineHeight",
|
|
266
|
+
"fontFamily",
|
|
267
|
+
"textAlign",
|
|
268
|
+
"textTransform",
|
|
269
|
+
"textIndent",
|
|
270
|
+
"textDecoration",
|
|
271
|
+
"letterSpacing",
|
|
272
|
+
"wordSpacing",
|
|
273
|
+
"tabSize",
|
|
274
|
+
"whiteSpace",
|
|
275
|
+
"wordWrap",
|
|
276
|
+
"wordBreak",
|
|
277
|
+
];
|
|
278
|
+
function caretCoordinates(textarea, position) {
|
|
279
|
+
const rect = textarea.getBoundingClientRect();
|
|
280
|
+
const fallback = {
|
|
281
|
+
left: rect.left,
|
|
282
|
+
top: rect.top,
|
|
283
|
+
bottom: rect.bottom,
|
|
284
|
+
height: rect.height,
|
|
285
|
+
};
|
|
286
|
+
if (typeof document === "undefined")
|
|
287
|
+
return fallback;
|
|
288
|
+
const style = window.getComputedStyle(textarea);
|
|
289
|
+
const mirror = document.createElement("div");
|
|
290
|
+
const cs = mirror.style;
|
|
291
|
+
cs.position = "absolute";
|
|
292
|
+
cs.visibility = "hidden";
|
|
293
|
+
cs.whiteSpace = "pre-wrap";
|
|
294
|
+
cs.overflow = "hidden";
|
|
295
|
+
const sourceStyle = style;
|
|
296
|
+
const mirrorStyle = cs;
|
|
297
|
+
for (const prop of MIRROR_PROPS) {
|
|
298
|
+
mirrorStyle[prop] = sourceStyle[prop];
|
|
299
|
+
}
|
|
300
|
+
cs.width = `${textarea.clientWidth}px`;
|
|
301
|
+
const value = textarea.value;
|
|
302
|
+
mirror.textContent = value.slice(0, position);
|
|
303
|
+
const marker = document.createElement("span");
|
|
304
|
+
marker.textContent = value.slice(position) || ".";
|
|
305
|
+
mirror.appendChild(marker);
|
|
306
|
+
document.body.appendChild(mirror);
|
|
307
|
+
const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.2;
|
|
308
|
+
const left = rect.left + marker.offsetLeft - textarea.scrollLeft;
|
|
309
|
+
const top = rect.top + marker.offsetTop - textarea.scrollTop;
|
|
310
|
+
document.body.removeChild(mirror);
|
|
311
|
+
return { left, top, bottom: top + lineHeight, height: lineHeight };
|
|
312
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type MentionSource } from "./mentionAutocomplete";
|
|
2
|
+
export type MentionMenuProps = {
|
|
3
|
+
/** Sources to use. Falls back to the global registry (`setMentionSources`). */
|
|
4
|
+
sources?: MentionSource[];
|
|
5
|
+
/** Character that opens the menu. Default `@`. */
|
|
6
|
+
triggerCharacter?: string;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Universal `@`-mention menu for the main BlockNote editor. Render it as a child
|
|
10
|
+
* of `<BlockNoteView>`. On selection it inserts the source's token (e.g.
|
|
11
|
+
* `@T123456`, `@S98765`, `@username`) as inline text at the cursor.
|
|
12
|
+
*
|
|
13
|
+
* <BlockNoteView editor={editor}>
|
|
14
|
+
* <MentionMenu />
|
|
15
|
+
* </BlockNoteView>
|
|
16
|
+
*/
|
|
17
|
+
export declare function MentionMenu({ sources: sourcesProp, triggerCharacter }: MentionMenuProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { SuggestionMenuController, useBlockNoteEditor } from "@blocknote/react";
|
|
3
|
+
import { buildMentionInsertText, getMentionSources, resolveMentionQuery, } from "./mentionAutocomplete";
|
|
4
|
+
/**
|
|
5
|
+
* Universal `@`-mention menu for the main BlockNote editor. Render it as a child
|
|
6
|
+
* of `<BlockNoteView>`. On selection it inserts the source's token (e.g.
|
|
7
|
+
* `@T123456`, `@S98765`, `@username`) as inline text at the cursor.
|
|
8
|
+
*
|
|
9
|
+
* <BlockNoteView editor={editor}>
|
|
10
|
+
* <MentionMenu />
|
|
11
|
+
* </BlockNoteView>
|
|
12
|
+
*/
|
|
13
|
+
export function MentionMenu({ sources: sourcesProp, triggerCharacter = "@" }) {
|
|
14
|
+
const editor = useBlockNoteEditor();
|
|
15
|
+
const getItems = async (query) => {
|
|
16
|
+
const sources = sourcesProp !== null && sourcesProp !== void 0 ? sourcesProp : getMentionSources();
|
|
17
|
+
const resolved = await resolveMentionQuery(query, sources);
|
|
18
|
+
if (!resolved)
|
|
19
|
+
return [];
|
|
20
|
+
const { source, items } = resolved;
|
|
21
|
+
return items.map((item) => {
|
|
22
|
+
var _a;
|
|
23
|
+
return ({
|
|
24
|
+
title: item.label,
|
|
25
|
+
subtext: (_a = item.detail) !== null && _a !== void 0 ? _a : undefined,
|
|
26
|
+
onItemClick: () => {
|
|
27
|
+
// The controller has already removed the trigger + query text; we insert
|
|
28
|
+
// the token plus a trailing space so the caret is ready for more typing.
|
|
29
|
+
editor.insertInlineContent([buildMentionInsertText(source, item), " "]);
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
return _jsx(SuggestionMenuController, { triggerCharacter: triggerCharacter, getItems: getItems });
|
|
35
|
+
}
|
|
@@ -379,7 +379,20 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
|
|
|
379
379
|
return lines;
|
|
380
380
|
}
|
|
381
381
|
lines.push(`<!-- ${kind}`);
|
|
382
|
-
fields.forEach((field) =>
|
|
382
|
+
fields.forEach((field) => {
|
|
383
|
+
// List-valued keys (e.g. `issues`) are written back as a YAML list to
|
|
384
|
+
// preserve the backend's format. The single panel field holds the items
|
|
385
|
+
// comma-separated, so split them back out into ` - item` lines.
|
|
386
|
+
if (LIST_META_KEYS.has(field.key.trim().toLowerCase())) {
|
|
387
|
+
const items = field.value.split(",").map((s) => s.trim()).filter(Boolean);
|
|
388
|
+
if (items.length) {
|
|
389
|
+
lines.push(`${field.key}:`);
|
|
390
|
+
items.forEach((item) => lines.push(` - ${item}`));
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
lines.push(`${field.key}: ${field.value}`);
|
|
395
|
+
});
|
|
383
396
|
lines.push("-->");
|
|
384
397
|
return lines;
|
|
385
398
|
}
|
|
@@ -1210,12 +1223,26 @@ function parseParagraph(lines, index) {
|
|
|
1210
1223
|
};
|
|
1211
1224
|
}
|
|
1212
1225
|
const META_COMMENT_OPEN_REGEX = /^<!--\s*(test|suite)(?=\s|-->|$)/i;
|
|
1226
|
+
// Keys whose value is a YAML-style list (`key:` followed by indented `- item`
|
|
1227
|
+
// lines) in the testomat.io comment format — e.g. `issues` holding URLs. These
|
|
1228
|
+
// round-trip back to a list on serialize; everything else stays a flat line.
|
|
1229
|
+
const LIST_META_KEYS = new Set(["issues"]);
|
|
1213
1230
|
function metaFieldsFromBody(bodyLines) {
|
|
1214
1231
|
const fields = [];
|
|
1215
1232
|
for (const raw of bodyLines) {
|
|
1216
1233
|
const line = raw.trim();
|
|
1217
1234
|
if (!line)
|
|
1218
1235
|
continue;
|
|
1236
|
+
// YAML list item: belongs to the most recent `key:` field. The whole item
|
|
1237
|
+
// (e.g. `https://github.com/...`) is kept verbatim — because we catch the
|
|
1238
|
+
// list line before the colon check, URLs with `://` are never split.
|
|
1239
|
+
if (line === "-" || line.startsWith("- ")) {
|
|
1240
|
+
const item = line.slice(1).trim();
|
|
1241
|
+
const current = fields[fields.length - 1];
|
|
1242
|
+
if (current && item)
|
|
1243
|
+
current.items.push(item);
|
|
1244
|
+
continue;
|
|
1245
|
+
}
|
|
1219
1246
|
const colon = line.indexOf(":");
|
|
1220
1247
|
// "Each line is `key: value`; lines without `:` are ignored."
|
|
1221
1248
|
if (colon === -1)
|
|
@@ -1224,9 +1251,13 @@ function metaFieldsFromBody(bodyLines) {
|
|
|
1224
1251
|
const value = line.slice(colon + 1).trim();
|
|
1225
1252
|
if (!key)
|
|
1226
1253
|
continue;
|
|
1227
|
-
fields.push({ key, value });
|
|
1254
|
+
fields.push({ key, value, items: [] });
|
|
1228
1255
|
}
|
|
1229
|
-
|
|
1256
|
+
// A field with collected list items → value is the items joined by ", ".
|
|
1257
|
+
return fields.map(({ key, value, items }) => ({
|
|
1258
|
+
key,
|
|
1259
|
+
value: items.length ? items.join(", ") : value,
|
|
1260
|
+
}));
|
|
1230
1261
|
}
|
|
1231
1262
|
function parseMetaComment(lines, index) {
|
|
1232
1263
|
const first = lines[index];
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Universal, prefix-based `@`-mention autocomplete.
|
|
3
|
+
*
|
|
4
|
+
* A single mechanism that turns typed tokens like `@`, `@T`, `@S` into inserted
|
|
5
|
+
* references such as `@T123456`, `@S98765`, or `@username`. Which list is shown
|
|
6
|
+
* (and what gets inserted) is decided by a configurable set of {@link MentionSource}s,
|
|
7
|
+
* each keyed by the characters that follow the `@`:
|
|
8
|
+
*
|
|
9
|
+
* { prefix: "T", search: (q) => api.findTests(q) } // @T -> fetch tests -> "@T123456"
|
|
10
|
+
* { prefix: "S", search: (q) => api.findSuites(q) } // @S -> fetch suites -> "@S98765"
|
|
11
|
+
* { prefix: "", items: users, insert: (u) => `@${u.label}` } // @ -> filter users -> "@john"
|
|
12
|
+
*
|
|
13
|
+
* This file is intentionally DOM-free: it holds only the pure logic (matching,
|
|
14
|
+
* source resolution, filtering, token building) plus the global source registry.
|
|
15
|
+
* The React wiring lives in `MentionAutocomplete.tsx`.
|
|
16
|
+
*/
|
|
17
|
+
export type MentionItem = {
|
|
18
|
+
/** Stable identifier. For test/suite sources this is what gets inserted (`@T{id}`). */
|
|
19
|
+
id: string;
|
|
20
|
+
/** Primary text shown in the popup and used for client-side filtering. */
|
|
21
|
+
label: string;
|
|
22
|
+
/** Optional secondary text (e.g. `#123`, an email, a description). */
|
|
23
|
+
detail?: string | null;
|
|
24
|
+
/**
|
|
25
|
+
* Optional explicit text to insert for this specific item. When omitted the
|
|
26
|
+
* source's `insert` builder (or the default `@{prefix}{id}`) is used.
|
|
27
|
+
*/
|
|
28
|
+
insertText?: string;
|
|
29
|
+
/** Sources may carry arbitrary extra data through to their renderers. */
|
|
30
|
+
[key: string]: unknown;
|
|
31
|
+
};
|
|
32
|
+
/** Static item list, or a (possibly async) provider of one. */
|
|
33
|
+
export type MentionItemsInput = MentionItem[] | (() => MentionItem[] | Promise<MentionItem[]>);
|
|
34
|
+
/** Result of an async `search`. May be raw items or a JSON:API document. */
|
|
35
|
+
export type MentionSearchResult = MentionItem[] | MentionJsonApiDocument | MentionJsonApiResource[] | null | undefined;
|
|
36
|
+
export type MentionSource = {
|
|
37
|
+
/**
|
|
38
|
+
* Characters that must appear immediately after `@` to select this source.
|
|
39
|
+
* Use `""` for the default/fallback source (e.g. usernames on a bare `@`).
|
|
40
|
+
* Matching is case-sensitive and longest-prefix wins, so `@T` routes to the
|
|
41
|
+
* `"T"` source rather than the `""` source.
|
|
42
|
+
*/
|
|
43
|
+
prefix: string;
|
|
44
|
+
/** Human label for the popup header / grouping (e.g. "Tests", "Users"). */
|
|
45
|
+
label?: string;
|
|
46
|
+
/**
|
|
47
|
+
* Static list (or provider). Filtered client-side by the typed query. Use
|
|
48
|
+
* this for data already in memory, like the list of project members.
|
|
49
|
+
*/
|
|
50
|
+
items?: MentionItemsInput;
|
|
51
|
+
/**
|
|
52
|
+
* Async fetcher invoked with the typed query (the text after the prefix).
|
|
53
|
+
* Use this for server-backed lookups like tests (`@T`) and suites (`@S`).
|
|
54
|
+
*/
|
|
55
|
+
search?: (query: string) => Promise<MentionSearchResult> | MentionSearchResult;
|
|
56
|
+
/**
|
|
57
|
+
* Builds the text inserted when an item is chosen. Defaults to
|
|
58
|
+
* `@{prefix}{id}` (so a test with id `123456` becomes `@T123456`).
|
|
59
|
+
*/
|
|
60
|
+
insert?: (item: MentionItem, source: MentionSource) => string;
|
|
61
|
+
/** Minimum query length before `search` runs. Default 0 (fires on empty query). */
|
|
62
|
+
minChars?: number;
|
|
63
|
+
/** Max items shown. Default 8. */
|
|
64
|
+
limit?: number;
|
|
65
|
+
};
|
|
66
|
+
/** A currently-active mention detected at the caret. */
|
|
67
|
+
export type ActiveMention = {
|
|
68
|
+
source: MentionSource;
|
|
69
|
+
/** Matched prefix (e.g. "T", "S", or ""). */
|
|
70
|
+
prefix: string;
|
|
71
|
+
/** Text typed after the prefix (what `search`/filter receives). */
|
|
72
|
+
query: string;
|
|
73
|
+
/** Index of the `@` in the source text. */
|
|
74
|
+
start: number;
|
|
75
|
+
/** Caret index (exclusive end of the token being edited). */
|
|
76
|
+
end: number;
|
|
77
|
+
/** Full token text between `@` (exclusive) and the caret, i.e. `prefix + query`. */
|
|
78
|
+
token: string;
|
|
79
|
+
};
|
|
80
|
+
export type MentionJsonApiAttributes = {
|
|
81
|
+
title?: string | null;
|
|
82
|
+
name?: string | null;
|
|
83
|
+
label?: string | null;
|
|
84
|
+
username?: string | null;
|
|
85
|
+
email?: string | null;
|
|
86
|
+
description?: string | null;
|
|
87
|
+
detail?: string | null;
|
|
88
|
+
[key: string]: unknown;
|
|
89
|
+
};
|
|
90
|
+
export type MentionJsonApiResource = {
|
|
91
|
+
id?: string | number | null;
|
|
92
|
+
type?: string | null;
|
|
93
|
+
attributes?: MentionJsonApiAttributes | null;
|
|
94
|
+
};
|
|
95
|
+
export type MentionJsonApiDocument = {
|
|
96
|
+
data?: MentionJsonApiResource[] | null;
|
|
97
|
+
};
|
|
98
|
+
/** Register the mention sources used when a consumer doesn't pass `sources` explicitly. */
|
|
99
|
+
export declare function setMentionSources(sources: MentionSource[] | null): void;
|
|
100
|
+
/** Read the globally-registered mention sources. */
|
|
101
|
+
export declare function getMentionSources(): MentionSource[];
|
|
102
|
+
/**
|
|
103
|
+
* Pick the source whose `prefix` matches the start of `token`. Longest prefix
|
|
104
|
+
* wins; the empty-prefix source is the ultimate fallback. Returns `null` when
|
|
105
|
+
* nothing matches (e.g. `@T` typed but only a `""` source is registered and it
|
|
106
|
+
* is absent).
|
|
107
|
+
*/
|
|
108
|
+
export declare function resolveMentionSource(token: string, sources: MentionSource[]): MentionSource | null;
|
|
109
|
+
/**
|
|
110
|
+
* Detect the mention being edited at `caret` within `text`.
|
|
111
|
+
*
|
|
112
|
+
* A mention is the run of non-whitespace characters after an `@` that sits at
|
|
113
|
+
* the start of the text or immediately after whitespace (so emails like
|
|
114
|
+
* `a@b.com` never trigger). Returns `null` when the caret is not inside such a
|
|
115
|
+
* token or no source matches.
|
|
116
|
+
*/
|
|
117
|
+
export declare function parseActiveMention(text: string, caret: number, sources: MentionSource[]): ActiveMention | null;
|
|
118
|
+
/** The text a chosen item inserts, honoring item/source overrides. */
|
|
119
|
+
export declare function buildMentionInsertText(source: MentionSource, item: MentionItem): string;
|
|
120
|
+
export type MentionApplication = {
|
|
121
|
+
/** The full text after replacing the active token with the inserted mention. */
|
|
122
|
+
text: string;
|
|
123
|
+
/** Caret position after insertion (after the token and its trailing space). */
|
|
124
|
+
caret: number;
|
|
125
|
+
};
|
|
126
|
+
/**
|
|
127
|
+
* Replace the active mention token in `text` with `insertText`, adding a single
|
|
128
|
+
* trailing space so the caret lands ready for the next word and the freshly
|
|
129
|
+
* inserted `@…` token does not immediately re-trigger the popup.
|
|
130
|
+
*/
|
|
131
|
+
export declare function applyMention(text: string, active: Pick<ActiveMention, "start" | "end">, insertText: string): MentionApplication;
|
|
132
|
+
/**
|
|
133
|
+
* Rank static items against a query: label starts-with beats label contains,
|
|
134
|
+
* which beats id matches. Case-insensitive. An empty query returns the head of
|
|
135
|
+
* the list unchanged.
|
|
136
|
+
*/
|
|
137
|
+
export declare function filterMentionItems(items: MentionItem[], query: string, limit?: number): MentionItem[];
|
|
138
|
+
/**
|
|
139
|
+
* Route a typed query (everything after the trigger char, e.g. `T123` or `bob`)
|
|
140
|
+
* to its source and resolve the items to show. Handles prefix stripping,
|
|
141
|
+
* `minChars`, async `search`, static `items` (incl. provider functions),
|
|
142
|
+
* normalization, and the `limit`. Returns `null` when no source matches.
|
|
143
|
+
*
|
|
144
|
+
* Shared by both mention frontends (the textarea hook and the BlockNote menu).
|
|
145
|
+
*/
|
|
146
|
+
export declare function resolveMentionQuery(query: string, sources: MentionSource[]): Promise<{
|
|
147
|
+
source: MentionSource;
|
|
148
|
+
items: MentionItem[];
|
|
149
|
+
} | null>;
|
|
150
|
+
export declare function parseMentionsFromJsonApi(document: MentionJsonApiDocument | MentionJsonApiResource[] | null | undefined): MentionItem[];
|
|
151
|
+
/** Coerce any supported `search`/`items` result into a clean `MentionItem[]`. */
|
|
152
|
+
export declare function normalizeMentionItems(result: MentionSearchResult): MentionItem[];
|