- 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)
245 lines
5.6 KiB
Vue
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>
|