feat(editor): show a completion popup as you type
Anchor a suggestion list at the caret (monospace metrics, re-anchored on scroll), with arrow/Enter/Tab/Esc handling and click-to-accept. Accepting a key like `status` chains straight into its value suggestions.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import type { HighlighterCore } from 'shiki/core'
|
||||
import { getCompletions, type CompletionContext } from './completion'
|
||||
|
||||
const props = defineProps<{ modelValue: string; error: string | null }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
@@ -9,7 +10,18 @@ const textarea = ref<HTMLTextAreaElement>()
|
||||
const backdrop = ref<HTMLElement>()
|
||||
const highlighter = ref<HighlighterCore>()
|
||||
|
||||
// ── Completion popup state ───────────────────────────────────────────────────
|
||||
const completion = ref<CompletionContext | null>(null)
|
||||
const selected = ref(0)
|
||||
const popup = ref({ left: 0, top: 0 })
|
||||
// Suppress the popup for one keystroke after Escape, so it doesn't pop straight
|
||||
// back open while the author is still typing the word they just dismissed.
|
||||
let justEscaped = false
|
||||
// Pixel metrics of the (monospace, non-wrapping) textarea, measured once.
|
||||
let metrics = { charWidth: 8, lineHeight: 20, padLeft: 16, padTop: 14 }
|
||||
|
||||
onMounted(async () => {
|
||||
measure()
|
||||
// Lazy-load Shiki as its own chunk so it stays out of the initial bundle.
|
||||
// Fine-grained: only the TOML grammar + one light theme, JS engine (no WASM).
|
||||
const [core, engine, toml, theme] = await Promise.all([
|
||||
@@ -25,6 +37,93 @@ onMounted(async () => {
|
||||
})
|
||||
})
|
||||
|
||||
function measure() {
|
||||
const el = textarea.value
|
||||
if (!el) return
|
||||
const cs = getComputedStyle(el)
|
||||
const ctx = document.createElement('canvas').getContext('2d')!
|
||||
ctx.font = `${cs.fontSize} ${cs.fontFamily}`
|
||||
const lh = parseFloat(cs.lineHeight) // px value in every modern browser
|
||||
metrics = {
|
||||
charWidth: ctx.measureText('0000000000').width / 10,
|
||||
lineHeight: Number.isNaN(lh) || lh < 4 ? parseFloat(cs.fontSize) * 1.6 : lh,
|
||||
padLeft: parseFloat(cs.paddingLeft),
|
||||
padTop: parseFloat(cs.paddingTop),
|
||||
}
|
||||
}
|
||||
|
||||
/** Recompute suggestions from the live textarea value + caret. */
|
||||
function refresh() {
|
||||
const el = textarea.value
|
||||
if (!el) return
|
||||
completion.value = justEscaped ? null : getCompletions(el.value, el.selectionStart)
|
||||
selected.value = 0
|
||||
if (completion.value) position()
|
||||
}
|
||||
|
||||
/** Place the popup just below the caret (monospace ⇒ column × charWidth). */
|
||||
function position() {
|
||||
const el = textarea.value
|
||||
if (!el) return
|
||||
const before = el.value.slice(0, el.selectionStart)
|
||||
const lineIndex = before.split('\n').length - 1
|
||||
const line = before.slice(before.lastIndexOf('\n') + 1)
|
||||
let col = 0
|
||||
for (const ch of line) col = ch === '\t' ? col + (2 - (col % 2)) : col + 1
|
||||
popup.value = {
|
||||
left: metrics.padLeft + col * metrics.charWidth - el.scrollLeft,
|
||||
top: metrics.padTop + (lineIndex + 1) * metrics.lineHeight - el.scrollTop,
|
||||
}
|
||||
}
|
||||
|
||||
function accept(i: number) {
|
||||
const el = textarea.value
|
||||
const ctx = completion.value
|
||||
if (!el || !ctx) return
|
||||
const item = ctx.items[i]
|
||||
const caret = ctx.from + item.insert.length
|
||||
completion.value = null
|
||||
emit('update:modelValue', el.value.slice(0, ctx.from) + item.insert + el.value.slice(ctx.to))
|
||||
nextTick(() => {
|
||||
el.setSelectionRange(caret, caret)
|
||||
el.focus()
|
||||
refresh() // chain, e.g. `status = ` immediately offers the enum values
|
||||
})
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key !== 'Escape') justEscaped = false
|
||||
const ctx = completion.value
|
||||
if (!ctx) return
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
selected.value = (selected.value + 1) % ctx.items.length
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
selected.value = (selected.value - 1 + ctx.items.length) % ctx.items.length
|
||||
break
|
||||
case 'Enter':
|
||||
case 'Tab':
|
||||
e.preventDefault()
|
||||
accept(selected.value)
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
completion.value = null
|
||||
justEscaped = true
|
||||
break
|
||||
// Caret jumps would leave the popup stranded at a stale spot — close it.
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowRight':
|
||||
case 'Home':
|
||||
case 'End':
|
||||
completion.value = null
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const highlighted = computed(() => {
|
||||
// a trailing newline needs a trailing char so the last backdrop line keeps height
|
||||
const code = props.modelValue.endsWith('\n') ? props.modelValue + ' ' : props.modelValue
|
||||
@@ -39,12 +138,14 @@ function escapeHtml(s: string): string {
|
||||
|
||||
function onInput(e: Event) {
|
||||
emit('update:modelValue', (e.target as HTMLTextAreaElement).value)
|
||||
refresh()
|
||||
}
|
||||
|
||||
function syncScroll() {
|
||||
if (!textarea.value || !backdrop.value) return
|
||||
backdrop.value.scrollTop = textarea.value.scrollTop
|
||||
backdrop.value.scrollLeft = textarea.value.scrollLeft
|
||||
if (completion.value) position()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -63,7 +164,24 @@ function syncScroll() {
|
||||
aria-label="Macroplan TOML source"
|
||||
@input="onInput"
|
||||
@scroll="syncScroll"
|
||||
@keydown="onKeydown"
|
||||
@blur="completion = null"
|
||||
></textarea>
|
||||
<ul
|
||||
v-if="completion"
|
||||
class="completion"
|
||||
:style="{ left: popup.left + 'px', top: popup.top + 'px' }"
|
||||
>
|
||||
<li
|
||||
v-for="(item, i) in completion.items"
|
||||
:key="item.label"
|
||||
:class="{ active: i === selected }"
|
||||
@mousedown.prevent="accept(i)"
|
||||
>
|
||||
<span class="label">{{ item.label }}</span>
|
||||
<span v-if="item.detail" class="detail">{{ item.detail }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-if="error" class="editor-error" role="alert">
|
||||
<span class="font-semibold">Can’t parse:</span> {{ error }}
|
||||
@@ -133,6 +251,42 @@ function syncScroll() {
|
||||
.input::selection {
|
||||
background: color-mix(in oklch, var(--color-primary) 28%, transparent);
|
||||
}
|
||||
/* Completion popup, anchored just below the caret (see position()). */
|
||||
.completion {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
margin: 0;
|
||||
padding: 0.25rem;
|
||||
list-style: none;
|
||||
min-width: 13rem;
|
||||
max-width: 22rem;
|
||||
max-height: 14rem;
|
||||
overflow-y: auto;
|
||||
background: var(--color-base-100);
|
||||
border: 1px solid var(--color-base-300);
|
||||
border-radius: 0.4rem;
|
||||
box-shadow: 0 8px 24px color-mix(in oklch, black 16%, transparent);
|
||||
}
|
||||
.completion li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1.25rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.completion li.active {
|
||||
background: color-mix(in oklch, var(--color-primary) 18%, transparent);
|
||||
}
|
||||
.completion .label {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.completion .detail {
|
||||
font-size: 0.68rem;
|
||||
color: color-mix(in oklch, var(--color-base-content) 55%, transparent);
|
||||
}
|
||||
.editor-error {
|
||||
padding: 0.6rem 1rem;
|
||||
font-size: 0.74rem;
|
||||
|
||||
Reference in New Issue
Block a user