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.
@@ -0,0 +1,218 @@
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
+ /* ------------------------------------------------------------------ *
18
+ * Global source registry (dependency-injection seam, mirrors the rest
19
+ * of the library's `setXFetcher` pattern).
20
+ * ------------------------------------------------------------------ */
21
+ let globalSources = [];
22
+ /** Register the mention sources used when a consumer doesn't pass `sources` explicitly. */
23
+ export function setMentionSources(sources) {
24
+ globalSources = Array.isArray(sources) ? sources : [];
25
+ }
26
+ /** Read the globally-registered mention sources. */
27
+ export function getMentionSources() {
28
+ return globalSources;
29
+ }
30
+ /* ------------------------------------------------------------------ *
31
+ * Pure logic
32
+ * ------------------------------------------------------------------ */
33
+ const WHITESPACE = /\s/;
34
+ /**
35
+ * Pick the source whose `prefix` matches the start of `token`. Longest prefix
36
+ * wins; the empty-prefix source is the ultimate fallback. Returns `null` when
37
+ * nothing matches (e.g. `@T` typed but only a `""` source is registered and it
38
+ * is absent).
39
+ */
40
+ export function resolveMentionSource(token, sources) {
41
+ var _a, _b;
42
+ let best = null;
43
+ for (const source of sources) {
44
+ const prefix = (_a = source.prefix) !== null && _a !== void 0 ? _a : "";
45
+ if (!token.startsWith(prefix))
46
+ continue;
47
+ if (best === null || prefix.length > ((_b = best.prefix) !== null && _b !== void 0 ? _b : "").length) {
48
+ best = source;
49
+ }
50
+ }
51
+ return best;
52
+ }
53
+ /**
54
+ * Detect the mention being edited at `caret` within `text`.
55
+ *
56
+ * A mention is the run of non-whitespace characters after an `@` that sits at
57
+ * the start of the text or immediately after whitespace (so emails like
58
+ * `a@b.com` never trigger). Returns `null` when the caret is not inside such a
59
+ * token or no source matches.
60
+ */
61
+ export function parseActiveMention(text, caret, sources) {
62
+ var _a;
63
+ if (!sources.length)
64
+ return null;
65
+ const pos = Math.max(0, Math.min(caret, text.length));
66
+ const before = text.slice(0, pos);
67
+ const at = before.lastIndexOf("@");
68
+ if (at === -1)
69
+ return null;
70
+ // The `@` must start a word: preceded by nothing or whitespace.
71
+ const charBefore = at === 0 ? "" : text[at - 1];
72
+ if (charBefore !== "" && !WHITESPACE.test(charBefore))
73
+ return null;
74
+ // The token is everything from just after `@` up to the caret. Whitespace
75
+ // ends a mention, so a caret past a space means we're no longer inside one.
76
+ const token = before.slice(at + 1);
77
+ if (WHITESPACE.test(token))
78
+ return null;
79
+ const source = resolveMentionSource(token, sources);
80
+ if (!source)
81
+ return null;
82
+ const prefix = (_a = source.prefix) !== null && _a !== void 0 ? _a : "";
83
+ return {
84
+ source,
85
+ prefix,
86
+ query: token.slice(prefix.length),
87
+ start: at,
88
+ end: pos,
89
+ token,
90
+ };
91
+ }
92
+ /** The text a chosen item inserts, honoring item/source overrides. */
93
+ export function buildMentionInsertText(source, item) {
94
+ var _a;
95
+ if (typeof item.insertText === "string")
96
+ return item.insertText;
97
+ if (typeof source.insert === "function")
98
+ return source.insert(item, source);
99
+ return `@${(_a = source.prefix) !== null && _a !== void 0 ? _a : ""}${item.id}`;
100
+ }
101
+ /**
102
+ * Replace the active mention token in `text` with `insertText`, adding a single
103
+ * trailing space so the caret lands ready for the next word and the freshly
104
+ * inserted `@…` token does not immediately re-trigger the popup.
105
+ */
106
+ export function applyMention(text, active, insertText) {
107
+ const before = text.slice(0, active.start);
108
+ const after = text.slice(active.end);
109
+ const hasLeadingSpace = after.length > 0 && WHITESPACE.test(after[0]);
110
+ const separator = hasLeadingSpace ? "" : " ";
111
+ return {
112
+ text: `${before}${insertText}${separator}${after}`,
113
+ // Land just past the single following space (added or pre-existing) so the
114
+ // caret is ready for the next word.
115
+ caret: before.length + insertText.length + 1,
116
+ };
117
+ }
118
+ /**
119
+ * Rank static items against a query: label starts-with beats label contains,
120
+ * which beats id matches. Case-insensitive. An empty query returns the head of
121
+ * the list unchanged.
122
+ */
123
+ export function filterMentionItems(items, query, limit = 8) {
124
+ const q = query.trim().toLowerCase();
125
+ if (!q)
126
+ return items.slice(0, limit);
127
+ const scored = [];
128
+ items.forEach((item, order) => {
129
+ var _a, _b, _c;
130
+ const label = ((_a = item.label) !== null && _a !== void 0 ? _a : "").toLowerCase();
131
+ const id = String((_b = item.id) !== null && _b !== void 0 ? _b : "").toLowerCase();
132
+ const detail = ((_c = item.detail) !== null && _c !== void 0 ? _c : "").toString().toLowerCase();
133
+ let score = -1;
134
+ if (label.startsWith(q))
135
+ score = 0;
136
+ else if (label.includes(q))
137
+ score = 1;
138
+ else if (id.startsWith(q) || id.includes(q))
139
+ score = 2;
140
+ else if (detail.includes(q))
141
+ score = 3;
142
+ if (score >= 0)
143
+ scored.push({ item, score, order });
144
+ });
145
+ scored.sort((a, b) => (a.score - b.score) || (a.order - b.order));
146
+ return scored.slice(0, limit).map((s) => s.item);
147
+ }
148
+ /**
149
+ * Route a typed query (everything after the trigger char, e.g. `T123` or `bob`)
150
+ * to its source and resolve the items to show. Handles prefix stripping,
151
+ * `minChars`, async `search`, static `items` (incl. provider functions),
152
+ * normalization, and the `limit`. Returns `null` when no source matches.
153
+ *
154
+ * Shared by both mention frontends (the textarea hook and the BlockNote menu).
155
+ */
156
+ export async function resolveMentionQuery(query, sources) {
157
+ var _a, _b, _c;
158
+ const source = resolveMentionSource(query, sources);
159
+ if (!source)
160
+ return null;
161
+ const prefix = (_a = source.prefix) !== null && _a !== void 0 ? _a : "";
162
+ const sub = query.slice(prefix.length);
163
+ const limit = (_b = source.limit) !== null && _b !== void 0 ? _b : 8;
164
+ if (typeof source.search === "function") {
165
+ const minChars = (_c = source.minChars) !== null && _c !== void 0 ? _c : 0;
166
+ if (sub.length < minChars)
167
+ return { source, items: [] };
168
+ const items = normalizeMentionItems(await source.search(sub)).slice(0, limit);
169
+ return { source, items };
170
+ }
171
+ const input = source.items;
172
+ const raw = typeof input === "function" ? await input() : input !== null && input !== void 0 ? input : [];
173
+ const items = filterMentionItems(normalizeMentionItems(raw), sub, limit);
174
+ return { source, items };
175
+ }
176
+ /* ------------------------------------------------------------------ *
177
+ * Normalization of async / JSON:API results into MentionItem[]
178
+ * ------------------------------------------------------------------ */
179
+ export function parseMentionsFromJsonApi(document) {
180
+ const resources = Array.isArray(document) ? document : document === null || document === void 0 ? void 0 : document.data;
181
+ if (!Array.isArray(resources) || resources.length === 0)
182
+ return [];
183
+ return resources
184
+ .map((resource) => normalizeJsonApiResource(resource))
185
+ .filter((value) => Boolean(value));
186
+ }
187
+ /** Coerce any supported `search`/`items` result into a clean `MentionItem[]`. */
188
+ export function normalizeMentionItems(result) {
189
+ if (!result)
190
+ return [];
191
+ if (Array.isArray(result)) {
192
+ if (result.length === 0)
193
+ return [];
194
+ if (isMentionItemArray(result))
195
+ return result;
196
+ return parseMentionsFromJsonApi(result);
197
+ }
198
+ return parseMentionsFromJsonApi(result);
199
+ }
200
+ function normalizeJsonApiResource(resource) {
201
+ var _a, _b, _c, _d, _e, _f, _g, _h;
202
+ if (!resource)
203
+ return null;
204
+ const attrs = (_a = resource.attributes) !== null && _a !== void 0 ? _a : {};
205
+ const id = resource.id;
206
+ if (id === null || id === undefined || id === "")
207
+ return null;
208
+ const label = (_e = (_d = (_c = (_b = attrs.title) !== null && _b !== void 0 ? _b : attrs.name) !== null && _c !== void 0 ? _c : attrs.label) !== null && _d !== void 0 ? _d : attrs.username) !== null && _e !== void 0 ? _e : String(id);
209
+ return {
210
+ id: String(id),
211
+ label: String(label),
212
+ detail: (_h = (_g = (_f = attrs.detail) !== null && _f !== void 0 ? _f : attrs.description) !== null && _g !== void 0 ? _g : attrs.email) !== null && _h !== void 0 ? _h : null,
213
+ };
214
+ }
215
+ function isMentionItemArray(value) {
216
+ const first = value[0];
217
+ return Boolean(first) && typeof (first === null || first === void 0 ? void 0 : first.label) === "string";
218
+ }
@@ -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/MentionAutocomplete";
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/MentionAutocomplete";
11
14
  export { setFileDisplayUrlResolver, resolveFileDisplayUrl, } from "./editor/fileDisplayUrl";
12
15
  export { createMarkdownPasteHandler } from "./editor/createMarkdownPasteHandler";
13
16
  export const testomatioEditorClassName = "markdown testomatio-editor";
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testomatio-editor-blocks",
3
- "version": "0.4.77",
3
+ "version": "0.4.78",
4
4
  "description": "Custom BlockNote schema, markdown conversion helpers, and UI for Testomatio-style test cases and steps.",
5
5
  "type": "module",
6
6
  "main": "./package/index.js",
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">