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:
@@ -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<HTMLTextAreaElement>()
|
||||
const backdrop = ref<HTMLElement>()
|
||||
const popupEl = ref<HTMLElement>()
|
||||
const highlighter = ref<HighlighterCore>()
|
||||
|
||||
// ── 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() {
|
||||
></textarea>
|
||||
<ul
|
||||
v-if="completion"
|
||||
ref="popupEl"
|
||||
class="completion"
|
||||
:style="{ left: popup.left + 'px', top: popup.top + 'px' }"
|
||||
>
|
||||
|
||||
33
src/components/popupFit.ts
Normal file
33
src/components/popupFit.ts
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user