mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Smooth manual message stream scrolling
This commit is contained in:
parent
79d0d6ea55
commit
3ab8b82683
4 changed files with 186 additions and 23 deletions
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue