Files
rio/frontend/code/components/switcher.ts
2025-03-11 19:14:15 +01:00

201 lines
7.7 KiB
TypeScript

import { ComponentId } from "../dataModels";
import { ComponentBase, ComponentState, DeltaState } from "./componentBase";
import { componentsById } from "../componentManagement";
import { commitCss } from "../utils";
export type SwitcherState = ComponentState & {
_type_: "Switcher-builtin";
content: ComponentId | null;
transition_time: number;
};
export class SwitcherComponent extends ComponentBase<SwitcherState> {
private activeChildContainer: HTMLElement | null = null;
private resizerElement: HTMLElement | null = null;
private idOfCurrentAnimation: number = 0;
private isInitialized: boolean = false;
createElement(): HTMLElement {
let element = document.createElement("div");
element.classList.add("rio-switcher");
return element;
}
updateElement(
deltaState: DeltaState<SwitcherState>,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
// Update the transition time first, in case the code below is about
// to start an animation.
if (deltaState.transition_time !== undefined) {
this.element.style.setProperty(
"--rio-switcher-transition-time",
`${deltaState.transition_time}s`
);
}
// Update the child
if (deltaState.content !== undefined) {
if (!this.isInitialized) {
if (deltaState.content !== null) {
// If this is the first time the switcher is being updated,
// don't animate anything.
this.activeChildContainer = document.createElement("div");
this.activeChildContainer.classList.add(
"rio-switcher-active-child"
);
this.element.appendChild(this.activeChildContainer);
this.replaceOnlyChild(
latentComponents,
deltaState.content,
this.activeChildContainer
);
}
} else if (deltaState.content !== this.state.content) {
this.replaceContent(
deltaState.content,
latentComponents,
deltaState.transition_time ?? this.state.transition_time
);
}
}
this.isInitialized = true;
}
private async replaceContent(
content: ComponentId | null,
latentComponents: Set<ComponentBase>,
transitionTime: number
): Promise<void> {
// Animating the size is trickier than you might expect. Firstly, CSS
// can't animate `width` and `height`, and secondly, our parent
// component might force us to be larger than we want to be.
//
// The solution is create a child element and animate its min-size from
// the size of the current child component to the size of the new child
// component. (The other children will be made `absolute` during the
// animation so they don't influence the Switcher's size.)
// Step 1: Get the size of the current child element
let oldChildContainer = this.activeChildContainer;
let oldWidth: number = 0,
oldHeight: number = 0;
if (oldChildContainer !== null) {
oldWidth = oldChildContainer.scrollWidth;
oldHeight = oldChildContainer.scrollHeight;
// The old component may be used somewhere else in the UI, so the
// switcher can't rely on it still being available. To get around this,
// create a copy of the element's HTML tree and use that for the
// animation.
//
// Moreover, the component may have already been removed from the
// switcher. This can happen when it was moved into another component.
// Thus, fetch the component by its id, rather than using the contained
// HTML node.
let oldComponent = componentsById[this.state.content!]!;
let oldElementClone = oldComponent.outerElement.cloneNode(
true
) as HTMLElement;
// Unparent the old component
this.replaceOnlyChild(latentComponents, null, oldChildContainer);
// Fill the childContainer with the cloned element
oldChildContainer.appendChild(oldElementClone);
oldChildContainer.classList.remove("rio-switcher-active-child");
}
// Step 2: Get the size of the new child component
let newChildContainer: HTMLElement | null = null;
let newWidth: number = 0,
newHeight: number = 0;
if (content !== null) {
// Add the child into a helper container
newChildContainer = document.createElement("div");
this.replaceOnlyChild(latentComponents, content, newChildContainer);
// Find out how large the new child will be. To simulate this, we
// must temporarily remove the current child from layouting, so that
// it can't influence the size of the Switcher.
this.element.appendChild(newChildContainer);
if (oldChildContainer !== null) {
oldChildContainer.style.position = "absolute";
}
// The child component's `updateElement` may not have run yet, which
// would result in a size of 0x0. Wait a bit before we query its
// size.
[newWidth, newHeight] = await new Promise((resolve) => {
requestAnimationFrame(() => {
resolve([
newChildContainer!.scrollWidth,
newChildContainer!.scrollHeight,
]);
});
});
if (oldChildContainer !== null) {
oldChildContainer.style.removeProperty("position");
}
newChildContainer.classList.add("rio-switcher-active-child");
}
this.activeChildContainer = newChildContainer;
// Step 3: Animate the size of the invisible child element. If we're
// currently still in the middle of an animation, re-use the existing
// element and simply update its target size.
let resizerElement: HTMLElement;
if (this.resizerElement === null) {
resizerElement = document.createElement("div");
resizerElement.classList.add("rio-switcher-resizer");
this.resizerElement = resizerElement;
resizerElement.style.minWidth = `${oldWidth}px`;
resizerElement.style.minHeight = `${oldHeight}px`;
this.element.appendChild(resizerElement);
this.element.classList.add("resizing");
commitCss(resizerElement);
} else {
resizerElement = this.resizerElement;
}
resizerElement.style.minWidth = `${newWidth}px`;
resizerElement.style.minHeight = `${newHeight}px`;
// Step 4: Clean up
this.idOfCurrentAnimation++;
let idOfCurrentAnimation = this.idOfCurrentAnimation;
// Clean up once the animation is finished
setTimeout(() => {
if (oldChildContainer !== null) {
oldChildContainer.remove();
}
// If another animation was started before this one finished, we
// don't need to do anything else.
if (this.idOfCurrentAnimation !== idOfCurrentAnimation) {
return;
}
resizerElement.remove();
this.resizerElement = null;
this.element.classList.remove("resizing");
}, transitionTime * 1000);
}
}