Compare commits

..

2 Commits

Author SHA1 Message Date
Julien Calixte
e380e6d336 test(completion): cover popup viewport fitting geometry
Some checks failed
Deploy to GitHub Pages / build (push) Failing after 24s
Deploy to GitHub Pages / deploy (push) Has been skipped
2026-07-02 21:45:41 +02:00
Julien Calixte
9ee7c05019 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.
2026-07-02 21:45:35 +02:00
3 changed files with 113 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,54 @@
import { describe, it, expect } from "vitest"
import { fitPopup } from "./popupFit"
// A roomy window with the editor pinned at the top-left, so `origin` adds nothing
// and only the caret/box geometry drives the result.
const base = {
lineHeight: 20,
box: { width: 256, height: 224 },
origin: { left: 0, top: 0 },
viewport: { width: 1000, height: 800 },
margin: 8,
}
describe("fitPopup", () => {
it("anchors just below the caret when it fits", () => {
const { left, top } = fitPopup({ ...base, caret: { left: 100, top: 200 } })
expect(left).toBe(100)
expect(top).toBe(220) // caret.top + lineHeight
})
it("shifts left so the right edge clears the window", () => {
// caret.left 900 + box 256 = 1156, past the 992 usable width by 164.
const { left } = fitPopup({ ...base, caret: { left: 900, top: 200 } })
expect(left).toBe(736) // 900 - 164
expect(left + base.box.width).toBe(base.viewport.width - base.margin)
})
it("flips above the caret line when there's no room below", () => {
// caret near the bottom: below would be 780+224 = 1004, past 792.
const { top } = fitPopup({ ...base, caret: { left: 100, top: 780 } })
expect(top).toBe(780 - 224) // popup bottom now sits on the caret line
})
it("never pushes the flipped popup off the top of the window", () => {
// Tiny window: neither below nor a full flip fits, so clamp to the margin.
const { top } = fitPopup({
...base,
caret: { left: 100, top: 40 },
viewport: { width: 1000, height: 120 },
})
expect(top).toBe(base.margin)
})
it("accounts for the editor's offset within the window (origin)", () => {
// Editor pushed 800px right; a caret at local 100 sits at window x=900, so the
// 256-wide box overflows and must shift left even though caret.left is small.
const { left } = fitPopup({
...base,
caret: { left: 100, top: 200 },
origin: { left: 800, top: 0 },
})
expect(800 + left + base.box.width).toBe(base.viewport.width - base.margin)
})
})

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