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,267 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
applyMention,
|
|
4
|
+
buildMentionInsertText,
|
|
5
|
+
filterMentionItems,
|
|
6
|
+
getMentionSources,
|
|
7
|
+
normalizeMentionItems,
|
|
8
|
+
parseActiveMention,
|
|
9
|
+
parseMentionsFromJsonApi,
|
|
10
|
+
resolveMentionQuery,
|
|
11
|
+
resolveMentionSource,
|
|
12
|
+
setMentionSources,
|
|
13
|
+
type MentionItem,
|
|
14
|
+
type MentionSource,
|
|
15
|
+
} from "./mentionAutocomplete";
|
|
16
|
+
|
|
17
|
+
const users: MentionItem[] = [
|
|
18
|
+
{ id: "u1", label: "alice", detail: "alice@team.io" },
|
|
19
|
+
{ id: "u2", label: "bob", detail: "bob@team.io" },
|
|
20
|
+
{ id: "u3", label: "albert", detail: "albert@team.io" },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const sources: MentionSource[] = [
|
|
24
|
+
{ prefix: "T", label: "Tests", search: () => [] },
|
|
25
|
+
{ prefix: "S", label: "Suites", search: () => [] },
|
|
26
|
+
{ prefix: "", label: "Users", items: users, insert: (item) => `@${item.label}` },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
describe("resolveMentionSource", () => {
|
|
30
|
+
it("routes single-letter prefixes to their source", () => {
|
|
31
|
+
expect(resolveMentionSource("T123", sources)?.prefix).toBe("T");
|
|
32
|
+
expect(resolveMentionSource("S9", sources)?.prefix).toBe("S");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("falls back to the empty-prefix source", () => {
|
|
36
|
+
expect(resolveMentionSource("bob", sources)?.prefix).toBe("");
|
|
37
|
+
expect(resolveMentionSource("", sources)?.prefix).toBe("");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("prefers the longest matching prefix", () => {
|
|
41
|
+
const withLong: MentionSource[] = [
|
|
42
|
+
{ prefix: "T" },
|
|
43
|
+
{ prefix: "TC" },
|
|
44
|
+
{ prefix: "" },
|
|
45
|
+
];
|
|
46
|
+
expect(resolveMentionSource("TC5", withLong)?.prefix).toBe("TC");
|
|
47
|
+
expect(resolveMentionSource("T5", withLong)?.prefix).toBe("T");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("returns null when no source matches and there is no fallback", () => {
|
|
51
|
+
expect(resolveMentionSource("T5", [{ prefix: "S" }])).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("matches prefixes case-sensitively", () => {
|
|
55
|
+
// lowercase 't' is not the tests prefix, so it falls through to users
|
|
56
|
+
expect(resolveMentionSource("tom", sources)?.prefix).toBe("");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("parseActiveMention", () => {
|
|
61
|
+
it("detects a bare @ (empty query) against the fallback source", () => {
|
|
62
|
+
const text = "assigned to @";
|
|
63
|
+
const active = parseActiveMention(text, text.length, sources);
|
|
64
|
+
expect(active).toMatchObject({ prefix: "", query: "", start: 12, end: 13 });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("detects a prefixed mention and strips the prefix from the query", () => {
|
|
68
|
+
const text = "see @T123";
|
|
69
|
+
const active = parseActiveMention(text, text.length, sources);
|
|
70
|
+
expect(active).toMatchObject({ prefix: "T", query: "123", token: "T123", start: 4 });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("parses when the caret is mid-token, not at the end", () => {
|
|
74
|
+
const text = "see @T123 done";
|
|
75
|
+
// caret right after "@T12"
|
|
76
|
+
const active = parseActiveMention(text, 8, sources);
|
|
77
|
+
expect(active).toMatchObject({ prefix: "T", query: "12", end: 8 });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("triggers at the very start of the text", () => {
|
|
81
|
+
const active = parseActiveMention("@bob", 4, sources);
|
|
82
|
+
expect(active).toMatchObject({ prefix: "", query: "bob", start: 0 });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("does not trigger inside an email (@ not preceded by whitespace)", () => {
|
|
86
|
+
const text = "ping bob@team.io";
|
|
87
|
+
expect(parseActiveMention(text, text.length, sources)).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("does not trigger once whitespace follows the @", () => {
|
|
91
|
+
const text = "hello @ world";
|
|
92
|
+
expect(parseActiveMention(text, text.length, sources)).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("uses the @ nearest to the caret", () => {
|
|
96
|
+
const text = "@alice and @bob";
|
|
97
|
+
const active = parseActiveMention(text, text.length, sources);
|
|
98
|
+
expect(active).toMatchObject({ query: "bob", start: 11 });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("returns null with no sources", () => {
|
|
102
|
+
expect(parseActiveMention("@bob", 4, [])).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("returns null when there is no @ before the caret", () => {
|
|
106
|
+
expect(parseActiveMention("plain text", 5, sources)).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("buildMentionInsertText", () => {
|
|
111
|
+
it("defaults to @{prefix}{id}", () => {
|
|
112
|
+
expect(buildMentionInsertText({ prefix: "T" }, { id: "123456", label: "Login" })).toBe(
|
|
113
|
+
"@T123456",
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("honors a source insert() override", () => {
|
|
118
|
+
const source: MentionSource = { prefix: "", insert: (item) => `@${item.label}` };
|
|
119
|
+
expect(buildMentionInsertText(source, { id: "u2", label: "bob" })).toBe("@bob");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("honors a per-item insertText override", () => {
|
|
123
|
+
expect(
|
|
124
|
+
buildMentionInsertText({ prefix: "T" }, { id: "1", label: "x", insertText: "@custom" }),
|
|
125
|
+
).toBe("@custom");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("applyMention", () => {
|
|
130
|
+
it("replaces the token and appends a trailing space", () => {
|
|
131
|
+
const text = "see @T12 here";
|
|
132
|
+
const active = parseActiveMention(text, 8, sources)!;
|
|
133
|
+
const result = applyMention(text, active, "@T123456");
|
|
134
|
+
expect(result.text).toBe("see @T123456 here");
|
|
135
|
+
expect(result.caret).toBe("see @T123456 ".length);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("does not double up a space when one already follows", () => {
|
|
139
|
+
const text = "@bob ";
|
|
140
|
+
const active = { start: 0, end: 4 };
|
|
141
|
+
const result = applyMention(text, active, "@bob");
|
|
142
|
+
expect(result.text).toBe("@bob ");
|
|
143
|
+
expect(result.caret).toBe(5);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("inserts at the end of the text", () => {
|
|
147
|
+
const text = "assigned to @al";
|
|
148
|
+
const active = parseActiveMention(text, text.length, sources)!;
|
|
149
|
+
const result = applyMention(text, active, "@albert");
|
|
150
|
+
expect(result.text).toBe("assigned to @albert ");
|
|
151
|
+
expect(result.caret).toBe(result.text.length);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("filterMentionItems", () => {
|
|
156
|
+
it("returns the head of the list for an empty query", () => {
|
|
157
|
+
expect(filterMentionItems(users, "").map((u) => u.label)).toEqual([
|
|
158
|
+
"alice",
|
|
159
|
+
"bob",
|
|
160
|
+
"albert",
|
|
161
|
+
]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("ranks starts-with above contains", () => {
|
|
165
|
+
// "al" -> alice & albert (starts-with) come before nothing else
|
|
166
|
+
expect(filterMentionItems(users, "al").map((u) => u.label)).toEqual(["alice", "albert"]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("matches case-insensitively", () => {
|
|
170
|
+
expect(filterMentionItems(users, "BOB").map((u) => u.label)).toEqual(["bob"]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("falls back to detail matches", () => {
|
|
174
|
+
expect(filterMentionItems(users, "team.io").length).toBe(3);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("respects the limit", () => {
|
|
178
|
+
expect(filterMentionItems(users, "", 2)).toHaveLength(2);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("normalization", () => {
|
|
183
|
+
it("parses JSON:API resources with varied label attributes", () => {
|
|
184
|
+
const items = parseMentionsFromJsonApi({
|
|
185
|
+
data: [
|
|
186
|
+
{ id: 10, type: "test", attributes: { title: "Login redirects", description: "auth" } },
|
|
187
|
+
{ id: "u5", type: "user", attributes: { username: "carol", email: "carol@team.io" } },
|
|
188
|
+
],
|
|
189
|
+
});
|
|
190
|
+
expect(items).toEqual([
|
|
191
|
+
{ id: "10", label: "Login redirects", detail: "auth" },
|
|
192
|
+
{ id: "u5", label: "carol", detail: "carol@team.io" },
|
|
193
|
+
]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("drops resources without an id", () => {
|
|
197
|
+
expect(parseMentionsFromJsonApi({ data: [{ attributes: { title: "x" } }] })).toEqual([]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("passes through arrays already shaped as MentionItem[]", () => {
|
|
201
|
+
const raw: MentionItem[] = [{ id: "1", label: "one" }];
|
|
202
|
+
expect(normalizeMentionItems(raw)).toBe(raw);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("normalizes a JSON:API array and handles nullish input", () => {
|
|
206
|
+
expect(normalizeMentionItems([{ id: 1, attributes: { name: "n" } }])).toEqual([
|
|
207
|
+
{ id: "1", label: "n", detail: null },
|
|
208
|
+
]);
|
|
209
|
+
expect(normalizeMentionItems(null)).toEqual([]);
|
|
210
|
+
expect(normalizeMentionItems([])).toEqual([]);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("resolveMentionQuery (main-editor routing)", () => {
|
|
215
|
+
const asyncSources: MentionSource[] = [
|
|
216
|
+
{
|
|
217
|
+
prefix: "T",
|
|
218
|
+
label: "Tests",
|
|
219
|
+
minChars: 1,
|
|
220
|
+
search: async (q) => [{ id: `t-${q}`, label: `Test ${q}` }],
|
|
221
|
+
},
|
|
222
|
+
{ prefix: "", label: "Users", items: users, insert: (u) => `@${u.label}` },
|
|
223
|
+
];
|
|
224
|
+
|
|
225
|
+
it("routes a prefixed query to its async source and strips the prefix", async () => {
|
|
226
|
+
const result = await resolveMentionQuery("T123", asyncSources);
|
|
227
|
+
expect(result?.source.prefix).toBe("T");
|
|
228
|
+
expect(result?.items).toEqual([{ id: "t-123", label: "Test 123" }]);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("filters the static fallback source for a bare query", async () => {
|
|
232
|
+
const result = await resolveMentionQuery("al", asyncSources);
|
|
233
|
+
expect(result?.source.prefix).toBe("");
|
|
234
|
+
expect(result?.items.map((i) => i.label)).toEqual(["alice", "albert"]);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("honors minChars by returning the source with no items", async () => {
|
|
238
|
+
const result = await resolveMentionQuery("T", asyncSources);
|
|
239
|
+
expect(result?.source.prefix).toBe("T");
|
|
240
|
+
expect(result?.items).toEqual([]);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("resolves a static provider function once", async () => {
|
|
244
|
+
let calls = 0;
|
|
245
|
+
const provider = () => {
|
|
246
|
+
calls += 1;
|
|
247
|
+
return users;
|
|
248
|
+
};
|
|
249
|
+
const src: MentionSource[] = [{ prefix: "", items: provider }];
|
|
250
|
+
const result = await resolveMentionQuery("bob", src);
|
|
251
|
+
expect(result?.items.map((i) => i.label)).toEqual(["bob"]);
|
|
252
|
+
expect(calls).toBe(1);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("returns null when no source matches", async () => {
|
|
256
|
+
expect(await resolveMentionQuery("X1", [{ prefix: "T" }])).toBeNull();
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("global source registry", () => {
|
|
261
|
+
it("stores and clears sources", () => {
|
|
262
|
+
setMentionSources(sources);
|
|
263
|
+
expect(getMentionSources()).toHaveLength(3);
|
|
264
|
+
setMentionSources(null);
|
|
265
|
+
expect(getMentionSources()).toEqual([]);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
@@ -0,0 +1,348 @@
|
|
|
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
|
+
export type MentionItem = {
|
|
19
|
+
/** Stable identifier. For test/suite sources this is what gets inserted (`@T{id}`). */
|
|
20
|
+
id: string;
|
|
21
|
+
/** Primary text shown in the popup and used for client-side filtering. */
|
|
22
|
+
label: string;
|
|
23
|
+
/** Optional secondary text (e.g. `#123`, an email, a description). */
|
|
24
|
+
detail?: string | null;
|
|
25
|
+
/**
|
|
26
|
+
* Optional explicit text to insert for this specific item. When omitted the
|
|
27
|
+
* source's `insert` builder (or the default `@{prefix}{id}`) is used.
|
|
28
|
+
*/
|
|
29
|
+
insertText?: string;
|
|
30
|
+
/** Sources may carry arbitrary extra data through to their renderers. */
|
|
31
|
+
[key: string]: unknown;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Static item list, or a (possibly async) provider of one. */
|
|
35
|
+
export type MentionItemsInput =
|
|
36
|
+
| MentionItem[]
|
|
37
|
+
| (() => MentionItem[] | Promise<MentionItem[]>);
|
|
38
|
+
|
|
39
|
+
/** Result of an async `search`. May be raw items or a JSON:API document. */
|
|
40
|
+
export type MentionSearchResult =
|
|
41
|
+
| MentionItem[]
|
|
42
|
+
| MentionJsonApiDocument
|
|
43
|
+
| MentionJsonApiResource[]
|
|
44
|
+
| null
|
|
45
|
+
| undefined;
|
|
46
|
+
|
|
47
|
+
export type MentionSource = {
|
|
48
|
+
/**
|
|
49
|
+
* Characters that must appear immediately after `@` to select this source.
|
|
50
|
+
* Use `""` for the default/fallback source (e.g. usernames on a bare `@`).
|
|
51
|
+
* Matching is case-sensitive and longest-prefix wins, so `@T` routes to the
|
|
52
|
+
* `"T"` source rather than the `""` source.
|
|
53
|
+
*/
|
|
54
|
+
prefix: string;
|
|
55
|
+
/** Human label for the popup header / grouping (e.g. "Tests", "Users"). */
|
|
56
|
+
label?: string;
|
|
57
|
+
/**
|
|
58
|
+
* Static list (or provider). Filtered client-side by the typed query. Use
|
|
59
|
+
* this for data already in memory, like the list of project members.
|
|
60
|
+
*/
|
|
61
|
+
items?: MentionItemsInput;
|
|
62
|
+
/**
|
|
63
|
+
* Async fetcher invoked with the typed query (the text after the prefix).
|
|
64
|
+
* Use this for server-backed lookups like tests (`@T`) and suites (`@S`).
|
|
65
|
+
*/
|
|
66
|
+
search?: (query: string) => Promise<MentionSearchResult> | MentionSearchResult;
|
|
67
|
+
/**
|
|
68
|
+
* Builds the text inserted when an item is chosen. Defaults to
|
|
69
|
+
* `@{prefix}{id}` (so a test with id `123456` becomes `@T123456`).
|
|
70
|
+
*/
|
|
71
|
+
insert?: (item: MentionItem, source: MentionSource) => string;
|
|
72
|
+
/** Minimum query length before `search` runs. Default 0 (fires on empty query). */
|
|
73
|
+
minChars?: number;
|
|
74
|
+
/** Max items shown. Default 8. */
|
|
75
|
+
limit?: number;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** A currently-active mention detected at the caret. */
|
|
79
|
+
export type ActiveMention = {
|
|
80
|
+
source: MentionSource;
|
|
81
|
+
/** Matched prefix (e.g. "T", "S", or ""). */
|
|
82
|
+
prefix: string;
|
|
83
|
+
/** Text typed after the prefix (what `search`/filter receives). */
|
|
84
|
+
query: string;
|
|
85
|
+
/** Index of the `@` in the source text. */
|
|
86
|
+
start: number;
|
|
87
|
+
/** Caret index (exclusive end of the token being edited). */
|
|
88
|
+
end: number;
|
|
89
|
+
/** Full token text between `@` (exclusive) and the caret, i.e. `prefix + query`. */
|
|
90
|
+
token: string;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/* ------------------------------------------------------------------ *
|
|
94
|
+
* JSON:API shapes (mirrors stepAutocomplete / snippetAutocomplete)
|
|
95
|
+
* ------------------------------------------------------------------ */
|
|
96
|
+
|
|
97
|
+
export type MentionJsonApiAttributes = {
|
|
98
|
+
title?: string | null;
|
|
99
|
+
name?: string | null;
|
|
100
|
+
label?: string | null;
|
|
101
|
+
username?: string | null;
|
|
102
|
+
email?: string | null;
|
|
103
|
+
description?: string | null;
|
|
104
|
+
detail?: string | null;
|
|
105
|
+
[key: string]: unknown;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export type MentionJsonApiResource = {
|
|
109
|
+
id?: string | number | null;
|
|
110
|
+
type?: string | null;
|
|
111
|
+
attributes?: MentionJsonApiAttributes | null;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export type MentionJsonApiDocument = {
|
|
115
|
+
data?: MentionJsonApiResource[] | null;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/* ------------------------------------------------------------------ *
|
|
119
|
+
* Global source registry (dependency-injection seam, mirrors the rest
|
|
120
|
+
* of the library's `setXFetcher` pattern).
|
|
121
|
+
* ------------------------------------------------------------------ */
|
|
122
|
+
|
|
123
|
+
let globalSources: MentionSource[] = [];
|
|
124
|
+
|
|
125
|
+
/** Register the mention sources used when a consumer doesn't pass `sources` explicitly. */
|
|
126
|
+
export function setMentionSources(sources: MentionSource[] | null): void {
|
|
127
|
+
globalSources = Array.isArray(sources) ? sources : [];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Read the globally-registered mention sources. */
|
|
131
|
+
export function getMentionSources(): MentionSource[] {
|
|
132
|
+
return globalSources;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/* ------------------------------------------------------------------ *
|
|
136
|
+
* Pure logic
|
|
137
|
+
* ------------------------------------------------------------------ */
|
|
138
|
+
|
|
139
|
+
const WHITESPACE = /\s/;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Pick the source whose `prefix` matches the start of `token`. Longest prefix
|
|
143
|
+
* wins; the empty-prefix source is the ultimate fallback. Returns `null` when
|
|
144
|
+
* nothing matches (e.g. `@T` typed but only a `""` source is registered and it
|
|
145
|
+
* is absent).
|
|
146
|
+
*/
|
|
147
|
+
export function resolveMentionSource(
|
|
148
|
+
token: string,
|
|
149
|
+
sources: MentionSource[],
|
|
150
|
+
): MentionSource | null {
|
|
151
|
+
let best: MentionSource | null = null;
|
|
152
|
+
for (const source of sources) {
|
|
153
|
+
const prefix = source.prefix ?? "";
|
|
154
|
+
if (!token.startsWith(prefix)) continue;
|
|
155
|
+
if (best === null || prefix.length > (best.prefix ?? "").length) {
|
|
156
|
+
best = source;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return best;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Detect the mention being edited at `caret` within `text`.
|
|
164
|
+
*
|
|
165
|
+
* A mention is the run of non-whitespace characters after an `@` that sits at
|
|
166
|
+
* the start of the text or immediately after whitespace (so emails like
|
|
167
|
+
* `a@b.com` never trigger). Returns `null` when the caret is not inside such a
|
|
168
|
+
* token or no source matches.
|
|
169
|
+
*/
|
|
170
|
+
export function parseActiveMention(
|
|
171
|
+
text: string,
|
|
172
|
+
caret: number,
|
|
173
|
+
sources: MentionSource[],
|
|
174
|
+
): ActiveMention | null {
|
|
175
|
+
if (!sources.length) return null;
|
|
176
|
+
const pos = Math.max(0, Math.min(caret, text.length));
|
|
177
|
+
const before = text.slice(0, pos);
|
|
178
|
+
|
|
179
|
+
const at = before.lastIndexOf("@");
|
|
180
|
+
if (at === -1) return null;
|
|
181
|
+
|
|
182
|
+
// The `@` must start a word: preceded by nothing or whitespace.
|
|
183
|
+
const charBefore = at === 0 ? "" : text[at - 1];
|
|
184
|
+
if (charBefore !== "" && !WHITESPACE.test(charBefore)) return null;
|
|
185
|
+
|
|
186
|
+
// The token is everything from just after `@` up to the caret. Whitespace
|
|
187
|
+
// ends a mention, so a caret past a space means we're no longer inside one.
|
|
188
|
+
const token = before.slice(at + 1);
|
|
189
|
+
if (WHITESPACE.test(token)) return null;
|
|
190
|
+
|
|
191
|
+
const source = resolveMentionSource(token, sources);
|
|
192
|
+
if (!source) return null;
|
|
193
|
+
|
|
194
|
+
const prefix = source.prefix ?? "";
|
|
195
|
+
return {
|
|
196
|
+
source,
|
|
197
|
+
prefix,
|
|
198
|
+
query: token.slice(prefix.length),
|
|
199
|
+
start: at,
|
|
200
|
+
end: pos,
|
|
201
|
+
token,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The text a chosen item inserts, honoring item/source overrides. */
|
|
206
|
+
export function buildMentionInsertText(source: MentionSource, item: MentionItem): string {
|
|
207
|
+
if (typeof item.insertText === "string") return item.insertText;
|
|
208
|
+
if (typeof source.insert === "function") return source.insert(item, source);
|
|
209
|
+
return `@${source.prefix ?? ""}${item.id}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export type MentionApplication = {
|
|
213
|
+
/** The full text after replacing the active token with the inserted mention. */
|
|
214
|
+
text: string;
|
|
215
|
+
/** Caret position after insertion (after the token and its trailing space). */
|
|
216
|
+
caret: number;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Replace the active mention token in `text` with `insertText`, adding a single
|
|
221
|
+
* trailing space so the caret lands ready for the next word and the freshly
|
|
222
|
+
* inserted `@…` token does not immediately re-trigger the popup.
|
|
223
|
+
*/
|
|
224
|
+
export function applyMention(
|
|
225
|
+
text: string,
|
|
226
|
+
active: Pick<ActiveMention, "start" | "end">,
|
|
227
|
+
insertText: string,
|
|
228
|
+
): MentionApplication {
|
|
229
|
+
const before = text.slice(0, active.start);
|
|
230
|
+
const after = text.slice(active.end);
|
|
231
|
+
const hasLeadingSpace = after.length > 0 && WHITESPACE.test(after[0]);
|
|
232
|
+
const separator = hasLeadingSpace ? "" : " ";
|
|
233
|
+
return {
|
|
234
|
+
text: `${before}${insertText}${separator}${after}`,
|
|
235
|
+
// Land just past the single following space (added or pre-existing) so the
|
|
236
|
+
// caret is ready for the next word.
|
|
237
|
+
caret: before.length + insertText.length + 1,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Rank static items against a query: label starts-with beats label contains,
|
|
243
|
+
* which beats id matches. Case-insensitive. An empty query returns the head of
|
|
244
|
+
* the list unchanged.
|
|
245
|
+
*/
|
|
246
|
+
export function filterMentionItems(
|
|
247
|
+
items: MentionItem[],
|
|
248
|
+
query: string,
|
|
249
|
+
limit = 8,
|
|
250
|
+
): MentionItem[] {
|
|
251
|
+
const q = query.trim().toLowerCase();
|
|
252
|
+
if (!q) return items.slice(0, limit);
|
|
253
|
+
|
|
254
|
+
const scored: Array<{ item: MentionItem; score: number; order: number }> = [];
|
|
255
|
+
items.forEach((item, order) => {
|
|
256
|
+
const label = (item.label ?? "").toLowerCase();
|
|
257
|
+
const id = String(item.id ?? "").toLowerCase();
|
|
258
|
+
const detail = (item.detail ?? "").toString().toLowerCase();
|
|
259
|
+
let score = -1;
|
|
260
|
+
if (label.startsWith(q)) score = 0;
|
|
261
|
+
else if (label.includes(q)) score = 1;
|
|
262
|
+
else if (id.startsWith(q) || id.includes(q)) score = 2;
|
|
263
|
+
else if (detail.includes(q)) score = 3;
|
|
264
|
+
if (score >= 0) scored.push({ item, score, order });
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
scored.sort((a, b) => (a.score - b.score) || (a.order - b.order));
|
|
268
|
+
return scored.slice(0, limit).map((s) => s.item);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Route a typed query (everything after the trigger char, e.g. `T123` or `bob`)
|
|
273
|
+
* to its source and resolve the items to show. Handles prefix stripping,
|
|
274
|
+
* `minChars`, async `search`, static `items` (incl. provider functions),
|
|
275
|
+
* normalization, and the `limit`. Returns `null` when no source matches.
|
|
276
|
+
*
|
|
277
|
+
* Shared by both mention frontends (the textarea hook and the BlockNote menu).
|
|
278
|
+
*/
|
|
279
|
+
export async function resolveMentionQuery(
|
|
280
|
+
query: string,
|
|
281
|
+
sources: MentionSource[],
|
|
282
|
+
): Promise<{ source: MentionSource; items: MentionItem[] } | null> {
|
|
283
|
+
const source = resolveMentionSource(query, sources);
|
|
284
|
+
if (!source) return null;
|
|
285
|
+
|
|
286
|
+
const prefix = source.prefix ?? "";
|
|
287
|
+
const sub = query.slice(prefix.length);
|
|
288
|
+
const limit = source.limit ?? 8;
|
|
289
|
+
|
|
290
|
+
if (typeof source.search === "function") {
|
|
291
|
+
const minChars = source.minChars ?? 0;
|
|
292
|
+
if (sub.length < minChars) return { source, items: [] };
|
|
293
|
+
const items = normalizeMentionItems(await source.search(sub)).slice(0, limit);
|
|
294
|
+
return { source, items };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const input = source.items;
|
|
298
|
+
const raw = typeof input === "function" ? await input() : input ?? [];
|
|
299
|
+
const items = filterMentionItems(normalizeMentionItems(raw), sub, limit);
|
|
300
|
+
return { source, items };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/* ------------------------------------------------------------------ *
|
|
304
|
+
* Normalization of async / JSON:API results into MentionItem[]
|
|
305
|
+
* ------------------------------------------------------------------ */
|
|
306
|
+
|
|
307
|
+
export function parseMentionsFromJsonApi(
|
|
308
|
+
document: MentionJsonApiDocument | MentionJsonApiResource[] | null | undefined,
|
|
309
|
+
): MentionItem[] {
|
|
310
|
+
const resources = Array.isArray(document) ? document : document?.data;
|
|
311
|
+
if (!Array.isArray(resources) || resources.length === 0) return [];
|
|
312
|
+
return resources
|
|
313
|
+
.map((resource) => normalizeJsonApiResource(resource))
|
|
314
|
+
.filter((value): value is MentionItem => Boolean(value));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Coerce any supported `search`/`items` result into a clean `MentionItem[]`. */
|
|
318
|
+
export function normalizeMentionItems(result: MentionSearchResult): MentionItem[] {
|
|
319
|
+
if (!result) return [];
|
|
320
|
+
if (Array.isArray(result)) {
|
|
321
|
+
if (result.length === 0) return [];
|
|
322
|
+
if (isMentionItemArray(result)) return result as MentionItem[];
|
|
323
|
+
return parseMentionsFromJsonApi(result as MentionJsonApiResource[]);
|
|
324
|
+
}
|
|
325
|
+
return parseMentionsFromJsonApi(result);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function normalizeJsonApiResource(
|
|
329
|
+
resource: MentionJsonApiResource | null | undefined,
|
|
330
|
+
): MentionItem | null {
|
|
331
|
+
if (!resource) return null;
|
|
332
|
+
const attrs = resource.attributes ?? {};
|
|
333
|
+
const id = resource.id;
|
|
334
|
+
if (id === null || id === undefined || id === "") return null;
|
|
335
|
+
|
|
336
|
+
const label =
|
|
337
|
+
attrs.title ?? attrs.name ?? attrs.label ?? attrs.username ?? String(id);
|
|
338
|
+
return {
|
|
339
|
+
id: String(id),
|
|
340
|
+
label: String(label),
|
|
341
|
+
detail: attrs.detail ?? attrs.description ?? attrs.email ?? null,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function isMentionItemArray(value: unknown[]): boolean {
|
|
346
|
+
const first = value[0] as Partial<MentionItem> | undefined;
|
|
347
|
+
return Boolean(first) && typeof first?.label === "string";
|
|
348
|
+
}
|
package/src/editor/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%;
|