修复:完善流式输出滚动跟随
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentEvent } from '../api'
|
||||
import ProjectPage from './ProjectPage.vue'
|
||||
|
||||
@@ -13,6 +13,7 @@ let outputResizeCallback: ResizeObserverCallback | null = null
|
||||
const observeOutputMock = vi.fn()
|
||||
const unobserveOutputMock = vi.fn()
|
||||
const disconnectOutputObserverMock = vi.fn()
|
||||
const mountedWrappers: Array<{ unmount: () => void }> = []
|
||||
|
||||
/**
|
||||
* JSDOM 不提供 ResizeObserver,这里保留组件注册的回调,模拟流式 Markdown
|
||||
@@ -139,7 +140,7 @@ function mountProject(runStatus: 'RUNNING' | 'INTERRUPTED', options: ProjectFixt
|
||||
throw new Error(`未处理的测试请求:${url}`)
|
||||
})
|
||||
|
||||
return shallowMount(ProjectPage, {
|
||||
const wrapper = shallowMount(ProjectPage, {
|
||||
global: {
|
||||
stubs: {
|
||||
'el-button': ElButtonStub,
|
||||
@@ -151,8 +152,16 @@ function mountProject(runStatus: 'RUNNING' | 'INTERRUPTED', options: ProjectFixt
|
||||
}
|
||||
}
|
||||
})
|
||||
mountedWrappers.push(wrapper)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
// ProjectPage 会注册全局滚动监听;每个用例结束后必须卸载,避免前一个实例
|
||||
// 修改下一用例的 followsLatestOutput 状态,造成测试假阳性或假阴性。
|
||||
while (mountedWrappers.length) mountedWrappers.pop()!.unmount()
|
||||
})
|
||||
|
||||
describe('ProjectPage 模型切换', () => {
|
||||
beforeEach(() => {
|
||||
// Vitest 会把钩子返回的函数当作清理回调,因此这里不能直接返回 mockReset() 的返回值。
|
||||
@@ -280,12 +289,112 @@ describe('ProjectPage 项目工作区', () => {
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: 1800, behavior: 'auto' })
|
||||
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
|
||||
|
||||
// 箭头点击后如果输出容器先发生尺寸变化,必须保留程序滚动目标,
|
||||
// 不能因 ResizeObserver 的再次滚动把“持续跟随”状态提前结束。
|
||||
Object.defineProperty(document.documentElement, 'scrollHeight', { configurable: true, value: 1900 })
|
||||
outputResizeCallback!([], {} as ResizeObserver)
|
||||
|
||||
// 大文档滚动可能先派发尚未到达目标底部的中间事件,不能把程序滚动误判为用户再次上滚。
|
||||
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1190 })
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
|
||||
|
||||
// 点击后浏览器可能先派发仍接近旧位置的事件;即使距离底部小于通用阈值,
|
||||
// 也不能提前结束程序滚动跟踪,否则紧接着的布局滚动会再次显示箭头。
|
||||
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1180 })
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1170 })
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
|
||||
|
||||
// 用户上滚事件先于 scroll 到达时,必须立即取消跟随,不能被尺寸观察器抢回底部。
|
||||
window.dispatchEvent(new WheelEvent('wheel', { deltaY: -24 }))
|
||||
scrollTo.mockClear()
|
||||
Object.defineProperty(document.documentElement, 'scrollHeight', { configurable: true, value: 2100 })
|
||||
outputResizeCallback!([], {} as ResizeObserver)
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(scrollTo).not.toHaveBeenCalled()
|
||||
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
|
||||
|
||||
// 已取消的程序滚动可能仍会派发到达底部的延迟事件,不能借此错误恢复跟随。
|
||||
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1500 })
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
|
||||
|
||||
// 用户明确向下滚动并抵达底部时,仍应恢复持续跟随。
|
||||
window.dispatchEvent(new WheelEvent('wheel', { deltaY: 32 }))
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
|
||||
|
||||
// 在输入控件内使用方向键不应改变页面的自动跟随状态。
|
||||
const modelInput = document.createElement('input')
|
||||
document.body.appendChild(modelInput)
|
||||
modelInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
|
||||
modelInput.remove()
|
||||
|
||||
// 控件内部的图标/子节点事件也不应触发页面滚动状态切换。
|
||||
const control = document.createElement('button')
|
||||
const icon = document.createElement('span')
|
||||
control.appendChild(icon)
|
||||
document.body.appendChild(control)
|
||||
window.dispatchEvent(new WheelEvent('wheel', { deltaY: -24 }))
|
||||
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1100 })
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
|
||||
icon.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
|
||||
control.remove()
|
||||
|
||||
// 空格键同样会向下翻页,回到底部后应恢复持续跟随。
|
||||
window.dispatchEvent(new WheelEvent('wheel', { deltaY: -24 }))
|
||||
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1100 })
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' }))
|
||||
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1500 })
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
|
||||
|
||||
// 滚动条拖动期间,旧程序滚动到达底部不能提前恢复跟随;释放后向下拖到底部才恢复。
|
||||
Object.defineProperty(document.documentElement, 'clientWidth', { configurable: true, value: 1000 })
|
||||
window.dispatchEvent(new WheelEvent('wheel', { deltaY: -24 }))
|
||||
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1100 })
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
|
||||
window.dispatchEvent(new MouseEvent('mousedown', { clientX: 1000, clientY: 300 }))
|
||||
window.dispatchEvent(new MouseEvent('mousemove', { clientX: 1000, clientY: 100 }))
|
||||
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1500 })
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
|
||||
window.dispatchEvent(new MouseEvent('mouseup', { clientX: 1000 }))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
|
||||
|
||||
// 向下拖动滚动条并释放到底部时才恢复跟随。
|
||||
window.dispatchEvent(new MouseEvent('mousedown', { clientX: 1000, clientY: 100 }))
|
||||
window.dispatchEvent(new MouseEvent('mousemove', { clientX: 1000, clientY: 300 }))
|
||||
window.dispatchEvent(new MouseEvent('mouseup', { clientX: 1000, clientY: 300 }))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
|
||||
|
||||
// 触摸滚动/滚动条拖动没有 wheel 事件,也必须能取消跟随。
|
||||
window.dispatchEvent(new TouchEvent('touchmove'))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
|
||||
await wrapper.get('.back-to-bottom').trigger('click')
|
||||
|
||||
// 点击箭头代表重新进入持续跟随模式;同一批 Markdown 的平滑渲染继续增高时也必须跟随。
|
||||
scrollTo.mockClear()
|
||||
Object.defineProperty(document.documentElement, 'scrollHeight', { configurable: true, value: 2000 })
|
||||
@@ -294,6 +403,17 @@ describe('ProjectPage 项目工作区', () => {
|
||||
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: 2000 })
|
||||
|
||||
// 即使新内容只让底部前移 20px,也必须记录程序目标,防止中间 scroll 事件误停跟随。
|
||||
scrollTo.mockClear()
|
||||
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1380 })
|
||||
Object.defineProperty(document.documentElement, 'scrollHeight', { configurable: true, value: 2000 })
|
||||
outputResizeCallback!([], {} as ResizeObserver)
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: 2000 })
|
||||
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1370 })
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
|
||||
|
||||
// 尺寸变化后的新流事件到达时仍应保持跟随,而不是重新显示箭头。
|
||||
scrollTo.mockClear()
|
||||
streamCallback!([{
|
||||
|
||||
@@ -62,8 +62,17 @@ let loadVersion = 0
|
||||
let previousScrollY = 0
|
||||
let hasObservedScrollPosition = false
|
||||
let pendingFollowScrollTarget: number | null = null
|
||||
type UserScrollIntent = 'none' | 'up' | 'down'
|
||||
let userScrollIntent: UserScrollIntent = 'none'
|
||||
let touchStartY: number | null = null
|
||||
let scrollbarDragLastY: number | null = null
|
||||
let scrollbarDragDirection: 'none' | 'up' | 'down' = 'none'
|
||||
let scrollbarDragActive = false
|
||||
const folderInput = ref<HTMLInputElement | null>(null)
|
||||
const LATEST_OUTPUT_THRESHOLD = 48
|
||||
// 程序滚动只有真正抵达目标位置才算完成,不能复用用户回到底部的宽松阈值。
|
||||
// 否则浏览器派发的中间 scroll 事件会提前清除待跟随目标,后续输出就会停止跟随。
|
||||
const PROGRAMMATIC_SCROLL_THRESHOLD = 2
|
||||
|
||||
const projectId = computed(() => String(route.params.id || ''))
|
||||
const waitingPlan = computed(() => pendingAsk.value?.kind === 'planning' && plan.value?.status === 'DRAFT')
|
||||
@@ -131,6 +140,11 @@ async function load(id: string) {
|
||||
previousScrollY = window.scrollY
|
||||
hasObservedScrollPosition = false
|
||||
pendingFollowScrollTarget = null
|
||||
userScrollIntent = 'none'
|
||||
touchStartY = null
|
||||
scrollbarDragLastY = null
|
||||
scrollbarDragDirection = 'none'
|
||||
scrollbarDragActive = false
|
||||
stopStream?.()
|
||||
stopStream = null
|
||||
project.value = null
|
||||
@@ -480,17 +494,20 @@ function updateScrollState() {
|
||||
const completingFollowScroll = pendingFollowScrollTarget !== null
|
||||
|
||||
if (completingFollowScroll) {
|
||||
// 大文档滚动可能产生多个中间事件;抵达目标附近前,这些事件都属于同一次程序滚动。
|
||||
const reachedRequestedTarget = Math.abs(currentScrollY - pendingFollowScrollTarget!) <= LATEST_OUTPUT_THRESHOLD
|
||||
if (reachedRequestedTarget || distanceFromBottom <= LATEST_OUTPUT_THRESHOLD) {
|
||||
// 大文档滚动可能产生多个中间事件;只有抵达程序滚动目标(允许极小的像素误差)
|
||||
// 才能结束跟踪,不能因为“接近底部”的用户阈值而提前清除目标。
|
||||
const reachedRequestedTarget = Math.abs(currentScrollY - pendingFollowScrollTarget!) <= PROGRAMMATIC_SCROLL_THRESHOLD
|
||||
if (reachedRequestedTarget) {
|
||||
pendingFollowScrollTarget = null
|
||||
followsLatestOutput.value = true
|
||||
}
|
||||
} else if (scrollingUp) {
|
||||
} else if (scrollingUp && userScrollIntent !== 'down') {
|
||||
followsLatestOutput.value = false
|
||||
} else if (distanceFromBottom <= LATEST_OUTPUT_THRESHOLD) {
|
||||
userScrollIntent = 'up'
|
||||
} else if (!scrollbarDragActive && distanceFromBottom <= LATEST_OUTPUT_THRESHOLD && userScrollIntent !== 'up') {
|
||||
// 用户主动回到底部后恢复跟随;仅内容高度增加时则保留原来的跟随意图。
|
||||
followsLatestOutput.value = true
|
||||
userScrollIntent = 'none'
|
||||
}
|
||||
previousScrollY = currentScrollY
|
||||
hasObservedScrollPosition = true
|
||||
@@ -506,7 +523,9 @@ function updateScrollState() {
|
||||
function moveViewportToLatestOutput(behavior?: ScrollBehavior) {
|
||||
const scrollHeight = document.documentElement.scrollHeight
|
||||
const targetScrollY = Math.max(0, scrollHeight - window.innerHeight)
|
||||
pendingFollowScrollTarget = Math.abs(window.scrollY - targetScrollY) <= LATEST_OUTPUT_THRESHOLD
|
||||
// 所有程序滚动(箭头点击、事件更新、ResizeObserver)统一只在真正抵达目标时
|
||||
// 清除待跟随标记,避免近底部的中间 scroll 事件破坏持续跟随状态。
|
||||
pendingFollowScrollTarget = Math.abs(window.scrollY - targetScrollY) <= PROGRAMMATIC_SCROLL_THRESHOLD
|
||||
? null
|
||||
: targetScrollY
|
||||
hasObservedScrollPosition = true
|
||||
@@ -518,9 +537,137 @@ function moveViewportToLatestOutput(behavior?: ScrollBehavior) {
|
||||
/** 点击悬浮箭头后立即回到最新输出,并重新启用后续输出跟随。 */
|
||||
function scrollToBottom() {
|
||||
followsLatestOutput.value = true
|
||||
userScrollIntent = 'none'
|
||||
touchStartY = null
|
||||
// 点击箭头即使当前已经接近底部,也必须追踪这次程序滚动的完整过程,
|
||||
// 防止浏览器先派发的中间 scroll 事件被误判成用户向上滚动。
|
||||
moveViewportToLatestOutput('auto')
|
||||
}
|
||||
|
||||
/** 判断事件是否发生在表单控件或可编辑元素内,避免控件内部操作误触发页面滚动状态。 */
|
||||
function isInteractiveTarget(target: EventTarget | null) {
|
||||
const element = target instanceof Element ? target : null
|
||||
return Boolean(element && (
|
||||
(element instanceof HTMLElement && element.isContentEditable)
|
||||
|| element.closest('input, textarea, select, option, button, [contenteditable="true"]')
|
||||
))
|
||||
}
|
||||
|
||||
/** 当前已经位于最新输出附近时恢复跟随,覆盖没有产生 scroll 事件的输入场景。 */
|
||||
function restoreFollowIfAtLatest() {
|
||||
const distanceFromBottom = document.documentElement.scrollHeight - window.scrollY - window.innerHeight
|
||||
if (distanceFromBottom <= LATEST_OUTPUT_THRESHOLD) {
|
||||
followsLatestOutput.value = true
|
||||
userScrollIntent = 'none'
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 在浏览器派发 scroll 之前捕获用户的真实上滚意图。
|
||||
*
|
||||
* <p>输出区域持续变化时 ResizeObserver 也会触发程序滚动;如果只依赖 scroll
|
||||
* 事件,程序滚动可能抢在用户的滚动事件之前执行,导致用户无法回看历史。</p>
|
||||
*/
|
||||
function handleUserWheel(event: WheelEvent) {
|
||||
if (isInteractiveTarget(event.target)) return
|
||||
if (event.deltaY !== 0) {
|
||||
// 一旦用户开始滚轮操作,先取消尚未完成的程序滚动;否则其延迟 scroll
|
||||
// 事件可能在用户上滚后再次把页面状态恢复到底部。
|
||||
pendingFollowScrollTarget = null
|
||||
}
|
||||
if (event.deltaY < 0) {
|
||||
followsLatestOutput.value = false
|
||||
userScrollIntent = 'up'
|
||||
} else if (event.deltaY > 0) {
|
||||
userScrollIntent = 'down'
|
||||
// 页面已经在底部附近时,浏览器可能不会再派发 scroll 事件。
|
||||
restoreFollowIfAtLatest()
|
||||
}
|
||||
}
|
||||
|
||||
/** 拖动浏览器右侧滚动条时没有 wheel 事件,需要单独取消程序滚动目标。 */
|
||||
function handleUserScrollbarDrag(event: MouseEvent) {
|
||||
const scrollbarStart = document.documentElement.clientWidth
|
||||
if (event.clientX >= scrollbarStart) {
|
||||
followsLatestOutput.value = false
|
||||
pendingFollowScrollTarget = null
|
||||
scrollbarDragLastY = event.clientY
|
||||
scrollbarDragDirection = 'none'
|
||||
scrollbarDragActive = true
|
||||
// 鼠标按下时还无法判断拖动方向,先按上滚保护,释放时再依据实际起止位置修正。
|
||||
userScrollIntent = 'up'
|
||||
}
|
||||
}
|
||||
|
||||
/** 根据滚动条指针的实际位移记录用户拖动方向,避免把程序 scroll 事件当成用户方向。 */
|
||||
function handleUserScrollbarMove(event: MouseEvent) {
|
||||
if (!scrollbarDragActive || scrollbarDragLastY === null) return
|
||||
if (event.clientY < scrollbarDragLastY) scrollbarDragDirection = 'up'
|
||||
else if (event.clientY > scrollbarDragLastY) scrollbarDragDirection = 'down'
|
||||
scrollbarDragLastY = event.clientY
|
||||
}
|
||||
|
||||
/** 释放滚动条后按实际起止位置确认是否回到底部,结束本次滚动条交互会话。 */
|
||||
function handleUserMouseUp() {
|
||||
if (!scrollbarDragActive) return
|
||||
const distanceFromBottom = document.documentElement.scrollHeight - window.scrollY - window.innerHeight
|
||||
scrollbarDragActive = false
|
||||
scrollbarDragLastY = null
|
||||
const draggedUp = scrollbarDragDirection === 'up'
|
||||
scrollbarDragDirection = 'none'
|
||||
if (!draggedUp && distanceFromBottom <= LATEST_OUTPUT_THRESHOLD) {
|
||||
followsLatestOutput.value = true
|
||||
userScrollIntent = 'none'
|
||||
}
|
||||
}
|
||||
|
||||
/** 记录触摸开始位置,供 touchmove 判断用户是向上还是向下拖动页面。 */
|
||||
function handleUserTouchStart(event: TouchEvent) {
|
||||
touchStartY = event.touches[0]?.clientY ?? null
|
||||
}
|
||||
|
||||
/** 触摸滚动同样要在 scroll 事件之前取消程序滚动,并记录用户滚动方向。 */
|
||||
function handleUserTouchMove(event: TouchEvent) {
|
||||
if (isInteractiveTarget(event.target)) return
|
||||
pendingFollowScrollTarget = null
|
||||
const currentY = event.touches[0]?.clientY
|
||||
if (touchStartY === null || currentY === undefined) {
|
||||
// 无法读取触点坐标时按上滚处理,确保不会因延迟程序事件抢回底部。
|
||||
followsLatestOutput.value = false
|
||||
userScrollIntent = 'up'
|
||||
return
|
||||
}
|
||||
const pageScrollDelta = touchStartY - currentY
|
||||
if (pageScrollDelta < 0) {
|
||||
followsLatestOutput.value = false
|
||||
userScrollIntent = 'up'
|
||||
} else if (pageScrollDelta > 0) {
|
||||
userScrollIntent = 'down'
|
||||
restoreFollowIfAtLatest()
|
||||
}
|
||||
}
|
||||
|
||||
/** 触摸手势结束后清理起始坐标,避免下一次无 touchstart 的异常事件复用旧坐标。 */
|
||||
function handleUserTouchEnd() {
|
||||
touchStartY = null
|
||||
}
|
||||
|
||||
/** 键盘 PageUp、Home、ArrowUp 同样代表用户主动回看历史,应立即暂停跟随。 */
|
||||
function handleUserKeydown(event: KeyboardEvent) {
|
||||
if (isInteractiveTarget(event.target)) return
|
||||
if (event.key === 'PageUp' || event.key === 'Home' || event.key === 'ArrowUp') {
|
||||
followsLatestOutput.value = false
|
||||
pendingFollowScrollTarget = null
|
||||
userScrollIntent = 'up'
|
||||
} else if (event.key === 'PageDown' || event.key === 'End' || event.key === 'ArrowDown' || event.key === ' ' || event.key === 'Spacebar') {
|
||||
pendingFollowScrollTarget = null
|
||||
userScrollIntent = 'down'
|
||||
restoreFollowIfAtLatest()
|
||||
}
|
||||
}
|
||||
|
||||
watch(projectId, id => { if (id) void load(id) }, { immediate: true })
|
||||
watch(() => events.value.length, async () => {
|
||||
await nextTick()
|
||||
@@ -543,6 +690,15 @@ onMounted(() => {
|
||||
if (outputContainer.value) outputResizeObserver.observe(outputContainer.value)
|
||||
|
||||
window.addEventListener('scroll', updateScrollState, { passive: true })
|
||||
window.addEventListener('wheel', handleUserWheel, { passive: true })
|
||||
window.addEventListener('mousedown', handleUserScrollbarDrag)
|
||||
window.addEventListener('mousemove', handleUserScrollbarMove)
|
||||
window.addEventListener('mouseup', handleUserMouseUp)
|
||||
window.addEventListener('touchstart', handleUserTouchStart, { passive: true })
|
||||
window.addEventListener('touchmove', handleUserTouchMove, { passive: true })
|
||||
window.addEventListener('touchend', handleUserTouchEnd, { passive: true })
|
||||
window.addEventListener('touchcancel', handleUserTouchEnd, { passive: true })
|
||||
window.addEventListener('keydown', handleUserKeydown)
|
||||
updateScrollState()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
@@ -551,6 +707,15 @@ onBeforeUnmount(() => {
|
||||
outputResizeObserver?.disconnect()
|
||||
outputResizeObserver = null
|
||||
window.removeEventListener('scroll', updateScrollState)
|
||||
window.removeEventListener('wheel', handleUserWheel)
|
||||
window.removeEventListener('mousedown', handleUserScrollbarDrag)
|
||||
window.removeEventListener('mousemove', handleUserScrollbarMove)
|
||||
window.removeEventListener('mouseup', handleUserMouseUp)
|
||||
window.removeEventListener('touchstart', handleUserTouchStart)
|
||||
window.removeEventListener('touchmove', handleUserTouchMove)
|
||||
window.removeEventListener('touchend', handleUserTouchEnd)
|
||||
window.removeEventListener('touchcancel', handleUserTouchEnd)
|
||||
window.removeEventListener('keydown', handleUserKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user