fix: prevent event propagation on card menu buttons and implement virtualization locking for overlays in ProgressiveCardGrid

This commit is contained in:
jxxghp
2026-05-15 18:27:56 +08:00
parent 523f8c4cc8
commit 06355ff91d
6 changed files with 111 additions and 7 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "moviepilot", "name": "moviepilot",
"version": "2.11.3", "version": "2.11.4",
"private": true, "private": true,
"type": "module", "type": "module",
"bin": "dist/service.js", "bin": "dist/service.js",

View File

@@ -252,7 +252,7 @@ const dropdownItems = ref([
</div> </div>
</div> </div>
<div class="absolute bottom-0 right-0"> <div class="absolute bottom-0 right-0">
<IconBtn> <IconBtn @click.stop>
<VIcon size="small" icon="mdi-dots-vertical" /> <VIcon size="small" icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click> <VMenu activator="parent" close-on-content-click>
<VList> <VList>

View File

@@ -521,7 +521,7 @@ watch(
</span> </span>
</div> </div>
<div v-if="!props.sortable" class="absolute bottom-0 right-0"> <div v-if="!props.sortable" class="absolute bottom-0 right-0">
<IconBtn> <IconBtn @click.stop>
<VIcon icon="mdi-dots-vertical" /> <VIcon icon="mdi-dots-vertical" />
<VMenu v-model="menuVisible" activator="parent" close-on-content-click> <VMenu v-model="menuVisible" activator="parent" close-on-content-click>
<VList> <VList>

View File

@@ -386,7 +386,7 @@ onMounted(() => {
</VBtn> </VBtn>
<!-- 更多选项按钮 --> <!-- 更多选项按钮 -->
<VBtn icon variant="text" class="mt-auto" size="36"> <VBtn icon variant="text" class="mt-auto" size="36" @click.stop>
<VIcon icon="mdi-dots-vertical" size="20" /> <VIcon icon="mdi-dots-vertical" size="20" />
<VMenu :activator="'parent'" :close-on-content-click="true" :location="'left'"> <VMenu :activator="'parent'" :close-on-content-click="true" :location="'left'">
<VList> <VList>

View File

@@ -372,7 +372,7 @@ function handleCardClick() {
:ripple="!props.batchMode && !props.sortable" :ripple="!props.batchMode && !props.sortable"
> >
<div v-if="!props.sortable" class="me-n3 absolute top-1 right-4"> <div v-if="!props.sortable" class="me-n3 absolute top-1 right-4">
<IconBtn> <IconBtn @click.stop>
<VIcon icon="mdi-dots-vertical" color="white" /> <VIcon icon="mdi-dots-vertical" color="white" />
<VMenu activator="parent" close-on-content-click> <VMenu activator="parent" close-on-content-click>
<VList> <VList>

View File

@@ -38,6 +38,13 @@ interface VirtualCell {
key: ItemKey key: ItemKey
} }
interface VirtualRange {
endIndex: number
endRow: number
startIndex: number
startRow: number
}
const containerRef = ref<HTMLElement | null>(null) const containerRef = ref<HTMLElement | null>(null)
const trackRef = ref<HTMLElement | null>(null) const trackRef = ref<HTMLElement | null>(null)
@@ -45,6 +52,8 @@ const layoutWidth = ref(0)
const viewportTop = ref(0) const viewportTop = ref(0)
const viewportBottom = ref(0) const viewportBottom = ref(0)
const heightVersion = ref(0) const heightVersion = ref(0)
const frozenVisibleRange = ref<VirtualRange | null>(null)
const isOverlayGrid = ref(false)
const itemHeights = new Map<ItemKey, number>() const itemHeights = new Map<ItemKey, number>()
const observedElements = new Map<HTMLElement, ItemKey>() const observedElements = new Map<HTMLElement, ItemKey>()
@@ -53,6 +62,7 @@ const itemRefCallbacks = new Map<ItemKey, (element: Element | ComponentPublicIns
let resizeObserver: ResizeObserver | null = null let resizeObserver: ResizeObserver | null = null
let itemResizeObserver: ResizeObserver | null = null let itemResizeObserver: ResizeObserver | null = null
let overlayLockObserver: MutationObserver | null = null
let scrollTarget: ScrollTarget | null = null let scrollTarget: ScrollTarget | null = null
let layoutFrameId: number | null = null let layoutFrameId: number | null = null
let scrollFrameId: number | null = null let scrollFrameId: number | null = null
@@ -149,7 +159,18 @@ const rowMetrics = computed(() => {
const totalHeight = computed(() => rowMetrics.value.totalHeight) const totalHeight = computed(() => rowMetrics.value.totalHeight)
const visibleRange = computed(() => { const calculatedVisibleRange = computed<VirtualRange>(() => {
if (isOverlayGrid.value) {
const rowCount = Math.max(1, Math.ceil(props.items.length / columnCount.value))
return {
endIndex: props.items.length,
endRow: rowCount - 1,
startIndex: 0,
startRow: 0,
}
}
const { heights, offsets, rowCount } = rowMetrics.value const { heights, offsets, rowCount } = rowMetrics.value
if (!props.items.length || rowCount === 0) { if (!props.items.length || rowCount === 0) {
@@ -176,6 +197,8 @@ const visibleRange = computed(() => {
} }
}) })
const visibleRange = computed(() => frozenVisibleRange.value ?? calculatedVisibleRange.value)
const visibleCells = computed<VirtualCell[]>(() => { const visibleCells = computed<VirtualCell[]>(() => {
const cells: VirtualCell[] = [] const cells: VirtualCell[] = []
@@ -190,7 +213,13 @@ const visibleCells = computed<VirtualCell[]>(() => {
return cells return cells
}) })
const topSpacerHeight = computed(() => rowMetrics.value.offsets[visibleRange.value.startRow] ?? 0) const topSpacerHeight = computed(() => {
if (isOverlayGrid.value) {
return 0
}
return rowMetrics.value.offsets[visibleRange.value.startRow] ?? 0
})
const visibleBlockHeight = computed(() => { const visibleBlockHeight = computed(() => {
if (!props.items.length || visibleRange.value.endIndex <= visibleRange.value.startIndex) { if (!props.items.length || visibleRange.value.endIndex <= visibleRange.value.startIndex) {
@@ -206,6 +235,10 @@ const visibleBlockHeight = computed(() => {
}) })
const bottomSpacerHeight = computed(() => { const bottomSpacerHeight = computed(() => {
if (isOverlayGrid.value) {
return 0
}
return Math.max(totalHeight.value - topSpacerHeight.value - visibleBlockHeight.value, 0) return Math.max(totalHeight.value - topSpacerHeight.value - visibleBlockHeight.value, 0)
}) })
@@ -266,6 +299,45 @@ function findLastRowAtOrBeforeOffset(offsets: number[], rowCount: number, offset
return answer return answer
} }
function isDocumentOverlayLocked() {
return typeof document !== 'undefined' && document.documentElement.classList.contains('v-overlay-scroll-blocked')
}
function isGridInsideOverlay() {
return Boolean(containerRef.value?.closest('.v-overlay, .v-overlay__content'))
}
function syncOverlayGridState() {
isOverlayGrid.value = isGridInsideOverlay()
}
function shouldPauseVirtualSync() {
return isDocumentOverlayLocked() && !isOverlayGrid.value
}
function freezeVisibleRange() {
if (frozenVisibleRange.value) {
return
}
// 弹窗打开期间固定当前渲染窗口,防止 body 锁滚动造成坐标跳变并卸载触发弹窗的卡片。
frozenVisibleRange.value = { ...calculatedVisibleRange.value }
}
function releaseVisibleRange() {
frozenVisibleRange.value = null
}
function handleOverlayLockChange() {
if (shouldPauseVirtualSync()) {
freezeVisibleRange()
return
}
releaseVisibleRange()
queueLayoutSync()
}
function getElementFromRef(element: Element | ComponentPublicInstance | null): HTMLElement | null { function getElementFromRef(element: Element | ComponentPublicInstance | null): HTMLElement | null {
if (!element || typeof HTMLElement === 'undefined') { if (!element || typeof HTMLElement === 'undefined') {
return null return null
@@ -312,6 +384,11 @@ function ensureItemResizeObserver() {
} }
itemResizeObserver = new ResizeObserver(entries => { itemResizeObserver = new ResizeObserver(entries => {
if (shouldPauseVirtualSync()) {
freezeVisibleRange()
return
}
let shouldUpdate = false let shouldUpdate = false
let scrollAdjustment = 0 let scrollAdjustment = 0
const currentViewportTop = viewportTop.value const currentViewportTop = viewportTop.value
@@ -506,6 +583,15 @@ function queueLayoutSync() {
layoutFrameId = window.requestAnimationFrame(() => { layoutFrameId = window.requestAnimationFrame(() => {
layoutFrameId = null layoutFrameId = null
if (shouldPauseVirtualSync()) {
freezeVisibleRange()
return
}
// 弹窗内容已经由 overlay 限定生命周期,直接完整渲染可避免弹窗内交互被虚拟回收打断。
syncOverlayGridState()
releaseVisibleRange()
syncLayoutWidth() syncLayoutWidth()
refreshScrollTarget() refreshScrollTarget()
syncViewport() syncViewport()
@@ -520,6 +606,13 @@ function queueViewportSync() {
scrollFrameId = window.requestAnimationFrame(() => { scrollFrameId = window.requestAnimationFrame(() => {
scrollFrameId = null scrollFrameId = null
if (shouldPauseVirtualSync()) {
freezeVisibleRange()
return
}
releaseVisibleRange()
syncViewport() syncViewport()
}) })
} }
@@ -681,6 +774,7 @@ function invalidateMeasurementsForLayoutChange() {
onMounted(() => { onMounted(() => {
mounted = true mounted = true
syncOverlayGridState()
scrollTarget = findScrollTarget() scrollTarget = findScrollTarget()
addScrollListener(scrollTarget) addScrollListener(scrollTarget)
@@ -689,6 +783,14 @@ onMounted(() => {
resizeObserver.observe(trackRef.value) resizeObserver.observe(trackRef.value)
} }
if (typeof MutationObserver !== 'undefined') {
overlayLockObserver = new MutationObserver(handleOverlayLockChange)
overlayLockObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
})
}
window.addEventListener('resize', queueLayoutSync, { passive: true }) window.addEventListener('resize', queueLayoutSync, { passive: true })
queueLayoutSync() queueLayoutSync()
@@ -716,6 +818,8 @@ onUnmounted(() => {
resizeObserver = null resizeObserver = null
itemResizeObserver?.disconnect() itemResizeObserver?.disconnect()
itemResizeObserver = null itemResizeObserver = null
overlayLockObserver?.disconnect()
overlayLockObserver = null
if (layoutFrameId !== null) { if (layoutFrameId !== null) {
window.cancelAnimationFrame(layoutFrameId) window.cancelAnimationFrame(layoutFrameId)