Files
rio/frontend/code/components/scrollContainer.ts
2024-04-21 15:03:47 +02:00

249 lines
9.2 KiB
TypeScript

import { pixelsPerRem, scrollBarSize } from '../app';
import { componentsById } from '../componentManagement';
import { LayoutContext } from '../layouting';
import { ComponentId } from '../dataModels';
import { ComponentBase, ComponentState } from './componentBase';
export type ScrollContainerState = ComponentState & {
_type_: 'ScrollContainer-builtin';
content?: ComponentId;
scroll_x?: 'never' | 'auto' | 'always';
scroll_y?: 'never' | 'auto' | 'always';
initial_x?: number;
initial_y?: number;
sticky_bottom?: boolean;
};
const NATURAL_SIZE = 1.0;
export class ScrollContainerComponent extends ComponentBase {
state: Required<ScrollContainerState>;
// Sometimes components are temporarily removed from the DOM or resized (for
// example, `getElementDimensions` does both), which can lead to the scroll
// position being changed or reset. In order to prevent this, we'll wrap our
// child in a container element.
private childContainer: HTMLElement;
private isFirstLayout: boolean = true;
private assumeVerticalScrollBarWillBeNeeded: boolean = true;
private numSequentialIncorrectAssumptions: number = 0;
private wasScrolledToBottom: boolean | null = null;
private shouldLayoutWithVerticalScrollbar(): boolean {
switch (this.state.scroll_y) {
case 'always':
return true;
case 'auto':
return this.assumeVerticalScrollBarWillBeNeeded;
case 'never':
return false;
}
}
createElement(): HTMLElement {
let element = document.createElement('div');
element.classList.add('rio-scroll-container');
this.childContainer = document.createElement('div');
element.appendChild(this.childContainer);
return element;
}
updateElement(
deltaState: ScrollContainerState,
latentComponents: Set<ComponentBase>
): void {
this.replaceOnlyChild(
latentComponents,
deltaState.content,
this.childContainer
);
}
updateNaturalWidth(ctx: LayoutContext): void {
if (this.state.scroll_x === 'never') {
let child = componentsById[this.state.content]!;
this.naturalWidth = child.requestedWidth;
} else {
this.naturalWidth = NATURAL_SIZE;
}
// If there will be a vertical scroll bar, reserve space for it
if (this.shouldLayoutWithVerticalScrollbar()) {
this.naturalWidth += scrollBarSize;
}
}
updateAllocatedWidth(ctx: LayoutContext): void {
let child = componentsById[this.state.content]!;
// If `sticky_bottom` is enabled, we need to find out whether we're
// scrolled all the way to the bottom before we change the child's
// allocation.
//
// We can't do this in `updateNaturalWidth` because the layouting
// algorithm doesn't always call that method.
if (
this.state.sticky_bottom &&
this.numSequentialIncorrectAssumptions === 0
) {
this.wasScrolledToBottom = this._checkIfScrolledToBottom(child);
}
let availableWidth = this.allocatedWidth;
if (this.shouldLayoutWithVerticalScrollbar()) {
availableWidth -= scrollBarSize;
}
// If the child needs more space than we have, we'll need to display a
// scroll bar. So just give the child the width it wants.
if (child.requestedWidth > availableWidth) {
child.allocatedWidth = child.requestedWidth;
this.element.style.overflowX = 'scroll';
} else {
// Otherwise, stretch the child to use up all the available width
child.allocatedWidth = availableWidth;
this.element.style.overflowX =
this.state.scroll_x === 'always' ? 'scroll' : 'hidden';
}
this.childContainer.style.width = `${child.allocatedWidth}rem`;
}
updateNaturalHeight(ctx: LayoutContext): void {
if (this.state.scroll_y === 'never') {
let child = componentsById[this.state.content]!;
this.naturalHeight = child.requestedHeight;
} else {
this.naturalHeight = NATURAL_SIZE;
}
// If there will be a horizontal scroll bar, reserve space for it
if (this.element.style.overflowX === 'scroll') {
this.naturalHeight += scrollBarSize;
}
}
updateAllocatedHeight(ctx: LayoutContext): void {
let child = componentsById[this.state.content]!;
let availableHeight = this.allocatedHeight;
if (this.element.style.overflowX === 'scroll') {
availableHeight -= scrollBarSize;
}
// If the child needs more space than we have, we'll need to display a
// scroll bar. So just give the child the height it wants.
let newAllocatedHeight: number;
if (child.requestedHeight > availableHeight) {
newAllocatedHeight = child.requestedHeight;
this.element.style.overflowY = 'scroll';
} else {
// Otherwise, stretch the child to use up all the available height
newAllocatedHeight = availableHeight;
if (this.state.scroll_y === 'always') {
this.element.style.overflowY = 'scroll';
} else {
this.element.style.overflowY = 'hidden';
}
}
// Now check if our assumption for the vertical scroll bar was correct.
// If not, we have to immediately re-layout the child.
let hasVerticalScrollbar = this.element.style.overflowY === 'scroll';
if (
this.state.scroll_y === 'auto' &&
this.assumeVerticalScrollBarWillBeNeeded !== hasVerticalScrollbar
) {
// Theoretically, there could be a situation where our assumptions
// are always wrong and we re-layout endlessly.
//
// It's acceptable to have an unnecessary scroll bar, but it's not
// acceptable to be missing a scroll bar when one is required. So we
// will only re-layout if this is the first time our assumption was
// wrong, or if we don't currently have a scroll bar.
if (
this.numSequentialIncorrectAssumptions == 0 ||
!this.assumeVerticalScrollBarWillBeNeeded
) {
this.numSequentialIncorrectAssumptions++;
this.assumeVerticalScrollBarWillBeNeeded =
!this.assumeVerticalScrollBarWillBeNeeded;
// While a re-layout is about to be requested, this doesn't mean
// that the current layouting process won't continue. Assign a
// reasonable amount of space to the child so any subsequent
// layouting functions don't crash because of unassigned values.
//
// The exact value doesn't matter, but this one is noticeable
// when debugging and easy to find in the code.
child.allocatedHeight = 7.77777777;
// Then, request a re-layout
ctx.requestImmediateReLayout(() => {
this.makeLayoutDirty();
});
return;
}
}
this.numSequentialIncorrectAssumptions = 0;
// Only change the allocatedHeight once we're sure that we won't be
// re-layouting again
child.allocatedHeight = newAllocatedHeight;
this.childContainer.style.height = `${child.allocatedHeight}rem`;
if (this.isFirstLayout) {
this.isFirstLayout = false;
// Our CSS `height` hasn't been updated yet, so we can't scroll
// down any further. We must assign the `height` manually.
this.element.style.height = `${this.allocatedHeight}rem`;
this.element.scroll({
top:
(child.allocatedHeight - this.allocatedHeight) *
pixelsPerRem *
this.state.initial_y,
left:
(child.allocatedWidth - this.allocatedWidth) *
pixelsPerRem *
this.state.initial_y,
behavior: 'instant',
});
}
// If `sticky_bottom` is enabled, check if we have to scroll down
else if (this.state.sticky_bottom && this.wasScrolledToBottom) {
// Our CSS `height` hasn't been updated yet, so we can't scroll
// down any further. We must assign the `height` manually.
this.element.style.height = `${this.allocatedHeight}rem`;
this.element.scroll({
top: child.allocatedHeight * pixelsPerRem + 999,
left: this.element.scrollLeft,
behavior: 'instant',
});
}
}
private _checkIfScrolledToBottom(child: ComponentBase): boolean {
// Calculate how much of the child is visible
let visibleHeight = this.allocatedHeight;
if (this.element.style.overflowX === 'scroll') {
visibleHeight -= scrollBarSize;
}
return (
(this.element.scrollTop + 1) / pixelsPerRem + visibleHeight >=
child.allocatedHeight - 0.00001
);
}
}