flyql-vue 0.0.37

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.
@@ -0,0 +1,928 @@
1
+ <template>
2
+ <div class="flyql-editor" :class="{ 'flyql-editor--focused': focused, 'flyql-dark': dark }" ref="editorRoot">
3
+ <span class="flyql-editor__icon">
4
+ <slot name="icon">
5
+ <svg
6
+ width="13"
7
+ height="13"
8
+ viewBox="0 0 24 24"
9
+ fill="none"
10
+ stroke="currentColor"
11
+ stroke-width="2"
12
+ stroke-linecap="round"
13
+ stroke-linejoin="round"
14
+ >
15
+ <circle cx="11" cy="11" r="8" />
16
+ <line x1="21" y1="21" x2="16.65" y2="16.65" />
17
+ </svg>
18
+ </slot>
19
+ </span>
20
+ <div class="flyql-editor__container" ref="containerRef">
21
+ <pre class="flyql-editor__highlight" ref="highlightRef" v-html="highlightedHtml" aria-hidden="true"></pre>
22
+ <textarea
23
+ class="flyql-editor__input"
24
+ ref="textareaRef"
25
+ rows="1"
26
+ :value="modelValue"
27
+ :placeholder="placeholder"
28
+ @input="onInput"
29
+ @keydown="onKeydown"
30
+ @focus="onFocus"
31
+ @blur="onBlur"
32
+ @scroll="onScroll"
33
+ @click="onCursorMove"
34
+ @paste="onPaste"
35
+ @compositionstart="engine.state.composing = true"
36
+ @compositionend="onCompositionEnd"
37
+ spellcheck="false"
38
+ autocomplete="off"
39
+ autocorrect="off"
40
+ autocapitalize="off"
41
+ aria-label="FlyQL query input"
42
+ role="combobox"
43
+ :aria-expanded="focused && activated && suggestions.length > 0"
44
+ :aria-activedescendant="
45
+ focused && activated && suggestions.length > 0
46
+ ? instanceId + '-suggestion-' + selectedIndex
47
+ : undefined
48
+ "
49
+ ></textarea>
50
+ </div>
51
+ <!-- Suggestion panel -->
52
+ <Teleport to="body">
53
+ <div
54
+ v-if="focused && activated"
55
+ ref="panelRef"
56
+ class="flyql-panel"
57
+ :class="{ 'flyql-dark': dark }"
58
+ @mousedown.prevent
59
+ :style="panelStyle"
60
+ >
61
+ <div v-if="debug" class="flyql-panel__header flyql-panel__debug">
62
+ <span v-if="context"
63
+ >state={{ context.state }} expecting={{ context.expecting }} key={{ context.key }} value={{
64
+ context.value
65
+ }}
66
+ op={{ context.keyValueOperator }}</span
67
+ >
68
+ <span v-else>no context</span>
69
+ <button class="flyql-panel__clear" title="Close suggestions" @click="activated = false">
70
+ <svg
71
+ width="10"
72
+ height="10"
73
+ viewBox="0 0 24 24"
74
+ fill="none"
75
+ stroke="currentColor"
76
+ stroke-width="2.5"
77
+ stroke-linecap="round"
78
+ >
79
+ <line x1="18" y1="6" x2="6" y2="18" />
80
+ <line x1="6" y1="6" x2="18" y2="18" />
81
+ </svg>
82
+ </button>
83
+ </div>
84
+ <div class="flyql-panel__loader" :class="{ 'flyql-panel__loader--active': isLoading }"></div>
85
+ <div
86
+ class="flyql-panel__header"
87
+ :class="{ 'flyql-panel__header--with-toggle': isValueContext && activated }"
88
+ >
89
+ <span :class="{ 'flyql-panel__header-label': isValueContext && activated }">Suggestions</span>
90
+ <span v-if="isValueContext && activated" class="flyql-panel__toggle">
91
+ <span class="flyql-panel__toggle-hint"
92
+ ><span class="flyql-panel__toggle-hint-icon">⇥</span> tab to switch</span
93
+ >
94
+ <span class="flyql-panel__toggle-group" role="tablist">
95
+ <button
96
+ role="tab"
97
+ :aria-selected="activeTab === 'values'"
98
+ class="flyql-panel__toggle-btn flyql-panel__toggle-btn--values"
99
+ :class="{ 'flyql-panel__toggle-btn--active': activeTab === 'values' }"
100
+ @mousedown.prevent="switchTab('values')"
101
+ >
102
+ Values
103
+ </button>
104
+ <button
105
+ role="tab"
106
+ :aria-selected="activeTab === 'columns'"
107
+ class="flyql-panel__toggle-btn flyql-panel__toggle-btn--columns"
108
+ :class="{ 'flyql-panel__toggle-btn--active': activeTab === 'columns' }"
109
+ @mousedown.prevent="switchTab('columns')"
110
+ >
111
+ Columns
112
+ </button>
113
+ </span>
114
+ </span>
115
+ </div>
116
+ <div class="flyql-panel__body" aria-live="polite">
117
+ <ul v-if="suggestions.length > 0" ref="listRef" class="flyql-panel__list" role="listbox">
118
+ <li
119
+ v-for="(item, index) in suggestions"
120
+ :key="index"
121
+ :id="instanceId + '-suggestion-' + index"
122
+ :ref="(el) => setItemRef(el, index)"
123
+ class="flyql-panel__item"
124
+ :class="{ 'flyql-panel__item--active': index === selectedIndex }"
125
+ :aria-selected="index === selectedIndex"
126
+ role="option"
127
+ @click="onSuggestionSelect(index)"
128
+ >
129
+ <span class="flyql-panel__badge" :class="'flyql-panel__badge--' + item.type">
130
+ {{ badgeText(item.type) }}
131
+ </span>
132
+ <span class="flyql-panel__label" v-html="highlightMatch(item.label)"></span>
133
+ <span
134
+ v-if="item.detail"
135
+ class="flyql-panel__detail"
136
+ :class="'flyql-panel__detail--' + item.type"
137
+ >{{ item.detail }}</span
138
+ >
139
+ </li>
140
+ </ul>
141
+ <div v-if="isLoading && suggestions.length === 0 && !message" class="flyql-panel__skeleton">
142
+ <div v-for="n in 6" :key="n" class="flyql-panel__skeleton-row">
143
+ <span class="flyql-panel__skeleton-badge"></span>
144
+ <span
145
+ class="flyql-panel__skeleton-text"
146
+ :style="{ width: 40 + ((n * 17) % 45) + '%' }"
147
+ ></span>
148
+ </div>
149
+ </div>
150
+ <div v-if="!isLoading && suggestions.length === 0 && message" class="flyql-panel__message">
151
+ {{ message }}
152
+ </div>
153
+ <div v-if="!isLoading && suggestions.length === 0 && !message" class="flyql-panel__empty">
154
+ No suggestions
155
+ </div>
156
+ </div>
157
+ <div
158
+ v-if="diagnostics.length > 0"
159
+ class="flyql-panel__diagnostics"
160
+ @mousedown.stop="panelInteracting = true"
161
+ @mouseup="panelInteracting = false"
162
+ >
163
+ <div class="flyql-panel__header">Diagnostics</div>
164
+ <div
165
+ v-for="(diag, idx) in diagnostics"
166
+ :key="idx"
167
+ class="flyql-panel__diagnostic-item"
168
+ :class="'flyql-panel__diagnostic-item--' + diag.severity"
169
+ @mouseenter="hoveredDiagIndex = idx"
170
+ @mouseleave="hoveredDiagIndex = -1"
171
+ >
172
+ <span
173
+ class="flyql-panel__diagnostic-bullet"
174
+ :class="'flyql-panel__diagnostic-bullet--' + diag.severity"
175
+ ></span>
176
+ <span class="flyql-panel__diagnostic-msg">{{ diag.message }}</span>
177
+ </div>
178
+ </div>
179
+ <div class="flyql-panel__footer">
180
+ <span v-if="context && context.key && suggestionType === 'value'" class="flyql-panel__footer-col">{{
181
+ context.key
182
+ }}</span>
183
+ <span class="flyql-panel__footer-label">{{ stateLabel }}</span>
184
+ <span
185
+ v-if="suggestionType === 'value' && suggestions.length > 0 && incomplete"
186
+ class="flyql-panel__footer-status"
187
+ >partial results</span
188
+ >
189
+ </div>
190
+ </div>
191
+ </Teleport>
192
+ </div>
193
+ </template>
194
+
195
+ <script setup>
196
+ import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
197
+ import { EditorEngine } from './engine.js'
198
+ import './flyql.css'
199
+
200
+ // ── Props & Emits ──
201
+
202
+ const props = defineProps({
203
+ modelValue: { type: String, default: '' },
204
+ columns: { type: Object, default: null },
205
+ parameters: { type: Array, default: () => [] },
206
+ onAutocomplete: { type: Function, default: null },
207
+ onKeyDiscovery: { type: Function, default: null },
208
+ placeholder: { type: String, default: '' },
209
+ autofocus: { type: Boolean, default: false },
210
+ debug: { type: Boolean, default: false },
211
+ debounceMs: { type: Number, default: 150 },
212
+ dark: { type: Boolean, default: false },
213
+ registry: { type: Object, default: null },
214
+ })
215
+
216
+ const emit = defineEmits(['update:modelValue', 'submit', 'parse-error', 'focus', 'blur', 'diagnostics'])
217
+
218
+ // ── Engine ──
219
+
220
+ const editorOpts = {
221
+ onAutocomplete: props.onAutocomplete,
222
+ onKeyDiscovery: props.onKeyDiscovery,
223
+ debounceMs: props.debounceMs,
224
+ parameters: props.parameters,
225
+ onLoadingChange: (loading) => {
226
+ isLoading.value = loading
227
+ stateLabel.value = engine.getStateLabel()
228
+ },
229
+ }
230
+ if (props.registry) {
231
+ editorOpts.registry = props.registry
232
+ }
233
+ const engine = new EditorEngine(props.columns, editorOpts)
234
+
235
+ // ── Instance ID for unique ARIA references ──
236
+
237
+ const instanceId = 'flyql-' + Math.random().toString(36).substring(2, 8)
238
+
239
+ // ── Refs ──
240
+
241
+ const textareaRef = ref(null)
242
+ const highlightRef = ref(null)
243
+ const containerRef = ref(null)
244
+ const editorRoot = ref(null)
245
+ const listRef = ref(null)
246
+ const focused = ref(false)
247
+ const activated = ref(false)
248
+ const panelRef = ref(null)
249
+ const panelLeft = ref(0)
250
+ const panelTop = ref(0)
251
+ let panelInteracting = false
252
+
253
+ // ── Reactive state read from engine ──
254
+
255
+ const suggestions = ref([])
256
+ const selectedIndex = ref(0)
257
+ const isLoading = ref(false)
258
+ const incomplete = ref(false)
259
+ const suggestionType = ref('')
260
+ const message = ref('')
261
+ const stateLabel = ref('')
262
+ const context = ref(null)
263
+ const activeTab = ref('values')
264
+ const isValueContext = computed(() => context.value?.expecting === 'value')
265
+ const diagnostics = ref([])
266
+ const hoveredDiagIndex = ref(-1)
267
+ const lastParseError = ref(null)
268
+
269
+ const panelStyle = computed(() => ({
270
+ left: panelLeft.value + 'px',
271
+ top: panelTop.value + 'px',
272
+ }))
273
+
274
+ function highlightMatch(label) {
275
+ return engine.highlightMatch(label)
276
+ }
277
+
278
+ function filterColumnValueDiagnostics() {
279
+ const ta = textareaRef.value
280
+ if (!ta) return
281
+ const ctx0 = engine.buildContext(ta.value.substring(0, ta.selectionStart))
282
+ if (ctx0 && ctx0.expecting === 'value') {
283
+ const columnValueCodes = new Set(['unknown_column_value', 'invalid_column_value'])
284
+ if (engine.activeTab === 'values') {
285
+ engine.diagnostics = engine.diagnostics.filter((d) => !columnValueCodes.has(d.code))
286
+ } else {
287
+ const colNames = Object.keys(engine.columns.columns).filter(
288
+ (n) => engine.columns.columns[n]?.suggest !== false,
289
+ )
290
+ engine.diagnostics = engine.diagnostics.filter((d) => {
291
+ if (!columnValueCodes.has(d.code)) return true
292
+ const val = (ctx0.value || '').toLowerCase()
293
+ if (!val) return false
294
+ return !colNames.some((n) => n.toLowerCase().startsWith(val))
295
+ })
296
+ }
297
+ }
298
+ }
299
+
300
+ function switchTab(tab) {
301
+ engine.setTab(tab)
302
+ engine.getDiagnostics()
303
+ filterColumnValueDiagnostics()
304
+ syncFromEngine()
305
+ }
306
+
307
+ // ── Sync engine state to Vue refs ──
308
+
309
+ function syncFromEngine() {
310
+ suggestions.value = engine.suggestions
311
+ selectedIndex.value = engine.state.selectedIndex
312
+ isLoading.value = engine.isLoading
313
+ incomplete.value = engine.incomplete
314
+ suggestionType.value = engine.suggestionType
315
+ message.value = engine.message
316
+ stateLabel.value = engine.getStateLabel()
317
+ context.value = engine.context
318
+ activeTab.value = engine.activeTab
319
+ diagnostics.value = engine.diagnostics
320
+
321
+ // Check for parse-error transitions
322
+ const currentError = engine.getParseError()
323
+ if (currentError !== lastParseError.value) {
324
+ lastParseError.value = currentError
325
+ emit('parse-error', currentError)
326
+ }
327
+ }
328
+
329
+ // ── Highlighting ──
330
+
331
+ const highlightedHtml = computed(() => {
332
+ return engine.getHighlightTokens(props.modelValue, diagnostics.value, hoveredDiagIndex.value)
333
+ })
334
+
335
+ // ── Panel Positioning ──
336
+
337
+ function updatePanelPosition(ctx) {
338
+ const ta = textareaRef.value
339
+ if (!ta || !ctx) return
340
+
341
+ const range = engine.getInsertRange(ctx, ta.value)
342
+ const textBeforeToken = ta.value.substring(0, range.start)
343
+
344
+ const mirror = document.createElement('div')
345
+ const style = getComputedStyle(ta)
346
+
347
+ mirror.style.position = 'absolute'
348
+ mirror.style.visibility = 'hidden'
349
+ mirror.style.whiteSpace = 'pre-wrap'
350
+ mirror.style.wordWrap = 'break-word'
351
+ mirror.style.overflowWrap = 'break-word'
352
+ mirror.style.width = style.width
353
+ mirror.style.fontFamily = style.fontFamily
354
+ mirror.style.fontSize = style.fontSize
355
+ mirror.style.lineHeight = style.lineHeight
356
+ mirror.style.padding = style.padding
357
+ mirror.style.border = style.border
358
+ mirror.style.boxSizing = style.boxSizing
359
+ mirror.style.letterSpacing = style.letterSpacing
360
+ mirror.style.tabSize = style.tabSize
361
+
362
+ const textNode = document.createTextNode(textBeforeToken)
363
+ const span = document.createElement('span')
364
+ span.textContent = '|'
365
+
366
+ mirror.appendChild(textNode)
367
+ mirror.appendChild(span)
368
+ document.body.appendChild(mirror)
369
+
370
+ try {
371
+ const spanRect = span.getBoundingClientRect()
372
+ const mirrorRect = mirror.getBoundingClientRect()
373
+ const taRect = ta.getBoundingClientRect()
374
+ const cursorLeft = taRect.left + (spanRect.left - mirrorRect.left) - ta.scrollLeft
375
+ const panelWidth = panelRef.value?.offsetWidth || 600
376
+ const viewportWidth = document.documentElement.clientWidth
377
+ if (cursorLeft + panelWidth > viewportWidth) {
378
+ panelLeft.value = Math.max(0, cursorLeft - panelWidth)
379
+ } else {
380
+ panelLeft.value = cursorLeft
381
+ }
382
+ const panelHeight = panelRef.value?.offsetHeight || 280
383
+ const spaceBelow = window.innerHeight - taRect.bottom - 4
384
+ if (spaceBelow < panelHeight && taRect.top > panelHeight) {
385
+ panelTop.value = taRect.top - panelHeight - 4
386
+ } else {
387
+ panelTop.value = taRect.bottom + 4
388
+ }
389
+ } finally {
390
+ document.body.removeChild(mirror)
391
+ }
392
+ }
393
+
394
+ // ── Event Handlers ──
395
+
396
+ async function triggerSuggestions() {
397
+ const ta = textareaRef.value
398
+ if (!ta) return
399
+ engine.setQuery(ta.value)
400
+ engine.setCursorPosition(ta.selectionStart)
401
+
402
+ // Run diagnostics — fast, sync operation
403
+ engine.getDiagnostics()
404
+ filterColumnValueDiagnostics()
405
+ syncFromEngine()
406
+ emit('diagnostics', diagnostics.value)
407
+
408
+ try {
409
+ const promise = engine.updateSuggestions()
410
+ syncFromEngine()
411
+ const ctx = await promise
412
+ syncFromEngine()
413
+ nextTick(() => {
414
+ updatePanelPosition(ctx)
415
+ })
416
+ } catch {
417
+ syncFromEngine()
418
+ }
419
+ }
420
+
421
+ function onCursorMove() {
422
+ activated.value = true
423
+ nextTick(() => {
424
+ triggerSuggestions()
425
+ })
426
+ }
427
+
428
+ function onInput(e) {
429
+ activated.value = true
430
+ const value = e.target.value
431
+ emit('update:modelValue', value)
432
+ if (engine.state.composing) return
433
+ nextTick(() => {
434
+ autoResize()
435
+ triggerSuggestions()
436
+ })
437
+ }
438
+
439
+ function onCompositionEnd(e) {
440
+ engine.state.composing = false
441
+ const value = e.target.value
442
+ emit('update:modelValue', value)
443
+ nextTick(() => {
444
+ triggerSuggestions()
445
+ })
446
+ }
447
+
448
+ function onPaste() {
449
+ activated.value = true
450
+ nextTick(() => {
451
+ autoResize()
452
+ triggerSuggestions()
453
+ })
454
+ }
455
+
456
+ function onKeydown(e) {
457
+ if (e.key === 'PageUp' || e.key === 'PageDown') {
458
+ e.preventDefault()
459
+ if (suggestions.value.length > 0) {
460
+ const len = suggestions.value.length
461
+ let idx = engine.state.selectedIndex
462
+ idx = e.key === 'PageUp' ? Math.max(0, idx - 10) : Math.min(len - 1, idx + 10)
463
+ engine.state.selectedIndex = idx
464
+ selectedIndex.value = idx
465
+ }
466
+ return
467
+ }
468
+ if (suggestions.value.length > 0) {
469
+ if (e.key === 'ArrowUp') {
470
+ e.preventDefault()
471
+ engine.navigateUp()
472
+ selectedIndex.value = engine.state.selectedIndex
473
+ return
474
+ }
475
+ if (e.key === 'ArrowDown') {
476
+ e.preventDefault()
477
+ engine.navigateDown()
478
+ selectedIndex.value = engine.state.selectedIndex
479
+ return
480
+ }
481
+ if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
482
+ e.preventDefault()
483
+ acceptSuggestion(selectedIndex.value)
484
+ return
485
+ }
486
+
487
+ // When column suggestions are showing and user types an operator character,
488
+ // accept the column and insert the operator with spaces
489
+ if (suggestionType.value === 'column' && '=><~'.includes(e.key) && e.key.length === 1) {
490
+ e.preventDefault()
491
+ acceptSuggestion(selectedIndex.value)
492
+ nextTick(() => {
493
+ const ta = textareaRef.value
494
+ if (ta) {
495
+ const pos = ta.selectionStart
496
+ const before = ta.value.substring(0, pos)
497
+ const after = ta.value.substring(pos)
498
+ // Remove trailing space that acceptSuggestion added, then insert ' op '
499
+ const trimmed = before.endsWith(' ') ? before.slice(0, -1) : before
500
+ ta.value = trimmed + ' ' + e.key + ' ' + after
501
+ const newPos = trimmed.length + 3
502
+ ta.selectionStart = newPos
503
+ ta.selectionEnd = newPos
504
+ ta.dispatchEvent(new InputEvent('input', { inputType: 'insertText', data: e.key, bubbles: true }))
505
+ }
506
+ })
507
+ return
508
+ }
509
+ }
510
+
511
+ if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
512
+ e.preventDefault()
513
+ return
514
+ }
515
+
516
+ if (e.key === 'Escape') {
517
+ e.preventDefault()
518
+ activated.value = false
519
+ return
520
+ }
521
+
522
+ if (e.key === 'Tab') {
523
+ if (activated.value && engine.context?.expecting === 'value') {
524
+ e.preventDefault()
525
+ engine.cycleTab()
526
+ engine.getDiagnostics()
527
+ filterColumnValueDiagnostics()
528
+ syncFromEngine()
529
+ } else if (activated.value && suggestions.value.length > 0) {
530
+ e.preventDefault()
531
+ acceptSuggestion(selectedIndex.value)
532
+ } else if (!activated.value) {
533
+ e.preventDefault()
534
+ activated.value = true
535
+ triggerSuggestions()
536
+ }
537
+ return
538
+ }
539
+
540
+ if (e.key === 'Home') {
541
+ e.preventDefault()
542
+ const ta = textareaRef.value
543
+ if (ta) {
544
+ ta.selectionStart = 0
545
+ if (!e.shiftKey) ta.selectionEnd = 0
546
+ }
547
+ nextTick(() => {
548
+ triggerSuggestions()
549
+ })
550
+ return
551
+ }
552
+
553
+ if (e.key === 'End') {
554
+ e.preventDefault()
555
+ const ta = textareaRef.value
556
+ if (ta) {
557
+ const len = ta.value.length
558
+ ta.selectionEnd = len
559
+ if (!e.shiftKey) ta.selectionStart = len
560
+ }
561
+ nextTick(() => {
562
+ triggerSuggestions()
563
+ })
564
+ return
565
+ }
566
+
567
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
568
+ nextTick(() => {
569
+ triggerSuggestions()
570
+ })
571
+ return
572
+ }
573
+
574
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
575
+ e.preventDefault()
576
+ emit('submit')
577
+ return
578
+ }
579
+ if (e.shiftKey && e.key === 'Enter') {
580
+ // Insert newline for multiline query support (AC #4)
581
+ // Do not preventDefault — let the browser insert the newline naturally
582
+ nextTick(() => {
583
+ autoResize()
584
+ triggerSuggestions()
585
+ })
586
+ return
587
+ }
588
+ }
589
+
590
+ function acceptSuggestion(index) {
591
+ const suggestion = engine.selectSuggestion(index)
592
+ if (!suggestion) return
593
+
594
+ const ta = textareaRef.value
595
+ if (!ta) return
596
+
597
+ const currentValue = ta.value
598
+ const selectionEnd = ta.selectionEnd
599
+
600
+ const ctx = engine.context || engine.buildContext(currentValue.substring(0, ta.selectionStart))
601
+ const range = engine.getInsertRange(ctx, currentValue)
602
+
603
+ if (selectionEnd > range.end) {
604
+ range.end = selectionEnd
605
+ }
606
+ let insertText = suggestion.insertText
607
+
608
+ // Pipe must attach directly to column — consume any preceding whitespace
609
+ if (suggestion.type === 'transformer' && suggestion.label === '|') {
610
+ while (range.start > 0 && currentValue[range.start - 1] === ' ') {
611
+ range.start--
612
+ }
613
+ }
614
+
615
+ if (
616
+ !suggestion.cursorOffset &&
617
+ !insertText.endsWith(' ') &&
618
+ !insertText.endsWith('.') &&
619
+ suggestion.type !== 'transformer'
620
+ ) {
621
+ const charAfter = currentValue[range.end] || ''
622
+ if (charAfter === ' ') {
623
+ range.end += 1
624
+ insertText += ' '
625
+ } else {
626
+ insertText += ' '
627
+ }
628
+ }
629
+
630
+ ta.focus()
631
+ ta.selectionStart = range.start
632
+ ta.selectionEnd = range.end
633
+
634
+ const inputEvent = new InputEvent('beforeinput', {
635
+ inputType: 'insertText',
636
+ data: insertText,
637
+ bubbles: true,
638
+ cancelable: true,
639
+ })
640
+ const cancelled = !ta.dispatchEvent(inputEvent)
641
+ if (!cancelled) {
642
+ const before = ta.value.substring(0, range.start)
643
+ const after = ta.value.substring(range.end)
644
+ ta.value = before + insertText + after
645
+ ta.dispatchEvent(new InputEvent('input', { inputType: 'insertText', data: insertText, bubbles: true }))
646
+ }
647
+
648
+ let newCursorPos = range.start + insertText.length
649
+ if (suggestion.cursorOffset) {
650
+ newCursorPos += suggestion.cursorOffset
651
+ }
652
+ ta.selectionStart = newCursorPos
653
+ ta.selectionEnd = newCursorPos
654
+ const newValue = ta.value
655
+
656
+ engine.setQuery(newValue)
657
+ engine.setCursorPosition(newCursorPos)
658
+
659
+ engine.getDiagnostics()
660
+ syncFromEngine()
661
+ emit('diagnostics', diagnostics.value)
662
+
663
+ if (diagnostics.value.length > 0) {
664
+ engine.suggestions = []
665
+ engine.message = ''
666
+ engine.suggestionType = ''
667
+ syncFromEngine()
668
+ } else {
669
+ engine
670
+ .updateSuggestions()
671
+ .then((nextCtx) => {
672
+ syncFromEngine()
673
+ nextTick(() => {
674
+ updatePanelPosition(nextCtx)
675
+ })
676
+ })
677
+ .catch(() => {
678
+ syncFromEngine()
679
+ })
680
+ }
681
+
682
+ emit('update:modelValue', newValue)
683
+
684
+ nextTick(() => {
685
+ autoResize()
686
+ })
687
+ }
688
+
689
+ function onSuggestionSelect(index) {
690
+ acceptSuggestion(index)
691
+ textareaRef.value?.focus()
692
+ }
693
+
694
+ function onScroll() {
695
+ const ta = textareaRef.value
696
+ const hl = highlightRef.value
697
+ if (ta && hl) {
698
+ hl.scrollTop = ta.scrollTop
699
+ hl.scrollLeft = ta.scrollLeft
700
+ }
701
+ }
702
+
703
+ function onFocus() {
704
+ focused.value = true
705
+ engine.state.setFocused(true)
706
+ emit('focus')
707
+ }
708
+
709
+ function onBlur() {
710
+ if (panelInteracting) return
711
+ focused.value = false
712
+ activated.value = false
713
+ engine.state.setFocused(false)
714
+ engine.state.setActivated(false)
715
+ emit('blur')
716
+ }
717
+
718
+ function autoResize() {
719
+ const ta = textareaRef.value
720
+ const hl = highlightRef.value
721
+ if (!ta) return
722
+ ta.style.height = 'auto'
723
+ ta.style.height = ta.scrollHeight + 'px'
724
+ if (hl) {
725
+ hl.style.height = ta.scrollHeight + 'px'
726
+ }
727
+ }
728
+
729
+ // ── Item refs for scroll into view ──
730
+
731
+ const itemRefs = ref({})
732
+
733
+ function setItemRef(el, index) {
734
+ if (el) {
735
+ itemRefs.value[index] = el
736
+ } else {
737
+ delete itemRefs.value[index]
738
+ }
739
+ }
740
+
741
+ function badgeText(type) {
742
+ switch (type) {
743
+ case 'column':
744
+ case 'columnRef':
745
+ return 'C'
746
+ case 'operator':
747
+ return 'Op'
748
+ case 'value':
749
+ return 'V'
750
+ case 'boolOp':
751
+ return 'B'
752
+ case 'transformer':
753
+ return 'T'
754
+ default:
755
+ return '?'
756
+ }
757
+ }
758
+
759
+ // ── Watchers ──
760
+
761
+ watch(activated, (val) => {
762
+ engine.state.setActivated(val)
763
+ if (!val) {
764
+ engine.clearKeyCache()
765
+ }
766
+ })
767
+
768
+ watch(selectedIndex, async (idx) => {
769
+ await nextTick()
770
+ const el = itemRefs.value[idx]
771
+ if (el) {
772
+ el.scrollIntoView({ block: 'nearest' })
773
+ }
774
+ })
775
+
776
+ watch(
777
+ () => props.modelValue,
778
+ () => {
779
+ nextTick(autoResize)
780
+ },
781
+ )
782
+
783
+ watch(
784
+ () => props.columns,
785
+ (newColumns) => {
786
+ engine.setColumns(newColumns)
787
+ },
788
+ )
789
+
790
+ watch(
791
+ () => props.parameters,
792
+ (newParams) => {
793
+ engine.setParameters(newParams)
794
+ },
795
+ )
796
+
797
+ watch(
798
+ () => props.onAutocomplete,
799
+ (newFn) => {
800
+ engine.onAutocomplete = newFn || null
801
+ },
802
+ )
803
+
804
+ watch(
805
+ () => props.onKeyDiscovery,
806
+ (newFn) => {
807
+ engine.onKeyDiscovery = newFn || null
808
+ },
809
+ )
810
+
811
+ watch(
812
+ () => props.registry,
813
+ (newReg) => {
814
+ engine.setRegistry(newReg)
815
+ engine.getDiagnostics()
816
+ syncFromEngine()
817
+ emit('diagnostics', diagnostics.value)
818
+ },
819
+ )
820
+
821
+ function onWindowScroll() {
822
+ if (focused.value && activated.value && context.value) {
823
+ updatePanelPosition(context.value)
824
+ }
825
+ }
826
+
827
+ onMounted(() => {
828
+ autoResize()
829
+ window.addEventListener('scroll', onWindowScroll, true)
830
+ if (props.autofocus) {
831
+ nextTick(() => {
832
+ textareaRef.value?.focus()
833
+ })
834
+ }
835
+ })
836
+
837
+ onBeforeUnmount(() => {
838
+ activated.value = false
839
+ window.removeEventListener('scroll', onWindowScroll, true)
840
+ })
841
+
842
+ // ── Public API ──
843
+
844
+ function focus() {
845
+ textareaRef.value?.focus()
846
+ }
847
+
848
+ function blur() {
849
+ textareaRef.value?.blur()
850
+ }
851
+
852
+ function getQueryStatus() {
853
+ engine.setQuery(props.modelValue)
854
+ return engine.getQueryStatus()
855
+ }
856
+
857
+ defineExpose({ focus, blur, getQueryStatus })
858
+ </script>
859
+
860
+ <style scoped>
861
+ .flyql-editor {
862
+ position: relative;
863
+ background: var(--flyql-bg);
864
+ border: 1px solid var(--flyql-border);
865
+ border-radius: 8px;
866
+ transition: border-color 0.15s;
867
+ }
868
+
869
+ .flyql-editor--focused {
870
+ border-color: var(--flyql-border-focus);
871
+ }
872
+
873
+ .flyql-editor__icon {
874
+ position: absolute;
875
+ left: 10px;
876
+ top: 9px;
877
+ font-size: 13px;
878
+ color: var(--flyql-placeholder-color);
879
+ pointer-events: none;
880
+ z-index: 1;
881
+ }
882
+
883
+ .flyql-editor__container {
884
+ position: relative;
885
+ }
886
+
887
+ .flyql-editor__highlight,
888
+ .flyql-editor__input {
889
+ font-family: var(--flyql-code-font-family);
890
+ font-size: var(--flyql-font-size);
891
+ line-height: 18px;
892
+ padding: 6px 8px 6px 32px;
893
+ margin: 0;
894
+ white-space: pre-wrap;
895
+ word-wrap: break-word;
896
+ overflow-wrap: break-word;
897
+ border: none;
898
+ outline: none;
899
+ box-sizing: border-box;
900
+ width: 100%;
901
+ }
902
+
903
+ .flyql-editor__highlight {
904
+ position: absolute;
905
+ top: 0;
906
+ left: 0;
907
+ right: 0;
908
+ bottom: 0;
909
+ pointer-events: none;
910
+ overflow: hidden;
911
+ color: var(--flyql-text);
912
+ background: transparent;
913
+ }
914
+
915
+ .flyql-editor__input {
916
+ position: relative;
917
+ display: block;
918
+ resize: none;
919
+ overflow: hidden;
920
+ background: transparent;
921
+ color: transparent;
922
+ caret-color: var(--flyql-text);
923
+ }
924
+
925
+ .flyql-editor__input::placeholder {
926
+ color: var(--flyql-placeholder-color);
927
+ }
928
+ </style>