@keenmate/pure-admin-core 2.9.0-rc03 → 2.9.0-rc05
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 +55 -13
- package/dist/css/main.css +554 -7
- package/package.json +3 -1
- package/snippets/buttons.html +51 -0
- package/snippets/cards.html +132 -47
- package/snippets/comparison.html +26 -22
- package/snippets/manifest.json +180 -115
- package/snippets/range-group.html +125 -0
- package/snippets/splitter.html +44 -38
- package/snippets/statistics.html +31 -0
- package/src/js/btn-split-auto-absorb.js +327 -0
- package/src/js/command-palette.js +472 -0
- package/src/js/file-selector.js +1275 -0
- package/src/js/internal/logging.js +121 -0
- package/src/js/logic-tree-renderer.js +303 -0
- package/src/js/modal-dialogs.js +460 -0
- package/src/js/overflow.js +371 -0
- package/src/js/pa-stat-fit.js +184 -0
- package/src/js/range-group.js +663 -0
- package/src/js/search-autocomplete-v2.js +907 -0
- package/src/js/search-autocomplete.js +434 -0
- package/src/js/settings-panel.js +245 -0
- package/src/js/split-button.js +141 -0
- package/src/js/splitter.js +1323 -0
- package/src/js/toast-service.js +302 -0
- package/src/js/tooltips-popovers.js +275 -0
- package/src/js/virtual-scroll.js +143 -0
- package/src/js/virtual-textbox.js +803 -0
- package/src/scss/_core.scss +7 -0
- package/src/scss/core-components/_buttons.scss +44 -0
- package/src/scss/core-components/_cards.scss +95 -6
- package/src/scss/core-components/_overflow.scss +50 -0
- package/src/scss/core-components/_range-group.scss +474 -0
- package/src/scss/core-components/_statistics.scss +163 -0
- package/src/scss/variables/_components.scss +41 -2
|
@@ -0,0 +1,803 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Virtual Textbox - Contenteditable Query Builder with Inline Tokens
|
|
3
|
+
* Replaces standard input with contenteditable div for rich inline formatting
|
|
4
|
+
* Tokens appear as inline badges within the text flow
|
|
5
|
+
* Example: Type ":name" → select operator → enter value → inline [name][starts_with]["abc"]
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
class VirtualTextbox {
|
|
9
|
+
constructor(element, fields, options = {}) {
|
|
10
|
+
this.element = element; // contenteditable div
|
|
11
|
+
this.fields = fields;
|
|
12
|
+
this.options = {
|
|
13
|
+
enableHighlighting: true,
|
|
14
|
+
enableGrouping: true, // Group field+operator+value together
|
|
15
|
+
...options
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
this.popup = null;
|
|
19
|
+
this.activeIndex = -1;
|
|
20
|
+
this.isOpen = false;
|
|
21
|
+
this.autocompleteResults = [];
|
|
22
|
+
this.currentQueryState = 'idle'; // 'idle' | 'expecting-operator' | 'expecting-value' | 'expecting-logical'
|
|
23
|
+
this.pendingToken = null; // Stores token being built (field, operator)
|
|
24
|
+
|
|
25
|
+
// Logical operators
|
|
26
|
+
this.logicalOperators = [
|
|
27
|
+
{ value: 'AND', label: 'AND', shortcuts: ['and', '&&', '&'], description: 'All conditions must match' },
|
|
28
|
+
{ value: 'OR', label: 'OR', shortcuts: ['or', '||', '|'], description: 'Any condition can match' }
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
// Operator definitions by field type with shortcuts
|
|
32
|
+
this.operators = {
|
|
33
|
+
text: [
|
|
34
|
+
{ value: 'equals', label: 'Equals', shortcuts: ['eq', '='], description: 'Exact match' },
|
|
35
|
+
{ value: 'not_equals', label: 'Not Equals', shortcuts: ['neq', 'ne', '!='], description: 'Does not match' },
|
|
36
|
+
{ value: 'starts_with', label: 'Starts With', shortcuts: ['sw', 'startswith'], description: 'Starts with text' },
|
|
37
|
+
{ value: 'ends_with', label: 'Ends With', shortcuts: ['ew', 'endswith'], description: 'Ends with text' },
|
|
38
|
+
{ value: 'contains', label: 'Contains', shortcuts: ['c', 'has'], description: 'Contains text' },
|
|
39
|
+
{ value: 'not_contains', label: 'Not Contains', shortcuts: ['nc', 'notcontains'], description: 'Does not contain' }
|
|
40
|
+
],
|
|
41
|
+
number: [
|
|
42
|
+
{ value: '=', label: 'Equals', shortcuts: ['eq', '=='], description: 'Exact value' },
|
|
43
|
+
{ value: '!=', label: 'Not Equals', shortcuts: ['neq', 'ne'], description: 'Different value' },
|
|
44
|
+
{ value: '>', label: 'Greater Than', shortcuts: ['gt'], description: 'Greater than value' },
|
|
45
|
+
{ value: '<', label: 'Less Than', shortcuts: ['lt'], description: 'Less than value' },
|
|
46
|
+
{ value: 'between', label: 'Between', shortcuts: ['bw', 'btw'], description: 'Between two values' },
|
|
47
|
+
{ value: '>=', label: 'Greater or Equal', shortcuts: ['gte', 'ge'], description: 'Greater or equal' },
|
|
48
|
+
{ value: '<=', label: 'Less or Equal', shortcuts: ['lte', 'le'], description: 'Less or equal' }
|
|
49
|
+
],
|
|
50
|
+
date: [
|
|
51
|
+
{ value: 'equals', label: 'On Date', shortcuts: ['eq', '=', 'on'], description: 'Exact date' },
|
|
52
|
+
{ value: 'before', label: 'Before', shortcuts: ['bf', '<'], description: 'Before date' },
|
|
53
|
+
{ value: 'after', label: 'After', shortcuts: ['af', '>'], description: 'After date' },
|
|
54
|
+
{ value: 'between', label: 'Between', shortcuts: ['bw', 'btw'], description: 'Date range' },
|
|
55
|
+
{ value: 'in_last', label: 'In Last', shortcuts: ['last', 'past'], description: 'In last X days' },
|
|
56
|
+
{ value: 'in_next', label: 'In Next', shortcuts: ['next', 'future'], description: 'In next X days' }
|
|
57
|
+
]
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
this.init();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
init() {
|
|
64
|
+
// Make element contenteditable if not already
|
|
65
|
+
if (this.element.getAttribute('contenteditable') !== 'true') {
|
|
66
|
+
this.element.setAttribute('contenteditable', 'true');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Create autocomplete popup
|
|
70
|
+
this.popup = document.createElement('div');
|
|
71
|
+
this.popup.className = 'pa-search-autocomplete';
|
|
72
|
+
this.popup.style.display = 'none';
|
|
73
|
+
document.body.appendChild(this.popup);
|
|
74
|
+
|
|
75
|
+
// Event listeners
|
|
76
|
+
this.element.addEventListener('input', (e) => this.handleInput(e));
|
|
77
|
+
this.element.addEventListener('keydown', (e) => this.handleKeydown(e));
|
|
78
|
+
this.element.addEventListener('paste', (e) => this.handlePaste(e));
|
|
79
|
+
document.addEventListener('click', (e) => this.handleClickOutside(e));
|
|
80
|
+
|
|
81
|
+
// Initialize state
|
|
82
|
+
this.updateQueryState();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Update query state based on pending token
|
|
87
|
+
*/
|
|
88
|
+
updateQueryState() {
|
|
89
|
+
if (!this.pendingToken) {
|
|
90
|
+
this.currentQueryState = 'idle';
|
|
91
|
+
} else if (this.pendingToken.type === 'field') {
|
|
92
|
+
this.currentQueryState = 'expecting-operator';
|
|
93
|
+
} else if (this.pendingToken.type === 'operator') {
|
|
94
|
+
this.currentQueryState = 'expecting-value';
|
|
95
|
+
} else if (this.pendingToken.type === 'value-completed') {
|
|
96
|
+
this.currentQueryState = 'expecting-logical';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Get current text content at cursor position
|
|
102
|
+
*/
|
|
103
|
+
getCurrentWord() {
|
|
104
|
+
const selection = window.getSelection();
|
|
105
|
+
if (!selection.rangeCount) return '';
|
|
106
|
+
|
|
107
|
+
const range = selection.getRangeAt(0);
|
|
108
|
+
const textNode = range.startContainer;
|
|
109
|
+
|
|
110
|
+
if (textNode.nodeType !== Node.TEXT_NODE) {
|
|
111
|
+
return '';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const text = textNode.textContent;
|
|
115
|
+
const cursorPos = range.startOffset;
|
|
116
|
+
|
|
117
|
+
// Find word boundaries
|
|
118
|
+
let start = cursorPos;
|
|
119
|
+
while (start > 0 && !/\s/.test(text[start - 1])) {
|
|
120
|
+
start--;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let end = cursorPos;
|
|
124
|
+
while (end < text.length && !/\s/.test(text[end])) {
|
|
125
|
+
end++;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return text.substring(start, end);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Search for autocomplete suggestions
|
|
133
|
+
*/
|
|
134
|
+
searchAutocomplete(query) {
|
|
135
|
+
if (this.currentQueryState === 'idle') {
|
|
136
|
+
// Search for fields (must start with :)
|
|
137
|
+
if (query.startsWith(':')) {
|
|
138
|
+
const searchTerm = query.substring(1).toLowerCase();
|
|
139
|
+
return this.fields.filter(field =>
|
|
140
|
+
field.name.toLowerCase().includes(searchTerm)
|
|
141
|
+
).map(field => ({
|
|
142
|
+
value: field.name,
|
|
143
|
+
label: field.name,
|
|
144
|
+
description: `${field.type} field`,
|
|
145
|
+
type: field.type
|
|
146
|
+
}));
|
|
147
|
+
}
|
|
148
|
+
} else if (this.currentQueryState === 'expecting-operator') {
|
|
149
|
+
// Get operators for current field type
|
|
150
|
+
const fieldDef = this.fields.find(f => f.name === this.pendingToken.value);
|
|
151
|
+
if (!fieldDef) return [];
|
|
152
|
+
|
|
153
|
+
const operators = this.operators[fieldDef.type] || [];
|
|
154
|
+
const searchTerm = query.toLowerCase();
|
|
155
|
+
|
|
156
|
+
return operators.filter(op =>
|
|
157
|
+
searchTerm === '' ||
|
|
158
|
+
op.value.toLowerCase().includes(searchTerm) ||
|
|
159
|
+
op.label.toLowerCase().includes(searchTerm) ||
|
|
160
|
+
(op.shortcuts && op.shortcuts.some(s => s.toLowerCase().includes(searchTerm)))
|
|
161
|
+
);
|
|
162
|
+
} else if (this.currentQueryState === 'expecting-logical') {
|
|
163
|
+
// Show logical operators
|
|
164
|
+
const searchTerm = query.toLowerCase();
|
|
165
|
+
return this.logicalOperators.filter(op =>
|
|
166
|
+
searchTerm === '' ||
|
|
167
|
+
op.value.toLowerCase().includes(searchTerm) ||
|
|
168
|
+
op.label.toLowerCase().includes(searchTerm) ||
|
|
169
|
+
(op.shortcuts && op.shortcuts.some(s => s.toLowerCase().includes(searchTerm)))
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Render autocomplete dropdown
|
|
178
|
+
*/
|
|
179
|
+
renderAutocomplete() {
|
|
180
|
+
if (this.autocompleteResults.length === 0) {
|
|
181
|
+
this.close();
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const isFieldSearch = this.currentQueryState === 'idle';
|
|
186
|
+
let html = '';
|
|
187
|
+
|
|
188
|
+
this.autocompleteResults.forEach((item, index) => {
|
|
189
|
+
const isActive = index === this.activeIndex;
|
|
190
|
+
const icon = isFieldSearch ? '🔍' : '⚡';
|
|
191
|
+
|
|
192
|
+
html += `
|
|
193
|
+
<div class="pa-search-autocomplete__item ${isActive ? 'pa-search-autocomplete__item--active' : ''}" data-index="${index}">
|
|
194
|
+
<span class="pa-search-autocomplete__item-icon">${icon}</span>
|
|
195
|
+
<div class="pa-search-autocomplete__item-content">
|
|
196
|
+
<span class="pa-search-autocomplete__item-name">${item.label}</span>
|
|
197
|
+
<span class="pa-search-autocomplete__item-type">${item.description}</span>
|
|
198
|
+
</div>
|
|
199
|
+
<span class="pa-search-autocomplete__item-badge">${item.value}</span>
|
|
200
|
+
</div>
|
|
201
|
+
`;
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
this.popup.innerHTML = html;
|
|
205
|
+
|
|
206
|
+
// Add click handlers
|
|
207
|
+
this.popup.querySelectorAll('.pa-search-autocomplete__item').forEach((item, index) => {
|
|
208
|
+
item.addEventListener('click', () => {
|
|
209
|
+
this.selectAutocomplete(this.autocompleteResults[index]);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
this.position();
|
|
214
|
+
this.open();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Select autocomplete item and insert token
|
|
219
|
+
*/
|
|
220
|
+
selectAutocomplete(item) {
|
|
221
|
+
if (!item) return;
|
|
222
|
+
|
|
223
|
+
// Remove the current word being typed
|
|
224
|
+
this.removeCurrentWord();
|
|
225
|
+
|
|
226
|
+
// Determine token type and variant
|
|
227
|
+
let tokenType, variant;
|
|
228
|
+
|
|
229
|
+
if (this.currentQueryState === 'idle') {
|
|
230
|
+
tokenType = 'field';
|
|
231
|
+
variant = 'field';
|
|
232
|
+
this.pendingToken = { type: 'field', value: item.value, fieldType: item.type };
|
|
233
|
+
} else if (this.currentQueryState === 'expecting-operator') {
|
|
234
|
+
tokenType = 'operator';
|
|
235
|
+
variant = 'operator';
|
|
236
|
+
this.pendingToken = { type: 'operator', value: item.value, field: this.pendingToken };
|
|
237
|
+
} else if (this.currentQueryState === 'expecting-logical') {
|
|
238
|
+
tokenType = 'logical';
|
|
239
|
+
variant = 'logical';
|
|
240
|
+
// Reset to idle after logical operator
|
|
241
|
+
this.pendingToken = null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Insert token at cursor
|
|
245
|
+
this.insertToken(item.value, variant);
|
|
246
|
+
|
|
247
|
+
// Update state
|
|
248
|
+
this.updateQueryState();
|
|
249
|
+
|
|
250
|
+
// Clear autocomplete
|
|
251
|
+
this.autocompleteResults = [];
|
|
252
|
+
this.activeIndex = -1;
|
|
253
|
+
this.close();
|
|
254
|
+
this.element.focus();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Remove current word being typed (for autocomplete replacement)
|
|
259
|
+
*/
|
|
260
|
+
removeCurrentWord() {
|
|
261
|
+
const selection = window.getSelection();
|
|
262
|
+
if (!selection.rangeCount) return;
|
|
263
|
+
|
|
264
|
+
const range = selection.getRangeAt(0);
|
|
265
|
+
const textNode = range.startContainer;
|
|
266
|
+
|
|
267
|
+
if (textNode.nodeType !== Node.TEXT_NODE) return;
|
|
268
|
+
|
|
269
|
+
const text = textNode.textContent;
|
|
270
|
+
const cursorPos = range.startOffset;
|
|
271
|
+
|
|
272
|
+
// Find word boundaries
|
|
273
|
+
let start = cursorPos;
|
|
274
|
+
while (start > 0 && !/\s/.test(text[start - 1])) {
|
|
275
|
+
start--;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Create range to delete word
|
|
279
|
+
const deleteRange = document.createRange();
|
|
280
|
+
deleteRange.setStart(textNode, start);
|
|
281
|
+
deleteRange.setEnd(textNode, cursorPos);
|
|
282
|
+
deleteRange.deleteContents();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Insert token at cursor position
|
|
287
|
+
*/
|
|
288
|
+
insertToken(value, variant) {
|
|
289
|
+
const selection = window.getSelection();
|
|
290
|
+
if (!selection.rangeCount) return;
|
|
291
|
+
|
|
292
|
+
const range = selection.getRangeAt(0);
|
|
293
|
+
|
|
294
|
+
// Map variants to color classes (no badges, just colored text)
|
|
295
|
+
const colorMap = {
|
|
296
|
+
'field': '#1565c0', // Blue
|
|
297
|
+
'operator': '#616161', // Gray
|
|
298
|
+
'value': '#2e7d32', // Green
|
|
299
|
+
'logical': '#e65100', // Orange
|
|
300
|
+
'paren': '#7b1fa2' // Purple
|
|
301
|
+
};
|
|
302
|
+
const color = colorMap[variant] || '#000000';
|
|
303
|
+
|
|
304
|
+
// Create colored text span (non-editable to prevent cursor getting stuck)
|
|
305
|
+
const token = document.createElement('span');
|
|
306
|
+
token.style.color = color;
|
|
307
|
+
token.style.fontWeight = (variant === 'logical' || variant === 'field') ? 'bold' : 'normal';
|
|
308
|
+
token.style.cursor = 'pointer';
|
|
309
|
+
token.setAttribute('contenteditable', 'false');
|
|
310
|
+
token.setAttribute('data-token-type', variant);
|
|
311
|
+
token.setAttribute('data-token-value', value);
|
|
312
|
+
token.textContent = value;
|
|
313
|
+
token.className = 'pa-query-token';
|
|
314
|
+
|
|
315
|
+
// Allow editing on double-click
|
|
316
|
+
token.addEventListener('dblclick', (e) => {
|
|
317
|
+
e.preventDefault();
|
|
318
|
+
e.stopPropagation();
|
|
319
|
+
this.editToken(token);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// Add space after token
|
|
323
|
+
const space = document.createTextNode(' ');
|
|
324
|
+
|
|
325
|
+
// Insert token and space
|
|
326
|
+
range.deleteContents();
|
|
327
|
+
range.insertNode(space);
|
|
328
|
+
range.insertNode(token);
|
|
329
|
+
|
|
330
|
+
// Move cursor after space
|
|
331
|
+
range.setStartAfter(space);
|
|
332
|
+
range.setEndAfter(space);
|
|
333
|
+
selection.removeAllRanges();
|
|
334
|
+
selection.addRange(range);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Check if we're inside a quoted string
|
|
339
|
+
*/
|
|
340
|
+
isInQuotedString() {
|
|
341
|
+
const selection = window.getSelection();
|
|
342
|
+
if (!selection.rangeCount) return false;
|
|
343
|
+
|
|
344
|
+
const range = selection.getRangeAt(0);
|
|
345
|
+
const textNode = range.startContainer;
|
|
346
|
+
if (textNode.nodeType !== Node.TEXT_NODE) return false;
|
|
347
|
+
|
|
348
|
+
const text = textNode.textContent;
|
|
349
|
+
const cursorPos = range.startOffset;
|
|
350
|
+
|
|
351
|
+
// Count quotes before cursor
|
|
352
|
+
let quoteCount = 0;
|
|
353
|
+
for (let i = 0; i < cursorPos; i++) {
|
|
354
|
+
if (text[i] === '"') quoteCount++;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// If odd number of quotes, we're inside a quoted string
|
|
358
|
+
return quoteCount % 2 === 1;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Get current word or quoted string at cursor
|
|
363
|
+
*/
|
|
364
|
+
getCurrentValue() {
|
|
365
|
+
const selection = window.getSelection();
|
|
366
|
+
if (!selection.rangeCount) return {
|
|
367
|
+
text: "",
|
|
368
|
+
range: document.createRange()
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
const range = selection.getRangeAt(0);
|
|
373
|
+
const textNode = range.startContainer;
|
|
374
|
+
|
|
375
|
+
if (textNode.nodeType !== Node.TEXT_NODE) {
|
|
376
|
+
return {
|
|
377
|
+
text: "",
|
|
378
|
+
range: document.createRange()
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const text = textNode.textContent;
|
|
383
|
+
const cursorPos = range.startOffset;
|
|
384
|
+
|
|
385
|
+
// Check if we're in a quoted string
|
|
386
|
+
let quoteStart = -1;
|
|
387
|
+
for (let i = cursorPos - 2; i >= 0; i--) {
|
|
388
|
+
if (text[i] === '"') {
|
|
389
|
+
quoteStart = i;
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (quoteStart >= 0) {
|
|
395
|
+
// Find closing quote
|
|
396
|
+
let quoteEnd = text.indexOf('"', quoteStart + 1);
|
|
397
|
+
if (quoteEnd > quoteStart) {
|
|
398
|
+
// Return the quoted string with quotes
|
|
399
|
+
|
|
400
|
+
const newRange = document.createRange()
|
|
401
|
+
newRange.setStart(textNode, quoteStart);
|
|
402
|
+
newRange.setEnd(textNode, quoteEnd - quoteStart + 2);
|
|
403
|
+
return {
|
|
404
|
+
text: text.substring(quoteStart, quoteEnd + 1),
|
|
405
|
+
range: newRange
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Not in quotes, get regular word
|
|
411
|
+
let start = cursorPos;
|
|
412
|
+
while (start > 0 && !/\s/.test(text[start - 1])) {
|
|
413
|
+
start--;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
let end = cursorPos;
|
|
417
|
+
while (end < text.length && !/\s/.test(text[end])) {
|
|
418
|
+
end++;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const newRange = document.createRange()
|
|
422
|
+
newRange.setStart(textNode, start);
|
|
423
|
+
newRange.setEnd(textNode, end - start + 1);
|
|
424
|
+
return {
|
|
425
|
+
text: text.substring(start, end),
|
|
426
|
+
range: newRange
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Complete value token (when user types text and presses Space/Enter)
|
|
432
|
+
*/
|
|
433
|
+
completeValueToken() {
|
|
434
|
+
const {text: currentValue, range: currentValueRange} = this.getCurrentValue();
|
|
435
|
+
|
|
436
|
+
if (currentValue && this.currentQueryState === 'expecting-value') {
|
|
437
|
+
// Check if it's a quoted string
|
|
438
|
+
let value = currentValue;
|
|
439
|
+
if (value.startsWith('"') && value.endsWith('"')) {
|
|
440
|
+
// Remove quotes for the token value
|
|
441
|
+
value = value.substring(1, value.length - 1);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Remove the typed text
|
|
445
|
+
// this.removeCurrentWord();
|
|
446
|
+
// console.log("currentValueRange ", currentValueRange)
|
|
447
|
+
currentValueRange.deleteContents();
|
|
448
|
+
|
|
449
|
+
// Insert value token
|
|
450
|
+
this.insertToken(value, 'value');
|
|
451
|
+
|
|
452
|
+
// After value, expect logical operator
|
|
453
|
+
this.pendingToken = { type: 'value-completed' };
|
|
454
|
+
this.updateQueryState();
|
|
455
|
+
|
|
456
|
+
return true;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return false;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Handle input changes
|
|
464
|
+
*/
|
|
465
|
+
handleInput(e) {
|
|
466
|
+
// Sync state with actual token structure in DOM
|
|
467
|
+
this.syncStateWithTokens();
|
|
468
|
+
|
|
469
|
+
const currentWord = this.getCurrentWord();
|
|
470
|
+
|
|
471
|
+
// Check for autocomplete trigger
|
|
472
|
+
if (this.currentQueryState === 'idle' || this.currentQueryState === 'expecting-operator' || this.currentQueryState === 'expecting-logical') {
|
|
473
|
+
this.autocompleteResults = this.searchAutocomplete(currentWord);
|
|
474
|
+
this.activeIndex = this.autocompleteResults.length > 0 ? 0 : -1;
|
|
475
|
+
|
|
476
|
+
if (this.autocompleteResults.length > 0) {
|
|
477
|
+
this.renderAutocomplete();
|
|
478
|
+
} else {
|
|
479
|
+
this.close();
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Sync internal state with actual tokens in DOM
|
|
486
|
+
* Called on input to detect token deletions
|
|
487
|
+
*/
|
|
488
|
+
syncStateWithTokens() {
|
|
489
|
+
const tokens = Array.from(this.element.querySelectorAll('span[data-token-type]'));
|
|
490
|
+
|
|
491
|
+
if (tokens.length === 0) {
|
|
492
|
+
// No tokens at all - reset to idle
|
|
493
|
+
this.pendingToken = null;
|
|
494
|
+
this.updateQueryState();
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Get the last token to determine state
|
|
499
|
+
const lastToken = tokens[tokens.length - 1];
|
|
500
|
+
const lastTokenType = lastToken.getAttribute('data-token-type');
|
|
501
|
+
const lastTokenValue = lastToken.textContent || lastToken.getAttribute('data-token-value');
|
|
502
|
+
|
|
503
|
+
if (lastTokenType === 'field') {
|
|
504
|
+
// Last token is a field - expecting operator
|
|
505
|
+
const fieldDef = this.fields.find(f => f.name === lastTokenValue);
|
|
506
|
+
this.pendingToken = {
|
|
507
|
+
type: 'field',
|
|
508
|
+
value: lastTokenValue,
|
|
509
|
+
fieldType: fieldDef ? fieldDef.type : 'text'
|
|
510
|
+
};
|
|
511
|
+
this.updateQueryState();
|
|
512
|
+
} else if (lastTokenType === 'operator') {
|
|
513
|
+
// Last token is an operator - expecting value
|
|
514
|
+
// Need to find the preceding field token
|
|
515
|
+
const fieldToken = tokens[tokens.length - 2];
|
|
516
|
+
if (fieldToken && fieldToken.getAttribute('data-token-type') === 'field') {
|
|
517
|
+
const fieldValue = fieldToken.textContent || fieldToken.getAttribute('data-token-value');
|
|
518
|
+
const fieldDef = this.fields.find(f => f.name === fieldValue);
|
|
519
|
+
this.pendingToken = {
|
|
520
|
+
type: 'operator',
|
|
521
|
+
value: lastTokenValue,
|
|
522
|
+
field: {
|
|
523
|
+
type: 'field',
|
|
524
|
+
value: fieldValue,
|
|
525
|
+
fieldType: fieldDef ? fieldDef.type : 'text'
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
this.updateQueryState();
|
|
529
|
+
}
|
|
530
|
+
} else if (lastTokenType === 'value') {
|
|
531
|
+
// Last token is a value - expect logical operator
|
|
532
|
+
this.pendingToken = { type: 'value-completed' };
|
|
533
|
+
this.updateQueryState();
|
|
534
|
+
} else if (lastTokenType === 'logical' || lastTokenType === 'paren') {
|
|
535
|
+
// After logical operator or opening paren - back to idle (expecting field)
|
|
536
|
+
this.pendingToken = null;
|
|
537
|
+
this.updateQueryState();
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Handle keyboard navigation and shortcuts
|
|
543
|
+
*/
|
|
544
|
+
handleKeydown(e) {
|
|
545
|
+
// Handle parentheses insertion
|
|
546
|
+
if (e.key === '(' || e.key === ')') {
|
|
547
|
+
e.preventDefault();
|
|
548
|
+
const parenType = e.key === '(' ? 'paren-open' : 'paren-close';
|
|
549
|
+
this.insertToken(e.key, 'paren');
|
|
550
|
+
|
|
551
|
+
// Update state based on paren type
|
|
552
|
+
if (e.key === '(') {
|
|
553
|
+
this.pendingToken = null; // Expecting field after (
|
|
554
|
+
} else {
|
|
555
|
+
this.pendingToken = { type: 'value-completed' }; // Expecting logical after )
|
|
556
|
+
}
|
|
557
|
+
this.updateQueryState();
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Handle space key - auto-complete if exactly one match
|
|
562
|
+
if (e.key === ' ' && (this.currentQueryState === 'expecting-operator' || this.currentQueryState === 'expecting-logical')) {
|
|
563
|
+
if (this.autocompleteResults.length === 1) {
|
|
564
|
+
e.preventDefault();
|
|
565
|
+
this.selectAutocomplete(this.autocompleteResults[0]);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Handle autocomplete navigation
|
|
571
|
+
if (this.autocompleteResults.length > 0 && this.isOpen) {
|
|
572
|
+
if (e.key === 'ArrowUp') {
|
|
573
|
+
e.preventDefault();
|
|
574
|
+
this.navigateAutocompletePrevious();
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
if (e.key === 'ArrowDown') {
|
|
578
|
+
e.preventDefault();
|
|
579
|
+
this.navigateAutocompleteNext();
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
if (e.key === 'Enter' || e.key === 'Tab') {
|
|
583
|
+
e.preventDefault();
|
|
584
|
+
this.selectAutocomplete(this.autocompleteResults[this.activeIndex]);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
// Space also accepts the selected item when autocomplete is open
|
|
588
|
+
if (e.key === ' ' && this.activeIndex >= 0) {
|
|
589
|
+
e.preventDefault();
|
|
590
|
+
this.selectAutocomplete(this.autocompleteResults[this.activeIndex]);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Handle value completion
|
|
596
|
+
if (this.currentQueryState === 'expecting-value') {
|
|
597
|
+
// Only complete on space if we're NOT in a quoted string AND there's actual text
|
|
598
|
+
if (e.key === ' ' && !this.isInQuotedString()) {
|
|
599
|
+
const {text: currentValue} = this.getCurrentValue();
|
|
600
|
+
// Only complete if there's actual text to tokenize
|
|
601
|
+
if (currentValue && currentValue.trim() !== '') {
|
|
602
|
+
e.preventDefault();
|
|
603
|
+
this.completeValueToken();
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
// Otherwise, let the space be inserted naturally (user is starting to type)
|
|
607
|
+
}
|
|
608
|
+
// Always complete on Enter or Tab (but only if there's text)
|
|
609
|
+
if (e.key === 'Enter' || e.key === 'Tab') {
|
|
610
|
+
const {text: currentValue} = this.getCurrentValue();
|
|
611
|
+
if (currentValue && currentValue.trim() !== '') {
|
|
612
|
+
e.preventDefault();
|
|
613
|
+
this.completeValueToken();
|
|
614
|
+
}
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Escape to close
|
|
620
|
+
if (e.key === 'Escape') {
|
|
621
|
+
this.close();
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Handle paste - strip formatting
|
|
627
|
+
*/
|
|
628
|
+
handlePaste(e) {
|
|
629
|
+
e.preventDefault();
|
|
630
|
+
const text = (e.clipboardData || window.clipboardData).getData('text/plain');
|
|
631
|
+
document.execCommand('insertText', false, text);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Navigate autocomplete suggestions
|
|
636
|
+
*/
|
|
637
|
+
navigateAutocompletePrevious() {
|
|
638
|
+
if (this.autocompleteResults.length === 0) return;
|
|
639
|
+
|
|
640
|
+
this.activeIndex = this.activeIndex <= 0
|
|
641
|
+
? this.autocompleteResults.length - 1
|
|
642
|
+
: this.activeIndex - 1;
|
|
643
|
+
|
|
644
|
+
this.renderAutocomplete();
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
navigateAutocompleteNext() {
|
|
648
|
+
if (this.autocompleteResults.length === 0) return;
|
|
649
|
+
|
|
650
|
+
this.activeIndex = this.activeIndex >= this.autocompleteResults.length - 1
|
|
651
|
+
? 0
|
|
652
|
+
: this.activeIndex + 1;
|
|
653
|
+
|
|
654
|
+
this.renderAutocomplete();
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Position popup below element (at cursor position ideally)
|
|
659
|
+
*/
|
|
660
|
+
position() {
|
|
661
|
+
const elementRect = this.element.getBoundingClientRect();
|
|
662
|
+
|
|
663
|
+
// Try to position near cursor
|
|
664
|
+
const selection = window.getSelection();
|
|
665
|
+
if (selection.rangeCount > 0) {
|
|
666
|
+
const range = selection.getRangeAt(0);
|
|
667
|
+
const rect = range.getBoundingClientRect();
|
|
668
|
+
|
|
669
|
+
if (rect.left > 0 && rect.top > 0) {
|
|
670
|
+
this.popup.style.position = 'absolute';
|
|
671
|
+
this.popup.style.left = rect.left + 'px';
|
|
672
|
+
this.popup.style.top = (rect.bottom + 4) + 'px';
|
|
673
|
+
this.popup.style.minWidth = Math.max(250, elementRect.width) + 'px';
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Fallback: position below element
|
|
679
|
+
this.popup.style.position = 'absolute';
|
|
680
|
+
this.popup.style.left = elementRect.left + 'px';
|
|
681
|
+
this.popup.style.top = (elementRect.bottom + 4) + 'px';
|
|
682
|
+
this.popup.style.minWidth = Math.max(250, elementRect.width) + 'px';
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Open popup
|
|
687
|
+
*/
|
|
688
|
+
open() {
|
|
689
|
+
this.popup.style.display = 'block';
|
|
690
|
+
this.isOpen = true;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Close popup
|
|
695
|
+
*/
|
|
696
|
+
close() {
|
|
697
|
+
this.popup.style.display = 'none';
|
|
698
|
+
this.isOpen = false;
|
|
699
|
+
this.activeIndex = -1;
|
|
700
|
+
this.autocompleteResults = [];
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Handle click outside
|
|
705
|
+
*/
|
|
706
|
+
handleClickOutside(e) {
|
|
707
|
+
if (this.isOpen && !this.popup.contains(e.target) && e.target !== this.element && !this.element.contains(e.target)) {
|
|
708
|
+
this.close();
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Get parsed query from tokens
|
|
714
|
+
*/
|
|
715
|
+
getQuery() {
|
|
716
|
+
const tokens = this.element.querySelectorAll('span[data-token-type]');
|
|
717
|
+
const query = [];
|
|
718
|
+
|
|
719
|
+
tokens.forEach(token => {
|
|
720
|
+
query.push({
|
|
721
|
+
type: token.getAttribute('data-token-type'),
|
|
722
|
+
value: token.textContent || token.getAttribute('data-token-value')
|
|
723
|
+
});
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
return query;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Edit a token (make it temporarily editable)
|
|
731
|
+
*/
|
|
732
|
+
editToken(token) {
|
|
733
|
+
const originalValue = token.textContent;
|
|
734
|
+
const originalColor = token.style.color;
|
|
735
|
+
|
|
736
|
+
// Make it editable
|
|
737
|
+
token.setAttribute('contenteditable', 'true');
|
|
738
|
+
token.style.backgroundColor = '#fff9e6';
|
|
739
|
+
token.style.padding = '2px 4px';
|
|
740
|
+
token.style.borderRadius = '3px';
|
|
741
|
+
|
|
742
|
+
// Select all text
|
|
743
|
+
const range = document.createRange();
|
|
744
|
+
range.selectNodeContents(token);
|
|
745
|
+
const selection = window.getSelection();
|
|
746
|
+
selection.removeAllRanges();
|
|
747
|
+
selection.addRange(range);
|
|
748
|
+
|
|
749
|
+
// Focus it
|
|
750
|
+
token.focus();
|
|
751
|
+
|
|
752
|
+
// Handle blur (when user clicks away or presses enter)
|
|
753
|
+
const finishEdit = () => {
|
|
754
|
+
token.setAttribute('contenteditable', 'false');
|
|
755
|
+
token.style.backgroundColor = '';
|
|
756
|
+
token.style.padding = '';
|
|
757
|
+
token.style.borderRadius = '';
|
|
758
|
+
|
|
759
|
+
const newValue = token.textContent.trim();
|
|
760
|
+
if (newValue === '') {
|
|
761
|
+
// Remove token if empty
|
|
762
|
+
token.remove();
|
|
763
|
+
} else {
|
|
764
|
+
// Update the data attribute
|
|
765
|
+
token.setAttribute('data-token-value', newValue);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Trigger input event to update tree
|
|
769
|
+
this.element.dispatchEvent(new Event('input', { bubbles: true }));
|
|
770
|
+
};
|
|
771
|
+
|
|
772
|
+
token.addEventListener('blur', finishEdit, { once: true });
|
|
773
|
+
token.addEventListener('keydown', (e) => {
|
|
774
|
+
if (e.key === 'Enter') {
|
|
775
|
+
e.preventDefault();
|
|
776
|
+
token.blur();
|
|
777
|
+
} else if (e.key === 'Escape') {
|
|
778
|
+
e.preventDefault();
|
|
779
|
+
token.textContent = originalValue;
|
|
780
|
+
token.blur();
|
|
781
|
+
}
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Clear all content
|
|
787
|
+
*/
|
|
788
|
+
clear() {
|
|
789
|
+
this.element.innerHTML = '';
|
|
790
|
+
this.pendingToken = null;
|
|
791
|
+
this.updateQueryState();
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Destroy instance
|
|
796
|
+
*/
|
|
797
|
+
destroy() {
|
|
798
|
+
if (this.popup && this.popup.parentNode) {
|
|
799
|
+
this.popup.parentNode.removeChild(this.popup);
|
|
800
|
+
}
|
|
801
|
+
this.element.removeAttribute('contenteditable');
|
|
802
|
+
}
|
|
803
|
+
}
|