diff --git a/src/components/PlanEditor.vue b/src/components/PlanEditor.vue index 230006f..35cca20 100644 --- a/src/components/PlanEditor.vue +++ b/src/components/PlanEditor.vue @@ -2,12 +2,14 @@ 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() const backdrop = ref() +const popupEl = ref() const highlighter = ref() // ── Completion popup state ─────────────────────────────────────────────────── @@ -75,10 +77,29 @@ function position() { 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, - } + 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) { @@ -199,6 +220,7 @@ function syncScroll() { >
    diff --git a/src/components/popupFit.ts b/src/components/popupFit.ts new file mode 100644 index 0000000..7a047bb --- /dev/null +++ b/src/components/popupFit.ts @@ -0,0 +1,33 @@ +/** Geometry for keeping the completion popup inside the browser window. + * + * The popup is anchored just below the caret. Near the right or bottom edge of + * the window that anchor spills off-screen and the popup gets clipped, so we: + * • shift it left until its right edge clears the window, and + * • flip it above the caret line when there's no room below. + * + * Coordinates are local to the editor (the textarea's border box); `origin` is + * that box's own top-left in the window, the bridge from local to window space. + */ +export function fitPopup(opts: { + caret: { left: number; top: number } + lineHeight: number + box: { width: number; height: number } + origin: { left: number; top: number } + viewport: { width: number; height: number } + margin?: number +}): { left: number; top: number } { + const { caret, lineHeight, box, origin, viewport } = opts + const margin = opts.margin ?? 8 + + let left = caret.left + const overRight = origin.left + left + box.width - (viewport.width - margin) + if (overRight > 0) left = Math.max(margin - origin.left, left - overRight) + + let top = caret.top + lineHeight + if (origin.top + top + box.height > viewport.height - margin) { + // Not enough room below — flip above, but never off the top of the window. + top = Math.max(margin - origin.top, caret.top - box.height) + } + + return { left, top } +}