rich-input 1.3.0 → 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 +122 -100
- package/components/rich-input.js +495 -38
- package/index.js +7 -1
- package/package.json +1 -1
- package/utils/highlights.js +75 -0
- package/utils/query-parser.js +370 -49
package/index.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { RichInput } from './components/rich-input.js';
|
|
7
|
-
import { parseSearchTokens, parseSearchQuery, getCaretContext, getSuggestions, applySuggestion, isDatalistValue } from './utils/query-parser.js';
|
|
7
|
+
import { DEFAULT_OPERATORS, normalizeOperators, DEFAULT_COMBINATORS, normalizeCombinators, DEFAULT_DELIMITERS, normalizeDelimiters, parseSearchTokens, parseSearchQuery, getCaretContext, getSuggestions, applySuggestion, isDatalistValue } from './utils/query-parser.js';
|
|
8
8
|
import { highlightManager, isOpaqueRangeSupported, isHighlightSupported } from './utils/highlights.js';
|
|
9
9
|
import { getCaretCoordinates, getRangeCoordinates, positionPopover, getCaretLeftWithMirrorDiv } from './utils/positioning.js';
|
|
10
10
|
import { setupContentEditableAdapter, isContentEditableFallbackActive } from './utils/contenteditable-adapter.js';
|
|
@@ -17,6 +17,12 @@ if (typeof customElements !== 'undefined') {
|
|
|
17
17
|
|
|
18
18
|
export {
|
|
19
19
|
RichInput,
|
|
20
|
+
DEFAULT_OPERATORS,
|
|
21
|
+
normalizeOperators,
|
|
22
|
+
DEFAULT_COMBINATORS,
|
|
23
|
+
normalizeCombinators,
|
|
24
|
+
DEFAULT_DELIMITERS,
|
|
25
|
+
normalizeDelimiters,
|
|
20
26
|
parseSearchTokens,
|
|
21
27
|
parseSearchQuery,
|
|
22
28
|
getCaretContext,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rich-input",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "A rich input field with keyword-based autocomplete and in-input highlighting powered by <datalist>, OpaqueRange, and the Custom Highlight API",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.js",
|
package/utils/highlights.js
CHANGED
|
@@ -52,6 +52,9 @@ class HighlightRegistryManager {
|
|
|
52
52
|
const rangesByKeyword = new Map();
|
|
53
53
|
const allKeywordRanges = [];
|
|
54
54
|
const allValueRanges = [];
|
|
55
|
+
const allOperatorRanges = [];
|
|
56
|
+
const allCombinatorRanges = [];
|
|
57
|
+
const allDelimiterRanges = [];
|
|
55
58
|
const allInvalidRanges = [];
|
|
56
59
|
|
|
57
60
|
for (const inst of this.instances) {
|
|
@@ -74,6 +77,27 @@ class HighlightRegistryManager {
|
|
|
74
77
|
}
|
|
75
78
|
}
|
|
76
79
|
|
|
80
|
+
if (typeof inst.getActiveOperatorRanges === 'function') {
|
|
81
|
+
const opRanges = inst.getActiveOperatorRanges();
|
|
82
|
+
if (opRanges && opRanges.length > 0) {
|
|
83
|
+
allOperatorRanges.push(...opRanges);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (typeof inst.getActiveCombinatorRanges === 'function') {
|
|
88
|
+
const combRanges = inst.getActiveCombinatorRanges();
|
|
89
|
+
if (combRanges && combRanges.length > 0) {
|
|
90
|
+
allCombinatorRanges.push(...combRanges);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (typeof inst.getActiveDelimiterRanges === 'function') {
|
|
95
|
+
const delimRanges = inst.getActiveDelimiterRanges();
|
|
96
|
+
if (delimRanges && delimRanges.length > 0) {
|
|
97
|
+
allDelimiterRanges.push(...delimRanges);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
77
101
|
if (typeof inst.getActiveInvalidRanges === 'function') {
|
|
78
102
|
const invRanges = inst.getActiveInvalidRanges();
|
|
79
103
|
if (invRanges && invRanges.length > 0) {
|
|
@@ -104,6 +128,57 @@ class HighlightRegistryManager {
|
|
|
104
128
|
}
|
|
105
129
|
|
|
106
130
|
// 2. Set generic highlights
|
|
131
|
+
let opHl = CSS.highlights.get('rich-input-operator');
|
|
132
|
+
if (!opHl) {
|
|
133
|
+
try {
|
|
134
|
+
opHl = new Highlight();
|
|
135
|
+
CSS.highlights.set('rich-input-operator', opHl);
|
|
136
|
+
} catch (e) {}
|
|
137
|
+
}
|
|
138
|
+
if (opHl) {
|
|
139
|
+
opHl.clear();
|
|
140
|
+
for (const r of allOperatorRanges) {
|
|
141
|
+
try {
|
|
142
|
+
if (isRangeCollapsed(r)) continue;
|
|
143
|
+
opHl.add(r);
|
|
144
|
+
} catch (e) {}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let combHl = CSS.highlights.get('rich-input-combinator');
|
|
149
|
+
if (!combHl) {
|
|
150
|
+
try {
|
|
151
|
+
combHl = new Highlight();
|
|
152
|
+
CSS.highlights.set('rich-input-combinator', combHl);
|
|
153
|
+
} catch (e) {}
|
|
154
|
+
}
|
|
155
|
+
if (combHl) {
|
|
156
|
+
combHl.clear();
|
|
157
|
+
for (const r of allCombinatorRanges) {
|
|
158
|
+
try {
|
|
159
|
+
if (isRangeCollapsed(r)) continue;
|
|
160
|
+
combHl.add(r);
|
|
161
|
+
} catch (e) {}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
let delimHl = CSS.highlights.get('rich-input-delimiter');
|
|
166
|
+
if (!delimHl) {
|
|
167
|
+
try {
|
|
168
|
+
delimHl = new Highlight();
|
|
169
|
+
CSS.highlights.set('rich-input-delimiter', delimHl);
|
|
170
|
+
} catch (e) {}
|
|
171
|
+
}
|
|
172
|
+
if (delimHl) {
|
|
173
|
+
delimHl.clear();
|
|
174
|
+
for (const r of allDelimiterRanges) {
|
|
175
|
+
try {
|
|
176
|
+
if (isRangeCollapsed(r)) continue;
|
|
177
|
+
delimHl.add(r);
|
|
178
|
+
} catch (e) {}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
107
182
|
let kwHl = CSS.highlights.get('rich-input-keyword');
|
|
108
183
|
if (!kwHl) {
|
|
109
184
|
try {
|
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,25 +377,36 @@ 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 empty input (at the start of a new token)
|
|
172
|
-
if (!activeToken || activeToken.type === 'whitespace') {
|
|
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') {
|
|
173
410
|
return {
|
|
174
411
|
mode: 'keyword',
|
|
175
412
|
query: '',
|
|
@@ -185,21 +422,25 @@ export function getCaretContext(inputStr, caretPos, configuredKeywords) {
|
|
|
185
422
|
if (caretPos <= activeToken.colonIndex) {
|
|
186
423
|
// User is editing the keyword name (filter based on full keyword name, not caret position)
|
|
187
424
|
const query = activeToken.keyword;
|
|
188
|
-
|
|
425
|
+
const context = {
|
|
189
426
|
mode: 'keyword',
|
|
190
427
|
query,
|
|
191
|
-
replaceStart: activeToken.
|
|
428
|
+
replaceStart: activeToken.keywordStart,
|
|
192
429
|
replaceEnd: activeToken.colonIndex + 1,
|
|
193
430
|
caretPos,
|
|
194
431
|
token: activeToken,
|
|
195
432
|
tokens,
|
|
196
433
|
};
|
|
434
|
+
if (activeToken.operator) {
|
|
435
|
+
context.operator = activeToken.operator;
|
|
436
|
+
}
|
|
437
|
+
return context;
|
|
197
438
|
} else {
|
|
198
439
|
// User is editing the keyword value (filter based on full value string, not caret position)
|
|
199
440
|
const isQuoted = activeToken.quoted;
|
|
200
441
|
const value = activeToken.innerValue;
|
|
201
442
|
|
|
202
|
-
|
|
443
|
+
const context = {
|
|
203
444
|
mode: 'value',
|
|
204
445
|
keyword: activeToken.keyword,
|
|
205
446
|
keywordLower: activeToken.keywordLower,
|
|
@@ -214,11 +455,41 @@ export function getCaretContext(inputStr, caretPos, configuredKeywords) {
|
|
|
214
455
|
token: activeToken,
|
|
215
456
|
tokens,
|
|
216
457
|
};
|
|
458
|
+
if (activeToken.operator) {
|
|
459
|
+
context.operator = activeToken.operator;
|
|
460
|
+
}
|
|
461
|
+
return context;
|
|
217
462
|
}
|
|
218
463
|
}
|
|
219
464
|
|
|
220
|
-
// 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)
|
|
221
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
|
+
}
|
|
222
493
|
const query = activeToken.raw;
|
|
223
494
|
return {
|
|
224
495
|
mode: 'keyword',
|
|
@@ -242,9 +513,10 @@ export function getCaretContext(inputStr, caretPos, configuredKeywords) {
|
|
|
242
513
|
* Generates suggestions for the given caret context.
|
|
243
514
|
* @param {Object} context
|
|
244
515
|
* @param {Map<string, Object>} configuredKeywords
|
|
516
|
+
* @param {string|string[]} [configuredCombinators=DEFAULT_COMBINATORS]
|
|
245
517
|
* @returns {Array<Object>}
|
|
246
518
|
*/
|
|
247
|
-
export function getSuggestions(context, configuredKeywords) {
|
|
519
|
+
export function getSuggestions(context, configuredKeywords, configuredCombinators = DEFAULT_COMBINATORS) {
|
|
248
520
|
if (!context || context.mode === 'none') return [];
|
|
249
521
|
|
|
250
522
|
const suggestions = [];
|
|
@@ -268,6 +540,44 @@ export function getSuggestions(context, configuredKeywords) {
|
|
|
268
540
|
});
|
|
269
541
|
}
|
|
270
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
|
+
}
|
|
271
581
|
} else if (context.mode === 'value') {
|
|
272
582
|
const kwConfig = configuredKeywords.get(context.keywordLower);
|
|
273
583
|
if (!kwConfig) return [];
|
|
@@ -322,12 +632,23 @@ export function applySuggestion(inputStr, suggestion, context) {
|
|
|
322
632
|
let after = inputStr.slice(replaceEnd);
|
|
323
633
|
let insertText = suggestion.insertText;
|
|
324
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
|
+
|
|
325
641
|
if (suggestion.type === 'keyword') {
|
|
326
642
|
// When inserting a keyword like `mix:`, do not add trailing space so user can immediately type value
|
|
327
643
|
// If after text starts with colon, remove it to avoid `mix::`
|
|
328
644
|
if (after.startsWith(':')) {
|
|
329
645
|
after = after.slice(1);
|
|
330
|
-
} else if (
|
|
646
|
+
} else if (
|
|
647
|
+
context.token?.type !== 'keyword' &&
|
|
648
|
+
after.length > 0 &&
|
|
649
|
+
!/^\s/.test(after) &&
|
|
650
|
+
!isFollowedByCloseDelimiter
|
|
651
|
+
) {
|
|
331
652
|
// Ensure space before following token so it doesn't become the value of this keyword
|
|
332
653
|
after = ' ' + after;
|
|
333
654
|
}
|
|
@@ -337,9 +658,9 @@ export function applySuggestion(inputStr, suggestion, context) {
|
|
|
337
658
|
}
|
|
338
659
|
|
|
339
660
|
// When inserting a value (e.g. `"We Play House Recordings"`)
|
|
340
|
-
// Add a trailing space if after does not already start with whitespace
|
|
661
|
+
// Add a trailing space if after does not already start with whitespace or a closing delimiter
|
|
341
662
|
const hasLeadingSpaceAfter = /^\s/.test(after);
|
|
342
|
-
if (after.length === 0 || !hasLeadingSpaceAfter) {
|
|
663
|
+
if ((after.length === 0 || !hasLeadingSpaceAfter) && !isFollowedByCloseDelimiter) {
|
|
343
664
|
insertText += ' ';
|
|
344
665
|
}
|
|
345
666
|
|