rich-input 1.0.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/LICENSE +21 -0
- package/README.md +419 -0
- package/assets/rich-input-parts.svg +480 -0
- package/components/rich-input.js +1003 -0
- package/index.js +32 -0
- package/package.json +35 -0
- package/utils/highlights.js +101 -0
- package/utils/positioning.js +111 -0
- package/utils/query-parser.js +344 -0
package/index.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <rich-input> entry point
|
|
3
|
+
* Defines <rich-input> custom element and exports RichInput class & parser utilities.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { RichInput } from './components/rich-input.js';
|
|
7
|
+
import { parseSearchTokens, parseSearchQuery, getCaretContext, getSuggestions, applySuggestion } from './utils/query-parser.js';
|
|
8
|
+
import { highlightManager, isOpaqueRangeSupported, isHighlightSupported } from './utils/highlights.js';
|
|
9
|
+
import { getCaretCoordinates, getRangeCoordinates, positionPopover } from './utils/positioning.js';
|
|
10
|
+
|
|
11
|
+
if (typeof customElements !== 'undefined') {
|
|
12
|
+
if (!customElements.get('rich-input')) {
|
|
13
|
+
customElements.define('rich-input', RichInput);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export {
|
|
18
|
+
RichInput,
|
|
19
|
+
parseSearchTokens,
|
|
20
|
+
parseSearchQuery,
|
|
21
|
+
getCaretContext,
|
|
22
|
+
getSuggestions,
|
|
23
|
+
applySuggestion,
|
|
24
|
+
highlightManager,
|
|
25
|
+
isOpaqueRangeSupported,
|
|
26
|
+
isHighlightSupported,
|
|
27
|
+
getCaretCoordinates,
|
|
28
|
+
getRangeCoordinates,
|
|
29
|
+
positionPopover,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export default RichInput;
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "rich-input",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A rich input field with keyword-based autocomplete and in-input highlighting powered by <datalist>, OpaqueRange, and the Custom Highlight API",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.js",
|
|
7
|
+
"module": "./index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./index.js"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"rich-input",
|
|
13
|
+
"custom-element",
|
|
14
|
+
"web-components",
|
|
15
|
+
"opaque-range",
|
|
16
|
+
"custom-highlight-api",
|
|
17
|
+
"autocomplete",
|
|
18
|
+
"keyword-search",
|
|
19
|
+
"search-input"
|
|
20
|
+
],
|
|
21
|
+
"author": {
|
|
22
|
+
"name": "Bramus Van Damme",
|
|
23
|
+
"email": "bramus@bram.us",
|
|
24
|
+
"url": "https://www.bram.us/"
|
|
25
|
+
},
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/bramus/rich-input.git"
|
|
30
|
+
},
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/bramus/rich-input/issues"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://github.com/bramus/rich-input#readme"
|
|
35
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Highlight manager for <rich-input>
|
|
3
|
+
* Uses the OpaqueRange API and CSS Custom Highlight API to apply syntax highlighting.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const isOpaqueRangeSupported =
|
|
7
|
+
typeof HTMLInputElement !== 'undefined' &&
|
|
8
|
+
typeof HTMLInputElement.prototype.createValueRange === 'function';
|
|
9
|
+
|
|
10
|
+
export const isHighlightSupported =
|
|
11
|
+
typeof window !== 'undefined' &&
|
|
12
|
+
typeof window.Highlight !== 'undefined' &&
|
|
13
|
+
typeof CSS !== 'undefined' &&
|
|
14
|
+
'highlights' in CSS;
|
|
15
|
+
|
|
16
|
+
class HighlightRegistryManager {
|
|
17
|
+
constructor() {
|
|
18
|
+
this.instances = new Set();
|
|
19
|
+
this.registeredKeywords = new Set();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
register(instance) {
|
|
23
|
+
this.instances.add(instance);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
unregister(instance) {
|
|
27
|
+
this.instances.delete(instance);
|
|
28
|
+
this.syncAll();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
recordKeyword(keyword) {
|
|
32
|
+
if (keyword) {
|
|
33
|
+
this.registeredKeywords.add(keyword.toLowerCase());
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
syncAll() {
|
|
38
|
+
if (!isHighlightSupported || !isOpaqueRangeSupported) return;
|
|
39
|
+
|
|
40
|
+
// Aggregate ranges across all active instances
|
|
41
|
+
const rangesByKeyword = new Map();
|
|
42
|
+
const allKeywordRanges = [];
|
|
43
|
+
const allValueRanges = [];
|
|
44
|
+
|
|
45
|
+
for (const inst of this.instances) {
|
|
46
|
+
const instanceRanges = inst.getActiveHighlightRanges();
|
|
47
|
+
for (const [kw, data] of instanceRanges.entries()) {
|
|
48
|
+
const kwLower = kw.toLowerCase();
|
|
49
|
+
this.registeredKeywords.add(kwLower);
|
|
50
|
+
|
|
51
|
+
if (!rangesByKeyword.has(kwLower)) {
|
|
52
|
+
rangesByKeyword.set(kwLower, []);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (data.valueRanges && data.valueRanges.length > 0) {
|
|
56
|
+
rangesByKeyword.get(kwLower).push(...data.valueRanges);
|
|
57
|
+
allValueRanges.push(...data.valueRanges);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (data.keywordRanges && data.keywordRanges.length > 0) {
|
|
61
|
+
allKeywordRanges.push(...data.keywordRanges);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 1. Set/update individual keyword highlights (e.g. ::highlight(label))
|
|
67
|
+
for (const kw of this.registeredKeywords) {
|
|
68
|
+
const ranges = rangesByKeyword.get(kw);
|
|
69
|
+
if (ranges && ranges.length > 0) {
|
|
70
|
+
try {
|
|
71
|
+
CSS.highlights.set(kw, new Highlight(...ranges));
|
|
72
|
+
} catch (e) {
|
|
73
|
+
console.warn(`[rich-input] Failed to register highlight for "${kw}":`, e);
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
CSS.highlights.delete(kw);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 2. Set generic highlights
|
|
81
|
+
if (allKeywordRanges.length > 0) {
|
|
82
|
+
try {
|
|
83
|
+
const kwHl = new Highlight(...allKeywordRanges);
|
|
84
|
+
CSS.highlights.set('rich-input-keyword', kwHl);
|
|
85
|
+
} catch (e) {}
|
|
86
|
+
} else {
|
|
87
|
+
CSS.highlights.delete('rich-input-keyword');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (allValueRanges.length > 0) {
|
|
91
|
+
try {
|
|
92
|
+
const valHl = new Highlight(...allValueRanges);
|
|
93
|
+
CSS.highlights.set('rich-input-value', valHl);
|
|
94
|
+
} catch (e) {}
|
|
95
|
+
} else {
|
|
96
|
+
CSS.highlights.delete('rich-input-value');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const highlightManager = new HighlightRegistryManager();
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Caret measurement and popover positioning for <rich-input>
|
|
3
|
+
* Uses OpaqueRange.getBoundingClientRect() with input fallback.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Gets caret or range coordinates relative to viewport.
|
|
8
|
+
* Accepts either an OpaqueRange instance or a character offset number.
|
|
9
|
+
* @param {HTMLInputElement} input
|
|
10
|
+
* @param {number|OpaqueRange} [target] Caret position number or OpaqueRange
|
|
11
|
+
* @returns {{ left: number, top: number, bottom: number, height: number, isCaret: boolean }}
|
|
12
|
+
*/
|
|
13
|
+
export function getCaretCoordinates(input, target) {
|
|
14
|
+
const inputRect = input.getBoundingClientRect();
|
|
15
|
+
let rect = null;
|
|
16
|
+
let tempRange = null;
|
|
17
|
+
|
|
18
|
+
if (target && typeof target.getBoundingClientRect === 'function') {
|
|
19
|
+
try {
|
|
20
|
+
const r = target.getBoundingClientRect();
|
|
21
|
+
if (r.height > 0 || r.width > 0 || r.left > 0) {
|
|
22
|
+
rect = r;
|
|
23
|
+
}
|
|
24
|
+
} catch (e) {}
|
|
25
|
+
} else if (typeof input.createValueRange === 'function' && input.value.length > 0) {
|
|
26
|
+
try {
|
|
27
|
+
const pos = Math.max(0, Math.min(typeof target === 'number' ? target : 0, input.value.length));
|
|
28
|
+
tempRange = input.createValueRange(pos, pos);
|
|
29
|
+
const r = tempRange.getBoundingClientRect();
|
|
30
|
+
if (r.height > 0 || r.width > 0 || r.left > 0) {
|
|
31
|
+
rect = r;
|
|
32
|
+
}
|
|
33
|
+
} catch (e) {}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Clean up temporary measurement range
|
|
37
|
+
if (tempRange) {
|
|
38
|
+
try {
|
|
39
|
+
tempRange.disconnect();
|
|
40
|
+
} catch (e) {}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (rect) {
|
|
44
|
+
// Keep caret left within input bounds
|
|
45
|
+
const left = Math.max(inputRect.left, Math.min(rect.left, inputRect.right));
|
|
46
|
+
const bottom = rect.bottom > 0 ? rect.bottom : inputRect.bottom;
|
|
47
|
+
const top = rect.top > 0 ? rect.top : inputRect.top;
|
|
48
|
+
return {
|
|
49
|
+
left,
|
|
50
|
+
top,
|
|
51
|
+
bottom,
|
|
52
|
+
height: rect.height || inputRect.height,
|
|
53
|
+
isCaret: true,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
left: inputRect.left,
|
|
59
|
+
top: inputRect.top,
|
|
60
|
+
bottom: inputRect.bottom,
|
|
61
|
+
height: inputRect.height,
|
|
62
|
+
isCaret: false,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Alias for getCaretCoordinates that clearly conveys range support.
|
|
68
|
+
*/
|
|
69
|
+
export const getRangeCoordinates = getCaretCoordinates;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Positions popover at caret or input element.
|
|
73
|
+
* @param {HTMLElement} popover
|
|
74
|
+
* @param {{ left: number, top: number, bottom: number, height: number }} caretCoords
|
|
75
|
+
* @param {HTMLInputElement} inputElement
|
|
76
|
+
*/
|
|
77
|
+
export function positionPopover(popover, caretCoords, inputElement) {
|
|
78
|
+
if (!popover) return;
|
|
79
|
+
|
|
80
|
+
const popoverWidth = popover.offsetWidth || 280;
|
|
81
|
+
const popoverHeight = popover.offsetHeight || 220;
|
|
82
|
+
|
|
83
|
+
const viewportWidth = window.innerWidth;
|
|
84
|
+
const viewportHeight = window.innerHeight;
|
|
85
|
+
|
|
86
|
+
let left = caretCoords.left;
|
|
87
|
+
|
|
88
|
+
// Viewport right edge clamp
|
|
89
|
+
if (left + popoverWidth > viewportWidth - 12) {
|
|
90
|
+
left = viewportWidth - popoverWidth - 12;
|
|
91
|
+
}
|
|
92
|
+
// Viewport left edge clamp
|
|
93
|
+
if (left < 12) {
|
|
94
|
+
left = 12;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let top = caretCoords.bottom + 6;
|
|
98
|
+
|
|
99
|
+
// Viewport bottom edge flip
|
|
100
|
+
if (top + popoverHeight > viewportHeight - 12) {
|
|
101
|
+
const spaceAbove = caretCoords.top - 6;
|
|
102
|
+
if (spaceAbove >= popoverHeight) {
|
|
103
|
+
top = spaceAbove - popoverHeight;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
popover.style.margin = '0';
|
|
108
|
+
popover.style.inset = 'auto';
|
|
109
|
+
popover.style.left = `${Math.round(left)}px`;
|
|
110
|
+
popover.style.top = `${Math.round(top)}px`;
|
|
111
|
+
}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query tokenizer and parser for <rich-input>
|
|
3
|
+
* Handles keyword:value tokenization, caret context inspection, and suggestion application.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Parses raw search input into token objects.
|
|
8
|
+
* @param {string} inputStr
|
|
9
|
+
* @returns {Array<Object>}
|
|
10
|
+
*/
|
|
11
|
+
export function parseSearchTokens(inputStr) {
|
|
12
|
+
if (typeof inputStr !== 'string') return [];
|
|
13
|
+
|
|
14
|
+
const tokens = [];
|
|
15
|
+
let i = 0;
|
|
16
|
+
const len = inputStr.length;
|
|
17
|
+
|
|
18
|
+
while (i < len) {
|
|
19
|
+
// 1. Whitespace
|
|
20
|
+
if (/\s/.test(inputStr[i])) {
|
|
21
|
+
const wsStart = i;
|
|
22
|
+
while (i < len && /\s/.test(inputStr[i])) {
|
|
23
|
+
i++;
|
|
24
|
+
}
|
|
25
|
+
tokens.push({
|
|
26
|
+
type: 'whitespace',
|
|
27
|
+
raw: inputStr.slice(wsStart, i),
|
|
28
|
+
start: wsStart,
|
|
29
|
+
end: i,
|
|
30
|
+
});
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const tokenStart = i;
|
|
35
|
+
|
|
36
|
+
// 2. Check for keyword pattern: [a-zA-Z0-9_-]+:
|
|
37
|
+
const remaining = inputStr.slice(i);
|
|
38
|
+
const colonMatch = remaining.match(/^([a-zA-Z0-9_-]+):/);
|
|
39
|
+
|
|
40
|
+
if (colonMatch) {
|
|
41
|
+
const keyword = colonMatch[1];
|
|
42
|
+
const keywordStart = i;
|
|
43
|
+
const colonIndex = i + keyword.length;
|
|
44
|
+
i += colonMatch[0].length; // Move index past ':'
|
|
45
|
+
|
|
46
|
+
const valueStart = i;
|
|
47
|
+
let quoted = false;
|
|
48
|
+
let quoteChar = '';
|
|
49
|
+
let isClosed = false;
|
|
50
|
+
let innerStart = i;
|
|
51
|
+
let innerEnd = i;
|
|
52
|
+
|
|
53
|
+
if (i < len && (inputStr[i] === '"' || inputStr[i] === "'")) {
|
|
54
|
+
// Quoted string value
|
|
55
|
+
quoted = true;
|
|
56
|
+
quoteChar = inputStr[i];
|
|
57
|
+
i++; // skip opening quote
|
|
58
|
+
innerStart = i;
|
|
59
|
+
|
|
60
|
+
while (i < len && inputStr[i] !== quoteChar) {
|
|
61
|
+
i++;
|
|
62
|
+
}
|
|
63
|
+
innerEnd = i;
|
|
64
|
+
|
|
65
|
+
if (i < len && inputStr[i] === quoteChar) {
|
|
66
|
+
isClosed = true;
|
|
67
|
+
i++; // skip closing quote
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
// Unquoted value: until next whitespace or end
|
|
71
|
+
innerStart = i;
|
|
72
|
+
while (i < len && !/\s/.test(inputStr[i])) {
|
|
73
|
+
i++;
|
|
74
|
+
}
|
|
75
|
+
innerEnd = i;
|
|
76
|
+
isClosed = true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const rawValue = inputStr.slice(valueStart, i);
|
|
80
|
+
const innerValue = inputStr.slice(innerStart, innerEnd);
|
|
81
|
+
|
|
82
|
+
tokens.push({
|
|
83
|
+
type: 'keyword',
|
|
84
|
+
raw: inputStr.slice(tokenStart, i),
|
|
85
|
+
start: tokenStart,
|
|
86
|
+
end: i,
|
|
87
|
+
keyword,
|
|
88
|
+
keywordLower: keyword.toLowerCase(),
|
|
89
|
+
keywordStart,
|
|
90
|
+
keywordEnd: colonIndex + 1,
|
|
91
|
+
colonIndex,
|
|
92
|
+
valueStart,
|
|
93
|
+
valueEnd: i,
|
|
94
|
+
rawValue,
|
|
95
|
+
innerValue,
|
|
96
|
+
innerStart,
|
|
97
|
+
innerEnd,
|
|
98
|
+
quoted,
|
|
99
|
+
quoteChar,
|
|
100
|
+
isClosed,
|
|
101
|
+
});
|
|
102
|
+
} else {
|
|
103
|
+
// 3. Plain text token (single word or unfinished keyword)
|
|
104
|
+
while (i < len && !/\s/.test(inputStr[i])) {
|
|
105
|
+
i++;
|
|
106
|
+
}
|
|
107
|
+
tokens.push({
|
|
108
|
+
type: 'text',
|
|
109
|
+
raw: inputStr.slice(tokenStart, i),
|
|
110
|
+
start: tokenStart,
|
|
111
|
+
end: i,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return tokens;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Parses search query into a structured object with keywords and free text.
|
|
121
|
+
* @param {string} inputStr
|
|
122
|
+
* @returns {{ raw: string, text: string, keywords: Record<string, string[]>, tokens: Array<Object> }}
|
|
123
|
+
*/
|
|
124
|
+
export function parseSearchQuery(inputStr) {
|
|
125
|
+
const tokens = parseSearchTokens(inputStr);
|
|
126
|
+
const keywords = {};
|
|
127
|
+
const textWords = [];
|
|
128
|
+
|
|
129
|
+
for (const token of tokens) {
|
|
130
|
+
if (token.type === 'keyword') {
|
|
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
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
raw: inputStr,
|
|
143
|
+
text: textWords.join(' '),
|
|
144
|
+
keywords,
|
|
145
|
+
tokens,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Determines caret context and what autocomplete suggestions are appropriate.
|
|
151
|
+
* @param {string} inputStr
|
|
152
|
+
* @param {number} caretPos
|
|
153
|
+
* @param {Map<string, Object>|Object} configuredKeywords
|
|
154
|
+
* @returns {Object}
|
|
155
|
+
*/
|
|
156
|
+
export function getCaretContext(inputStr, caretPos, configuredKeywords) {
|
|
157
|
+
if (typeof inputStr !== 'string') inputStr = '';
|
|
158
|
+
caretPos = Math.max(0, Math.min(caretPos || 0, inputStr.length));
|
|
159
|
+
|
|
160
|
+
const tokens = parseSearchTokens(inputStr);
|
|
161
|
+
|
|
162
|
+
// Find token at caret
|
|
163
|
+
let activeToken = null;
|
|
164
|
+
for (const token of tokens) {
|
|
165
|
+
if (caretPos >= token.start && caretPos <= token.end) {
|
|
166
|
+
activeToken = token;
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// If caret is in whitespace or between tokens
|
|
172
|
+
if (!activeToken || activeToken.type === 'whitespace') {
|
|
173
|
+
// Check if the previous token was a colon without value or if we're in open space
|
|
174
|
+
return {
|
|
175
|
+
mode: 'none',
|
|
176
|
+
caretPos,
|
|
177
|
+
tokens,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 1. Caret is within a keyword token
|
|
182
|
+
if (activeToken.type === 'keyword') {
|
|
183
|
+
if (caretPos <= activeToken.colonIndex) {
|
|
184
|
+
// User is editing the keyword name
|
|
185
|
+
const query = inputStr.slice(activeToken.start, caretPos);
|
|
186
|
+
return {
|
|
187
|
+
mode: 'keyword',
|
|
188
|
+
query,
|
|
189
|
+
replaceStart: activeToken.start,
|
|
190
|
+
replaceEnd: activeToken.colonIndex + 1,
|
|
191
|
+
caretPos,
|
|
192
|
+
token: activeToken,
|
|
193
|
+
tokens,
|
|
194
|
+
};
|
|
195
|
+
} else {
|
|
196
|
+
// User is editing the keyword value
|
|
197
|
+
const isQuoted = activeToken.quoted;
|
|
198
|
+
const innerStart = activeToken.innerStart;
|
|
199
|
+
const innerEnd = activeToken.innerEnd;
|
|
200
|
+
const valuePrefix = inputStr.slice(innerStart, Math.min(caretPos, innerEnd));
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
mode: 'value',
|
|
204
|
+
keyword: activeToken.keyword,
|
|
205
|
+
keywordLower: activeToken.keywordLower,
|
|
206
|
+
valuePrefix,
|
|
207
|
+
quoted: isQuoted,
|
|
208
|
+
quoteChar: activeToken.quoteChar || '"',
|
|
209
|
+
isClosed: activeToken.isClosed,
|
|
210
|
+
replaceStart: activeToken.valueStart,
|
|
211
|
+
replaceEnd: activeToken.valueEnd,
|
|
212
|
+
caretPos,
|
|
213
|
+
token: activeToken,
|
|
214
|
+
tokens,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 2. Caret is within a plain text token
|
|
220
|
+
if (activeToken.type === 'text') {
|
|
221
|
+
const query = inputStr.slice(activeToken.start, caretPos);
|
|
222
|
+
return {
|
|
223
|
+
mode: 'keyword',
|
|
224
|
+
query,
|
|
225
|
+
replaceStart: activeToken.start,
|
|
226
|
+
replaceEnd: activeToken.end,
|
|
227
|
+
caretPos,
|
|
228
|
+
token: activeToken,
|
|
229
|
+
tokens,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
mode: 'none',
|
|
235
|
+
caretPos,
|
|
236
|
+
tokens,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Generates suggestions for the given caret context.
|
|
242
|
+
* @param {Object} context
|
|
243
|
+
* @param {Map<string, Object>} configuredKeywords
|
|
244
|
+
* @returns {Array<Object>}
|
|
245
|
+
*/
|
|
246
|
+
export function getSuggestions(context, configuredKeywords) {
|
|
247
|
+
if (!context || context.mode === 'none') return [];
|
|
248
|
+
|
|
249
|
+
const suggestions = [];
|
|
250
|
+
|
|
251
|
+
if (context.mode === 'keyword') {
|
|
252
|
+
const q = (context.query || '').toLowerCase().trim();
|
|
253
|
+
for (const [id, kw] of configuredKeywords.entries()) {
|
|
254
|
+
const idLower = id.toLowerCase();
|
|
255
|
+
const labelLower = (kw.label || '').toLowerCase();
|
|
256
|
+
const matches = !q || idLower.startsWith(q) || labelLower.startsWith(q) || idLower.includes(q);
|
|
257
|
+
|
|
258
|
+
if (matches) {
|
|
259
|
+
suggestions.push({
|
|
260
|
+
type: 'keyword',
|
|
261
|
+
id: kw.id,
|
|
262
|
+
label: kw.label || kw.id,
|
|
263
|
+
display: `${kw.id}:`,
|
|
264
|
+
insertText: `${kw.id}:`,
|
|
265
|
+
description: kw.label || kw.id,
|
|
266
|
+
dataType: kw.dataType || 'string',
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
} else if (context.mode === 'value') {
|
|
271
|
+
const kwConfig = configuredKeywords.get(context.keywordLower);
|
|
272
|
+
if (!kwConfig) return [];
|
|
273
|
+
|
|
274
|
+
const prefix = (context.valuePrefix || '').toLowerCase();
|
|
275
|
+
|
|
276
|
+
for (const opt of kwConfig.options) {
|
|
277
|
+
const valLower = (opt.value || '').toLowerCase();
|
|
278
|
+
const lblLower = (opt.label || '').toLowerCase();
|
|
279
|
+
const txtLower = (opt.text || '').toLowerCase();
|
|
280
|
+
const matches = !prefix || valLower.includes(prefix) || lblLower.includes(prefix) || txtLower.includes(prefix);
|
|
281
|
+
|
|
282
|
+
if (matches) {
|
|
283
|
+
// Needs quotes if option contains spaces, or if already quoted, or if contains colons
|
|
284
|
+
const needsQuotes = context.quoted || opt.value.includes(' ') || opt.value.includes(':');
|
|
285
|
+
const quoteChar = context.quoteChar || '"';
|
|
286
|
+
const formatted = needsQuotes ? `${quoteChar}${opt.value}${quoteChar}` : opt.value;
|
|
287
|
+
const displayText = opt.text || opt.value;
|
|
288
|
+
|
|
289
|
+
suggestions.push({
|
|
290
|
+
type: 'value',
|
|
291
|
+
keyword: kwConfig.id,
|
|
292
|
+
keywordLabel: kwConfig.label || kwConfig.id,
|
|
293
|
+
value: opt.value,
|
|
294
|
+
label: opt.label || opt.value,
|
|
295
|
+
display: displayText,
|
|
296
|
+
insertText: formatted,
|
|
297
|
+
description: (opt.label && opt.label !== opt.value && opt.label !== displayText) ? opt.label : '',
|
|
298
|
+
dataType: kwConfig.dataType || 'string',
|
|
299
|
+
element: opt.element,
|
|
300
|
+
image: opt.image,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return suggestions;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Applies a selected suggestion to the current input string.
|
|
311
|
+
* @param {string} inputStr
|
|
312
|
+
* @param {Object} suggestion
|
|
313
|
+
* @param {Object} context
|
|
314
|
+
* @returns {{ newValue: string, newCaret: number }}
|
|
315
|
+
*/
|
|
316
|
+
export function applySuggestion(inputStr, suggestion, context) {
|
|
317
|
+
const replaceStart = context.replaceStart;
|
|
318
|
+
const replaceEnd = context.replaceEnd;
|
|
319
|
+
|
|
320
|
+
const before = inputStr.slice(0, replaceStart);
|
|
321
|
+
let after = inputStr.slice(replaceEnd);
|
|
322
|
+
let insertText = suggestion.insertText;
|
|
323
|
+
|
|
324
|
+
if (suggestion.type === 'keyword') {
|
|
325
|
+
// When inserting a keyword like `mix:`, do not add trailing space so user can immediately type value
|
|
326
|
+
// If after text starts with colon, remove it to avoid `mix::`
|
|
327
|
+
if (after.startsWith(':')) {
|
|
328
|
+
after = after.slice(1);
|
|
329
|
+
}
|
|
330
|
+
const newValue = before + insertText + after;
|
|
331
|
+
const newCaret = before.length + insertText.length;
|
|
332
|
+
return { newValue, newCaret };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// When inserting a value (e.g. `"We Play House Recordings"`)
|
|
336
|
+
// Add a trailing space if after does not already start with whitespace
|
|
337
|
+
if (after.length === 0 || !/^\s/.test(after)) {
|
|
338
|
+
insertText += ' ';
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const newValue = before + insertText + after;
|
|
342
|
+
const newCaret = before.length + insertText.length;
|
|
343
|
+
return { newValue, newCaret };
|
|
344
|
+
}
|