Compare commits
2 Commits
16b07844a8
...
e380e6d336
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e380e6d336 | ||
|
|
9ee7c05019 |
@@ -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' }"
|
||||
>
|
||||
|
||||
54
src/components/popupFit.test.ts
Normal file
54
src/components/popupFit.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
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