Smooth manual message stream scrolling

This commit is contained in:
murashit 2026-06-24 10:37:26 +09:00
parent 79d0d6ea55
commit 3ab8b82683
4 changed files with 186 additions and 23 deletions

View file

@ -8,6 +8,7 @@ interface ComposerBoundaryScrollByAction {
kind: "scroll-by";
direction: ComposerBoundaryScrollDirection;
amount: ComposerBoundaryScrollAmount;
repeated?: boolean;
}
interface ComposerBoundaryScrollToAction {
@ -17,6 +18,7 @@ interface ComposerBoundaryScrollToAction {
export interface ComposerBoundaryScrollKeyEvent {
key: string;
repeat: boolean;
ctrlKey: boolean;
metaKey: boolean;
altKey: boolean;
@ -57,21 +59,29 @@ export function composerBoundaryScrollDirection(
function composerBoundaryScrollKeyAction(event: ComposerBoundaryScrollKeyEvent): ComposerBoundaryScrollAction | null {
if (!event.ctrlKey) {
if (event.key === "ArrowUp") return { kind: "scroll-by", direction: -1, amount: "text-lines" };
if (event.key === "ArrowDown") return { kind: "scroll-by", direction: 1, amount: "text-lines" };
if (event.key === "PageUp") return { kind: "scroll-by", direction: -1, amount: "page" };
if (event.key === "PageDown") return { kind: "scroll-by", direction: 1, amount: "page" };
if (event.key === "ArrowUp") return composerBoundaryScrollByAction(-1, "text-lines", event.repeat);
if (event.key === "ArrowDown") return composerBoundaryScrollByAction(1, "text-lines", event.repeat);
if (event.key === "PageUp") return composerBoundaryScrollByAction(-1, "page", event.repeat);
if (event.key === "PageDown") return composerBoundaryScrollByAction(1, "page", event.repeat);
if (event.key === "Home") return { kind: "scroll-to", edge: "start" };
if (event.key === "End") return { kind: "scroll-to", edge: "end" };
return null;
}
const key = event.key.toLowerCase();
if (key === "p") return { kind: "scroll-by", direction: -1, amount: "text-lines" };
if (key === "n") return { kind: "scroll-by", direction: 1, amount: "text-lines" };
if (key === "p") return composerBoundaryScrollByAction(-1, "text-lines", event.repeat);
if (key === "n") return composerBoundaryScrollByAction(1, "text-lines", event.repeat);
return null;
}
function composerBoundaryScrollByAction(
direction: ComposerBoundaryScrollDirection,
amount: ComposerBoundaryScrollAmount,
repeat: boolean,
): ComposerBoundaryScrollByAction {
return repeat ? { kind: "scroll-by", direction, amount, repeated: true } : { kind: "scroll-by", direction, amount };
}
function composerCursorOnFirstLine(composer: ComposerBoundaryScrollTextState): boolean {
return !composer.value.slice(0, composer.selectionStart).includes("\n");
}

View file

@ -4,10 +4,13 @@ import { MESSAGE_CONTENT_RENDERED_EVENT } from "./content-events";
type MessageScrollDirection = -1 | 1;
const MESSAGE_FLOW_TEXT_LINE_SCROLL_LINES = 4;
const MESSAGE_FLOW_REPEATED_TEXT_LINE_SCROLL_LINES = 4;
export type MessageStreamScrollCommand =
| { kind: "show-latest" }
| { kind: "scroll-to"; edge: "start" | "end" }
| { kind: "scroll-by"; amount: "text-lines" | "page"; direction: MessageScrollDirection };
| { kind: "scroll-by"; amount: "text-lines" | "page"; direction: MessageScrollDirection; repeated?: boolean };
export interface MessageStreamScrollPort {
dispatchScrollCommand(command: MessageStreamScrollCommand): void;
@ -210,15 +213,18 @@ function applyMessageFlowScrollCommand(runtime: MessageFlowRuntime, command: Mes
break;
case "scroll-to":
if (command.edge === "start") {
scrollMessageFlowToStart(runtime);
scrollMessageFlowToStart(runtime, messageFlowManualScrollBehavior(runtime, false));
} else {
runtime.followingEnd = true;
scrollMessageFlowToEnd(runtime);
scheduleMessageFlowEndRestore(runtime);
scrollMessageFlowToEnd(runtime, messageFlowManualScrollBehavior(runtime, false));
}
break;
case "scroll-by":
scrollMessageFlowBy(runtime, messageFlowScrollDelta(runtime, command.amount, command.direction));
scrollMessageFlowBy(
runtime,
messageFlowScrollDelta(runtime, command.amount, command.direction, command.repeated === true),
messageFlowManualScrollBehavior(runtime, command.repeated === true),
);
break;
}
}
@ -229,34 +235,64 @@ function handleMessageFlowBlockLayout(runtime: MessageFlowRuntime, element: HTML
scheduleMessageFlowEndRestore(runtime);
}
function scrollMessageFlowBy(runtime: MessageFlowRuntime, delta: number): void {
function scrollMessageFlowBy(runtime: MessageFlowRuntime, delta: number, behavior: ScrollBehavior = "auto"): void {
const container = runtime.container;
if (!container) return;
container.scrollTop += delta;
runtime.followingEnd = isMessageFlowAtEnd(container);
const targetTop = clampMessageFlowScrollTop(container, container.scrollTop + delta);
scrollMessageFlowToTop(container, targetTop, behavior);
runtime.followingEnd = isMessageFlowTopAtEnd(container, targetTop);
}
function scrollMessageFlowToStart(runtime: MessageFlowRuntime): void {
function scrollMessageFlowToStart(runtime: MessageFlowRuntime, behavior: ScrollBehavior = "auto"): void {
const container = runtime.container;
if (!container) return;
container.scrollTop = 0;
scrollMessageFlowToTop(container, 0, behavior);
runtime.followingEnd = false;
}
function messageFlowScrollDelta(runtime: MessageFlowRuntime, amount: "text-lines" | "page", direction: MessageScrollDirection): number {
function messageFlowScrollDelta(
runtime: MessageFlowRuntime,
amount: "text-lines" | "page",
direction: MessageScrollDirection,
repeated: boolean,
): number {
const container = runtime.container;
if (!container) return 0;
if (amount === "page") return Math.max(1, Math.floor(container.clientHeight * 0.8)) * direction;
return Math.max(1, Math.round(textLineHeight(container) * 2)) * direction;
const lines = repeated ? MESSAGE_FLOW_REPEATED_TEXT_LINE_SCROLL_LINES : MESSAGE_FLOW_TEXT_LINE_SCROLL_LINES;
return Math.max(1, Math.round(textLineHeight(container) * lines)) * direction;
}
function scrollMessageFlowToEnd(runtime: MessageFlowRuntime): void {
function scrollMessageFlowToEnd(runtime: MessageFlowRuntime, behavior: ScrollBehavior = "auto"): void {
const container = runtime.container;
if (!container) return;
container.scrollTop = Math.max(0, container.scrollHeight - container.clientHeight);
scrollMessageFlowToTop(container, messageFlowEndScrollTop(container), behavior);
runtime.followingEnd = true;
}
function scrollMessageFlowToTop(container: HTMLElement, top: number, behavior: ScrollBehavior): void {
if (behavior === "smooth") {
container.scrollTo({ top, behavior });
return;
}
container.scrollTop = top;
}
function messageFlowManualScrollBehavior(runtime: MessageFlowRuntime, repeated: boolean): ScrollBehavior {
if (repeated) return "auto";
const win = runtime.container?.win;
return win?.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth";
}
function messageFlowEndScrollTop(container: HTMLElement): number {
return Math.max(0, container.scrollHeight - container.clientHeight);
}
function clampMessageFlowScrollTop(container: HTMLElement, top: number): number {
return Math.max(0, Math.min(top, messageFlowEndScrollTop(container)));
}
function scheduleMessageFlowEndRestore(runtime: MessageFlowRuntime): void {
const container = runtime.container;
if (!container || runtime.restoreFrame !== null) return;
@ -307,7 +343,11 @@ function messageFlowBlockElements(container: HTMLElement): HTMLElement[] {
}
function isMessageFlowAtEnd(container: HTMLElement): boolean {
return container.scrollHeight - container.clientHeight - container.scrollTop <= 4;
return isMessageFlowTopAtEnd(container, container.scrollTop);
}
function isMessageFlowTopAtEnd(container: HTMLElement, top: number): boolean {
return messageFlowEndScrollTop(container) - top <= 4;
}
function isMessageFlowViewportHidden(container: HTMLElement): boolean {

View file

@ -24,6 +24,21 @@ describe("composer boundary scroll shortcuts", () => {
});
});
it("marks repeated text-line scrolling", () => {
expect(direction("ArrowDown", "first\nsecond", 9, { repeat: true })).toEqual({
kind: "scroll-by",
direction: 1,
amount: "text-lines",
repeated: true,
});
expect(direction("n", "first\nsecond", 9, { ctrlKey: true, repeat: true })).toEqual({
kind: "scroll-by",
direction: 1,
amount: "text-lines",
repeated: true,
});
});
it("scrolls by page from any composer line for PageUp and PageDown", () => {
expect(direction("PageUp", "first\nsecond", 9)).toEqual({ kind: "scroll-by", direction: -1, amount: "page" });
expect(direction("PageDown", "first\nsecond", 3)).toEqual({ kind: "scroll-by", direction: 1, amount: "page" });
@ -32,6 +47,12 @@ describe("composer boundary scroll shortcuts", () => {
direction: 1,
amount: "page",
});
expect(direction("PageDown", "first\nsecond", 3, { repeat: true })).toEqual({
kind: "scroll-by",
direction: 1,
amount: "page",
repeated: true,
});
});
it("scrolls to stream edges from any composer line for Home and End", () => {
@ -75,6 +96,7 @@ function direction(
altKey: boolean;
shiftKey: boolean;
isComposing: boolean;
repeat: boolean;
selectionEnd: number;
visualBoundary: boolean | ((direction: -1 | 1) => boolean);
}> = {},
@ -89,6 +111,7 @@ function direction(
return composerBoundaryScrollDirection(
{
key,
repeat: options.repeat ?? false,
ctrlKey: options.ctrlKey ?? false,
metaKey: options.metaKey ?? false,
altKey: options.altKey ?? false,

View file

@ -26,6 +26,7 @@ describe("message stream flow scrolling", () => {
return animationFrameCallbacks.length;
}) as typeof window.requestAnimationFrame;
window.cancelAnimationFrame = (() => undefined) as typeof window.cancelAnimationFrame;
window.matchMedia = createTestMatchMedia(false);
});
afterEach(() => {
@ -34,15 +35,18 @@ describe("message stream flow scrolling", () => {
it("pins to the DOM scroll end and follows appended content while pinned", () => {
const { controller, messages, render } = renderFlowMessageStream(["first"], { first: 300 });
const scrollCalls = installScrollToCapture(messages);
void act(() => {
controller.dispatch({ kind: "show-latest" });
});
expect(messages.scrollTop).toBe(200);
expect(scrollCalls).toEqual([]);
render(["first", "second"], { first: 300, second: 180 });
expect(messages.scrollTop).toBe(380);
expect(scrollCalls).toEqual([]);
});
it("keeps the current reading position when content is appended after the user scrolls away", () => {
@ -168,6 +172,7 @@ describe("message stream flow scrolling", () => {
it("scrolls by composer text-line and page commands", () => {
const { controller, messages } = renderFlowMessageStream(["first", "second"], { first: 300, second: 300 });
const scrollCalls = installScrollToCapture(messages);
messages.style.lineHeight = "20px";
messages.scrollTop = 240;
messages.dispatchEvent(new Event("scroll"));
@ -175,16 +180,67 @@ describe("message stream flow scrolling", () => {
void act(() => {
controller.dispatch({ kind: "scroll-by", amount: "text-lines", direction: -1 });
});
expect(messages.scrollTop).toBe(200);
expect(messages.scrollTop).toBe(160);
expect(scrollCalls).toEqual([{ top: 160, behavior: "smooth" }]);
void act(() => {
controller.dispatch({ kind: "scroll-by", amount: "page", direction: 1 });
});
expect(messages.scrollTop).toBe(280);
expect(messages.scrollTop).toBe(240);
expect(scrollCalls).toEqual([
{ top: 160, behavior: "smooth" },
{ top: 240, behavior: "smooth" },
]);
});
it("uses instant composer scrolling when reduced motion is preferred", () => {
window.matchMedia = createTestMatchMedia(true);
const { controller, messages } = renderFlowMessageStream(["first", "second"], { first: 300, second: 300 });
const scrollCalls = installScrollToCapture(messages);
messages.style.lineHeight = "20px";
messages.scrollTop = 240;
messages.dispatchEvent(new Event("scroll"));
void act(() => {
controller.dispatch({ kind: "scroll-by", amount: "text-lines", direction: -1 });
});
expect(messages.scrollTop).toBe(160);
expect(scrollCalls).toEqual([]);
});
it("uses the normal text-line distance for repeated composer scrolling without smooth animation", () => {
const { controller, messages } = renderFlowMessageStream(["first", "second"], { first: 300, second: 300 });
const scrollCalls = installScrollToCapture(messages);
messages.style.lineHeight = "20px";
messages.scrollTop = 240;
messages.dispatchEvent(new Event("scroll"));
void act(() => {
controller.dispatch({ kind: "scroll-by", amount: "text-lines", direction: -1, repeated: true });
});
expect(messages.scrollTop).toBe(160);
expect(scrollCalls).toEqual([]);
});
it("uses the normal page distance for repeated composer page scrolling without smooth animation", () => {
const { controller, messages } = renderFlowMessageStream(["first", "second"], { first: 300, second: 300 });
const scrollCalls = installScrollToCapture(messages);
messages.scrollTop = 240;
messages.dispatchEvent(new Event("scroll"));
void act(() => {
controller.dispatch({ kind: "scroll-by", amount: "page", direction: 1, repeated: true });
});
expect(messages.scrollTop).toBe(320);
expect(scrollCalls).toEqual([]);
});
it("scrolls to stream edges from composer commands", () => {
const { controller, messages } = renderFlowMessageStream(["first", "second"], { first: 300, second: 300 });
const scrollCalls = installScrollToCapture(messages);
messages.scrollTop = 240;
messages.dispatchEvent(new Event("scroll"));
@ -192,14 +248,24 @@ describe("message stream flow scrolling", () => {
controller.dispatch({ kind: "scroll-to", edge: "start" });
});
expect(messages.scrollTop).toBe(0);
expect(scrollCalls).toEqual([{ top: 0, behavior: "smooth" }]);
void act(() => {
controller.dispatch({ kind: "scroll-to", edge: "end" });
});
expect(messages.scrollTop).toBe(500);
expect(scrollCalls).toEqual([
{ top: 0, behavior: "smooth" },
{ top: 500, behavior: "smooth" },
]);
});
});
interface CapturedScrollToOptions {
top: number | undefined;
behavior: ScrollBehavior | undefined;
}
interface TestMessageStreamScrollController extends MessageStreamScrollControllerBinding {
dispatch(command: MessageStreamScrollCommand): void;
}
@ -323,10 +389,34 @@ function installFlowMetrics(
});
}
function installScrollToCapture(messages: HTMLElement): CapturedScrollToOptions[] {
const calls: CapturedScrollToOptions[] = [];
messages.scrollTo = ((optionsOrX?: ScrollToOptions | number, y?: number) => {
const top = typeof optionsOrX === "number" ? y : optionsOrX?.top;
const behavior = typeof optionsOrX === "number" ? undefined : optionsOrX?.behavior;
calls.push({ top, behavior });
if (top !== undefined) messages.scrollTop = top;
}) as typeof messages.scrollTo;
return calls;
}
const messageScrollTop = new WeakMap<HTMLElement, number>();
let resizeObserverCallbacks: ResizeObserverCallback[] = [];
let animationFrameCallbacks: FrameRequestCallback[] = [];
function createTestMatchMedia(matches: boolean): typeof window.matchMedia {
return ((query: string) => ({
matches,
media: query,
onchange: null,
addEventListener: () => undefined,
removeEventListener: () => undefined,
addListener: () => undefined,
removeListener: () => undefined,
dispatchEvent: () => false,
})) as typeof window.matchMedia;
}
class TestResizeObserver implements ResizeObserver {
private readonly callback: ResizeObserverCallback;