Files
macroplan/src/components/PlanEditor.vue
Julien Calixte 8c14ae9c42 feat(editor): always accept completions on Enter
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.
2026-07-10 14:30:24 +01:00

341 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[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">Cant 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>