mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
1220 lines
44 KiB
TypeScript
1220 lines
44 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { h } from "preact";
|
|
import { useLayoutEffect, useRef } from "preact/hooks";
|
|
import { act } from "preact/test-utils";
|
|
|
|
import {
|
|
type MessageStreamVirtualizerHandle,
|
|
type MessageStreamVirtualizerView,
|
|
useMessageStreamVirtualizer,
|
|
} from "../../../../../src/features/chat/ui/message-stream/virtualizer";
|
|
import type { MessageStreamBlock } from "../../../../../src/features/chat/ui/message-stream/context";
|
|
import { renderUiRoot, unmountUiRoot } from "../../../../../src/shared/ui/ui-root";
|
|
import { installObsidianDomShims } from "../../../../support/dom";
|
|
|
|
installObsidianDomShims();
|
|
|
|
describe("TestMessageStreamVirtualizer", () => {
|
|
afterEach(() => {
|
|
document.body.replaceChildren();
|
|
});
|
|
|
|
it("pins to the virtualized end when content is appended while pinned", () => {
|
|
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
|
|
const controller = createMessageStreamVirtualizerDriver(container);
|
|
|
|
renderVirtualItems(controller, container, ["first"], [180], "follow-bottom");
|
|
expect(container.scrollTop).toBe(80);
|
|
|
|
renderVirtualItems(controller, container, ["first", "second"], [180, 220], "auto");
|
|
|
|
expect(container.scrollTop).toBe(300);
|
|
controller.dispose();
|
|
});
|
|
|
|
it("requests the end before measurements and remains there after measurements resolve", () => {
|
|
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
|
|
const controller = createMessageStreamVirtualizerDriver(container);
|
|
const blocks = [{ key: "first", node: null }];
|
|
container.dataset["testTotalSize"] = "180";
|
|
|
|
controller.render(blocks, "follow-bottom");
|
|
expect(container.scrollTop).toBe(80);
|
|
|
|
measureVirtualItem(controller, "first", 0, 180);
|
|
|
|
expect(controller.getTotalSize()).toBe(180);
|
|
expect(container.scrollTop).toBe(80);
|
|
controller.dispose();
|
|
});
|
|
|
|
it("settles at the end after the rendered virtualizer height reaches the DOM", () => {
|
|
withAnimationFrame((flushFrames) => {
|
|
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
|
|
const controller = createMessageStreamVirtualizerDriver(container);
|
|
const blocks = [{ key: "first", node: null }];
|
|
container.dataset["testTotalSize"] = "180";
|
|
container.dataset["testScrollHeight"] = "100";
|
|
container.dataset["testClampToScrollHeight"] = "true";
|
|
|
|
controller.render(blocks, "follow-bottom");
|
|
expect(container.scrollTop).toBe(0);
|
|
|
|
flushFrames();
|
|
expect(container.scrollTop).toBe(0);
|
|
|
|
delete container.dataset["testScrollHeight"];
|
|
flushFrames();
|
|
|
|
expect(container.scrollTop).toBe(80);
|
|
controller.dispose();
|
|
});
|
|
});
|
|
|
|
it("keeps settling for a few frames when the rendered virtualizer height is delayed", () => {
|
|
withAnimationFrame((flushFrames) => {
|
|
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
|
|
const controller = createMessageStreamVirtualizerDriver(container);
|
|
const blocks = [{ key: "first", node: null }];
|
|
container.dataset["testTotalSize"] = "180";
|
|
container.dataset["testScrollHeight"] = "100";
|
|
container.dataset["testClampToScrollHeight"] = "true";
|
|
|
|
controller.render(blocks, "follow-bottom");
|
|
expect(container.scrollTop).toBe(0);
|
|
|
|
flushFrames();
|
|
expect(container.scrollTop).toBe(0);
|
|
|
|
flushFrames();
|
|
expect(container.scrollTop).toBe(0);
|
|
|
|
delete container.dataset["testScrollHeight"];
|
|
flushFrames();
|
|
|
|
expect(container.scrollTop).toBe(80);
|
|
controller.dispose();
|
|
});
|
|
});
|
|
|
|
it("includes message container block padding in the virtualized end", () => {
|
|
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
|
|
container.style.paddingLeft = "12px";
|
|
const controller = createMessageStreamVirtualizerDriver(container);
|
|
|
|
renderVirtualItems(controller, container, ["first"], [180], "force-bottom");
|
|
|
|
expect(controller.getTotalSize()).toBe(204);
|
|
expect(container.scrollTop).toBe(104);
|
|
controller.dispose();
|
|
});
|
|
|
|
it("does not follow appended content after the user scrolls away from the end", () => {
|
|
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
|
|
const controller = createMessageStreamVirtualizerDriver(container);
|
|
|
|
renderVirtualItems(controller, container, ["first"], [300], "force-bottom");
|
|
expect(container.scrollTop).toBe(200);
|
|
|
|
userScrollTo(container, 80);
|
|
|
|
renderVirtualItems(controller, container, ["first", "second"], [300, 180], "auto");
|
|
|
|
expect(container.scrollTop).toBe(80);
|
|
controller.dispose();
|
|
});
|
|
|
|
it("repins when the user scrolls back to the end", () => {
|
|
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
|
|
const controller = createMessageStreamVirtualizerDriver(container);
|
|
|
|
renderVirtualItems(controller, container, ["first", "second"], [300, 180], "force-bottom");
|
|
expect(container.scrollTop).toBe(380);
|
|
|
|
userScrollTo(container, 80);
|
|
|
|
userScrollTo(container, 380);
|
|
|
|
renderVirtualItems(controller, container, ["first", "second", "third"], [300, 180, 140], "auto");
|
|
|
|
expect(container.scrollTop).toBe(520);
|
|
controller.dispose();
|
|
});
|
|
|
|
it("keeps forced bottom pinned when the programmatic scroll event sees stale DOM scroll size", () => {
|
|
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
|
|
const controller = createMessageStreamVirtualizerDriver(container);
|
|
|
|
renderVirtualItems(controller, container, ["first", "submitted"], [300, 100], "follow-bottom");
|
|
expect(container.scrollTop).toBe(300);
|
|
|
|
container.dataset["testScrollHeight"] = "420";
|
|
container.dispatchEvent(new Event("scroll"));
|
|
|
|
delete container.dataset["testScrollHeight"];
|
|
renderVirtualItems(controller, container, ["first", "submitted", "reply"], [300, 100, 100], "auto");
|
|
|
|
expect(container.scrollTop).toBe(400);
|
|
controller.dispose();
|
|
});
|
|
|
|
it("keeps pinned intent when a programmatic scroll event observes a stale non-bottom offset", () => {
|
|
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
|
|
const controller = createMessageStreamVirtualizerDriver(container);
|
|
|
|
renderVirtualItems(controller, container, ["first"], [300], "follow-bottom");
|
|
expect(container.scrollTop).toBe(200);
|
|
|
|
container.dataset["testTotalSize"] = "450";
|
|
container.dispatchEvent(new Event("scroll"));
|
|
|
|
renderVirtualItems(controller, container, ["first", "second"], [300, 150], "auto");
|
|
|
|
expect(container.scrollTop).toBe(350);
|
|
controller.dispose();
|
|
});
|
|
|
|
it("keeps following the end when a pinned item grows after measurement", () => {
|
|
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
|
|
const controller = createMessageStreamVirtualizerDriver(container);
|
|
|
|
renderVirtualItems(controller, container, ["first", "command"], [200, 100], "follow-bottom");
|
|
expect(container.scrollTop).toBe(200);
|
|
|
|
container.dataset["testTotalSize"] = "450";
|
|
measureVirtualItem(controller, "command", 1, 250);
|
|
|
|
expect(container.scrollTop).toBe(350);
|
|
controller.dispose();
|
|
});
|
|
|
|
it("settles at the end when a pinned item grows before the DOM scroll height catches up", () => {
|
|
withAnimationFrame((flushFrames) => {
|
|
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
|
|
const controller = createMessageStreamVirtualizerDriver(container);
|
|
|
|
renderVirtualItems(controller, container, ["first", "command"], [200, 100], "follow-bottom");
|
|
flushFrames();
|
|
flushFrames();
|
|
expect(container.scrollTop).toBe(200);
|
|
|
|
container.dataset["testTotalSize"] = "450";
|
|
container.dataset["testScrollHeight"] = "300";
|
|
container.dataset["testClampToScrollHeight"] = "true";
|
|
measureVirtualItem(controller, "command", 1, 250);
|
|
|
|
expect(container.scrollTop).toBe(200);
|
|
|
|
flushFrames();
|
|
expect(container.scrollTop).toBe(200);
|
|
|
|
delete container.dataset["testScrollHeight"];
|
|
flushFrames();
|
|
|
|
expect(container.scrollTop).toBe(350);
|
|
controller.dispose();
|
|
});
|
|
});
|
|
|
|
it("keeps following the end when pinned content grows in the DOM before measurement", () => {
|
|
withAnimationFrame((flushFrames) => {
|
|
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
|
|
const controller = createMessageStreamVirtualizerDriver(container);
|
|
|
|
renderVirtualItems(controller, container, ["first", "markdown"], [200, 100], "follow-bottom");
|
|
flushFrames();
|
|
flushFrames();
|
|
expect(container.scrollTop).toBe(200);
|
|
|
|
container.dataset["testScrollHeight"] = "461";
|
|
container.dataset["testClampToScrollHeight"] = "true";
|
|
container.dataset["testTotalSize"] = "450";
|
|
measureVirtualItem(controller, "markdown", 1, 250);
|
|
|
|
expect(container.scrollTop).toBe(361);
|
|
|
|
delete container.dataset["testScrollHeight"];
|
|
flushFrames();
|
|
|
|
expect(container.scrollTop).toBe(350);
|
|
controller.dispose();
|
|
});
|
|
});
|
|
|
|
it("keeps following the end when a non-last rendered message grows while pinned", () => {
|
|
withAnimationFrame((flushFrames) => {
|
|
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
|
|
const controller = createMessageStreamVirtualizerDriver(container);
|
|
|
|
renderVirtualItems(controller, container, ["first", "markdown", "live"], [160, 100, 80], "follow-bottom");
|
|
flushFrames();
|
|
flushFrames();
|
|
expect(container.scrollTop).toBe(240);
|
|
|
|
container.dataset["testScrollHeight"] = "500";
|
|
container.dataset["testClampToScrollHeight"] = "true";
|
|
container.dataset["testTotalSize"] = "500";
|
|
measureVirtualItem(controller, "markdown", 1, 260);
|
|
|
|
expect(container.scrollTop).toBe(400);
|
|
controller.dispose();
|
|
});
|
|
});
|
|
|
|
it("keeps the pending end settle when a stale programmatic scroll event fires during pinned item growth", () => {
|
|
withAnimationFrame((flushFrames) => {
|
|
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
|
|
const controller = createMessageStreamVirtualizerDriver(container);
|
|
|
|
renderVirtualItems(controller, container, ["first", "command"], [200, 100], "follow-bottom");
|
|
flushFrames();
|
|
flushFrames();
|
|
expect(container.scrollTop).toBe(200);
|
|
|
|
container.dataset["testTotalSize"] = "450";
|
|
container.dataset["testScrollHeight"] = "300";
|
|
container.dataset["testClampToScrollHeight"] = "true";
|
|
measureVirtualItem(controller, "command", 1, 250);
|
|
container.dispatchEvent(new Event("scroll"));
|
|
|
|
delete container.dataset["testScrollHeight"];
|
|
flushFrames();
|
|
flushFrames();
|
|
|
|
expect(container.scrollTop).toBe(350);
|
|
controller.dispose();
|
|
});
|
|
});
|
|
|
|
it("does not postpone the pending end settle while small pinned growth measurements keep arriving", () => {
|
|
withAnimationFrame((flushFrames) => {
|
|
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
|
|
const controller = createMessageStreamVirtualizerDriver(container);
|
|
|
|
renderVirtualItems(controller, container, ["first", "command"], [200, 100], "follow-bottom");
|
|
flushFrames();
|
|
flushFrames();
|
|
expect(container.scrollTop).toBe(200);
|
|
|
|
container.dataset["testScrollHeight"] = "300";
|
|
container.dataset["testClampToScrollHeight"] = "true";
|
|
|
|
container.dataset["testTotalSize"] = "320";
|
|
measureVirtualItem(controller, "command", 1, 120);
|
|
flushFrames();
|
|
|
|
container.dataset["testTotalSize"] = "340";
|
|
measureVirtualItem(controller, "command", 1, 140);
|
|
delete container.dataset["testScrollHeight"];
|
|
flushFrames();
|
|
|
|
expect(container.scrollTop).toBe(240);
|
|
controller.dispose();
|
|
});
|
|
});
|
|
|
|
it("settles at the end when ResizeObserver reports pinned item growth before DOM scroll height catches up", () => {
|
|
withResizeObserverEntries((resizeElement, flushFrames) => {
|
|
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
|
|
const controller = createMessageStreamVirtualizerDriver(container);
|
|
const command = measuredElement("command", 1, 100);
|
|
|
|
container.dataset["testTotalSize"] = "300";
|
|
controller.render(
|
|
[
|
|
{ key: "first", node: null },
|
|
{ key: "command", node: null },
|
|
],
|
|
"follow-bottom",
|
|
);
|
|
controller.getTotalSize();
|
|
measureVirtualItem(controller, "first", 0, 200);
|
|
controller.measureElement(command);
|
|
flushFrames();
|
|
flushFrames();
|
|
expect(container.scrollTop).toBe(200);
|
|
|
|
container.dataset["testTotalSize"] = "450";
|
|
container.dataset["testScrollHeight"] = "300";
|
|
container.dataset["testClampToScrollHeight"] = "true";
|
|
resizeElement(command, 250);
|
|
|
|
flushFrames();
|
|
expect(container.scrollTop).toBe(200);
|
|
|
|
flushFrames();
|
|
expect(container.scrollTop).toBe(200);
|
|
|
|
delete container.dataset["testScrollHeight"];
|
|
flushFrames();
|
|
|
|
expect(container.scrollTop).toBe(350);
|
|
controller.dispose();
|
|
});
|
|
});
|
|
|
|
it("does not postpone the pending end settle while ResizeObserver keeps reporting small pinned growth", () => {
|
|
withResizeObserverEntries((resizeElement, flushFrames) => {
|
|
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
|
|
const controller = createMessageStreamVirtualizerDriver(container);
|
|
const command = measuredElement("command", 1, 100);
|
|
|
|
container.dataset["testTotalSize"] = "300";
|
|
controller.render(
|
|
[
|
|
{ key: "first", node: null },
|
|
{ key: "command", node: null },
|
|
],
|
|
"follow-bottom",
|
|
);
|
|
controller.getTotalSize();
|
|
measureVirtualItem(controller, "first", 0, 200);
|
|
controller.measureElement(command);
|
|
flushFrames();
|
|
flushFrames();
|
|
expect(container.scrollTop).toBe(200);
|
|
|
|
container.dataset["testScrollHeight"] = "300";
|
|
container.dataset["testClampToScrollHeight"] = "true";
|
|
|
|
container.dataset["testTotalSize"] = "320";
|
|
resizeElement(command, 120);
|
|
flushFrames();
|
|
|
|
container.dataset["testTotalSize"] = "340";
|
|
resizeElement(command, 140);
|
|
delete container.dataset["testScrollHeight"];
|
|
flushFrames();
|
|
flushFrames();
|
|
|
|
expect(container.scrollTop).toBe(240);
|
|
controller.dispose();
|
|
});
|
|
});
|
|
|
|
it("defers ResizeObserver-triggered rerenders until the next animation frame", () => {
|
|
withResizeObserverEntries((resizeElement, flushFrames) => {
|
|
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
|
|
let renderCount = 0;
|
|
const controller = createMessageStreamVirtualizerDriver(container, {
|
|
onRender: () => {
|
|
renderCount += 1;
|
|
},
|
|
});
|
|
const command = measuredElement("command", 1, 100);
|
|
|
|
container.dataset["testTotalSize"] = "300";
|
|
controller.render(
|
|
[
|
|
{ key: "first", node: null },
|
|
{ key: "command", node: null },
|
|
],
|
|
"follow-bottom",
|
|
);
|
|
controller.getTotalSize();
|
|
measureVirtualItem(controller, "first", 0, 200);
|
|
controller.measureElement(command);
|
|
void act(flushFrames);
|
|
const settledRenderCount = renderCount;
|
|
|
|
container.dataset["testTotalSize"] = "450";
|
|
resizeElement(command, 250);
|
|
|
|
expect(renderCount).toBe(settledRenderCount);
|
|
|
|
void act(flushFrames);
|
|
|
|
expect(renderCount).toBeGreaterThan(settledRenderCount);
|
|
controller.dispose();
|
|
});
|
|
});
|
|
|
|
it("uses the pre-render bottom position when appending after an item resize", () => {
|
|
const container = messageContainer({ scrollTop: 0, clientHeight: 160 });
|
|
const controller = createMessageStreamVirtualizerDriver(container);
|
|
|
|
renderVirtualItems(controller, container, ["first", "second"], [200, 200], "follow-bottom");
|
|
expect(container.scrollTop).toBe(240);
|
|
|
|
container.dataset["testTotalSize"] = "480";
|
|
measureVirtualItem(controller, "second", 1, 280);
|
|
|
|
renderVirtualItems(controller, container, ["first", "second", "third"], [200, 280, 120], "auto");
|
|
|
|
expect(container.scrollTop).toBe(440);
|
|
controller.dispose();
|
|
});
|
|
|
|
it("keeps the current reading position when an item below the viewport grows while unpinned", () => {
|
|
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
|
|
const controller = createMessageStreamVirtualizerDriver(container);
|
|
|
|
renderVirtualItems(controller, container, ["first", "streaming"], [300, 100], "preserve");
|
|
container.scrollTop = 100;
|
|
container.dispatchEvent(new Event("scroll"));
|
|
|
|
container.dataset["testTotalSize"] = "550";
|
|
measureVirtualItem(controller, "streaming", 1, 250);
|
|
|
|
expect(container.scrollTop).toBe(100);
|
|
controller.dispose();
|
|
});
|
|
|
|
it("keeps the visible reading anchor when an earlier item resizes while unpinned", () => {
|
|
withResizeObserverEntries((resizeElement, flushFrames) => {
|
|
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
|
|
const controller = createMessageStreamVirtualizerDriver(container);
|
|
const first = measuredElement("first", 0, 300);
|
|
|
|
renderVirtualItems(controller, container, ["first", "second", "third"], [300, 300, 300], "preserve");
|
|
controller.measureElement(first);
|
|
userScrollTo(container, 360);
|
|
|
|
container.dataset["testTotalSize"] = "1100";
|
|
resizeElement(first, 500);
|
|
flushFrames();
|
|
|
|
expect(container.scrollTop).toBe(560);
|
|
controller.dispose();
|
|
});
|
|
});
|
|
|
|
it("keeps the current reading offset when a visible item resizes while unpinned", () => {
|
|
withResizeObserverEntries((resizeElement, flushFrames) => {
|
|
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
|
|
const controller = createMessageStreamVirtualizerDriver(container);
|
|
const second = measuredElement("second", 1, 300);
|
|
|
|
renderVirtualItems(controller, container, ["first", "second", "third"], [300, 300, 300], "preserve");
|
|
controller.measureElement(second);
|
|
userScrollTo(container, 360);
|
|
|
|
container.dataset["testTotalSize"] = "1050";
|
|
resizeElement(second, 450);
|
|
flushFrames();
|
|
|
|
expect(container.scrollTop).toBe(360);
|
|
controller.dispose();
|
|
});
|
|
});
|
|
|
|
it("keeps the reading position when the message viewport shrinks away from the end", () => {
|
|
withResizeObserver((triggerResize) => {
|
|
const container = messageContainer({ scrollTop: 0, clientHeight: 160 });
|
|
const controller = createMessageStreamVirtualizerDriver(container);
|
|
|
|
renderVirtualItems(controller, container, ["first", "second"], [240, 260], "force-bottom");
|
|
expect(container.scrollTop).toBe(340);
|
|
|
|
userScrollTo(container, 120);
|
|
setContainerClientHeight(container, 100);
|
|
triggerResize();
|
|
|
|
expect(container.scrollTop).toBe(120);
|
|
controller.dispose();
|
|
});
|
|
});
|
|
|
|
it("resets stale measurements when the message viewport is restored from zero size", () => {
|
|
withResizeObserver((triggerResize, flushFrames) => {
|
|
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
|
|
const controller = createMessageStreamVirtualizerDriver(container);
|
|
const first = renderedMeasuredElement("first", 0, 300);
|
|
first.append(renderedMeasuredElement("stale-nested", 0, 900));
|
|
appendRenderedVirtualizer(
|
|
container,
|
|
first,
|
|
renderedMeasuredElement("second", 1, 100),
|
|
renderedMeasuredElement("stale-sibling", 1, 900),
|
|
);
|
|
|
|
renderVirtualItems(controller, container, ["first", "second"], [300, 100], "follow-bottom");
|
|
expect(container.scrollTop).toBe(300);
|
|
|
|
userScrollTo(container, 120);
|
|
setContainerClientSize(container, { width: 0, height: 0 });
|
|
triggerResize();
|
|
setContainerClientSize(container, { width: 240, height: 100 });
|
|
triggerResize();
|
|
flushFrames();
|
|
|
|
expect(controller.getTotalSize()).toBe(400);
|
|
expect(container.scrollTop).toBe(300);
|
|
controller.dispose();
|
|
});
|
|
});
|
|
|
|
it("resets stale measurements when a hidden-at-start message viewport first renders at a usable size", () => {
|
|
withResizeObserver((_triggerResize, flushFrames) => {
|
|
const container = messageContainer({ scrollTop: 0, clientHeight: 0 });
|
|
const controller = createMessageStreamVirtualizerDriver(container);
|
|
appendRenderedVirtualizer(container, renderedMeasuredElement("first", 0, 300), renderedMeasuredElement("second", 1, 100));
|
|
setContainerClientSize(container, { width: 0, height: 0 });
|
|
|
|
renderVirtualItems(controller, container, ["first", "second"], [300, 100], "preserve");
|
|
|
|
setContainerClientSize(container, { width: 240, height: 100 });
|
|
controller.render(
|
|
[
|
|
{ key: "first", node: null },
|
|
{ key: "second", node: null },
|
|
],
|
|
"preserve",
|
|
);
|
|
flushFrames();
|
|
|
|
expect(controller.getTotalSize()).toBe(400);
|
|
expect(container.scrollTop).toBe(300);
|
|
controller.dispose();
|
|
});
|
|
});
|
|
|
|
it("scrolls by two text lines for composer edge shortcuts", () => {
|
|
const container = messageContainer({ scrollTop: 120, clientHeight: 100 });
|
|
container.style.lineHeight = "18px";
|
|
const controller = createMessageStreamVirtualizerDriver(container);
|
|
|
|
renderVirtualItems(controller, container, ["first", "second"], [240, 240], "preserve");
|
|
container.scrollTop = 120;
|
|
container.dispatchEvent(new Event("scroll"));
|
|
|
|
controller.scrollByTextLines(-1);
|
|
container.dispatchEvent(new Event("scroll"));
|
|
expect(container.scrollTop).toBe(84);
|
|
|
|
controller.scrollByTextLines(1);
|
|
container.dispatchEvent(new Event("scroll"));
|
|
expect(container.scrollTop).toBe(120);
|
|
controller.dispose();
|
|
});
|
|
|
|
it("keeps bottom-following intent when composer edge scrolling is already at the bottom", () => {
|
|
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
|
|
container.style.lineHeight = "18px";
|
|
const controller = createMessageStreamVirtualizerDriver(container);
|
|
|
|
renderVirtualItems(controller, container, ["first"], [300], "force-bottom");
|
|
expect(container.scrollTop).toBe(200);
|
|
|
|
controller.scrollByTextLines(1);
|
|
expect(container.scrollTop).toBe(200);
|
|
|
|
renderVirtualItems(controller, container, ["first", "second"], [300, 140], "auto");
|
|
expect(container.scrollTop).toBe(340);
|
|
controller.dispose();
|
|
});
|
|
|
|
it("repins when composer edge scrolling reaches the bottom", () => {
|
|
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
|
|
container.style.lineHeight = "18px";
|
|
const controller = createMessageStreamVirtualizerDriver(container);
|
|
|
|
renderVirtualItems(controller, container, ["first"], [300], "force-bottom");
|
|
userScrollTo(container, 180);
|
|
|
|
controller.scrollByTextLines(1);
|
|
expect(container.scrollTop).toBe(200);
|
|
|
|
container.dataset["testTotalSize"] = "340";
|
|
controller.repinToBottomIfPinned();
|
|
expect(container.scrollTop).toBe(240);
|
|
controller.dispose();
|
|
});
|
|
|
|
it("scrolls by text lines from the rendered bottom before a scroll event settles", () => {
|
|
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
|
|
container.style.lineHeight = "18px";
|
|
const controller = createMessageStreamVirtualizerDriver(container);
|
|
|
|
const keys = ["first", "second"];
|
|
container.dataset["testTotalSize"] = "480";
|
|
controller.render(
|
|
keys.map((key) => ({ key, node: null })),
|
|
"force-bottom",
|
|
);
|
|
controller.getTotalSize();
|
|
keys.forEach((key, index) => {
|
|
measureVirtualItem(controller, key, index, 240);
|
|
});
|
|
controller.getTotalSize();
|
|
expect(container.scrollTop).toBe(380);
|
|
|
|
controller.scrollByTextLines(-1);
|
|
container.dispatchEvent(new Event("scroll"));
|
|
|
|
expect(container.scrollTop).toBe(344);
|
|
controller.dispose();
|
|
});
|
|
|
|
it("preserves the visible anchor when composer edge scrolling measures earlier items", () => {
|
|
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
|
|
container.style.lineHeight = "18px";
|
|
const controller = createMessageStreamVirtualizerDriver(container);
|
|
|
|
renderVirtualItems(controller, container, ["first", "second", "third"], [240, 240, 240], "force-bottom");
|
|
expect(container.scrollTop).toBe(620);
|
|
|
|
controller.scrollByTextLines(-1);
|
|
container.dispatchEvent(new Event("scroll"));
|
|
expect(container.scrollTop).toBe(584);
|
|
const anchorBefore = virtualItemTop(controller, container, "third");
|
|
|
|
container.dataset["testTotalSize"] = "980";
|
|
measureVirtualItem(controller, "first", 0, 500);
|
|
|
|
expect(virtualItemTop(controller, container, "third")).toBe(anchorBefore);
|
|
controller.dispose();
|
|
});
|
|
|
|
it("treats body-targeted keyboard scrolling as message viewport user intent", () => {
|
|
withAnimationFrame((flushFrames) => {
|
|
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
|
|
const controller = createMessageStreamVirtualizerDriver(container);
|
|
|
|
renderVirtualItems(controller, container, ["first", "second"], [300, 200], "force-bottom");
|
|
expect(container.scrollTop).toBe(400);
|
|
|
|
document.body.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowUp" }));
|
|
container.scrollTop = 360;
|
|
container.dispatchEvent(new Event("scroll"));
|
|
flushFrames();
|
|
flushFrames();
|
|
|
|
expect(container.scrollTop).toBe(360);
|
|
controller.dispose();
|
|
});
|
|
});
|
|
|
|
it("scrolls by a viewport step for composer PageUp and PageDown shortcuts", () => {
|
|
const container = messageContainer({ scrollTop: 220, clientHeight: 200 });
|
|
const controller = createMessageStreamVirtualizerDriver(container);
|
|
|
|
renderVirtualItems(controller, container, ["first", "second", "third"], [240, 240, 240], "preserve");
|
|
container.scrollTop = 220;
|
|
container.dispatchEvent(new Event("scroll"));
|
|
|
|
controller.scrollByPage(-1);
|
|
container.dispatchEvent(new Event("scroll"));
|
|
expect(container.scrollTop).toBe(60);
|
|
|
|
controller.scrollByPage(1);
|
|
container.dispatchEvent(new Event("scroll"));
|
|
expect(container.scrollTop).toBe(220);
|
|
|
|
container.scrollTop = 510;
|
|
container.dispatchEvent(new Event("scroll"));
|
|
controller.scrollByPage(1);
|
|
container.dispatchEvent(new Event("scroll"));
|
|
expect(container.scrollTop).toBe(520);
|
|
controller.dispose();
|
|
});
|
|
|
|
it("preserves the visible anchor when composer PageUp measurements shrink earlier items", () => {
|
|
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
|
|
const controller = createMessageStreamVirtualizerDriver(container);
|
|
const keys = numberedKeys("item", 50);
|
|
container.dataset["testTotalSize"] = "4800";
|
|
|
|
controller.render(
|
|
keys.map((key) => ({ key, node: null })),
|
|
"force-bottom",
|
|
);
|
|
expect(container.scrollTop).toBe(4700);
|
|
|
|
controller.scrollByPage(-1);
|
|
expect(container.scrollTop).toBe(4620);
|
|
const anchorKey = firstVisibleVirtualItemKey(controller, container, keys);
|
|
const anchorBefore = virtualItemTop(controller, container, anchorKey);
|
|
|
|
for (const item of controller.getVirtualItems()) {
|
|
measureVirtualItem(controller, keys[item.index] ?? "", item.index, 20);
|
|
}
|
|
container.dataset["testTotalSize"] = String(controller.getTotalSize());
|
|
clampScrollTop(container);
|
|
container.dispatchEvent(new Event("scroll"));
|
|
|
|
expect(virtualItemTop(controller, container, anchorKey)).toBe(anchorBefore);
|
|
controller.dispose();
|
|
});
|
|
|
|
it("keeps repeated composer line scrolling anchored when measurements shrink", () => {
|
|
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
|
|
container.style.lineHeight = "18px";
|
|
const controller = createMessageStreamVirtualizerDriver(container);
|
|
const keys = numberedKeys("item", 50);
|
|
container.dataset["testTotalSize"] = "4800";
|
|
|
|
controller.render(
|
|
keys.map((key) => ({ key, node: null })),
|
|
"force-bottom",
|
|
);
|
|
userScrollTo(container, 4660);
|
|
|
|
controller.scrollByTextLines(-1);
|
|
expect(container.scrollTop).toBe(4624);
|
|
const anchorKey = firstVisibleVirtualItemKey(controller, container, keys);
|
|
const anchorBefore = virtualItemTop(controller, container, anchorKey);
|
|
|
|
for (const item of controller.getVirtualItems()) {
|
|
measureVirtualItem(controller, keys[item.index] ?? "", item.index, 20);
|
|
}
|
|
container.dataset["testTotalSize"] = String(controller.getTotalSize());
|
|
clampScrollTop(container);
|
|
container.dispatchEvent(new Event("scroll"));
|
|
|
|
expect(virtualItemTop(controller, container, anchorKey)).toBe(anchorBefore);
|
|
controller.scrollByTextLines(-1);
|
|
expect(virtualItemTop(controller, container, anchorKey)).toBe(anchorBefore + 36);
|
|
controller.dispose();
|
|
});
|
|
|
|
it("drops stale same-key measurements when force-bottom replaces the message stream", () => {
|
|
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
|
|
const controller = createMessageStreamVirtualizerDriver(container);
|
|
|
|
renderVirtualItems(controller, container, ["same-key"], [360], "force-bottom");
|
|
expect(controller.getTotalSize()).toBe(360);
|
|
expect(container.scrollTop).toBe(260);
|
|
|
|
renderVirtualItems(controller, container, ["same-key"], [120], "force-bottom");
|
|
|
|
expect(controller.getTotalSize()).toBe(120);
|
|
expect(container.scrollTop).toBe(20);
|
|
controller.dispose();
|
|
});
|
|
|
|
it("keeps same-key measurements while preserving the current reading position", () => {
|
|
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
|
|
const controller = createMessageStreamVirtualizerDriver(container);
|
|
|
|
renderVirtualItems(controller, container, ["same-key"], [360], "force-bottom");
|
|
userScrollTo(container, 140);
|
|
|
|
renderVirtualItems(controller, container, ["same-key"], [360], "preserve");
|
|
|
|
expect(controller.getTotalSize()).toBe(360);
|
|
expect(container.scrollTop).toBe(140);
|
|
controller.dispose();
|
|
});
|
|
|
|
it("keeps measured earlier blocks when following appended content", () => {
|
|
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
|
|
const controller = createMessageStreamVirtualizerDriver(container);
|
|
|
|
renderVirtualItems(controller, container, ["first"], [240], "follow-bottom");
|
|
|
|
renderVirtualItems(controller, container, ["first", "second"], [240, 160], "auto");
|
|
|
|
expect(controller.getTotalSize()).toBe(400);
|
|
expect(container.scrollTop).toBe(300);
|
|
controller.dispose();
|
|
});
|
|
|
|
it("renders the new message stream from the end after force-bottom replaces a long reading position", () => {
|
|
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
|
|
const controller = createMessageStreamVirtualizerDriver(container);
|
|
|
|
renderVirtualItems(
|
|
controller,
|
|
container,
|
|
numberedKeys("old", 80),
|
|
Array.from({ length: 80 }, () => 40),
|
|
"force-bottom",
|
|
);
|
|
userScrollTo(container, 2400);
|
|
|
|
controller.render(
|
|
numberedKeys("new", 8).map((key) => ({ key, node: null })),
|
|
"force-bottom",
|
|
);
|
|
|
|
expect(controller.getVirtualItems().map((item) => item.index)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]);
|
|
controller.dispose();
|
|
});
|
|
});
|
|
|
|
function renderVirtualItems(
|
|
controller: MessageStreamVirtualizerDriver,
|
|
container: HTMLElement,
|
|
keys: string[],
|
|
heights: number[],
|
|
intent: "auto" | "force-bottom" | "follow-bottom" | "preserve",
|
|
): void {
|
|
const blocks = keys.map((key) => ({ key, node: null }));
|
|
container.dataset["testTotalSize"] = String(heights.reduce((total, height) => total + height, 0) + messageBlockPadding(container) * 2);
|
|
controller.render(blocks, intent);
|
|
controller.getTotalSize();
|
|
keys.forEach((key, index) => {
|
|
measureVirtualItem(controller, key, index, heights[index] ?? 0);
|
|
});
|
|
controller.getTotalSize();
|
|
container.dispatchEvent(new Event("scroll"));
|
|
}
|
|
|
|
function firstVisibleVirtualItemKey(controller: MessageStreamVirtualizerDriver, container: HTMLElement, keys: string[]): string {
|
|
const firstItem = controller.getVirtualItems().find((item) => item.end > container.scrollTop);
|
|
if (!firstItem) throw new Error("Expected a rendered virtual item.");
|
|
return keys[firstItem.index] ?? "";
|
|
}
|
|
|
|
function virtualItemTop(controller: MessageStreamVirtualizerDriver, container: HTMLElement, key: string): number {
|
|
const item = controller.getVirtualItems().find((candidate) => candidate.key === key);
|
|
if (!item) throw new Error(`Expected virtual item ${key}.`);
|
|
return item.start - container.scrollTop;
|
|
}
|
|
|
|
function userScrollTo(container: HTMLElement, scrollTop: number): void {
|
|
container.dispatchEvent(new Event("wheel"));
|
|
container.scrollTop = scrollTop;
|
|
container.dispatchEvent(new Event("scroll"));
|
|
}
|
|
|
|
function clampScrollTop(container: HTMLElement): void {
|
|
const scrollTop = container.scrollTop;
|
|
container.scrollTop = scrollTop;
|
|
}
|
|
|
|
function measureVirtualItem(controller: MessageStreamVirtualizerDriver, key: string, index: number, height: number): void {
|
|
controller.measureElement(measuredElement(key, index, height));
|
|
}
|
|
|
|
function measuredElement(key: string, index: number, height: number): HTMLElement {
|
|
const element = document.createElement("div");
|
|
element.setAttribute("data-codex-panel-block-key", key);
|
|
element.setAttribute("data-index", String(index));
|
|
Object.defineProperty(element, "offsetHeight", { value: height, configurable: true });
|
|
Object.defineProperty(element, "isConnected", { value: true, configurable: true });
|
|
return element;
|
|
}
|
|
|
|
function renderedMeasuredElement(key: string, index: number, height: number): HTMLElement {
|
|
const element = measuredElement(key, index, height);
|
|
element.classList.add("codex-panel__message-block");
|
|
return element;
|
|
}
|
|
|
|
function appendRenderedVirtualizer(container: HTMLElement, ...blocks: HTMLElement[]): void {
|
|
const virtualizer = document.createElement("div");
|
|
virtualizer.classList.add("codex-panel__message-virtualizer");
|
|
virtualizer.append(...blocks);
|
|
container.append(virtualizer);
|
|
}
|
|
|
|
interface MessageStreamVirtualizerDriver extends MessageStreamVirtualizerHandle {
|
|
render(blocks: readonly MessageStreamBlock[], intent: "auto" | "force-bottom" | "follow-bottom" | "preserve"): void;
|
|
getTotalSize(): number;
|
|
getVirtualItems(): ReturnType<MessageStreamVirtualizerView["getVirtualItems"]>;
|
|
measureElement(element: HTMLElement | null): void;
|
|
dispose(): void;
|
|
}
|
|
|
|
function createMessageStreamVirtualizerDriver(
|
|
container: HTMLElement,
|
|
options: { onRender?: () => void } = {},
|
|
): MessageStreamVirtualizerDriver {
|
|
const host = document.createElement("div");
|
|
document.body.appendChild(host);
|
|
let view: MessageStreamVirtualizerView | null = null;
|
|
let handle: MessageStreamVirtualizerHandle | null = null;
|
|
return {
|
|
render(blocks, intent) {
|
|
const renderBlocks = [...blocks];
|
|
void act(() => {
|
|
renderUiRoot(
|
|
host,
|
|
h(VirtualizerHarness, {
|
|
blocks: renderBlocks,
|
|
container,
|
|
intent,
|
|
...(options.onRender ? { onRender: options.onRender } : {}),
|
|
onHandle: (next) => (handle = next),
|
|
onView: (next) => (view = next),
|
|
}),
|
|
);
|
|
});
|
|
},
|
|
getTotalSize() {
|
|
return view?.getTotalSize() ?? 0;
|
|
},
|
|
getVirtualItems() {
|
|
return view?.getVirtualItems() ?? [];
|
|
},
|
|
measureElement(element) {
|
|
view?.measureElement(element);
|
|
},
|
|
scrollByTextLines(direction) {
|
|
handle?.scrollByTextLines(direction);
|
|
},
|
|
scrollByPage(direction) {
|
|
handle?.scrollByPage(direction);
|
|
},
|
|
pinToBottom() {
|
|
handle?.pinToBottom();
|
|
},
|
|
repinToBottomIfPinned() {
|
|
handle?.repinToBottomIfPinned();
|
|
},
|
|
dispose() {
|
|
unmountUiRoot(host);
|
|
host.remove();
|
|
},
|
|
};
|
|
}
|
|
|
|
function VirtualizerHarness({
|
|
blocks,
|
|
container,
|
|
intent,
|
|
onRender,
|
|
onHandle,
|
|
onView,
|
|
}: {
|
|
blocks: readonly MessageStreamBlock[];
|
|
container: HTMLElement;
|
|
intent: "auto" | "force-bottom" | "follow-bottom" | "preserve";
|
|
onRender?: () => void;
|
|
onHandle: (handle: MessageStreamVirtualizerHandle | null) => void;
|
|
onView: (view: MessageStreamVirtualizerView) => void;
|
|
}) {
|
|
onRender?.();
|
|
const scrollElementRef = useRef<HTMLElement | null>(container);
|
|
const intentRef = useRef(intent);
|
|
intentRef.current = intent;
|
|
const view = useMessageStreamVirtualizer({
|
|
blocks,
|
|
scrollElementRef,
|
|
consumeScrollIntent: () => intentRef.current,
|
|
registerVirtualizer: (handle) => {
|
|
onHandle(handle);
|
|
return () => {
|
|
onHandle(null);
|
|
};
|
|
},
|
|
});
|
|
useLayoutEffect(() => {
|
|
onView(view);
|
|
}, [onView, view]);
|
|
return null;
|
|
}
|
|
|
|
function messageContainer(metrics: { scrollTop: number; clientHeight: number }): HTMLElement {
|
|
const container = document.createElement("div");
|
|
let scrollTop = metrics.scrollTop;
|
|
let clientHeight = metrics.clientHeight;
|
|
let clientWidth = 240;
|
|
container.dataset["testClientHeight"] = String(metrics.clientHeight);
|
|
container.dataset["testClientWidth"] = "240";
|
|
Object.defineProperties(container, {
|
|
scrollTop: {
|
|
get: () => scrollTop,
|
|
set: (value: number) => {
|
|
const scrollSize =
|
|
container.dataset["testClampToScrollHeight"] === "true"
|
|
? container.scrollHeight
|
|
: Math.max(container.scrollHeight, controllerTotalSize(container));
|
|
scrollTop = Math.max(0, Math.min(value, Math.max(0, scrollSize - clientHeight)));
|
|
},
|
|
configurable: true,
|
|
},
|
|
scrollHeight: {
|
|
get: () => {
|
|
const scrollHeight = Number(container.dataset["testScrollHeight"]);
|
|
return Number.isFinite(scrollHeight) && scrollHeight > 0 ? scrollHeight : controllerTotalSize(container);
|
|
},
|
|
configurable: true,
|
|
},
|
|
clientHeight: {
|
|
get: () => {
|
|
const nextClientHeight = Number(container.dataset["testClientHeight"]);
|
|
clientHeight = Number.isFinite(nextClientHeight) && nextClientHeight >= 0 ? nextClientHeight : clientHeight;
|
|
return clientHeight;
|
|
},
|
|
configurable: true,
|
|
},
|
|
offsetHeight: {
|
|
get: () => container.clientHeight,
|
|
configurable: true,
|
|
},
|
|
clientWidth: {
|
|
get: () => {
|
|
const nextClientWidth = Number(container.dataset["testClientWidth"]);
|
|
clientWidth = Number.isFinite(nextClientWidth) && nextClientWidth >= 0 ? nextClientWidth : clientWidth;
|
|
return clientWidth;
|
|
},
|
|
configurable: true,
|
|
},
|
|
offsetWidth: {
|
|
get: () => container.clientWidth,
|
|
configurable: true,
|
|
},
|
|
});
|
|
document.body.appendChild(container);
|
|
container.scrollTo = ((optionsOrX?: ScrollToOptions | number, y?: number) => {
|
|
const top = typeof optionsOrX === "number" ? (y ?? container.scrollTop) : (optionsOrX?.top ?? container.scrollTop);
|
|
container.scrollTop = top;
|
|
}) as typeof container.scrollTo;
|
|
return container;
|
|
}
|
|
|
|
function setContainerClientHeight(container: HTMLElement, clientHeight: number): void {
|
|
container.dataset["testClientHeight"] = String(clientHeight);
|
|
}
|
|
|
|
function setContainerClientSize(container: HTMLElement, size: { width: number; height: number }): void {
|
|
container.dataset["testClientWidth"] = String(size.width);
|
|
container.dataset["testClientHeight"] = String(size.height);
|
|
}
|
|
|
|
function numberedKeys(prefix: string, count: number): string[] {
|
|
return Array.from({ length: count }, (_value, index) => `${prefix}-${String(index)}`);
|
|
}
|
|
|
|
function controllerTotalSize(container: HTMLElement): number {
|
|
const totalSize = Number(container.dataset["testTotalSize"]);
|
|
return Number.isFinite(totalSize) && totalSize > 0 ? totalSize : 0;
|
|
}
|
|
|
|
function messageBlockPadding(container: HTMLElement): number {
|
|
const value = Number.parseFloat(container.ownerDocument.defaultView?.getComputedStyle(container).paddingLeft ?? "");
|
|
return Number.isFinite(value) ? value : 0;
|
|
}
|
|
|
|
function withResizeObserver(run: (triggerResize: () => void, flushFrames: () => void) => void): void {
|
|
const previousResizeObserver = window.ResizeObserver;
|
|
const previousRequestAnimationFrame = window.requestAnimationFrame;
|
|
const previousCancelAnimationFrame = window.cancelAnimationFrame;
|
|
const callbacks: ResizeObserverCallback[] = [];
|
|
const frames: FrameRequestCallback[] = [];
|
|
class TestResizeObserver implements ResizeObserver {
|
|
constructor(callback: ResizeObserverCallback) {
|
|
callbacks.push(callback);
|
|
}
|
|
|
|
observe(): void {
|
|
// Test callback triggering is explicit.
|
|
}
|
|
|
|
unobserve(): void {
|
|
// Test callback triggering is explicit.
|
|
}
|
|
|
|
disconnect(): void {
|
|
// Test callback triggering is explicit.
|
|
}
|
|
}
|
|
window.ResizeObserver = TestResizeObserver;
|
|
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
|
frames.push(callback);
|
|
return frames.length;
|
|
}) as typeof window.requestAnimationFrame;
|
|
window.cancelAnimationFrame = (() => undefined) as typeof window.cancelAnimationFrame;
|
|
try {
|
|
const flushFrames = () => {
|
|
for (const callback of callbacks) callback([], {} as ResizeObserver);
|
|
const pending = frames.splice(0);
|
|
for (const frame of pending) frame(0);
|
|
};
|
|
run(
|
|
() => {
|
|
frames.splice(0);
|
|
flushFrames();
|
|
},
|
|
() => {
|
|
const pending = frames.splice(0);
|
|
for (const frame of pending) frame(0);
|
|
},
|
|
);
|
|
} finally {
|
|
window.ResizeObserver = previousResizeObserver;
|
|
window.requestAnimationFrame = previousRequestAnimationFrame;
|
|
window.cancelAnimationFrame = previousCancelAnimationFrame;
|
|
}
|
|
}
|
|
|
|
function withAnimationFrame(run: (flushFrames: () => void) => void): void {
|
|
const previousRequestAnimationFrame = window.requestAnimationFrame;
|
|
const previousCancelAnimationFrame = window.cancelAnimationFrame;
|
|
const frames: { id: number; callback: FrameRequestCallback }[] = [];
|
|
let nextFrameId = 1;
|
|
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
|
const id = nextFrameId++;
|
|
frames.push({ id, callback });
|
|
return id;
|
|
}) as typeof window.requestAnimationFrame;
|
|
window.cancelAnimationFrame = ((id: number) => {
|
|
const index = frames.findIndex((frame) => frame.id === id);
|
|
if (index >= 0) frames.splice(index, 1);
|
|
}) as typeof window.cancelAnimationFrame;
|
|
try {
|
|
run(() => {
|
|
const pending = frames.splice(0);
|
|
for (const frame of pending) frame.callback(0);
|
|
});
|
|
} finally {
|
|
window.requestAnimationFrame = previousRequestAnimationFrame;
|
|
window.cancelAnimationFrame = previousCancelAnimationFrame;
|
|
}
|
|
}
|
|
|
|
function withResizeObserverEntries(
|
|
run: (resizeElement: (element: HTMLElement, height: number) => void, flushFrames: () => void) => void,
|
|
): void {
|
|
const previousResizeObserver = window.ResizeObserver;
|
|
const previousRequestAnimationFrame = window.requestAnimationFrame;
|
|
const previousCancelAnimationFrame = window.cancelAnimationFrame;
|
|
const callbacks: ResizeObserverCallback[] = [];
|
|
const frames: { id: number; callback: FrameRequestCallback }[] = [];
|
|
let nextFrameId = 1;
|
|
class TestResizeObserver implements ResizeObserver {
|
|
constructor(callback: ResizeObserverCallback) {
|
|
callbacks.push(callback);
|
|
}
|
|
|
|
observe(): void {
|
|
// Resize entries are triggered explicitly by the test.
|
|
}
|
|
|
|
unobserve(): void {
|
|
// Resize entries are triggered explicitly by the test.
|
|
}
|
|
|
|
disconnect(): void {
|
|
// Resize entries are triggered explicitly by the test.
|
|
}
|
|
}
|
|
window.ResizeObserver = TestResizeObserver;
|
|
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
|
const id = nextFrameId++;
|
|
frames.push({ id, callback });
|
|
return id;
|
|
}) as typeof window.requestAnimationFrame;
|
|
window.cancelAnimationFrame = ((id: number) => {
|
|
const index = frames.findIndex((frame) => frame.id === id);
|
|
if (index >= 0) frames.splice(index, 1);
|
|
}) as typeof window.cancelAnimationFrame;
|
|
try {
|
|
run(
|
|
(element, height) => {
|
|
Object.defineProperty(element, "offsetHeight", { value: height, configurable: true });
|
|
const entry = {
|
|
target: element,
|
|
borderBoxSize: [{ blockSize: height }],
|
|
contentBoxSize: [],
|
|
contentRect: element.getBoundingClientRect(),
|
|
devicePixelContentBoxSize: [],
|
|
} as unknown as ResizeObserverEntry;
|
|
for (const callback of callbacks) callback([entry], {} as ResizeObserver);
|
|
},
|
|
() => {
|
|
const pending = frames.splice(0);
|
|
for (const frame of pending) frame.callback(0);
|
|
},
|
|
);
|
|
} finally {
|
|
window.ResizeObserver = previousResizeObserver;
|
|
window.requestAnimationFrame = previousRequestAnimationFrame;
|
|
window.cancelAnimationFrame = previousCancelAnimationFrame;
|
|
}
|
|
}
|