The info popover was clipped to the card, so the prompt sat inside the thumbnail frame instead of over the page. Co-authored-by: Cursor <cursoragent@cursor.com>
121 lines
3.2 KiB
Vue
121 lines
3.2 KiB
Vue
<template>
|
|
<div
|
|
ref="root"
|
|
class="relative shrink-0"
|
|
@mouseenter="open"
|
|
@mouseleave="scheduleClose"
|
|
@focusin="open"
|
|
@focusout="onFocusOut"
|
|
@wheel.prevent="onIconWheel"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="relative z-10 flex h-6 w-6 items-center justify-center rounded-full text-zinc-500 hover:text-amber-200"
|
|
:aria-label="ariaLabel"
|
|
title="Copy prompt"
|
|
@click.stop="emit('copy', text)"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-4 w-4">
|
|
<circle cx="12" cy="12" r="10" />
|
|
<path d="M12 16v-4M12 8h.01" stroke-linecap="round" />
|
|
</svg>
|
|
</button>
|
|
<Teleport to="body">
|
|
<div
|
|
v-if="show"
|
|
ref="panel"
|
|
class="fixed z-[80] max-h-[min(20rem,50vh)] w-[min(28rem,calc(100vw-1.5rem))] overflow-y-auto overscroll-contain whitespace-pre-wrap break-words rounded-xl border border-white/10 bg-zinc-950 p-3 text-xs leading-relaxed text-zinc-300 shadow-2xl"
|
|
:style="panelStyle"
|
|
@mouseenter="open"
|
|
@mouseleave="scheduleClose"
|
|
@click.stop
|
|
>
|
|
{{ text }}
|
|
</div>
|
|
</Teleport>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
defineProps<{
|
|
text: string
|
|
ariaLabel: string
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
copy: [text: string]
|
|
}>()
|
|
|
|
const root = ref<HTMLElement | null>(null)
|
|
const panel = ref<HTMLElement | null>(null)
|
|
const show = ref(false)
|
|
const panelStyle = ref<Record<string, string>>({})
|
|
let closeTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
function place() {
|
|
const el = root.value
|
|
if (!el) return
|
|
const rect = el.getBoundingClientRect()
|
|
const width = Math.min(448, window.innerWidth - 24)
|
|
const maxHeight = Math.min(320, window.innerHeight * 0.5)
|
|
let left = rect.right - width
|
|
left = Math.min(Math.max(12, left), window.innerWidth - width - 12)
|
|
const below = rect.bottom + 8
|
|
const above = rect.top - 8 - maxHeight
|
|
const top = below + maxHeight <= window.innerHeight - 12 ? below : Math.max(12, above)
|
|
panelStyle.value = {
|
|
top: `${Math.round(top)}px`,
|
|
left: `${Math.round(left)}px`,
|
|
width: `${Math.round(width)}px`
|
|
}
|
|
}
|
|
|
|
function open() {
|
|
if (closeTimer) {
|
|
clearTimeout(closeTimer)
|
|
closeTimer = null
|
|
}
|
|
show.value = true
|
|
nextTick(place)
|
|
}
|
|
|
|
function close() {
|
|
show.value = false
|
|
}
|
|
|
|
function scheduleClose() {
|
|
if (closeTimer) clearTimeout(closeTimer)
|
|
closeTimer = setTimeout(close, 120)
|
|
}
|
|
|
|
function onFocusOut(event: FocusEvent) {
|
|
const next = event.relatedTarget as Node | null
|
|
if (root.value?.contains(next) || panel.value?.contains(next)) return
|
|
scheduleClose()
|
|
}
|
|
|
|
function onIconWheel(event: WheelEvent) {
|
|
const el = panel.value
|
|
if (!el) return
|
|
el.scrollTop += event.deltaY
|
|
}
|
|
|
|
watch(show, (value) => {
|
|
if (typeof window === 'undefined') return
|
|
if (value) {
|
|
window.addEventListener('resize', place)
|
|
window.addEventListener('scroll', place, true)
|
|
return
|
|
}
|
|
window.removeEventListener('resize', place)
|
|
window.removeEventListener('scroll', place, true)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
if (closeTimer) clearTimeout(closeTimer)
|
|
if (typeof window === 'undefined') return
|
|
window.removeEventListener('resize', place)
|
|
window.removeEventListener('scroll', place, true)
|
|
})
|
|
</script>
|