Compare commits
2 Commits
16b07844a8
...
e380e6d336
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e380e6d336 | ||
|
|
9ee7c05019 |
@@ -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' }"
|
||||||
>
|
>
|
||||||
|
|||||||
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