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,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
|
+
}
|
|
@@ -3223,6 +3223,77 @@ describe("test/suite metadata comments", () => {
|
|
|
3223
3223
|
expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
|
|
3224
3224
|
});
|
|
3225
3225
|
|
|
3226
|
+
it("parses a YAML-list issues field without splitting URLs on ://", () => {
|
|
3227
|
+
const markdown = [
|
|
3228
|
+
"<!-- test",
|
|
3229
|
+
"id: @T4ffddec3",
|
|
3230
|
+
"type: manual",
|
|
3231
|
+
"issues:",
|
|
3232
|
+
" - https://github.com/testomatio/testomatio/issues/8963",
|
|
3233
|
+
"-->",
|
|
3234
|
+
].join("\n");
|
|
3235
|
+
const blocks = markdownToBlocks(markdown);
|
|
3236
|
+
expect((blocks[0].props as any).metaFields).toBe(
|
|
3237
|
+
JSON.stringify([
|
|
3238
|
+
{ key: "id", value: "@T4ffddec3" },
|
|
3239
|
+
{ key: "type", value: "manual" },
|
|
3240
|
+
{
|
|
3241
|
+
key: "issues",
|
|
3242
|
+
value: "https://github.com/testomatio/testomatio/issues/8963",
|
|
3243
|
+
},
|
|
3244
|
+
]),
|
|
3245
|
+
);
|
|
3246
|
+
});
|
|
3247
|
+
|
|
3248
|
+
it("joins multiple YAML-list issues items with a comma", () => {
|
|
3249
|
+
const markdown = [
|
|
3250
|
+
"<!-- test",
|
|
3251
|
+
"id: @T4ffddec3",
|
|
3252
|
+
"issues:",
|
|
3253
|
+
" - https://github.com/testomatio/testomatio/issues/8963",
|
|
3254
|
+
" - https://github.com/testomatio/testomatio/issues/9001",
|
|
3255
|
+
"-->",
|
|
3256
|
+
].join("\n");
|
|
3257
|
+
const blocks = markdownToBlocks(markdown);
|
|
3258
|
+
expect((blocks[0].props as any).metaFields).toBe(
|
|
3259
|
+
JSON.stringify([
|
|
3260
|
+
{ key: "id", value: "@T4ffddec3" },
|
|
3261
|
+
{
|
|
3262
|
+
key: "issues",
|
|
3263
|
+
value:
|
|
3264
|
+
"https://github.com/testomatio/testomatio/issues/8963, https://github.com/testomatio/testomatio/issues/9001",
|
|
3265
|
+
},
|
|
3266
|
+
]),
|
|
3267
|
+
);
|
|
3268
|
+
});
|
|
3269
|
+
|
|
3270
|
+
it("round-trips a YAML-list issues field (single item)", () => {
|
|
3271
|
+
const markdown = [
|
|
3272
|
+
"<!-- test",
|
|
3273
|
+
"id: @T4ffddec3",
|
|
3274
|
+
"type: manual",
|
|
3275
|
+
"priority: normal",
|
|
3276
|
+
"issues:",
|
|
3277
|
+
" - https://github.com/testomatio/testomatio/issues/8963",
|
|
3278
|
+
"-->",
|
|
3279
|
+
].join("\n");
|
|
3280
|
+
const blocks = markdownToBlocks(markdown);
|
|
3281
|
+
expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
|
|
3282
|
+
});
|
|
3283
|
+
|
|
3284
|
+
it("round-trips a YAML-list issues field (multiple items)", () => {
|
|
3285
|
+
const markdown = [
|
|
3286
|
+
"<!-- test",
|
|
3287
|
+
"id: @T4ffddec3",
|
|
3288
|
+
"issues:",
|
|
3289
|
+
" - https://github.com/testomatio/testomatio/issues/8963",
|
|
3290
|
+
" - https://github.com/testomatio/testomatio/issues/9001",
|
|
3291
|
+
"-->",
|
|
3292
|
+
].join("\n");
|
|
3293
|
+
const blocks = markdownToBlocks(markdown);
|
|
3294
|
+
expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
|
|
3295
|
+
});
|
|
3296
|
+
|
|
3226
3297
|
it("ignores lines without a colon inside a metadata block", () => {
|
|
3227
3298
|
const markdown = [
|
|
3228
3299
|
"<!-- test",
|
|
@@ -451,7 +451,20 @@ function serializeBlock(
|
|
|
451
451
|
}
|
|
452
452
|
|
|
453
453
|
lines.push(`<!-- ${kind}`);
|
|
454
|
-
fields.forEach((field) =>
|
|
454
|
+
fields.forEach((field) => {
|
|
455
|
+
// List-valued keys (e.g. `issues`) are written back as a YAML list to
|
|
456
|
+
// preserve the backend's format. The single panel field holds the items
|
|
457
|
+
// comma-separated, so split them back out into ` - item` lines.
|
|
458
|
+
if (LIST_META_KEYS.has(field.key.trim().toLowerCase())) {
|
|
459
|
+
const items = field.value.split(",").map((s) => s.trim()).filter(Boolean);
|
|
460
|
+
if (items.length) {
|
|
461
|
+
lines.push(`${field.key}:`);
|
|
462
|
+
items.forEach((item) => lines.push(` - ${item}`));
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
lines.push(`${field.key}: ${field.value}`);
|
|
467
|
+
});
|
|
455
468
|
lines.push("-->");
|
|
456
469
|
return lines;
|
|
457
470
|
}
|
|
@@ -1422,20 +1435,41 @@ function parseParagraph(lines: string[], index: number): { block: CustomPartialB
|
|
|
1422
1435
|
|
|
1423
1436
|
const META_COMMENT_OPEN_REGEX = /^<!--\s*(test|suite)(?=\s|-->|$)/i;
|
|
1424
1437
|
|
|
1438
|
+
// Keys whose value is a YAML-style list (`key:` followed by indented `- item`
|
|
1439
|
+
// lines) in the testomat.io comment format — e.g. `issues` holding URLs. These
|
|
1440
|
+
// round-trip back to a list on serialize; everything else stays a flat line.
|
|
1441
|
+
const LIST_META_KEYS = new Set(["issues"]);
|
|
1442
|
+
|
|
1425
1443
|
function metaFieldsFromBody(bodyLines: string[]): { key: string; value: string }[] {
|
|
1426
|
-
const fields: { key: string; value: string }[] = [];
|
|
1444
|
+
const fields: { key: string; value: string; items: string[] }[] = [];
|
|
1427
1445
|
for (const raw of bodyLines) {
|
|
1428
1446
|
const line = raw.trim();
|
|
1429
1447
|
if (!line) continue;
|
|
1448
|
+
|
|
1449
|
+
// YAML list item: belongs to the most recent `key:` field. The whole item
|
|
1450
|
+
// (e.g. `https://github.com/...`) is kept verbatim — because we catch the
|
|
1451
|
+
// list line before the colon check, URLs with `://` are never split.
|
|
1452
|
+
if (line === "-" || line.startsWith("- ")) {
|
|
1453
|
+
const item = line.slice(1).trim();
|
|
1454
|
+
const current = fields[fields.length - 1];
|
|
1455
|
+
if (current && item) current.items.push(item);
|
|
1456
|
+
continue;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1430
1459
|
const colon = line.indexOf(":");
|
|
1431
1460
|
// "Each line is `key: value`; lines without `:` are ignored."
|
|
1432
1461
|
if (colon === -1) continue;
|
|
1433
1462
|
const key = line.slice(0, colon).trim();
|
|
1434
1463
|
const value = line.slice(colon + 1).trim();
|
|
1435
1464
|
if (!key) continue;
|
|
1436
|
-
fields.push({ key, value });
|
|
1465
|
+
fields.push({ key, value, items: [] });
|
|
1437
1466
|
}
|
|
1438
|
-
|
|
1467
|
+
|
|
1468
|
+
// A field with collected list items → value is the items joined by ", ".
|
|
1469
|
+
return fields.map(({ key, value, items }) => ({
|
|
1470
|
+
key,
|
|
1471
|
+
value: items.length ? items.join(", ") : value,
|
|
1472
|
+
}));
|
|
1439
1473
|
}
|
|
1440
1474
|
|
|
1441
1475
|
function parseMetaComment(
|