AI_Painting_V2.0/src/components/Popover/index.vue
WangLeo 16d1496283 修复 Popover 宽度不稳定:移除比例组件硬编码宽度,改用 min-width + ResizeObserver 自适应内容
- Popover 新增 ResizeObserver 监听内容尺寸变化,自动重定位保持居中
- popover-content 补充 maxWidth/minWidth 约束,完善 width='auto' 模式
- 所有关闭路径(点击外部、关闭其他弹窗、modelValue watch)统一清理 observer
- 移除 painting/video 比例组件的 Popover 硬编码宽度,改用 min-width: 300px
- 修复 painting 分辨率选项非弹性布局导致的宽度抖动(white-space: nowrap)
- 修复 painting W/H 输入框盒模型和 flex 收缩问题(box-sizing + min-width: 0)
2026-06-05 16:03:18 +08:00

245 lines
5.6 KiB
Vue

<template>
<div class="custom-popover" ref="popoverRef">
<div class="popover-trigger" ref="triggerRef" @click.stop="togglePopover">
<slot name="reference" />
</div>
<Teleport to="body">
<div
v-if="visible"
ref="contentRef"
class="popover-content"
:class="[placement]"
:style="contentStyle"
>
<slot />
</div>
</Teleport>
</div>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
placement: {
type: String,
default: 'top'
},
width: {
type: [String, Number],
default: 'auto'
},
trigger: {
type: String,
default: 'click'
}
})
const emit = defineEmits(['update:modelValue'])
const popoverRef = ref(null)
const triggerRef = ref(null)
const contentRef = ref(null)
const visible = ref(props.modelValue)
const position = ref({ top: 0, left: 0 })
const popoverId = ref(Math.random().toString(36).substr(2, 9))
let resizeObserver = null
if (!window.__currentOpenPopoverId__) {
window.__currentOpenPopoverId__ = null
}
const contentStyle = computed(() => {
const w = typeof props.width === 'number' ? `${props.width}px` : props.width
return {
...position.value,
width: w,
maxWidth: w === 'auto' ? 'none' : w,
minWidth: w === 'auto' ? '0' : w
}
})
const togglePopover = async () => {
if (visible.value) {
visible.value = false
window.__currentOpenPopoverId__ = null
stopResizeObserver()
} else {
if (window.__currentOpenPopoverId__ && window.__currentOpenPopoverId__ !== popoverId.value) {
window.dispatchEvent(new CustomEvent('close-other-popovers', { detail: { excludeId: popoverId.value } }))
}
if (window.__currentOpenSelectId__) {
window.dispatchEvent(new CustomEvent('close-other-selects', { detail: { excludeId: null } }))
}
visible.value = true
window.__currentOpenPopoverId__ = popoverId.value
await nextTick()
updatePosition()
startResizeObserver()
}
emit('update:modelValue', visible.value)
}
const updatePosition = () => {
if (!triggerRef.value || !contentRef.value) return
const triggerRect = triggerRef.value.getBoundingClientRect()
const contentRect = contentRef.value.getBoundingClientRect()
const gap = 25
let top = 0
let left = 0
switch (props.placement) {
case 'top':
top = triggerRect.top - contentRect.height - gap
left = triggerRect.left + (triggerRect.width - contentRect.width) / 2
break
case 'bottom':
top = triggerRect.bottom + gap
left = triggerRect.left + (triggerRect.width - contentRect.width) / 2
break
case 'left':
top = triggerRect.top + (triggerRect.height - contentRect.height) / 2
left = triggerRect.left - contentRect.width - gap
break
case 'right':
top = triggerRect.top + (triggerRect.height - contentRect.height) / 2
left = triggerRect.right + gap
break
}
position.value = {
top: `${top}px`,
left: `${left}px`
}
}
const startResizeObserver = () => {
if (!contentRef.value) return
stopResizeObserver()
resizeObserver = new ResizeObserver(() => {
updatePosition()
})
resizeObserver.observe(contentRef.value)
}
const stopResizeObserver = () => {
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver = null
}
}
const handleClickOutside = (e) => {
if (!visible.value) return
const triggerEl = popoverRef.value
const contentEl = contentRef.value
if (
triggerEl &&
!triggerEl.contains(e.target) &&
contentEl &&
!contentEl.contains(e.target)
) {
visible.value = false
window.__currentOpenPopoverId__ = null
stopResizeObserver()
emit('update:modelValue', false)
}
}
const handleCloseOtherPopovers = (e) => {
if (e.detail.excludeId !== popoverId.value) {
visible.value = false
stopResizeObserver()
}
}
const handleCloseOtherSelects = () => {
visible.value = false
window.__currentOpenPopoverId__ = null
stopResizeObserver()
}
watch(() => props.modelValue, async (val) => {
visible.value = val
if (val) {
await nextTick()
updatePosition()
startResizeObserver()
} else {
stopResizeObserver()
}
})
onMounted(() => {
document.addEventListener('click', handleClickOutside)
window.addEventListener('resize', updatePosition)
window.addEventListener('scroll', updatePosition, true)
window.addEventListener('close-other-popovers', handleCloseOtherPopovers)
window.addEventListener('close-other-selects', handleCloseOtherSelects)
})
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside)
window.removeEventListener('resize', updatePosition)
window.removeEventListener('scroll', updatePosition, true)
window.removeEventListener('close-other-popovers', handleCloseOtherPopovers)
window.removeEventListener('close-other-selects', handleCloseOtherSelects)
stopResizeObserver()
})
</script>
<style scoped>
.custom-popover {
display: inline-block;
}
.popover-trigger {
display: inline-block;
}
.popover-content {
position: fixed;
background: #ffffff;
border-radius: 16px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08);
border: 1px solid #e8e8e8;
z-index: 2000;
animation: popoverFadeIn 0.2s ease;
}
.popover-content.top {
transform-origin: bottom center;
}
.popover-content.bottom {
transform-origin: top center;
}
.popover-content.left {
transform-origin: right center;
}
.popover-content.right {
transform-origin: left center;
}
@keyframes popoverFadeIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
</style>