mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-30 17:14:40 +08:00
Merge pull request #13457 from Kilo-Org/fix-chat-scroll-user-intent
fix(vscode): preserve chat scroll intent
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@kilocode/kilo-ui": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Preserve the chat reading position when dragging the scrollbar or using extended scrolling gestures while a response streams.
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
import { afterAll, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
|
||||
const observers: Array<() => void> = []
|
||||
@@ -22,9 +22,13 @@ type Listener = {
|
||||
class FakeElement {
|
||||
scrollHeight = 100
|
||||
clientHeight = 100
|
||||
clientWidth = 100
|
||||
offsetWidth = 100
|
||||
scrollTop = 0
|
||||
style = { overflowAnchor: "" }
|
||||
hovered = false
|
||||
dir = ""
|
||||
rect = { left: 0, top: 0, right: 100, bottom: 100 }
|
||||
ownerDocument!: FakeDocument
|
||||
private children = new Set<FakeElement>()
|
||||
private listeners = new Map<string, Listener[]>()
|
||||
@@ -46,6 +50,10 @@ class FakeElement {
|
||||
return selector === ":hover" && this.hovered
|
||||
}
|
||||
|
||||
getBoundingClientRect() {
|
||||
return this.rect
|
||||
}
|
||||
|
||||
scrollTo(options: ScrollToOptions) {
|
||||
this.scrollTop = options.top ?? this.scrollTop
|
||||
}
|
||||
@@ -104,6 +112,27 @@ class FakeWheelEvent {
|
||||
) {}
|
||||
}
|
||||
|
||||
class FakeMouseEvent {
|
||||
constructor(
|
||||
readonly target: FakeElement,
|
||||
readonly clientX = 0,
|
||||
readonly clientY = 0,
|
||||
) {}
|
||||
}
|
||||
|
||||
class FakePointerEvent extends FakeMouseEvent {
|
||||
constructor(
|
||||
readonly pointerId: number,
|
||||
target: FakeElement,
|
||||
clientX = 0,
|
||||
clientY = 0,
|
||||
) {
|
||||
super(target, clientX, clientY)
|
||||
}
|
||||
}
|
||||
|
||||
class FakeTouchEvent extends FakeMouseEvent {}
|
||||
|
||||
class FakeKeyboardEvent {
|
||||
readonly defaultPrevented = false
|
||||
readonly shiftKey = false
|
||||
@@ -163,6 +192,19 @@ function setup(options?: { doc?: FakeDocument; interacted?: () => void; working?
|
||||
return { ...root, doc, el, resize, mutate }
|
||||
}
|
||||
|
||||
function overflow(ctx: ReturnType<typeof setup>, height = 1000, top = 800) {
|
||||
ctx.el.scrollHeight = height
|
||||
ctx.el.clientHeight = 200
|
||||
ctx.el.scrollTop = top
|
||||
}
|
||||
|
||||
function gutter(ctx: ReturnType<typeof setup>, dir = "") {
|
||||
ctx.el.clientWidth = 85
|
||||
ctx.el.offsetWidth = 100
|
||||
ctx.el.rect = { left: 0, top: 0, right: 100, bottom: 100 }
|
||||
ctx.el.dir = dir
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
observers.length = 0
|
||||
mutators.length = 0
|
||||
@@ -537,4 +579,176 @@ describe("createAutoScroll non-scrollable layouts", () => {
|
||||
expect(ctx.el.scrollTop).toBe(600)
|
||||
ctx.dispose()
|
||||
})
|
||||
|
||||
test("pauses for a document-targeted mousedown in the scrollbar gutter", () => {
|
||||
const ctx = setup({ working: true })
|
||||
overflow(ctx)
|
||||
gutter(ctx)
|
||||
|
||||
ctx.doc.fire("mousedown", new FakeMouseEvent(ctx.doc, 95, 50) as unknown as Event)
|
||||
ctx.el.scrollTop = 600
|
||||
ctx.scroll.handleScroll()
|
||||
|
||||
expect(ctx.scroll.userScrolled()).toBe(true)
|
||||
expect(ctx.el.scrollTop).toBe(600)
|
||||
ctx.dispose()
|
||||
})
|
||||
|
||||
test("tracks gestures on the live document when a detached transcript is adopted", () => {
|
||||
const detached = new FakeDocument()
|
||||
const live = new FakeDocument()
|
||||
const prior = Object.getOwnPropertyDescriptor(globalThis, "document")
|
||||
Object.defineProperty(globalThis, "document", { value: live, configurable: true })
|
||||
|
||||
try {
|
||||
const ctx = setup({ doc: detached, working: true })
|
||||
overflow(ctx)
|
||||
gutter(ctx)
|
||||
ctx.el.ownerDocument = live
|
||||
|
||||
live.fire("mousedown", new FakeMouseEvent(live, 95, 50) as unknown as Event)
|
||||
ctx.el.scrollTop = 600
|
||||
ctx.scroll.handleScroll()
|
||||
|
||||
expect(ctx.scroll.userScrolled()).toBe(true)
|
||||
expect(ctx.el.scrollTop).toBe(600)
|
||||
ctx.dispose()
|
||||
} finally {
|
||||
if (prior) Object.defineProperty(globalThis, "document", prior)
|
||||
if (!prior) Reflect.deleteProperty(globalThis, "document")
|
||||
}
|
||||
})
|
||||
|
||||
test("tracks document capture presses when the target receives no event", () => {
|
||||
const ctx = setup({ working: true })
|
||||
overflow(ctx)
|
||||
gutter(ctx)
|
||||
|
||||
ctx.doc.fire("pointerdown", new FakePointerEvent(1, ctx.doc, 95, 50) as unknown as Event)
|
||||
ctx.doc.fire("mousedown", new FakeMouseEvent(ctx.doc, 95, 50) as unknown as Event)
|
||||
ctx.el.scrollTop = 600
|
||||
ctx.scroll.handleScroll()
|
||||
|
||||
expect(ctx.scroll.userScrolled()).toBe(true)
|
||||
expect(ctx.el.scrollTop).toBe(600)
|
||||
ctx.dispose()
|
||||
})
|
||||
|
||||
test("keeps a pointer gesture active beyond the grace period", () => {
|
||||
const ctx = setup({ working: true })
|
||||
overflow(ctx)
|
||||
let now = 10
|
||||
const clock = spyOn(performance, "now").mockImplementation(() => now)
|
||||
|
||||
try {
|
||||
ctx.doc.fire("pointerdown", new FakePointerEvent(1, ctx.el) as unknown as Event)
|
||||
ctx.scroll.handleScroll()
|
||||
now = 1000
|
||||
ctx.doc.fire("pointermove", new FakePointerEvent(1, ctx.doc) as unknown as Event)
|
||||
ctx.el.scrollTop = 600
|
||||
ctx.scroll.handleScroll()
|
||||
|
||||
expect(ctx.scroll.userScrolled()).toBe(true)
|
||||
expect(ctx.el.scrollTop).toBe(600)
|
||||
} finally {
|
||||
clock.mockRestore()
|
||||
ctx.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps the release grace after a document mouse gesture", () => {
|
||||
const ctx = setup({ working: true })
|
||||
overflow(ctx)
|
||||
gutter(ctx)
|
||||
let now = 10
|
||||
const clock = spyOn(performance, "now").mockImplementation(() => now)
|
||||
|
||||
try {
|
||||
ctx.doc.fire("mousedown", new FakeMouseEvent(ctx.doc, 95, 50) as unknown as Event)
|
||||
ctx.scroll.handleScroll()
|
||||
now = 100
|
||||
ctx.doc.fire("mouseup", new FakeMouseEvent(ctx.doc) as unknown as Event)
|
||||
now = 200
|
||||
ctx.el.scrollTop = 600
|
||||
ctx.scroll.handleScroll()
|
||||
|
||||
expect(ctx.scroll.userScrolled()).toBe(true)
|
||||
expect(ctx.el.scrollTop).toBe(600)
|
||||
} finally {
|
||||
clock.mockRestore()
|
||||
ctx.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("does not mark an off-gutter document press", () => {
|
||||
const ctx = setup({ working: true })
|
||||
overflow(ctx)
|
||||
gutter(ctx)
|
||||
|
||||
ctx.doc.fire("mousedown", new FakeMouseEvent(ctx.doc, 50, 50) as unknown as Event)
|
||||
ctx.el.scrollTop = 600
|
||||
ctx.scroll.handleScroll()
|
||||
|
||||
expect(ctx.scroll.userScrolled()).toBe(false)
|
||||
expect(ctx.el.scrollTop).toBe(1000)
|
||||
ctx.dispose()
|
||||
})
|
||||
|
||||
test("routes a nested document press to the deepest owner", () => {
|
||||
const doc = new FakeDocument()
|
||||
const outer = setup({ doc, working: true })
|
||||
const inner = setup({ doc, working: true })
|
||||
outer.el.append(inner.el)
|
||||
overflow(outer)
|
||||
overflow(inner, 500, 400)
|
||||
gutter(outer)
|
||||
inner.el.clientWidth = 65
|
||||
inner.el.offsetWidth = 80
|
||||
inner.el.rect = { left: 10, top: 10, right: 90, bottom: 90 }
|
||||
|
||||
doc.fire("pointerdown", new FakePointerEvent(1, doc, 80, 50) as unknown as Event)
|
||||
inner.el.scrollTop = 200
|
||||
inner.scroll.handleScroll()
|
||||
|
||||
expect(inner.scroll.userScrolled()).toBe(true)
|
||||
expect(outer.scroll.userScrolled()).toBe(false)
|
||||
outer.dispose()
|
||||
inner.dispose()
|
||||
})
|
||||
|
||||
test("keeps touch activity alive through a late move", () => {
|
||||
const ctx = setup({ working: true })
|
||||
overflow(ctx)
|
||||
let now = 10
|
||||
const clock = spyOn(performance, "now").mockImplementation(() => now)
|
||||
|
||||
try {
|
||||
ctx.doc.fire("touchstart", new FakeTouchEvent(ctx.el) as unknown as Event)
|
||||
ctx.scroll.handleScroll()
|
||||
now = 1000
|
||||
ctx.doc.fire("touchmove", new FakeTouchEvent(ctx.doc) as unknown as Event)
|
||||
ctx.el.scrollTop = 600
|
||||
ctx.scroll.handleScroll()
|
||||
|
||||
expect(ctx.scroll.userScrolled()).toBe(true)
|
||||
expect(ctx.el.scrollTop).toBe(600)
|
||||
} finally {
|
||||
clock.mockRestore()
|
||||
ctx.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("resume clears an in-progress gesture before correcting the viewport", () => {
|
||||
const ctx = setup({ working: true })
|
||||
overflow(ctx)
|
||||
|
||||
ctx.el.fire("pointerdown", new FakePointerEvent(1, ctx.el) as unknown as Event)
|
||||
ctx.scroll.resume()
|
||||
ctx.el.scrollTop = 600
|
||||
ctx.scroll.handleScroll()
|
||||
|
||||
expect(ctx.scroll.userScrolled()).toBe(false)
|
||||
expect(ctx.el.scrollTop).toBe(1000)
|
||||
ctx.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -67,6 +67,7 @@ export function createAutoScroll(options: AutoScrollOptions) {
|
||||
}
|
||||
|
||||
const resume = () => {
|
||||
userActivity.reset()
|
||||
if (store.userScrolled) setStore("userScrolled", false)
|
||||
force()
|
||||
}
|
||||
|
||||
@@ -3,8 +3,12 @@ interface UserActivityOptions {
|
||||
onWheelUp: () => void
|
||||
}
|
||||
|
||||
type Kind = "pointer" | "mouse" | "touch"
|
||||
type Gesture = { kind: Kind; id?: number }
|
||||
|
||||
const SCROLL_KEYS = new Set(["ArrowDown", "ArrowUp", "End", "Home", "PageDown", "PageUp", " "])
|
||||
const owners = new WeakMap<Document, Set<HTMLElement>>()
|
||||
const gestures = new WeakMap<Document, HTMLElement>()
|
||||
|
||||
const isPotentialScrollInput = (event: Event) => {
|
||||
if (!(event.target instanceof Element)) return true
|
||||
@@ -12,21 +16,92 @@ const isPotentialScrollInput = (event: Event) => {
|
||||
return !event.target.closest("button, input, textarea, select") && !editable?.isContentEditable
|
||||
}
|
||||
|
||||
const deepest = (items: HTMLElement[]) => items.find((el) => !items.some((item) => item !== el && el.contains(item)))
|
||||
|
||||
const gutter = (el: HTMLElement, event: Event) => {
|
||||
const width = el.offsetWidth - el.clientWidth
|
||||
if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(el.offsetWidth) || el.offsetWidth <= 0) return false
|
||||
|
||||
const rect = el.getBoundingClientRect()
|
||||
const x = (event as MouseEvent).clientX
|
||||
const y = (event as MouseEvent).clientY
|
||||
const span = rect.right - rect.left
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y) || span <= 0) return false
|
||||
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return false
|
||||
|
||||
const size = (width * span) / el.offsetWidth
|
||||
const left = el.dir === "rtl" || el.ownerDocument.defaultView?.getComputedStyle(el).direction === "rtl"
|
||||
return left ? x <= rect.left + size : x >= rect.right - size
|
||||
}
|
||||
|
||||
const resolve = (event: Event, doc: Document) => {
|
||||
const items = [...(owners.get(doc) ?? [])]
|
||||
const target = event.target
|
||||
if (target !== doc && target instanceof Element && target !== doc.body && target !== doc.documentElement) {
|
||||
return deepest(items.filter((el) => el.contains(target)))
|
||||
}
|
||||
return deepest(items.filter((el) => gutter(el, event)))
|
||||
}
|
||||
|
||||
export const createUserActivity = (options: UserActivityOptions) => {
|
||||
let marked = false
|
||||
let time = 0
|
||||
let scroll: HTMLElement | undefined
|
||||
let doc: Document | undefined
|
||||
let gesture: Gesture | undefined
|
||||
|
||||
// Mark input that may cause the next scroll so layout-driven scroll events
|
||||
// do not get mistaken for the user leaving auto-follow mode.
|
||||
const mark = (event: Event) => {
|
||||
if (!isPotentialScrollInput(event)) return
|
||||
if (scroll && scroll.scrollHeight - scroll.clientHeight <= 1) return
|
||||
const mark = (event?: Event) => {
|
||||
if (event && !isPotentialScrollInput(event)) return
|
||||
if (!scroll || scroll.scrollHeight - scroll.clientHeight <= 1) return
|
||||
marked = true
|
||||
time = performance.now()
|
||||
}
|
||||
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
const start = (event: Event, kind: Kind, local = false) => {
|
||||
if (!scroll || !doc || !isPotentialScrollInput(event)) return
|
||||
if (scroll.scrollHeight - scroll.clientHeight <= 1) return
|
||||
if ("button" in event && event.button !== 0) return
|
||||
if (!local && resolve(event, doc) !== scroll) return
|
||||
|
||||
const owner = gestures.get(doc)
|
||||
if (owner && owner !== scroll) return
|
||||
|
||||
if (gesture) {
|
||||
if (kind === "touch" && gesture.kind === "pointer") gesture = { kind }
|
||||
mark()
|
||||
return
|
||||
}
|
||||
|
||||
gesture = { kind, id: kind === "pointer" ? (event as PointerEvent).pointerId : undefined }
|
||||
gestures.set(doc, scroll)
|
||||
mark()
|
||||
}
|
||||
|
||||
const match = (event: Event, kind: Kind) => {
|
||||
if (!gesture || gesture.kind !== kind) return false
|
||||
return kind !== "pointer" || gesture.id === (event as PointerEvent).pointerId
|
||||
}
|
||||
|
||||
const move = (event: Event, kind: Kind) => {
|
||||
if (!doc || !scroll || gestures.get(doc) !== scroll || !match(event, kind)) return
|
||||
mark()
|
||||
}
|
||||
|
||||
const end = (event: Event, kind: Kind) => {
|
||||
if (!doc || !scroll || gestures.get(doc) !== scroll || !match(event, kind)) return
|
||||
mark()
|
||||
gesture = undefined
|
||||
gestures.delete(doc)
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
if (doc && scroll && gestures.get(doc) === scroll) gestures.delete(doc)
|
||||
marked = false
|
||||
time = 0
|
||||
gesture = undefined
|
||||
}
|
||||
|
||||
const wheel = (event: WheelEvent) => {
|
||||
if (!isPotentialScrollInput(event)) return
|
||||
if (!scroll || scroll.scrollHeight - scroll.clientHeight <= 1) return
|
||||
if (event.deltaY >= 0 || scroll.scrollTop <= 0) return
|
||||
@@ -34,40 +109,68 @@ export const createUserActivity = (options: UserActivityOptions) => {
|
||||
options.onWheelUp()
|
||||
}
|
||||
|
||||
const handleKey = (event: KeyboardEvent) => {
|
||||
if (!scroll || event.defaultPrevented || !SCROLL_KEYS.has(event.key)) return
|
||||
const key = (event: KeyboardEvent) => {
|
||||
if (!scroll || event.defaultPrevented || !SCROLL_KEYS.has(event.key) || !isPotentialScrollInput(event)) return
|
||||
const target = event.target
|
||||
const root = target === scroll.ownerDocument.body || target === scroll.ownerDocument.documentElement
|
||||
const up = event.key === "ArrowUp" || event.key === "Home" || event.key === "PageUp" || (event.key === " " && event.shiftKey)
|
||||
const root =
|
||||
target === scroll.ownerDocument ||
|
||||
target === scroll.ownerDocument.body ||
|
||||
target === scroll.ownerDocument.documentElement
|
||||
const up =
|
||||
event.key === "ArrowUp" || event.key === "Home" || event.key === "PageUp" || (event.key === " " && event.shiftKey)
|
||||
const matches = [...(owners.get(scroll.ownerDocument) ?? [])].filter((el) => {
|
||||
const owns = root ? el.matches(":hover") : target instanceof Node && el.contains(target)
|
||||
if (!owns) return false
|
||||
return up ? el.scrollTop > 1 : el.scrollHeight - el.clientHeight - el.scrollTop > 1
|
||||
})
|
||||
const owner = matches.find((el) => !matches.some((candidate) => candidate !== el && el.contains(candidate)))
|
||||
if (owner !== scroll) return
|
||||
if (deepest(matches) !== scroll) return
|
||||
mark(event)
|
||||
}
|
||||
|
||||
return {
|
||||
listen: (el: HTMLElement) => {
|
||||
scroll = el
|
||||
const registered = owners.get(el.ownerDocument) ?? new Set<HTMLElement>()
|
||||
doc = el.isConnected || typeof document === "undefined" ? el.ownerDocument : document
|
||||
const root = doc
|
||||
const registered = owners.get(root) ?? new Set<HTMLElement>()
|
||||
registered.add(el)
|
||||
owners.set(el.ownerDocument, registered)
|
||||
el.addEventListener("wheel", handleWheel, { passive: true, capture: true })
|
||||
el.addEventListener("pointerdown", mark, { passive: true })
|
||||
el.addEventListener("touchstart", mark, { passive: true })
|
||||
el.ownerDocument.addEventListener("keydown", handleKey, { passive: true })
|
||||
owners.set(root, registered)
|
||||
|
||||
const down = [
|
||||
["pointerdown", (event: Event) => start(event, "pointer", true)],
|
||||
["mousedown", (event: Event) => start(event, "mouse", true)],
|
||||
["touchstart", (event: Event) => start(event, "touch", true)],
|
||||
] as const
|
||||
const handlers = [
|
||||
["pointerdown", (event: Event) => start(event, "pointer")],
|
||||
["mousedown", (event: Event) => start(event, "mouse")],
|
||||
["touchstart", (event: Event) => start(event, "touch")],
|
||||
["pointermove", (event: Event) => move(event, "pointer")],
|
||||
["mousemove", (event: Event) => move(event, "mouse")],
|
||||
["touchmove", (event: Event) => move(event, "touch")],
|
||||
["pointerup", (event: Event) => end(event, "pointer")],
|
||||
["mouseup", (event: Event) => end(event, "mouse")],
|
||||
["touchend", (event: Event) => end(event, "touch")],
|
||||
["pointercancel", (event: Event) => end(event, "pointer")],
|
||||
["touchcancel", (event: Event) => end(event, "touch")],
|
||||
] as const
|
||||
const opts = { capture: true, passive: true }
|
||||
|
||||
for (const [type, handler] of handlers) root.addEventListener(type, handler, opts)
|
||||
for (const [type, handler] of down) el.addEventListener(type, handler, opts)
|
||||
el.addEventListener("wheel", wheel, opts)
|
||||
root.addEventListener("keydown", key, { passive: true })
|
||||
|
||||
return () => {
|
||||
if (scroll === el) scroll = undefined
|
||||
reset()
|
||||
registered.delete(el)
|
||||
if (registered.size === 0) owners.delete(el.ownerDocument)
|
||||
el.removeEventListener("wheel", handleWheel, { capture: true })
|
||||
el.removeEventListener("pointerdown", mark)
|
||||
el.removeEventListener("touchstart", mark)
|
||||
el.ownerDocument.removeEventListener("keydown", handleKey)
|
||||
if (registered.size === 0) owners.delete(root)
|
||||
for (const [type, handler] of handlers) root.removeEventListener(type, handler, opts)
|
||||
for (const [type, handler] of down) el.removeEventListener(type, handler, opts)
|
||||
el.removeEventListener("wheel", wheel, opts)
|
||||
root.removeEventListener("keydown", key)
|
||||
if (scroll === el) scroll = undefined
|
||||
if (doc === root) doc = undefined
|
||||
}
|
||||
},
|
||||
consumeScroll: () => {
|
||||
@@ -75,6 +178,7 @@ export const createUserActivity = (options: UserActivityOptions) => {
|
||||
marked = false
|
||||
return value
|
||||
},
|
||||
isRecent: () => time > 0 && performance.now() - time < options.grace,
|
||||
isRecent: () => gesture !== undefined || (time > 0 && performance.now() - time < options.grace),
|
||||
reset,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,13 @@ import { expect, test, type Page } from "@playwright/test"
|
||||
const GLOBALS = "colorScheme:dark;theme:kilo-vscode;vscodeTheme:dark-modern"
|
||||
const STORY_ID = "chat--message-list-layout-correction"
|
||||
|
||||
test.use({
|
||||
launchOptions: {
|
||||
ignoreDefaultArgs: ["--hide-scrollbars"],
|
||||
args: ["--disable-features=OverlayScrollbar,OverlayScrollbars"],
|
||||
},
|
||||
})
|
||||
|
||||
async function settle(page: Page, frames = 2) {
|
||||
await page.evaluate(
|
||||
(count) =>
|
||||
@@ -21,6 +28,14 @@ async function distance(page: Page) {
|
||||
return page.locator(".message-list").evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop)
|
||||
}
|
||||
|
||||
async function state(page: Page) {
|
||||
return page.locator(".message-list").evaluate((el) => ({
|
||||
top: el.scrollTop,
|
||||
height: el.scrollHeight,
|
||||
distance: el.scrollHeight - el.clientHeight - el.scrollTop,
|
||||
}))
|
||||
}
|
||||
|
||||
test("keeps following after a stable-height layout correction", async ({ page }) => {
|
||||
await page.goto(`/iframe.html?id=${STORY_ID}&viewMode=story&globals=${GLOBALS}`, { waitUntil: "load" })
|
||||
const list = page.locator(".message-list")
|
||||
@@ -64,3 +79,116 @@ test("keeps the reading position when the prompt rail scrolls upward", async ({
|
||||
await expect.poll(() => list.evaluate((el) => el.scrollTop)).toBe(top)
|
||||
await expect(page.getByRole("button", { name: "Scroll to bottom" })).toBeVisible()
|
||||
})
|
||||
|
||||
test("pauses on a native scrollbar drag and resumes at the bottom", async ({ page }) => {
|
||||
await page.goto(`/iframe.html?id=${STORY_ID}&viewMode=story&globals=${GLOBALS}`, { waitUntil: "load" })
|
||||
const list = page.locator(".message-list")
|
||||
await expect(list).toBeVisible()
|
||||
await settle(page, 10)
|
||||
await expect.poll(() => distance(page)).toBeLessThanOrEqual(2)
|
||||
|
||||
await list.evaluate((el) => {
|
||||
const count = (key: "pointer" | "mouse" | "scroll") => () => {
|
||||
el.dataset[key] = String(Number(el.dataset[key] ?? "0") + 1)
|
||||
}
|
||||
const block = (event: Event) => {
|
||||
if (event.target === el) event.stopPropagation()
|
||||
}
|
||||
el.dataset.pointer = "0"
|
||||
el.dataset.mouse = "0"
|
||||
el.dataset.scroll = "0"
|
||||
el.addEventListener("pointerdown", count("pointer"), { capture: true })
|
||||
el.addEventListener("mousedown", count("mouse"), { capture: true })
|
||||
el.addEventListener("scroll", count("scroll"), { passive: true })
|
||||
el.ownerDocument.addEventListener("pointerdown", block, { capture: true })
|
||||
el.ownerDocument.addEventListener("mousedown", block, { capture: true })
|
||||
})
|
||||
|
||||
const coords = await list.evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
const gutter = el.offsetWidth - el.clientWidth
|
||||
const thumb = Math.max(20, (el.clientHeight * el.clientHeight) / el.scrollHeight)
|
||||
return {
|
||||
x: rect.right - gutter / 2,
|
||||
y: rect.bottom - thumb / 2 - 2,
|
||||
target: Math.max(rect.top + thumb / 2 + 2, rect.bottom - thumb / 2 - 242),
|
||||
}
|
||||
})
|
||||
|
||||
await page.mouse.move(coords.x, coords.y)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(coords.x, coords.target, { steps: 12 })
|
||||
await page.mouse.up()
|
||||
|
||||
await expect.poll(() => list.evaluate((el) => Number(el.dataset.scroll ?? "0"))).toBeGreaterThan(0)
|
||||
await expect.poll(() => distance(page)).toBeGreaterThan(40)
|
||||
await settle(page, 10)
|
||||
const before = await state(page)
|
||||
await settle(page, 4)
|
||||
const stable = await state(page)
|
||||
expect(Math.abs(stable.top - before.top)).toBeLessThanOrEqual(1)
|
||||
expect(Math.abs(stable.height - before.height)).toBeLessThanOrEqual(1)
|
||||
expect(stable.distance).toBeGreaterThan(40)
|
||||
expect(await list.getAttribute("data-pointer")).toBe("0")
|
||||
expect(await list.getAttribute("data-mouse")).toBe("0")
|
||||
expect(await list.getAttribute("data-scroll")).toMatch(/[1-9]/)
|
||||
await expect(page.getByRole("button", { name: "Scroll to bottom" })).toBeVisible()
|
||||
|
||||
await page.getByTestId("append-stream").click()
|
||||
await expect.poll(() => list.evaluate((el) => el.scrollHeight)).toBeGreaterThan(stable.height)
|
||||
await settle(page, 10)
|
||||
const after = await state(page)
|
||||
expect(after.top).toBeCloseTo(stable.top, 0)
|
||||
expect(after.distance).toBeGreaterThan(40)
|
||||
await expect(page.getByRole("button", { name: "Scroll to bottom" })).toBeVisible()
|
||||
|
||||
await page.getByRole("button", { name: "Scroll to bottom" }).click()
|
||||
await expect.poll(() => distance(page)).toBeLessThanOrEqual(2)
|
||||
await expect(page.getByRole("button", { name: "Scroll to bottom" })).toBeHidden()
|
||||
|
||||
await page.getByTestId("append-stream").click()
|
||||
await expect.poll(() => distance(page)).toBeLessThanOrEqual(2)
|
||||
await expect(page.getByRole("button", { name: "Scroll to bottom" })).toBeHidden()
|
||||
})
|
||||
|
||||
test("keeps a long native scrollbar drag user-controlled", async ({ page }) => {
|
||||
await page.goto(`/iframe.html?id=${STORY_ID}&viewMode=story&globals=${GLOBALS}`, { waitUntil: "load" })
|
||||
const list = page.locator(".message-list")
|
||||
await expect(list).toBeVisible()
|
||||
await settle(page, 10)
|
||||
await expect.poll(() => distance(page)).toBeLessThanOrEqual(2)
|
||||
|
||||
await list.evaluate((el) => {
|
||||
const count = () => {
|
||||
el.dataset.scroll = String(Number(el.dataset.scroll ?? "0") + 1)
|
||||
}
|
||||
const block = (event: Event) => {
|
||||
if (event.target === el) event.stopPropagation()
|
||||
}
|
||||
el.dataset.scroll = "0"
|
||||
el.addEventListener("scroll", count, { passive: true })
|
||||
el.ownerDocument.addEventListener("pointerdown", block, { capture: true })
|
||||
el.ownerDocument.addEventListener("mousedown", block, { capture: true })
|
||||
})
|
||||
|
||||
const coords = await list.evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
const gutter = el.offsetWidth - el.clientWidth
|
||||
const thumb = Math.max(20, (el.clientHeight * el.clientHeight) / el.scrollHeight)
|
||||
return {
|
||||
x: rect.right - gutter / 2,
|
||||
y: rect.bottom - thumb / 2 - 2,
|
||||
target: Math.max(rect.top + thumb / 2 + 2, rect.bottom - thumb / 2 - 242),
|
||||
}
|
||||
})
|
||||
|
||||
await page.mouse.move(coords.x, coords.y)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(coords.x, coords.target, { steps: 18 })
|
||||
await page.waitForTimeout(350)
|
||||
await page.mouse.up()
|
||||
|
||||
await expect.poll(() => list.evaluate((el) => Number(el.dataset.scroll ?? "0"))).toBeGreaterThan(0)
|
||||
await expect.poll(() => distance(page)).toBeGreaterThan(40)
|
||||
await expect(page.getByRole("button", { name: "Scroll to bottom" })).toBeVisible()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user