rich-input 1.2.2 → 1.4.0
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/README.md +127 -98
- package/assets/rich-input-parts.svg +92 -61
- package/components/rich-input.js +614 -54
- package/index.js +7 -1
- package/package.json +5 -2
- package/utils/highlights.js +75 -0
- package/utils/query-parser.js +379 -52
package/utils/query-parser.js
CHANGED
|
@@ -3,14 +3,187 @@
|
|
|
3
3
|
* Handles keyword:value tokenization, caret context inspection, and suggestion application.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
export const DEFAULT_OPERATORS = ['-'];
|
|
7
|
+
export const DEFAULT_COMBINATORS = [];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Normalizes an operators configuration (string or array) into an array of non-empty operator strings.
|
|
11
|
+
* @param {string|string[]|null|undefined} operators
|
|
12
|
+
* @returns {string[]}
|
|
13
|
+
*/
|
|
14
|
+
export function normalizeOperators(operators) {
|
|
15
|
+
if (operators === undefined) {
|
|
16
|
+
return [...DEFAULT_OPERATORS];
|
|
17
|
+
}
|
|
18
|
+
if (operators === null || operators === '') {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
const rawList = Array.isArray(operators)
|
|
22
|
+
? operators
|
|
23
|
+
: typeof operators === 'string'
|
|
24
|
+
? operators.split(/\s+/)
|
|
25
|
+
: [];
|
|
26
|
+
|
|
27
|
+
const result = [];
|
|
28
|
+
const seen = new Set();
|
|
29
|
+
for (const item of rawList) {
|
|
30
|
+
if (typeof item !== 'string') continue;
|
|
31
|
+
const parts = item.trim().split(/\s+/).filter(Boolean);
|
|
32
|
+
for (let part of parts) {
|
|
33
|
+
part = part.replace(/^(.)\1+$/, '$1');
|
|
34
|
+
if (!seen.has(part)) {
|
|
35
|
+
seen.add(part);
|
|
36
|
+
result.push(part);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Normalizes a combinators configuration (string or array) into an array of non-empty combinator strings.
|
|
45
|
+
* @param {string|string[]|null|undefined} combinators
|
|
46
|
+
* @returns {string[]}
|
|
47
|
+
*/
|
|
48
|
+
export function normalizeCombinators(combinators) {
|
|
49
|
+
if (combinators === undefined) {
|
|
50
|
+
return [...DEFAULT_COMBINATORS];
|
|
51
|
+
}
|
|
52
|
+
if (combinators === null || combinators === '') {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
const rawList = Array.isArray(combinators)
|
|
56
|
+
? combinators
|
|
57
|
+
: typeof combinators === 'string'
|
|
58
|
+
? combinators.split(/\s+/)
|
|
59
|
+
: [];
|
|
60
|
+
|
|
61
|
+
const result = [];
|
|
62
|
+
const seen = new Set();
|
|
63
|
+
for (const item of rawList) {
|
|
64
|
+
if (typeof item !== 'string') continue;
|
|
65
|
+
const parts = item.trim().split(/\s+/).filter(Boolean);
|
|
66
|
+
for (const part of parts) {
|
|
67
|
+
if (!seen.has(part)) {
|
|
68
|
+
seen.add(part);
|
|
69
|
+
result.push(part);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Matches if a string starts with any of the configured operators (longest match first).
|
|
78
|
+
* @param {string} str
|
|
79
|
+
* @param {string[]} normalizedOperators
|
|
80
|
+
* @returns {string|null}
|
|
81
|
+
*/
|
|
82
|
+
function matchOperator(str, normalizedOperators) {
|
|
83
|
+
if (!str || !normalizedOperators || normalizedOperators.length === 0) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
const sorted = [...normalizedOperators].sort((a, b) => b.length - a.length);
|
|
87
|
+
for (const op of sorted) {
|
|
88
|
+
if (str.startsWith(op)) {
|
|
89
|
+
return op;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const DEFAULT_DELIMITERS = ['()'];
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Normalizes delimiters input into a deduplicated array of delimiter pair strings (e.g. ['()', '{}', '[]']).
|
|
99
|
+
* @param {string|string[]|null|undefined} delimiters
|
|
100
|
+
* @returns {string[]}
|
|
101
|
+
*/
|
|
102
|
+
export function normalizeDelimiters(delimiters) {
|
|
103
|
+
if (delimiters === undefined) {
|
|
104
|
+
return [...DEFAULT_DELIMITERS];
|
|
105
|
+
}
|
|
106
|
+
if (delimiters === null || delimiters === '') {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
const rawList = Array.isArray(delimiters)
|
|
110
|
+
? delimiters
|
|
111
|
+
: typeof delimiters === 'string'
|
|
112
|
+
? delimiters.split(/\s+/)
|
|
113
|
+
: [];
|
|
114
|
+
|
|
115
|
+
const flatTokens = [];
|
|
116
|
+
for (const item of rawList) {
|
|
117
|
+
if (typeof item !== 'string') continue;
|
|
118
|
+
const parts = item.trim().split(/\s+/).filter(Boolean);
|
|
119
|
+
flatTokens.push(...parts);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const result = [];
|
|
123
|
+
const seen = new Set();
|
|
124
|
+
|
|
125
|
+
for (let i = 0; i < flatTokens.length; i++) {
|
|
126
|
+
let token = flatTokens[i];
|
|
127
|
+
if (token.length === 1 && i + 1 < flatTokens.length && flatTokens[i + 1].length === 1) {
|
|
128
|
+
token = token + flatTokens[i + 1];
|
|
129
|
+
i++;
|
|
130
|
+
} else if (token.length > 2) {
|
|
131
|
+
token = token.replace(/(.)\1+/g, '$1');
|
|
132
|
+
}
|
|
133
|
+
if (token.length >= 2 && !seen.has(token)) {
|
|
134
|
+
seen.add(token);
|
|
135
|
+
result.push(token);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function buildDelimiterEntries(normalizedDelims) {
|
|
143
|
+
const entries = [];
|
|
144
|
+
if (!normalizedDelims || normalizedDelims.length === 0) return entries;
|
|
145
|
+
for (const pair of normalizedDelims) {
|
|
146
|
+
if (typeof pair !== 'string' || pair.length < 2) continue;
|
|
147
|
+
const open = pair[0];
|
|
148
|
+
const close = pair[pair.length - 1];
|
|
149
|
+
entries.push({ delimiter: open, role: 'open', pair });
|
|
150
|
+
if (close !== open) {
|
|
151
|
+
entries.push({ delimiter: close, role: 'close', pair });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return entries;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function matchDelimiter(str, delimEntries) {
|
|
158
|
+
if (!str || !delimEntries || delimEntries.length === 0) return null;
|
|
159
|
+
for (const entry of delimEntries) {
|
|
160
|
+
if (str.startsWith(entry.delimiter)) {
|
|
161
|
+
return entry;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
6
167
|
/**
|
|
7
168
|
* Parses raw search input into token objects.
|
|
8
169
|
* @param {string} inputStr
|
|
170
|
+
* @param {string|string[]} [operators=DEFAULT_OPERATORS]
|
|
171
|
+
* @param {string|string[]} [combinators=DEFAULT_COMBINATORS]
|
|
172
|
+
* @param {string|string[]} [delimiters=DEFAULT_DELIMITERS]
|
|
9
173
|
* @returns {Array<Object>}
|
|
10
174
|
*/
|
|
11
|
-
export function parseSearchTokens(
|
|
175
|
+
export function parseSearchTokens(
|
|
176
|
+
inputStr,
|
|
177
|
+
operators = DEFAULT_OPERATORS,
|
|
178
|
+
combinators = DEFAULT_COMBINATORS,
|
|
179
|
+
delimiters = DEFAULT_DELIMITERS
|
|
180
|
+
) {
|
|
12
181
|
if (typeof inputStr !== 'string') return [];
|
|
13
182
|
|
|
183
|
+
const normalizedOps = normalizeOperators(operators);
|
|
184
|
+
const normalizedCombs = normalizeCombinators(combinators);
|
|
185
|
+
const normalizedDelims = normalizeDelimiters(delimiters);
|
|
186
|
+
const delimEntries = buildDelimiterEntries(normalizedDelims);
|
|
14
187
|
const tokens = [];
|
|
15
188
|
let i = 0;
|
|
16
189
|
const len = inputStr.length;
|
|
@@ -31,17 +204,49 @@ export function parseSearchTokens(inputStr) {
|
|
|
31
204
|
continue;
|
|
32
205
|
}
|
|
33
206
|
|
|
207
|
+
// 1b. Delimiter
|
|
208
|
+
const matchedDelim = matchDelimiter(inputStr.slice(i), delimEntries);
|
|
209
|
+
if (matchedDelim) {
|
|
210
|
+
tokens.push({
|
|
211
|
+
type: 'delimiter',
|
|
212
|
+
delimiter: matchedDelim.delimiter,
|
|
213
|
+
role: matchedDelim.role,
|
|
214
|
+
pair: matchedDelim.pair,
|
|
215
|
+
raw: matchedDelim.delimiter,
|
|
216
|
+
start: i,
|
|
217
|
+
end: i + matchedDelim.delimiter.length,
|
|
218
|
+
});
|
|
219
|
+
i += matchedDelim.delimiter.length;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
|
|
34
223
|
const tokenStart = i;
|
|
35
224
|
|
|
36
|
-
// 2. Check for keyword pattern: [a-zA-Z0-9_-]
|
|
225
|
+
// 2. Check for optional operator followed by keyword pattern: [a-zA-Z0-9][a-zA-Z0-9_-]*:
|
|
37
226
|
const remaining = inputStr.slice(i);
|
|
38
|
-
const
|
|
227
|
+
const matchedOp = matchOperator(remaining, normalizedOps);
|
|
228
|
+
let colonMatch = null;
|
|
229
|
+
let opLen = 0;
|
|
230
|
+
|
|
231
|
+
if (matchedOp) {
|
|
232
|
+
const afterOp = remaining.slice(matchedOp.length);
|
|
233
|
+
const afterOpMatch = afterOp.match(/^([a-zA-Z0-9][a-zA-Z0-9_-]*):/);
|
|
234
|
+
if (afterOpMatch) {
|
|
235
|
+
colonMatch = afterOpMatch;
|
|
236
|
+
opLen = matchedOp.length;
|
|
237
|
+
}
|
|
238
|
+
} else {
|
|
239
|
+
colonMatch = remaining.match(/^([a-zA-Z0-9][a-zA-Z0-9_-]*):/);
|
|
240
|
+
}
|
|
39
241
|
|
|
40
242
|
if (colonMatch) {
|
|
41
243
|
const keyword = colonMatch[1];
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
|
|
244
|
+
const operator = matchedOp || null;
|
|
245
|
+
const operatorStart = operator ? tokenStart : null;
|
|
246
|
+
const operatorEnd = operator ? tokenStart + opLen : null;
|
|
247
|
+
const keywordStart = tokenStart + opLen;
|
|
248
|
+
const colonIndex = keywordStart + keyword.length;
|
|
249
|
+
i = colonIndex + 1; // Move index past ':'
|
|
45
250
|
|
|
46
251
|
const valueStart = i;
|
|
47
252
|
let quoted = false;
|
|
@@ -67,9 +272,13 @@ export function parseSearchTokens(inputStr) {
|
|
|
67
272
|
i++; // skip closing quote
|
|
68
273
|
}
|
|
69
274
|
} else {
|
|
70
|
-
// Unquoted value: until next whitespace or end
|
|
275
|
+
// Unquoted value: until next whitespace, delimiter, or end
|
|
71
276
|
innerStart = i;
|
|
72
|
-
while (
|
|
277
|
+
while (
|
|
278
|
+
i < len &&
|
|
279
|
+
!/\s/.test(inputStr[i]) &&
|
|
280
|
+
!matchDelimiter(inputStr.slice(i), delimEntries)
|
|
281
|
+
) {
|
|
73
282
|
i++;
|
|
74
283
|
}
|
|
75
284
|
innerEnd = i;
|
|
@@ -84,6 +293,9 @@ export function parseSearchTokens(inputStr) {
|
|
|
84
293
|
raw: inputStr.slice(tokenStart, i),
|
|
85
294
|
start: tokenStart,
|
|
86
295
|
end: i,
|
|
296
|
+
operator,
|
|
297
|
+
operatorStart,
|
|
298
|
+
operatorEnd,
|
|
87
299
|
keyword,
|
|
88
300
|
keywordLower: keyword.toLowerCase(),
|
|
89
301
|
keywordStart,
|
|
@@ -100,16 +312,38 @@ export function parseSearchTokens(inputStr) {
|
|
|
100
312
|
isClosed,
|
|
101
313
|
});
|
|
102
314
|
} else {
|
|
103
|
-
// 3.
|
|
104
|
-
while (
|
|
315
|
+
// 3. Standalone word token (combinator or plain text)
|
|
316
|
+
while (
|
|
317
|
+
i < len &&
|
|
318
|
+
!/\s/.test(inputStr[i]) &&
|
|
319
|
+
!matchDelimiter(inputStr.slice(i), delimEntries)
|
|
320
|
+
) {
|
|
105
321
|
i++;
|
|
106
322
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
323
|
+
const raw = inputStr.slice(tokenStart, i);
|
|
324
|
+
if (normalizedCombs.includes(raw)) {
|
|
325
|
+
tokens.push({
|
|
326
|
+
type: 'combinator',
|
|
327
|
+
combinator: raw,
|
|
328
|
+
raw,
|
|
329
|
+
start: tokenStart,
|
|
330
|
+
end: i,
|
|
331
|
+
});
|
|
332
|
+
} else {
|
|
333
|
+
const textOp = matchOperator(raw, normalizedOps);
|
|
334
|
+
const textToken = {
|
|
335
|
+
type: 'text',
|
|
336
|
+
raw,
|
|
337
|
+
start: tokenStart,
|
|
338
|
+
end: i,
|
|
339
|
+
};
|
|
340
|
+
if (textOp) {
|
|
341
|
+
textToken.operator = textOp;
|
|
342
|
+
textToken.operatorStart = tokenStart;
|
|
343
|
+
textToken.operatorEnd = tokenStart + textOp.length;
|
|
344
|
+
}
|
|
345
|
+
tokens.push(textToken);
|
|
346
|
+
}
|
|
113
347
|
}
|
|
114
348
|
}
|
|
115
349
|
|
|
@@ -117,31 +351,23 @@ export function parseSearchTokens(inputStr) {
|
|
|
117
351
|
}
|
|
118
352
|
|
|
119
353
|
/**
|
|
120
|
-
* Parses search query into a structured object with
|
|
354
|
+
* Parses search query into a structured object with the raw string and lexical tokens.
|
|
121
355
|
* @param {string} inputStr
|
|
122
|
-
* @
|
|
356
|
+
* @param {string|string[]} [operators=DEFAULT_OPERATORS]
|
|
357
|
+
* @param {string|string[]} [combinators=DEFAULT_COMBINATORS]
|
|
358
|
+
* @param {string|string[]} [delimiters=DEFAULT_DELIMITERS]
|
|
359
|
+
* @returns {{ raw: string, tokens: Array<Object> }}
|
|
123
360
|
*/
|
|
124
|
-
export function parseSearchQuery(
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const kw = token.keywordLower;
|
|
132
|
-
if (!keywords[kw]) {
|
|
133
|
-
keywords[kw] = [];
|
|
134
|
-
}
|
|
135
|
-
keywords[kw].push(token.innerValue);
|
|
136
|
-
} else if (token.type === 'text') {
|
|
137
|
-
textWords.push(token.raw);
|
|
138
|
-
}
|
|
139
|
-
}
|
|
361
|
+
export function parseSearchQuery(
|
|
362
|
+
inputStr,
|
|
363
|
+
operators = DEFAULT_OPERATORS,
|
|
364
|
+
combinators = DEFAULT_COMBINATORS,
|
|
365
|
+
delimiters = DEFAULT_DELIMITERS
|
|
366
|
+
) {
|
|
367
|
+
const tokens = parseSearchTokens(inputStr, operators, combinators, delimiters);
|
|
140
368
|
|
|
141
369
|
return {
|
|
142
370
|
raw: inputStr,
|
|
143
|
-
text: textWords.join(' '),
|
|
144
|
-
keywords,
|
|
145
371
|
tokens,
|
|
146
372
|
};
|
|
147
373
|
}
|
|
@@ -151,28 +377,41 @@ export function parseSearchQuery(inputStr) {
|
|
|
151
377
|
* @param {string} inputStr
|
|
152
378
|
* @param {number} caretPos
|
|
153
379
|
* @param {Map<string, Object>|Object} configuredKeywords
|
|
380
|
+
* @param {string|string[]} [operators=DEFAULT_OPERATORS]
|
|
381
|
+
* @param {string|string[]} [combinators=DEFAULT_COMBINATORS]
|
|
382
|
+
* @param {string|string[]} [delimiters=DEFAULT_DELIMITERS]
|
|
154
383
|
* @returns {Object}
|
|
155
384
|
*/
|
|
156
|
-
export function getCaretContext(
|
|
385
|
+
export function getCaretContext(
|
|
386
|
+
inputStr,
|
|
387
|
+
caretPos,
|
|
388
|
+
configuredKeywords,
|
|
389
|
+
operators = DEFAULT_OPERATORS,
|
|
390
|
+
combinators = DEFAULT_COMBINATORS,
|
|
391
|
+
delimiters = DEFAULT_DELIMITERS
|
|
392
|
+
) {
|
|
157
393
|
if (typeof inputStr !== 'string') inputStr = '';
|
|
158
394
|
caretPos = Math.max(0, Math.min(caretPos || 0, inputStr.length));
|
|
159
395
|
|
|
160
|
-
const tokens = parseSearchTokens(inputStr);
|
|
396
|
+
const tokens = parseSearchTokens(inputStr, operators, combinators, delimiters);
|
|
161
397
|
|
|
162
|
-
// Find token at caret
|
|
398
|
+
// Find token at caret (skip whitespace and delimiter boundary tokens)
|
|
163
399
|
let activeToken = null;
|
|
164
400
|
for (const token of tokens) {
|
|
401
|
+
if (token.type === 'whitespace' || token.type === 'delimiter') continue;
|
|
165
402
|
if (caretPos >= token.start && caretPos <= token.end) {
|
|
166
403
|
activeToken = token;
|
|
167
404
|
break;
|
|
168
405
|
}
|
|
169
406
|
}
|
|
170
407
|
|
|
171
|
-
// If caret is in whitespace or
|
|
172
|
-
if (!activeToken || activeToken.type === 'whitespace') {
|
|
173
|
-
// Check if the previous token was a colon without value or if we're in open space
|
|
408
|
+
// If caret is in whitespace, delimiter, or empty input (at the start of a new token)
|
|
409
|
+
if (!activeToken || activeToken.type === 'whitespace' || activeToken.type === 'delimiter') {
|
|
174
410
|
return {
|
|
175
|
-
mode: '
|
|
411
|
+
mode: 'keyword',
|
|
412
|
+
query: '',
|
|
413
|
+
replaceStart: caretPos,
|
|
414
|
+
replaceEnd: caretPos,
|
|
176
415
|
caretPos,
|
|
177
416
|
tokens,
|
|
178
417
|
};
|
|
@@ -183,21 +422,25 @@ export function getCaretContext(inputStr, caretPos, configuredKeywords) {
|
|
|
183
422
|
if (caretPos <= activeToken.colonIndex) {
|
|
184
423
|
// User is editing the keyword name (filter based on full keyword name, not caret position)
|
|
185
424
|
const query = activeToken.keyword;
|
|
186
|
-
|
|
425
|
+
const context = {
|
|
187
426
|
mode: 'keyword',
|
|
188
427
|
query,
|
|
189
|
-
replaceStart: activeToken.
|
|
428
|
+
replaceStart: activeToken.keywordStart,
|
|
190
429
|
replaceEnd: activeToken.colonIndex + 1,
|
|
191
430
|
caretPos,
|
|
192
431
|
token: activeToken,
|
|
193
432
|
tokens,
|
|
194
433
|
};
|
|
434
|
+
if (activeToken.operator) {
|
|
435
|
+
context.operator = activeToken.operator;
|
|
436
|
+
}
|
|
437
|
+
return context;
|
|
195
438
|
} else {
|
|
196
439
|
// User is editing the keyword value (filter based on full value string, not caret position)
|
|
197
440
|
const isQuoted = activeToken.quoted;
|
|
198
441
|
const value = activeToken.innerValue;
|
|
199
442
|
|
|
200
|
-
|
|
443
|
+
const context = {
|
|
201
444
|
mode: 'value',
|
|
202
445
|
keyword: activeToken.keyword,
|
|
203
446
|
keywordLower: activeToken.keywordLower,
|
|
@@ -212,11 +455,41 @@ export function getCaretContext(inputStr, caretPos, configuredKeywords) {
|
|
|
212
455
|
token: activeToken,
|
|
213
456
|
tokens,
|
|
214
457
|
};
|
|
458
|
+
if (activeToken.operator) {
|
|
459
|
+
context.operator = activeToken.operator;
|
|
460
|
+
}
|
|
461
|
+
return context;
|
|
215
462
|
}
|
|
216
463
|
}
|
|
217
464
|
|
|
218
|
-
// 2. Caret is within a
|
|
465
|
+
// 2. Caret is within a combinator token
|
|
466
|
+
if (activeToken.type === 'combinator') {
|
|
467
|
+
return {
|
|
468
|
+
mode: 'keyword',
|
|
469
|
+
query: activeToken.raw,
|
|
470
|
+
replaceStart: activeToken.start,
|
|
471
|
+
replaceEnd: activeToken.end,
|
|
472
|
+
caretPos,
|
|
473
|
+
token: activeToken,
|
|
474
|
+
tokens,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// 3. Caret is within a plain text token (filter based on full word, not caret position)
|
|
219
479
|
if (activeToken.type === 'text') {
|
|
480
|
+
if (activeToken.operator) {
|
|
481
|
+
const query = activeToken.raw.slice(activeToken.operator.length);
|
|
482
|
+
return {
|
|
483
|
+
mode: 'keyword',
|
|
484
|
+
operator: activeToken.operator,
|
|
485
|
+
query,
|
|
486
|
+
replaceStart: activeToken.operatorEnd,
|
|
487
|
+
replaceEnd: activeToken.end,
|
|
488
|
+
caretPos,
|
|
489
|
+
token: activeToken,
|
|
490
|
+
tokens,
|
|
491
|
+
};
|
|
492
|
+
}
|
|
220
493
|
const query = activeToken.raw;
|
|
221
494
|
return {
|
|
222
495
|
mode: 'keyword',
|
|
@@ -240,9 +513,10 @@ export function getCaretContext(inputStr, caretPos, configuredKeywords) {
|
|
|
240
513
|
* Generates suggestions for the given caret context.
|
|
241
514
|
* @param {Object} context
|
|
242
515
|
* @param {Map<string, Object>} configuredKeywords
|
|
516
|
+
* @param {string|string[]} [configuredCombinators=DEFAULT_COMBINATORS]
|
|
243
517
|
* @returns {Array<Object>}
|
|
244
518
|
*/
|
|
245
|
-
export function getSuggestions(context, configuredKeywords) {
|
|
519
|
+
export function getSuggestions(context, configuredKeywords, configuredCombinators = DEFAULT_COMBINATORS) {
|
|
246
520
|
if (!context || context.mode === 'none') return [];
|
|
247
521
|
|
|
248
522
|
const suggestions = [];
|
|
@@ -252,7 +526,7 @@ export function getSuggestions(context, configuredKeywords) {
|
|
|
252
526
|
for (const [id, kw] of configuredKeywords.entries()) {
|
|
253
527
|
const idLower = id.toLowerCase();
|
|
254
528
|
const labelLower = (kw.label || '').toLowerCase();
|
|
255
|
-
const matches = !q || idLower.startsWith(q) || labelLower.startsWith(q)
|
|
529
|
+
const matches = !q || idLower.startsWith(q) || labelLower.startsWith(q);
|
|
256
530
|
|
|
257
531
|
if (matches) {
|
|
258
532
|
suggestions.push({
|
|
@@ -266,6 +540,44 @@ export function getSuggestions(context, configuredKeywords) {
|
|
|
266
540
|
});
|
|
267
541
|
}
|
|
268
542
|
}
|
|
543
|
+
|
|
544
|
+
if (!context.operator) {
|
|
545
|
+
const normalizedCombs = normalizeCombinators(configuredCombinators);
|
|
546
|
+
if (normalizedCombs.length > 0) {
|
|
547
|
+
let shouldIncludeCombinators = true;
|
|
548
|
+
if (!q && Array.isArray(context.tokens)) {
|
|
549
|
+
const precedingTokens = context.tokens.filter(
|
|
550
|
+
(t) => t.type !== 'whitespace' && t.end <= context.replaceStart
|
|
551
|
+
);
|
|
552
|
+
const lastPreceding = precedingTokens[precedingTokens.length - 1];
|
|
553
|
+
// Don't suggest combinators at the very start of an empty query (unless typing a query prefix),
|
|
554
|
+
// immediately after another combinator, or immediately after an opening delimiter
|
|
555
|
+
if (
|
|
556
|
+
precedingTokens.length === 0 ||
|
|
557
|
+
lastPreceding?.type === 'combinator' ||
|
|
558
|
+
(lastPreceding?.type === 'delimiter' && lastPreceding?.role === 'open')
|
|
559
|
+
) {
|
|
560
|
+
shouldIncludeCombinators = false;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
if (shouldIncludeCombinators) {
|
|
564
|
+
for (const comb of normalizedCombs) {
|
|
565
|
+
const combLower = comb.toLowerCase();
|
|
566
|
+
if (!q || combLower.startsWith(q)) {
|
|
567
|
+
suggestions.push({
|
|
568
|
+
type: 'combinator',
|
|
569
|
+
id: comb,
|
|
570
|
+
combinator: comb,
|
|
571
|
+
label: comb,
|
|
572
|
+
display: comb,
|
|
573
|
+
insertText: comb,
|
|
574
|
+
description: 'Combinator',
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
269
581
|
} else if (context.mode === 'value') {
|
|
270
582
|
const kwConfig = configuredKeywords.get(context.keywordLower);
|
|
271
583
|
if (!kwConfig) return [];
|
|
@@ -320,11 +632,25 @@ export function applySuggestion(inputStr, suggestion, context) {
|
|
|
320
632
|
let after = inputStr.slice(replaceEnd);
|
|
321
633
|
let insertText = suggestion.insertText;
|
|
322
634
|
|
|
635
|
+
const followingToken = Array.isArray(context.tokens)
|
|
636
|
+
? context.tokens.find((t) => t.start === replaceEnd)
|
|
637
|
+
: null;
|
|
638
|
+
const isFollowedByCloseDelimiter =
|
|
639
|
+
followingToken?.type === 'delimiter' && followingToken?.role === 'close';
|
|
640
|
+
|
|
323
641
|
if (suggestion.type === 'keyword') {
|
|
324
642
|
// When inserting a keyword like `mix:`, do not add trailing space so user can immediately type value
|
|
325
643
|
// If after text starts with colon, remove it to avoid `mix::`
|
|
326
644
|
if (after.startsWith(':')) {
|
|
327
645
|
after = after.slice(1);
|
|
646
|
+
} else if (
|
|
647
|
+
context.token?.type !== 'keyword' &&
|
|
648
|
+
after.length > 0 &&
|
|
649
|
+
!/^\s/.test(after) &&
|
|
650
|
+
!isFollowedByCloseDelimiter
|
|
651
|
+
) {
|
|
652
|
+
// Ensure space before following token so it doesn't become the value of this keyword
|
|
653
|
+
after = ' ' + after;
|
|
328
654
|
}
|
|
329
655
|
const newValue = before + insertText + after;
|
|
330
656
|
const newCaret = before.length + insertText.length;
|
|
@@ -332,13 +658,14 @@ export function applySuggestion(inputStr, suggestion, context) {
|
|
|
332
658
|
}
|
|
333
659
|
|
|
334
660
|
// When inserting a value (e.g. `"We Play House Recordings"`)
|
|
335
|
-
// Add a trailing space if after does not already start with whitespace
|
|
336
|
-
|
|
661
|
+
// Add a trailing space if after does not already start with whitespace or a closing delimiter
|
|
662
|
+
const hasLeadingSpaceAfter = /^\s/.test(after);
|
|
663
|
+
if ((after.length === 0 || !hasLeadingSpaceAfter) && !isFollowedByCloseDelimiter) {
|
|
337
664
|
insertText += ' ';
|
|
338
665
|
}
|
|
339
666
|
|
|
340
667
|
const newValue = before + insertText + after;
|
|
341
|
-
const newCaret = before.length + insertText.length;
|
|
668
|
+
const newCaret = before.length + insertText.length + (hasLeadingSpaceAfter ? 1 : 0);
|
|
342
669
|
return { newValue, newCaret };
|
|
343
670
|
}
|
|
344
671
|
|