fix(completion): keep the popup inside the window near screen edges

Anchoring the popup below the caret let it spill off the right/bottom of
the window when the caret sat at the edge, so the browser clipped it.
After render, measure the popup and shift it left off the right edge or
flip it above the caret line when there's no room below.
This commit is contained in:
Julien Calixte
2026-07-02 21:45:35 +02:00
parent 16b07844a8
commit 9ee7c05019
2 changed files with 59 additions and 4 deletions

View File

@@ -2,12 +2,14 @@
import { ref, computed, onMounted, nextTick } from "vue" import { ref, computed, onMounted, nextTick } from "vue"
import type { HighlighterCore } from "shiki/core" import type { HighlighterCore } from "shiki/core"
import { getCompletions, type CompletionContext } from "./completion" import { getCompletions, type CompletionContext } from "./completion"
import { fitPopup } from "./popupFit"
const props = defineProps<{ modelValue: string; error: string | null }>() const props = defineProps<{ modelValue: string; error: string | null }>()
const emit = defineEmits<{ "update:modelValue": [value: string] }>() const emit = defineEmits<{ "update:modelValue": [value: string] }>()
const textarea = ref<HTMLTextAreaElement>() const textarea = ref<HTMLTextAreaElement>()
const backdrop = ref<HTMLElement>() const backdrop = ref<HTMLElement>()
const popupEl = ref<HTMLElement>()
const highlighter = ref<HighlighterCore>() const highlighter = ref<HighlighterCore>()
// ── Completion popup state ─────────────────────────────────────────────────── // ── Completion popup state ───────────────────────────────────────────────────
@@ -75,10 +77,29 @@ function position() {
const line = before.slice(before.lastIndexOf("\n") + 1) const line = before.slice(before.lastIndexOf("\n") + 1)
let col = 0 let col = 0
for (const ch of line) col = ch === "\t" ? col + (2 - (col % 2)) : col + 1 for (const ch of line) col = ch === "\t" ? col + (2 - (col % 2)) : col + 1
popup.value = { const caretLeft = metrics.padLeft + col * metrics.charWidth - el.scrollLeft
left: metrics.padLeft + col * metrics.charWidth - el.scrollLeft, const caretTop = metrics.padTop + lineIndex * metrics.lineHeight - el.scrollTop
top: metrics.padTop + (lineIndex + 1) * 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) { function accept(i: number) {
@@ -199,6 +220,7 @@ function syncScroll() {
></textarea> ></textarea>
<ul <ul
v-if="completion" v-if="completion"
ref="popupEl"
class="completion" class="completion"
:style="{ left: popup.left + 'px', top: popup.top + 'px' }" :style="{ left: popup.left + 'px', top: popup.top + 'px' }"
> >

View File

@@ -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 }
}