blob: c22989bac0999c607ad0bda6813f2b86206ae51e [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* Owns every intentional write to the transcript's `scrollTop`. Astryx
* auto-follow is disabled by the host.
*
* pinned → content that grows writes `scrollTop = scrollHeight`
* positioning → one Turn is being put at one place in the scrollport: a
* navigation to it, or keeping the reader's Turn where it was
* while the list of Turns changes under it
* otherwise → this authority writes nothing
*
* At most one of these holds. Every command and every reader input ends a
* positioning.
*
* Native anchoring stays off: the transcript virtualizer keeps the reader on
* the same content itself, and a second corrector would fight it.
*
* Input establishes reading intent; scroll and resize only report geometry.
* Layout can shrink, clamp the offset, then grow before scroll is delivered.
* Geometry alone therefore cannot establish that the reader chose to move.
*/
import {
createContext,
useContext,
useRef,
useSyncExternalStore,
type ReactNode,
} from 'react';
import { ChatLayoutScrollButton } from '@astryxdesign/core/Chat';
/** Astryx's own thresholds, so the affordance keeps the feel readers learnt. */
const PIN_THRESHOLD_PX = 10;
const BUTTON_THRESHOLD_PX = 100;
/** How long a positioning keeps writing while the rows around its Turn measure. */
const SETTLE_FRAMES = 30;
/** How the virtualized list addresses Turns. */
export interface TranscriptLayout {
/** The Turn under a scroll offset. */
turnAt(scrollTop: number): string | undefined;
/** Where a Turn's top sits in the scroller's content; `undefined` when it is not in the list. */
offsetOf(turnId: string): number | undefined;
/** An animated or centered reveal, which the virtualizer runs itself. */
reveal(turnId: string, options: { align: 'start' | 'center'; smooth: boolean }): void;
}
/** How a list of Turns changed: where Turns were added, if anywhere but in place. */
export type TranscriptTurnListChange = 'same' | 'append' | 'prepend' | 'reset';
export interface TranscriptNavigation {
readonly turnId: string;
readonly align: 'start' | 'center';
readonly smooth?: boolean;
/**
* What brings the Turn into the list. Once it settles and the list has
* rendered, a Turn still absent is not coming, and the navigation ends.
*/
readonly arrival?: PromiseLike<unknown>;
/** The positioning finished without being superseded. */
onSettled?(): void;
}
export interface TranscriptScrollSnapshot {
/** Following the tail: growth writes `scrollTop`. */
readonly pinned: boolean;
/**
* A Turn is being put somewhere, or waits to arrive so it can be. The reading
* position says nothing about where the reader wants to be until it ends.
*/
readonly positioning: boolean;
/** Far enough up that the return-to-tail affordance earns its place. */
readonly awayFromTail: boolean;
/**
* The Turn the reader is on: the one under the scrollport's top edge. One
* reading line for every consumer — the bookmark that survives a session
* switch and the rail's current tick have to name the same Turn.
*/
readonly readingTurnId: string | undefined;
}
export interface TranscriptScrollAuthority {
/** Take the scroller. Returns the detach for the effect that called it. */
attach(root: HTMLElement | null, layout?: TranscriptLayout): () => void;
/** One-shot: put the tail back under the reader and follow it again. */
pinToTail(): void;
/** The reader chose a position, so stop following and stop positioning. */
releasePin(): void;
/**
* Take the reader to a Turn. One that is not in the list yet is taken to
* when it arrives, unless another command or the reader moves first, or its
* `arrival` settles without it.
*/
navigate(target: TranscriptNavigation): void;
/**
* The list of Turns just changed. Anything but growth at the tail keeps the
* reader on the Turn they were reading, at the same place in the scrollport.
*/
turnsChanged(change: TranscriptTurnListChange): void;
/**
* The reading position, measured now and published like any other move. For
* a caller that has just moved the viewport or the content itself and cannot
* wait for the scroll or resize that will report it.
*/
measureReadingTurn(): string | undefined;
subscribe(listener: () => void): () => void;
getSnapshot(): TranscriptScrollSnapshot;
}
/** Whether the browser can route vertical input through the nested scroll chain. */
function reachesTranscript(event: Event, root: HTMLElement, direction: 'up' | 'down'): boolean {
for (const node of event.composedPath()) {
if (node === root) return true;
if (!(node instanceof HTMLElement)) continue;
const style = getComputedStyle(node);
if (!['auto', 'scroll', 'overlay'].includes(style.overflowY)) continue;
const remaining = direction === 'up'
? node.scrollTop : node.scrollHeight - node.clientHeight - node.scrollTop;
if (remaining > 0 || ['contain', 'none'].includes(style.overscrollBehaviorY)) return false;
}
return false;
}
export function createTranscriptScrollAuthority(): TranscriptScrollAuthority {
let root: HTMLElement | null = null;
let pinned = true;
let awayFromTail = false;
// Geometry belongs to a known input operation, never the other way around.
// scrollend also covers smooth keyboard scrolling and touchpad inertia.
let gesture: { top: number; direction?: 'up' | 'down' } | undefined;
let pointer: number | undefined;
let touchHeld = false;
let layout: TranscriptLayout | undefined;
let readingTurnId: string | undefined;
/** Where the reading Turn's top sits relative to the scrollport's top. */
let readingGap = 0;
let positioning: {
readonly turnId: string;
/** Writes the Turn this far below the scrollport's top; a reveal otherwise. */
readonly gap?: number;
readonly navigation?: TranscriptNavigation;
revealed: boolean;
framesLeft: number;
frame?: number;
} | undefined;
let snapshot: TranscriptScrollSnapshot = { pinned, positioning: false, awayFromTail, readingTurnId };
const listeners = new Set<() => void>();
const distanceToTail = (): number =>
root ? root.scrollHeight - root.scrollTop - root.clientHeight : 0;
const readTurn = (): string | undefined => {
if (!root || !layout) return undefined;
const turnId = layout.turnAt(root.scrollTop);
const offset = turnId === undefined ? undefined : layout.offsetOf(turnId);
if (offset !== undefined) readingGap = offset - root.scrollTop;
return turnId;
};
const publish = (): void => {
const next = positioning !== undefined;
if (snapshot.pinned === pinned && snapshot.positioning === next
&& snapshot.awayFromTail === awayFromTail && snapshot.readingTurnId === readingTurnId) return;
snapshot = { pinned, positioning: next, awayFromTail, readingTurnId };
for (const listener of listeners) listener();
};
const endPositioning = (): void => {
if (!positioning) return;
if (positioning.frame !== undefined) cancelAnimationFrame(positioning.frame);
positioning = undefined;
};
const writeToTail = (): void => {
if (!root) return;
root.scrollTop = root.scrollHeight;
awayFromTail = false;
publish();
};
/** Puts the Turn in place once; `false` while it is not in the list. */
const place = (): boolean => {
const current = positioning;
if (!current || !root || !layout) return false;
const offset = layout.offsetOf(current.turnId);
if (offset === undefined) return false;
if (current.gap !== undefined) {
const top = offset - current.gap;
if (Math.abs(root.scrollTop - top) >= 0.5) root.scrollTop = top;
} else if (!current.revealed) {
layout.reveal(current.turnId, {
align: current.navigation?.align ?? 'start',
smooth: current.navigation?.smooth ?? false,
});
}
current.revealed = true;
return true;
};
// Rows around the Turn mount and measure over the frames after it is placed,
// and each measurement moves its offset, so placing repeats until they settle.
const settle = (): void => {
const current = positioning;
if (!place() || !current || current.frame !== undefined) return;
const step = (): void => {
current.frame = undefined;
if (positioning !== current) return;
place();
if (--current.framesLeft > 0) {
current.frame = requestAnimationFrame(step);
return;
}
positioning = undefined;
readingTurnId = readTurn();
publish();
current.navigation?.onSettled?.();
};
current.frame = requestAnimationFrame(step);
};
return {
attach(next, nextLayout) {
root = next;
const target = root;
if (!target) return () => undefined;
layout = nextLayout;
const previousOverflowAnchor = target.style.overflowAnchor;
target.style.overflowAnchor = 'none';
const begin = (event: Event, direction: 'up' | 'down'): void => {
if (event.defaultPrevented || !reachesTranscript(event, target, direction)) return;
endPositioning();
const remaining = direction === 'up' ? target.scrollTop : distanceToTail();
gesture = { top: gesture?.top ?? target.scrollTop, direction };
// An edge gesture produces no scroll and therefore no scrollend. A
// passive wheel can arrive after the threaded scroll it caused, already
// at the top edge; that input did move the reader.
if (remaining <= 0) {
if (direction === 'up' && distanceToTail() > PIN_THRESHOLD_PX) pinned = false;
onScrollEnd();
return;
}
pinned = false;
publish();
};
const onWheel = (event: WheelEvent): void => {
if (event.ctrlKey || event.metaKey || event.deltaY === 0) return;
begin(event, event.deltaY < 0 ? 'up' : 'down');
};
const onKeyDown = (event: KeyboardEvent): void => {
const element = event.target;
if (!(element instanceof HTMLElement) || element.isContentEditable
|| element.closest('input, textarea, select') || event.altKey || event.metaKey) return;
if (event.key === ' ' && element.closest('button, summary, [role="button"]')) return;
if (event.ctrlKey && !['Home', 'End'].includes(event.key)) return;
const direction = ['ArrowUp', 'PageUp', 'Home'].includes(event.key)
|| (event.key === ' ' && event.shiftKey) ? 'up'
: ['ArrowDown', 'PageDown', 'End', ' '].includes(event.key) ? 'down' : undefined;
if (direction) begin(event, direction);
};
const onPointerDown = (event: PointerEvent): void => {
if (event.defaultPrevented || event.button !== 0 || event.pointerType === 'touch'
|| event.target !== target) return;
endPositioning();
publish();
pointer = event.pointerId;
gesture = { top: target.scrollTop };
};
const onPointerMove = (event: PointerEvent): void => {
if (pointer === event.pointerId) gesture ??= { top: target.scrollTop };
};
const onPointerUp = (): void => {
if (pointer === undefined) return;
pointer = undefined;
onScrollEnd();
const pending = gesture;
if (!pending || pending.direction !== undefined) return;
// Native track clicks can start their smooth scroll after pointerup.
// Scroll steps precede rAF; retire a click that still has not moved
// there, rather than leaving a non-scrolling click armed indefinitely.
requestAnimationFrame(() => {
if (gesture !== pending || pending.direction !== undefined) return;
gesture = undefined;
if (pinned) writeToTail();
});
};
let touchY: number | undefined;
const onTouchStart = (event: TouchEvent): void => {
touchHeld = true;
touchY = event.touches.length === 1 ? event.touches[0]!.clientY : undefined;
};
const onTouchMove = (event: TouchEvent): void => {
const nextY = event.touches.length === 1 ? event.touches[0]!.clientY : undefined;
if (touchY !== undefined && nextY !== undefined && touchY !== nextY) {
begin(event, nextY < touchY ? 'down' : 'up');
}
touchY = nextY;
};
const onTouchEnd = (event: TouchEvent): void => {
touchY = undefined;
if (event.touches.length > 0) return;
touchHeld = false;
onScrollEnd();
};
const onScroll = (): void => {
awayFromTail = distanceToTail() > BUTTON_THRESHOLD_PX;
readingTurnId = readTurn();
if (gesture) {
const delta = target.scrollTop - gesture.top;
gesture.top = target.scrollTop;
if (delta !== 0) {
const direction = pointer !== undefined
? (delta < 0 ? 'up' : 'down')
: gesture.direction ?? (delta < 0 ? 'up' : 'down');
// A reversed input may arrive while the previous smooth scroll
// still moves in the opposite direction. Its end is not the end
// of the new input's default action.
if ((delta < 0 ? 'up' : 'down') === direction) {
gesture.direction = direction;
pinned = false;
}
}
}
publish();
};
const onScrollEnd = (): void => {
const ended = gesture;
if (!ended) return;
const top = ended.top;
// Chromium can end a scrollbar animation while a subsequent keyboard
// animation is still moving the same scroller. Let the next rendering
// step report any continuation before retiring its input provenance.
// This schedules no scroll and uses no time-based ignore window.
requestAnimationFrame(() => requestAnimationFrame(() => {
if (gesture !== ended || ended.top !== top || pointer !== undefined || touchHeld) return;
// Input that can scroll and observed reader movement already release
// the pin. Settling an unmoved edge gesture must not release it too.
pinned = pinned || (ended.direction === 'down' && distanceToTail() <= PIN_THRESHOLD_PX);
gesture = undefined;
publish();
if (pinned) writeToTail();
}));
};
target.addEventListener('wheel', onWheel, { passive: true });
// React's delegated widget handlers run above the scroller. Observe
// keyboard input after they can prevent its native scrolling default.
target.ownerDocument.addEventListener('keydown', onKeyDown);
target.addEventListener('pointerdown', onPointerDown);
target.addEventListener('pointermove', onPointerMove, { passive: true });
target.addEventListener('touchstart', onTouchStart, { passive: true });
target.addEventListener('touchmove', onTouchMove, { passive: true });
target.addEventListener('touchend', onTouchEnd);
target.addEventListener('touchcancel', onTouchEnd);
target.ownerDocument.addEventListener('pointerup', onPointerUp);
target.ownerDocument.addEventListener('pointercancel', onPointerUp);
target.addEventListener('scroll', onScroll, { passive: true });
target.addEventListener('scrollend', onScrollEnd);
// Observe the viewport and its direct content boxes, including content
// outside Turns. Resize changes position only; it never changes intent.
const box = new ResizeObserver(() => {
if (pinned && !gesture) writeToTail();
else if (positioning) place();
awayFromTail = distanceToTail() > BUTTON_THRESHOLD_PX;
readingTurnId = readTurn();
publish();
});
const observeBox = (): void => {
box.disconnect();
box.observe(target);
for (const child of target.children) box.observe(child);
};
const childList = new MutationObserver(observeBox);
childList.observe(target, { childList: true });
observeBox();
if (pinned) writeToTail();
else settle();
readingTurnId = readTurn();
publish();
return () => {
childList.disconnect();
box.disconnect();
target.removeEventListener('wheel', onWheel);
target.ownerDocument.removeEventListener('keydown', onKeyDown);
target.removeEventListener('pointerdown', onPointerDown);
target.removeEventListener('pointermove', onPointerMove);
target.removeEventListener('touchstart', onTouchStart);
target.removeEventListener('touchmove', onTouchMove);
target.removeEventListener('touchend', onTouchEnd);
target.removeEventListener('touchcancel', onTouchEnd);
target.ownerDocument.removeEventListener('pointerup', onPointerUp);
target.ownerDocument.removeEventListener('pointercancel', onPointerUp);
target.removeEventListener('scroll', onScroll);
target.removeEventListener('scrollend', onScrollEnd);
target.style.overflowAnchor = previousOverflowAnchor;
gesture = undefined;
pointer = undefined;
touchHeld = false;
if (root === target) {
endPositioning();
root = null;
layout = undefined;
}
};
},
pinToTail() {
endPositioning();
gesture = undefined;
pointer = undefined;
touchHeld = false;
pinned = true;
writeToTail();
publish();
},
releasePin() {
endPositioning();
gesture = undefined;
pinned = false;
awayFromTail = distanceToTail() > BUTTON_THRESHOLD_PX;
publish();
},
navigate(navigation) {
endPositioning();
gesture = undefined;
pinned = false;
positioning = {
turnId: navigation.turnId,
gap: navigation.align === 'start' && !navigation.smooth ? 0 : undefined,
navigation,
revealed: false,
framesLeft: SETTLE_FRAMES,
};
publish();
settle();
const settled = () => requestAnimationFrame(() => {
if (positioning?.navigation !== navigation) return;
if (layout?.offsetOf(navigation.turnId) !== undefined) {
settle();
return;
}
endPositioning();
readingTurnId = readTurn();
publish();
});
navigation.arrival?.then(settled, settled);
},
turnsChanged(change) {
if (change === 'same' || change === 'append') {
settle();
return;
}
if (positioning) positioning.framesLeft = SETTLE_FRAMES;
else if (!pinned && readingTurnId !== undefined) {
positioning = { turnId: readingTurnId, gap: readingGap, revealed: false, framesLeft: SETTLE_FRAMES };
// The reader's Turn left the list, so there is nowhere to keep them.
if (layout?.offsetOf(readingTurnId) === undefined) positioning = undefined;
}
publish();
settle();
},
measureReadingTurn() {
readingTurnId = readTurn();
publish();
return readingTurnId;
},
subscribe(listener) {
listeners.add(listener);
return () => { listeners.delete(listener); };
},
getSnapshot() {
return snapshot;
},
};
}
const TranscriptScrollContext = createContext<TranscriptScrollAuthority | null>(null);
/**
* Deliberately holds no React state: the pin crosses its thresholds on
* scroll, and a provider that re-rendered on each crossing would re-render the
* whole transcript under it. The button subscribes instead.
*/
export function TranscriptScrollAuthorityProvider({ children }: { children: ReactNode }) {
const authority = useRef<TranscriptScrollAuthority | undefined>(undefined);
authority.current ??= createTranscriptScrollAuthority();
return (
<TranscriptScrollContext value={authority.current}>{children}</TranscriptScrollContext>
);
}
/**
* Every `ChatSurfaceLayout` provides one, so a missing authority is a tree that
* was assembled wrong rather than a state to degrade into — the same contract
* `ChatView` already states about its layout.
*/
export function useTranscriptScrollAuthority(): TranscriptScrollAuthority {
const authority = useContext(TranscriptScrollContext);
if (!authority) {
throw new Error('useTranscriptScrollAuthority must be used inside ChatSurfaceLayout');
}
return authority;
}
/**
* The dock's scroll-to-bottom affordance, driven by Maka's pin rather than
* Astryx's — with auto-scroll off, `isScrolledUp` never updates again, so the
* stock button would be permanently invisible.
*
* The label stays unset on purpose: `ChatSurfaceLayout` overrides Astryx's
* `scrollToBottom` string through the locale provider that wraps this.
*/
export function TranscriptScrollButton() {
const authority = useTranscriptScrollAuthority();
const snapshot = useSyncExternalStore(
authority.subscribe,
authority.getSnapshot,
authority.getSnapshot,
);
return (
<ChatLayoutScrollButton
isVisible={snapshot.awayFromTail}
onClick={authority.pinToTail}
/>
);
}