修复:完善流式输出滚动跟随
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { defineComponent, h } from 'vue'
|
import { defineComponent, h } from 'vue'
|
||||||
import { flushPromises, shallowMount } from '@vue/test-utils'
|
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 type { AgentEvent } from '../api'
|
||||||
import ProjectPage from './ProjectPage.vue'
|
import ProjectPage from './ProjectPage.vue'
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ let outputResizeCallback: ResizeObserverCallback | null = null
|
|||||||
const observeOutputMock = vi.fn()
|
const observeOutputMock = vi.fn()
|
||||||
const unobserveOutputMock = vi.fn()
|
const unobserveOutputMock = vi.fn()
|
||||||
const disconnectOutputObserverMock = vi.fn()
|
const disconnectOutputObserverMock = vi.fn()
|
||||||
|
const mountedWrappers: Array<{ unmount: () => void }> = []
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JSDOM 不提供 ResizeObserver,这里保留组件注册的回调,模拟流式 Markdown
|
* JSDOM 不提供 ResizeObserver,这里保留组件注册的回调,模拟流式 Markdown
|
||||||
@@ -139,7 +140,7 @@ function mountProject(runStatus: 'RUNNING' | 'INTERRUPTED', options: ProjectFixt
|
|||||||
throw new Error(`未处理的测试请求:${url}`)
|
throw new Error(`未处理的测试请求:${url}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
return shallowMount(ProjectPage, {
|
const wrapper = shallowMount(ProjectPage, {
|
||||||
global: {
|
global: {
|
||||||
stubs: {
|
stubs: {
|
||||||
'el-button': ElButtonStub,
|
'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 模型切换', () => {
|
describe('ProjectPage 模型切换', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Vitest 会把钩子返回的函数当作清理回调,因此这里不能直接返回 mockReset() 的返回值。
|
// Vitest 会把钩子返回的函数当作清理回调,因此这里不能直接返回 mockReset() 的返回值。
|
||||||
@@ -280,12 +289,112 @@ describe('ProjectPage 项目工作区', () => {
|
|||||||
expect(scrollTo).toHaveBeenCalledWith({ top: 1800, behavior: 'auto' })
|
expect(scrollTo).toHaveBeenCalledWith({ top: 1800, behavior: 'auto' })
|
||||||
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
|
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 })
|
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1190 })
|
||||||
window.dispatchEvent(new Event('scroll'))
|
window.dispatchEvent(new Event('scroll'))
|
||||||
await wrapper.vm.$nextTick()
|
await wrapper.vm.$nextTick()
|
||||||
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
|
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 的平滑渲染继续增高时也必须跟随。
|
// 点击箭头代表重新进入持续跟随模式;同一批 Markdown 的平滑渲染继续增高时也必须跟随。
|
||||||
scrollTo.mockClear()
|
scrollTo.mockClear()
|
||||||
Object.defineProperty(document.documentElement, 'scrollHeight', { configurable: true, value: 2000 })
|
Object.defineProperty(document.documentElement, 'scrollHeight', { configurable: true, value: 2000 })
|
||||||
@@ -294,6 +403,17 @@ describe('ProjectPage 项目工作区', () => {
|
|||||||
|
|
||||||
expect(scrollTo).toHaveBeenCalledWith({ top: 2000 })
|
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()
|
scrollTo.mockClear()
|
||||||
streamCallback!([{
|
streamCallback!([{
|
||||||
|
|||||||
@@ -62,8 +62,17 @@ let loadVersion = 0
|
|||||||
let previousScrollY = 0
|
let previousScrollY = 0
|
||||||
let hasObservedScrollPosition = false
|
let hasObservedScrollPosition = false
|
||||||
let pendingFollowScrollTarget: number | null = null
|
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 folderInput = ref<HTMLInputElement | null>(null)
|
||||||
const LATEST_OUTPUT_THRESHOLD = 48
|
const LATEST_OUTPUT_THRESHOLD = 48
|
||||||
|
// 程序滚动只有真正抵达目标位置才算完成,不能复用用户回到底部的宽松阈值。
|
||||||
|
// 否则浏览器派发的中间 scroll 事件会提前清除待跟随目标,后续输出就会停止跟随。
|
||||||
|
const PROGRAMMATIC_SCROLL_THRESHOLD = 2
|
||||||
|
|
||||||
const projectId = computed(() => String(route.params.id || ''))
|
const projectId = computed(() => String(route.params.id || ''))
|
||||||
const waitingPlan = computed(() => pendingAsk.value?.kind === 'planning' && plan.value?.status === 'DRAFT')
|
const waitingPlan = computed(() => pendingAsk.value?.kind === 'planning' && plan.value?.status === 'DRAFT')
|
||||||
@@ -131,6 +140,11 @@ async function load(id: string) {
|
|||||||
previousScrollY = window.scrollY
|
previousScrollY = window.scrollY
|
||||||
hasObservedScrollPosition = false
|
hasObservedScrollPosition = false
|
||||||
pendingFollowScrollTarget = null
|
pendingFollowScrollTarget = null
|
||||||
|
userScrollIntent = 'none'
|
||||||
|
touchStartY = null
|
||||||
|
scrollbarDragLastY = null
|
||||||
|
scrollbarDragDirection = 'none'
|
||||||
|
scrollbarDragActive = false
|
||||||
stopStream?.()
|
stopStream?.()
|
||||||
stopStream = null
|
stopStream = null
|
||||||
project.value = null
|
project.value = null
|
||||||
@@ -480,17 +494,20 @@ function updateScrollState() {
|
|||||||
const completingFollowScroll = pendingFollowScrollTarget !== null
|
const completingFollowScroll = pendingFollowScrollTarget !== null
|
||||||
|
|
||||||
if (completingFollowScroll) {
|
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
|
pendingFollowScrollTarget = null
|
||||||
followsLatestOutput.value = true
|
followsLatestOutput.value = true
|
||||||
}
|
}
|
||||||
} else if (scrollingUp) {
|
} else if (scrollingUp && userScrollIntent !== 'down') {
|
||||||
followsLatestOutput.value = false
|
followsLatestOutput.value = false
|
||||||
} else if (distanceFromBottom <= LATEST_OUTPUT_THRESHOLD) {
|
userScrollIntent = 'up'
|
||||||
|
} else if (!scrollbarDragActive && distanceFromBottom <= LATEST_OUTPUT_THRESHOLD && userScrollIntent !== 'up') {
|
||||||
// 用户主动回到底部后恢复跟随;仅内容高度增加时则保留原来的跟随意图。
|
// 用户主动回到底部后恢复跟随;仅内容高度增加时则保留原来的跟随意图。
|
||||||
followsLatestOutput.value = true
|
followsLatestOutput.value = true
|
||||||
|
userScrollIntent = 'none'
|
||||||
}
|
}
|
||||||
previousScrollY = currentScrollY
|
previousScrollY = currentScrollY
|
||||||
hasObservedScrollPosition = true
|
hasObservedScrollPosition = true
|
||||||
@@ -506,7 +523,9 @@ function updateScrollState() {
|
|||||||
function moveViewportToLatestOutput(behavior?: ScrollBehavior) {
|
function moveViewportToLatestOutput(behavior?: ScrollBehavior) {
|
||||||
const scrollHeight = document.documentElement.scrollHeight
|
const scrollHeight = document.documentElement.scrollHeight
|
||||||
const targetScrollY = Math.max(0, scrollHeight - window.innerHeight)
|
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
|
? null
|
||||||
: targetScrollY
|
: targetScrollY
|
||||||
hasObservedScrollPosition = true
|
hasObservedScrollPosition = true
|
||||||
@@ -518,9 +537,137 @@ function moveViewportToLatestOutput(behavior?: ScrollBehavior) {
|
|||||||
/** 点击悬浮箭头后立即回到最新输出,并重新启用后续输出跟随。 */
|
/** 点击悬浮箭头后立即回到最新输出,并重新启用后续输出跟随。 */
|
||||||
function scrollToBottom() {
|
function scrollToBottom() {
|
||||||
followsLatestOutput.value = true
|
followsLatestOutput.value = true
|
||||||
|
userScrollIntent = 'none'
|
||||||
|
touchStartY = null
|
||||||
|
// 点击箭头即使当前已经接近底部,也必须追踪这次程序滚动的完整过程,
|
||||||
|
// 防止浏览器先派发的中间 scroll 事件被误判成用户向上滚动。
|
||||||
moveViewportToLatestOutput('auto')
|
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(projectId, id => { if (id) void load(id) }, { immediate: true })
|
||||||
watch(() => events.value.length, async () => {
|
watch(() => events.value.length, async () => {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
@@ -543,6 +690,15 @@ onMounted(() => {
|
|||||||
if (outputContainer.value) outputResizeObserver.observe(outputContainer.value)
|
if (outputContainer.value) outputResizeObserver.observe(outputContainer.value)
|
||||||
|
|
||||||
window.addEventListener('scroll', updateScrollState, { passive: true })
|
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()
|
updateScrollState()
|
||||||
})
|
})
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
@@ -551,6 +707,15 @@ onBeforeUnmount(() => {
|
|||||||
outputResizeObserver?.disconnect()
|
outputResizeObserver?.disconnect()
|
||||||
outputResizeObserver = null
|
outputResizeObserver = null
|
||||||
window.removeEventListener('scroll', updateScrollState)
|
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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user