testomatio-editor-blocks 0.4.77 → 0.4.79
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/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/editor/useMentionAutocomplete.d.ts +38 -0
- package/package/editor/useMentionAutocomplete.js +312 -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/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/editor/useMentionAutocomplete.tsx +463 -0
- package/src/index.ts +31 -0
|
@@ -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
|
+
}
|
package/package/index.d.ts
CHANGED
|
@@ -8,6 +8,9 @@ export { tagBadgeExtension, TagBadgeExtension, TAGS_DETECT_REGEXP, detectTags, t
|
|
|
8
8
|
export { blocksToMarkdown, markdownToBlocks, type CustomEditorBlock, type CustomPartialBlock, type MarkdownToBlocksOptions, } from "./editor/customMarkdownConverter";
|
|
9
9
|
export { useStepAutocomplete, parseStepsFromJsonApi, setStepsFetcher, type StepSuggestion, type StepJsonApiDocument, type StepJsonApiResource, } from "./editor/stepAutocomplete";
|
|
10
10
|
export { useStepImageUpload, setImageUploadHandler, type StepImageUploadHandler, } from "./editor/stepImageUpload";
|
|
11
|
+
export { setMentionSources, getMentionSources, parseActiveMention, resolveMentionSource, buildMentionInsertText, applyMention, filterMentionItems, resolveMentionQuery, normalizeMentionItems, parseMentionsFromJsonApi, type MentionSource, type MentionItem, type MentionItemsInput, type MentionSearchResult, type ActiveMention, type MentionJsonApiDocument, type MentionJsonApiResource, } from "./editor/mentionAutocomplete";
|
|
12
|
+
export { MentionMenu, type MentionMenuProps, } from "./editor/MentionMenu";
|
|
13
|
+
export { useMentionAutocomplete, type UseMentionAutocompleteOptions, type UseMentionAutocompleteResult, } from "./editor/useMentionAutocomplete";
|
|
11
14
|
export { setFileDisplayUrlResolver, resolveFileDisplayUrl, type FileDisplayUrlResolver, } from "./editor/fileDisplayUrl";
|
|
12
15
|
export { createMarkdownPasteHandler } from "./editor/createMarkdownPasteHandler";
|
|
13
16
|
export declare const testomatioEditorClassName = "markdown testomatio-editor";
|
package/package/index.js
CHANGED
|
@@ -8,6 +8,9 @@ export { tagBadgeExtension, TagBadgeExtension, TAGS_DETECT_REGEXP, detectTags, }
|
|
|
8
8
|
export { blocksToMarkdown, markdownToBlocks, } from "./editor/customMarkdownConverter";
|
|
9
9
|
export { useStepAutocomplete, parseStepsFromJsonApi, setStepsFetcher, } from "./editor/stepAutocomplete";
|
|
10
10
|
export { useStepImageUpload, setImageUploadHandler, } from "./editor/stepImageUpload";
|
|
11
|
+
export { setMentionSources, getMentionSources, parseActiveMention, resolveMentionSource, buildMentionInsertText, applyMention, filterMentionItems, resolveMentionQuery, normalizeMentionItems, parseMentionsFromJsonApi, } from "./editor/mentionAutocomplete";
|
|
12
|
+
export { MentionMenu, } from "./editor/MentionMenu";
|
|
13
|
+
export { useMentionAutocomplete, } from "./editor/useMentionAutocomplete";
|
|
11
14
|
export { setFileDisplayUrlResolver, resolveFileDisplayUrl, } from "./editor/fileDisplayUrl";
|
|
12
15
|
export { createMarkdownPasteHandler } from "./editor/createMarkdownPasteHandler";
|
|
13
16
|
export const testomatioEditorClassName = "markdown testomatio-editor";
|
package/package/styles.css
CHANGED
|
@@ -1844,6 +1844,81 @@ html.dark .bn-step-editor--preview a.step-preview-link {
|
|
|
1844
1844
|
color: var(--step-muted);
|
|
1845
1845
|
}
|
|
1846
1846
|
|
|
1847
|
+
/* Universal @-mention autocomplete popup (portaled to <body>, fixed to caret). */
|
|
1848
|
+
.bn-mention-popup {
|
|
1849
|
+
position: fixed;
|
|
1850
|
+
min-width: 15rem;
|
|
1851
|
+
max-width: 24rem;
|
|
1852
|
+
background: var(--bg-white-opaque);
|
|
1853
|
+
border-radius: 0.65rem;
|
|
1854
|
+
border: 1px solid var(--step-border-light);
|
|
1855
|
+
box-shadow: 0 18px 35px var(--shadow-medium);
|
|
1856
|
+
padding: 0.25rem;
|
|
1857
|
+
display: flex;
|
|
1858
|
+
flex-direction: column;
|
|
1859
|
+
gap: 0.1rem;
|
|
1860
|
+
z-index: 9999;
|
|
1861
|
+
max-height: 15rem;
|
|
1862
|
+
overflow-y: auto;
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
.bn-mention-popup__header {
|
|
1866
|
+
display: flex;
|
|
1867
|
+
align-items: center;
|
|
1868
|
+
gap: 0.4rem;
|
|
1869
|
+
padding: 0.3rem 0.6rem 0.35rem;
|
|
1870
|
+
font-size: 0.72rem;
|
|
1871
|
+
text-transform: uppercase;
|
|
1872
|
+
letter-spacing: 0.04em;
|
|
1873
|
+
color: var(--step-muted);
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
.bn-mention-popup__prefix {
|
|
1877
|
+
font-weight: 700;
|
|
1878
|
+
color: var(--text-primary);
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
.bn-mention-popup__status {
|
|
1882
|
+
padding: 0.5rem 0.75rem;
|
|
1883
|
+
font-size: 0.85rem;
|
|
1884
|
+
color: var(--step-muted);
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
.bn-mention-item {
|
|
1888
|
+
border: none;
|
|
1889
|
+
background: transparent;
|
|
1890
|
+
border-radius: 0.5rem;
|
|
1891
|
+
padding: 0.4rem 0.75rem;
|
|
1892
|
+
text-align: left;
|
|
1893
|
+
display: flex;
|
|
1894
|
+
align-items: baseline;
|
|
1895
|
+
justify-content: space-between;
|
|
1896
|
+
gap: 0.75rem;
|
|
1897
|
+
cursor: pointer;
|
|
1898
|
+
color: var(--text-primary);
|
|
1899
|
+
width: 100%;
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
.bn-mention-item:hover,
|
|
1903
|
+
.bn-mention-item--active {
|
|
1904
|
+
background: var(--step-bg-light);
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
.bn-mention-item__label {
|
|
1908
|
+
font-size: 0.92rem;
|
|
1909
|
+
white-space: nowrap;
|
|
1910
|
+
overflow: hidden;
|
|
1911
|
+
text-overflow: ellipsis;
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
.bn-mention-item__detail {
|
|
1915
|
+
font-size: 0.75rem;
|
|
1916
|
+
font-weight: 500;
|
|
1917
|
+
color: var(--step-muted);
|
|
1918
|
+
white-space: nowrap;
|
|
1919
|
+
flex-shrink: 0;
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1847
1922
|
.bn-inline-image {
|
|
1848
1923
|
display: block;
|
|
1849
1924
|
max-width: 100%;
|
package/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -23,6 +23,13 @@ import { customSchema, type CustomEditor } from "./editor/customSchema";
|
|
|
23
23
|
import { tagBadgeExtension } from "./editor/tagBadge";
|
|
24
24
|
import { setStepsFetcher, type StepJsonApiDocument } from "./editor/stepAutocomplete";
|
|
25
25
|
import { setSnippetFetcher, type SnippetJsonApiDocument } from "./editor/snippetAutocomplete";
|
|
26
|
+
import {
|
|
27
|
+
setMentionSources,
|
|
28
|
+
type MentionItem,
|
|
29
|
+
type MentionSearchResult,
|
|
30
|
+
type MentionSource,
|
|
31
|
+
} from "./editor/mentionAutocomplete";
|
|
32
|
+
import { MentionMenu } from "./editor/MentionMenu";
|
|
26
33
|
import { setImageUploadHandler } from "./editor/stepImageUpload";
|
|
27
34
|
import { canInsertStepOrSnippet, addStepsBlock, addSnippetBlock } from "./editor/blocks/step";
|
|
28
35
|
import { addTestBlock } from "./editor/blocks/testMeta";
|
|
@@ -287,6 +294,73 @@ const DEMO_SNIPPET_FIXTURES: SnippetJsonApiDocument = {
|
|
|
287
294
|
],
|
|
288
295
|
};
|
|
289
296
|
|
|
297
|
+
const DEMO_USERS: MentionItem[] = [
|
|
298
|
+
{ id: "u-davert", label: "davert", detail: "Michael Bodnarchuk" },
|
|
299
|
+
{ id: "u-anna", label: "anna", detail: "Anna QA" },
|
|
300
|
+
{ id: "u-taras", label: "taras", detail: "Taras Manager" },
|
|
301
|
+
{ id: "u-siren", label: "siern", detail: "Sirena QA" },
|
|
302
|
+
{ id: "u-bob", label: "bob", detail: "Bob Tester" },
|
|
303
|
+
{ id: "u-carol", label: "carol", detail: "Carol Dev" },
|
|
304
|
+
{ id: "u-dan", label: "dan", detail: "Dan Ops" },
|
|
305
|
+
{ id: "u-eve", label: "eve", detail: "Eve Security" },
|
|
306
|
+
];
|
|
307
|
+
|
|
308
|
+
// Simulated "tests" API dataset — insertion uses the numeric id -> "@T{id}".
|
|
309
|
+
const DEMO_TESTS: MentionItem[] = [
|
|
310
|
+
{ id: "10231", label: "Login redirects to dashboard", detail: "#10231" },
|
|
311
|
+
{ id: "10232", label: "Login rejects wrong password", detail: "#10232" },
|
|
312
|
+
{ id: "10245", label: "Logout clears the session", detail: "#10245" },
|
|
313
|
+
{ id: "10310", label: "Checkout applies discount code", detail: "#10310" },
|
|
314
|
+
{ id: "10311", label: "Checkout blocks empty cart", detail: "#10311" },
|
|
315
|
+
{ id: "10420", label: "Profile avatar upload", detail: "#10420" },
|
|
316
|
+
{ id: "10421", label: "Profile email change requires confirm", detail: "#10421" },
|
|
317
|
+
{ id: "10530", label: "Password reset sends email", detail: "#10530" },
|
|
318
|
+
];
|
|
319
|
+
|
|
320
|
+
const DEMO_SUITES: MentionItem[] = [
|
|
321
|
+
{ id: "1001", label: "Authentication", detail: "#1001" },
|
|
322
|
+
{ id: "1002", label: "Checkout flow", detail: "#1002" },
|
|
323
|
+
{ id: "1003", label: "User profile", detail: "#1003" },
|
|
324
|
+
{ id: "1004", label: "Notifications", detail: "#1004" },
|
|
325
|
+
{ id: "1005", label: "Admin panel", detail: "#1005" },
|
|
326
|
+
];
|
|
327
|
+
|
|
328
|
+
const demoDelay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Demo @-mention config. Prefixes are fully data-driven: @T / @S hit a
|
|
332
|
+
* (simulated) API and insert an id token, while a bare @ filters the in-memory
|
|
333
|
+
* user list and inserts the handle.
|
|
334
|
+
*/
|
|
335
|
+
const DEMO_MENTION_SOURCES: MentionSource[] = [
|
|
336
|
+
{
|
|
337
|
+
prefix: "T",
|
|
338
|
+
label: "Tests",
|
|
339
|
+
search: async (query): Promise<MentionSearchResult> => {
|
|
340
|
+
await demoDelay(120); // simulate an API round-trip
|
|
341
|
+
const q = query.toLowerCase();
|
|
342
|
+
return DEMO_TESTS.filter((test) => test.label.toLowerCase().includes(q));
|
|
343
|
+
},
|
|
344
|
+
// default insert -> "@T{id}", e.g. "@T10231"
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
prefix: "S",
|
|
348
|
+
label: "Suites",
|
|
349
|
+
search: async (query): Promise<MentionSearchResult> => {
|
|
350
|
+
await demoDelay(120);
|
|
351
|
+
const q = query.toLowerCase();
|
|
352
|
+
return DEMO_SUITES.filter((suite) => suite.label.toLowerCase().includes(q));
|
|
353
|
+
},
|
|
354
|
+
// default insert -> "@S{id}", e.g. "@S1001"
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
prefix: "",
|
|
358
|
+
label: "Users",
|
|
359
|
+
items: DEMO_USERS,
|
|
360
|
+
insert: (item) => `@${item.label}`, // insert -> "@davert"
|
|
361
|
+
},
|
|
362
|
+
];
|
|
363
|
+
|
|
290
364
|
function CustomSlashMenu() {
|
|
291
365
|
const editor = useBlockNoteEditor<Schema["blockSchema"], Schema["inlineContentSchema"], Schema["styleSchema"]>();
|
|
292
366
|
|
|
@@ -512,6 +586,7 @@ function App() {
|
|
|
512
586
|
// Demo defaults: configure global handlers so the editor works without manual providers.
|
|
513
587
|
setStepsFetcher(() => DEMO_STEP_FIXTURES);
|
|
514
588
|
setSnippetFetcher(() => DEMO_SNIPPET_FIXTURES);
|
|
589
|
+
setMentionSources(DEMO_MENTION_SOURCES);
|
|
515
590
|
|
|
516
591
|
const handler = editor?.uploadFile
|
|
517
592
|
? async (file: Blob) => {
|
|
@@ -538,6 +613,7 @@ function App() {
|
|
|
538
613
|
setStepsFetcher(null);
|
|
539
614
|
setSnippetFetcher(null);
|
|
540
615
|
setImageUploadHandler(null);
|
|
616
|
+
setMentionSources(null);
|
|
541
617
|
};
|
|
542
618
|
}, [editor, uploadStepImage]);
|
|
543
619
|
|
|
@@ -675,6 +751,7 @@ function App() {
|
|
|
675
751
|
className="markdown testomatio-editor"
|
|
676
752
|
>
|
|
677
753
|
<CustomSlashMenu />
|
|
754
|
+
<MentionMenu />
|
|
678
755
|
</BlockNoteView>
|
|
679
756
|
</div>
|
|
680
757
|
<aside className="app__preview">
|
|
@@ -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
|
+
}
|