Enter now accepts the highlighted item like Tab whenever the popup is open; dismiss with Escape first to insert a plain newline. Drops the `engaged` gate that only accepted after navigating the menu.
341 lines
11 KiB
Vue
341 lines
11 KiB
Vue
<script setup lang="ts">
|
||
import { ref, computed, onMounted, nextTick } from "vue"
|
||
import type { HighlighterCore } from "shiki/core"
|
||
import { getCompletions, type CompletionContext } from "./completion"
|
||
import { fitPopup } from "./popupFit"
|
||
|
||
const props = defineProps<{ modelValue: string; error: string | null }>()
|
||
const emit = defineEmits<{ "update:modelValue": [value: string] }>()
|
||
|
||
const textarea = ref<HTMLTextAreaElement>()
|
||
const backdrop = ref<HTMLElement>()
|
||
const popupEl = 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([
|
||
import("shiki/core"),
|
||
import("shiki/engine/javascript"),
|
||
import("shiki/langs/toml.mjs"),
|
||
import("shiki/themes/github-light.mjs"),
|
||
])
|
||
highlighter.value = await core.createHighlighterCore({
|
||
themes: [theme.default],
|
||
langs: [toml.default],
|
||
engine: engine.createJavaScriptRegexEngine(),
|
||
})
|
||
})
|
||
|
||
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
|
||
const caretLeft = metrics.padLeft + col * metrics.charWidth - el.scrollLeft
|
||
const caretTop = metrics.padTop + lineIndex * metrics.lineHeight - el.scrollTop
|
||
popup.value = { left: caretLeft, top: caretTop + metrics.lineHeight }
|
||
// The popup has no measurable size until Vue has rendered it; adjust once it does.
|
||
nextTick(() => keepInView(caretLeft, caretTop))
|
||
}
|
||
|
||
/** Nudge the popup back inside the window once it has a measurable size. */
|
||
function keepInView(caretLeft: number, caretTop: number) {
|
||
const el = textarea.value
|
||
const list = popupEl.value
|
||
if (!el || !list) return
|
||
// popup left/top are relative to `.code`, which the textarea fills exactly, so
|
||
// the textarea rect bridges those local coordinates to the window.
|
||
const ta = el.getBoundingClientRect()
|
||
const box = list.getBoundingClientRect()
|
||
popup.value = fitPopup({
|
||
caret: { left: caretLeft, top: caretTop },
|
||
lineHeight: metrics.lineHeight,
|
||
box: { width: box.width, height: box.height },
|
||
origin: { left: ta.left, top: ta.top },
|
||
viewport: { width: window.innerWidth, height: window.innerHeight },
|
||
})
|
||
}
|
||
|
||
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()
|
||
// Chain only after completing a key (insert ends `= `), so its values pop
|
||
// up next. A value or header is terminal — re-running would re-offer it.
|
||
if (item.insert.endsWith("= ")) refresh()
|
||
})
|
||
}
|
||
|
||
function onKeydown(e: KeyboardEvent) {
|
||
if (e.key !== "Escape") justEscaped = false
|
||
const ctx = completion.value
|
||
if (!ctx) return
|
||
const move = (delta: number) => {
|
||
e.preventDefault()
|
||
selected.value = (selected.value + delta + ctx.items.length) % ctx.items.length
|
||
}
|
||
// Ctrl+N / Ctrl+P mirror ArrowDown / ArrowUp (readline-style navigation).
|
||
if (e.ctrlKey && (e.key === "n" || e.key === "p")) {
|
||
move(e.key === "n" ? 1 : -1)
|
||
return
|
||
}
|
||
switch (e.key) {
|
||
case "ArrowDown":
|
||
move(1)
|
||
break
|
||
case "ArrowUp":
|
||
move(-1)
|
||
break
|
||
case "Enter":
|
||
case "Tab":
|
||
// Both accept the highlighted item whenever the popup is open. To insert a
|
||
// newline instead, dismiss the popup with Escape first, then press Enter.
|
||
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
|
||
const hl = highlighter.value
|
||
if (!hl) return `<pre class="shiki"><code>${escapeHtml(code)}</code></pre>`
|
||
return hl.codeToHtml(code, { lang: "toml", theme: "github-light" })
|
||
})
|
||
|
||
function escapeHtml(s: string): string {
|
||
return s.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]!)
|
||
}
|
||
|
||
function onInput(e: Event) {
|
||
emit("update:modelValue", (e.target as HTMLTextAreaElement).value)
|
||
// Deleting text (backspace, forward-delete, cut) shouldn't summon the popup —
|
||
// the author is removing, not asking for suggestions. Close it and stop.
|
||
if ((e as InputEvent).inputType?.startsWith("delete")) {
|
||
completion.value = null
|
||
return
|
||
}
|
||
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>
|
||
|
||
<template>
|
||
<div class="editor">
|
||
<div class="code">
|
||
<div ref="backdrop" class="backdrop" aria-hidden="true" v-html="highlighted" />
|
||
<textarea
|
||
ref="textarea"
|
||
class="input"
|
||
:value="modelValue"
|
||
spellcheck="false"
|
||
autocapitalize="off"
|
||
autocorrect="off"
|
||
autocomplete="off"
|
||
aria-label="Macroplan TOML source"
|
||
@input="onInput"
|
||
@scroll="syncScroll"
|
||
@keydown="onKeydown"
|
||
@blur="completion = null"
|
||
></textarea>
|
||
<ul
|
||
v-if="completion"
|
||
ref="popupEl"
|
||
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 }}
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.editor {
|
||
position: relative;
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100%;
|
||
min-height: 0;
|
||
}
|
||
.code {
|
||
position: relative;
|
||
flex: 1;
|
||
min-height: 0;
|
||
}
|
||
/* Backdrop (highlighted) and textarea (input) share identical box metrics so the
|
||
transparent input text sits exactly over its colored Shiki copy. */
|
||
.backdrop,
|
||
.input {
|
||
position: absolute;
|
||
inset: 0;
|
||
margin: 0;
|
||
border: 0;
|
||
padding: 0.9rem 1rem;
|
||
font-family: inherit; /* Fira Code, from the global theme */
|
||
font-size: 0.8rem;
|
||
/* Integer px, NOT a unitless ratio: a fractional line box (0.8rem × 1.6 =
|
||
20.48px) is rounded differently by a <textarea> than by the <pre> backdrop,
|
||
so the caret drifts further from its glyph the more lines precede it —
|
||
visible at the bottom of a tall file. An integer height rounds identically. */
|
||
line-height: 20px;
|
||
tab-size: 2;
|
||
white-space: pre;
|
||
}
|
||
.backdrop {
|
||
z-index: 0;
|
||
pointer-events: none;
|
||
overflow: hidden; /* scroll is driven by the textarea via syncScroll */
|
||
background: var(--color-base-100);
|
||
color: var(--color-base-content);
|
||
}
|
||
/* Neutralise Shiki's own <pre> chrome — the .backdrop provides padding/metrics. */
|
||
.backdrop :deep(pre.shiki) {
|
||
margin: 0;
|
||
padding: 0;
|
||
background: transparent !important;
|
||
white-space: pre;
|
||
}
|
||
.backdrop :deep(pre.shiki),
|
||
.backdrop :deep(pre.shiki code) {
|
||
font: inherit;
|
||
}
|
||
.input {
|
||
z-index: 1;
|
||
/* A textarea has an intrinsic block size (its `rows`), so inset:0 alone won't
|
||
stretch it the way it does the plain-div backdrop — pin both axes to fill. */
|
||
width: 100%;
|
||
height: 100%;
|
||
overflow: auto;
|
||
resize: none;
|
||
outline: none;
|
||
background: transparent;
|
||
color: transparent;
|
||
caret-color: var(--color-base-content);
|
||
}
|
||
.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: 16rem;
|
||
max-width: 30rem;
|
||
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;
|
||
background: var(--color-error);
|
||
color: var(--color-error-content);
|
||
border-top: 1px solid color-mix(in oklch, black 12%, var(--color-error));
|
||
}
|
||
</style>
|