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.
- package/LICENSE +21 -0
- package/package.json +54 -0
- package/src/FlyqlColumns.vue +783 -0
- package/src/FlyqlEditor.vue +928 -0
- package/src/columns-engine.js +846 -0
- package/src/engine.js +891 -0
- package/src/flyql.css +531 -0
- package/src/index.js +5 -0
- package/src/state.js +61 -0
- package/src/suggestions.js +709 -0
|
@@ -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>
|