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

392 lines
13 KiB
TypeScript

import { ComponentBase, ComponentState, DeltaState } from "./componentBase";
import { ColorSet } from "../dataModels";
import { applyIcon, applySwitcheroo } from "../designApplication";
import { MappingTween } from "../tweens/mappingTweens";
import { BaseTween } from "../tweens/baseTween";
import { KineticTween } from "../tweens/kineticTween";
import { pixelsPerRem } from "../app";
import {
firstDefined,
getAllocatedHeightInPx,
getAllocatedWidthInPx,
} from "../utils";
export type SwitcherBarState = ComponentState & {
_type_: "SwitcherBar-builtin";
names: string[];
icons: (string | null)[] | null;
color: ColorSet;
orientation: "horizontal" | "vertical";
spacing: number;
allow_none: boolean;
selectedName: string | null;
};
export class SwitcherBarComponent extends ComponentBase<SwitcherBarState> {
private innerElement: HTMLElement; // Used for alignment
private markerElement: HTMLElement; // Highlights the selected item
private backgroundOptionsElement: HTMLElement; // Displays all options
private markerOptionsElement: HTMLElement; // Options, but different color
// Animation state
private animationIsRunning: boolean = false;
private fadeTween: BaseTween;
private moveTween: BaseTween;
private markerAtAnimationStart: [number, number, number, number] = [
0, 0, 0, 0,
];
private markerAtAnimationEnd: [number, number, number, number] = [
0, 0, 0, 0,
];
private markerCurrent: [number, number, number, number] = [0, 0, 0, 0];
// Allows to determine whether this is the first time the element is being
// updated.
private isInitialized: boolean = false;
// Used to update the marker should the element be resized
private resizeObserver: ResizeObserver;
createElement(): HTMLElement {
// Create the elements
let outerElement = document.createElement("div");
outerElement.classList.add("rio-switcher-bar");
// Centers the bar
this.innerElement = document.createElement("div");
outerElement.appendChild(this.innerElement);
// Highlights the selected item
this.markerElement = document.createElement("div");
this.markerElement.classList.add("rio-switcher-bar-marker");
// Prepare animations
this.fadeTween = new MappingTween({
mapping: (value: number) => value,
duration: 0.18,
});
this.moveTween = new KineticTween({
acceleration: 350 * pixelsPerRem,
});
// The marker needs updating when the element is resized
this.resizeObserver = new ResizeObserver(this.onResize.bind(this));
this.resizeObserver.observe(this.innerElement);
return outerElement;
}
onDestruction(): void {
super.onDestruction();
this.resizeObserver.disconnect();
}
onResize(): void {
// Update the marker position
if (this.state.selectedName !== null) {
this.markerAtAnimationEnd = this.getMarkerTarget()!;
this.updateCssToMatchState();
}
// Pass on all of the allocated size to the marker options
this.markerOptionsElement.style.width = `${getAllocatedWidthInPx(
this.backgroundOptionsElement
)}px`;
this.markerOptionsElement.style.height = `${getAllocatedHeightInPx(
this.backgroundOptionsElement
)}px`;
}
/// Update the HTML & CSS to match the current state
updateCssToMatchState(): void {
// Position the marker
let t = this.moveTween.progress;
for (let i = 0; i < 4; i++) {
let start = this.markerAtAnimationStart[i];
let delta = this.markerAtAnimationEnd[i] - start;
this.markerCurrent[i] = start + delta * t;
}
// Account for the fade animation
let fade = this.fadeTween.current;
let markerCurWidth = this.markerCurrent[2] * fade;
let markerCurHeight = this.markerCurrent[3] * fade;
let markerCurLeft =
this.markerCurrent[0] +
(this.markerCurrent[2] - markerCurWidth) / 2;
let markerCurTop =
this.markerCurrent[1] +
(this.markerCurrent[3] - markerCurHeight) / 2;
// Move the marker
this.markerElement.style.left = `${markerCurLeft}px`;
this.markerElement.style.top = `${markerCurTop}px`;
this.markerElement.style.width = `${markerCurWidth}px`;
this.markerElement.style.height = `${markerCurHeight}px`;
// The inner options are positioned relative to the marker. Move them in
// the opposite direction so they stay put.
this.markerOptionsElement.style.left = `-${markerCurLeft}px`;
this.markerOptionsElement.style.top = `-${markerCurTop}px`;
}
animationWorker() {
// Update the tweens
let keepGoing = false;
if (this.fadeTween.isRunning) {
this.fadeTween.update();
keepGoing = true;
}
if (this.moveTween.isRunning) {
this.moveTween.update();
keepGoing = true;
}
// Update the CSS
this.updateCssToMatchState();
// Keep going?
if (keepGoing) {
requestAnimationFrame(this.animationWorker.bind(this));
} else {
this.animationIsRunning = false;
}
}
ensureAnimationIsRunning(): void {
if (this.animationIsRunning) {
return;
}
this.animationIsRunning = true;
requestAnimationFrame(this.animationWorker.bind(this));
}
/// Start moving the marker to match the current state, taking care of
/// animations and everything
animateToCurrentTarget(): void {
// Move the marker
if (this.state.selectedName !== null) {
this.markerAtAnimationStart = [...this.markerCurrent];
this.markerAtAnimationEnd = this.getMarkerTarget()!;
let animatedPosition =
this.state.orientation == "horizontal"
? this.markerAtAnimationEnd[0]
: this.markerAtAnimationEnd[1];
// If the marker is currently completely invisible, teleport.
if (this.fadeTween.current === 0) {
this.moveTween.teleportTo(animatedPosition);
} else {
this.moveTween.transitionTo(animatedPosition);
}
}
// Fade the marker in/out
if (this.state.selectedName === null) {
this.fadeTween.transitionTo(0);
} else {
this.fadeTween.transitionTo(1);
}
// Make sure there's somebody tending to the tweens
this.ensureAnimationIsRunning();
}
/// If an item is selected, returns the position and size the marker should
/// be in order to highlight the selected item. Returns `null` if no item is
/// currently selected.
getMarkerTarget(): [number, number, number, number] | null {
// Nothing selected
if (this.state.selectedName === null) {
return null;
}
// Find the selected item
let selectedIndex = this.state.names.indexOf(this.state.selectedName!);
console.assert(
selectedIndex !== -1,
`Invalid name selected: ${this.state.selectedName} is not in ${this.state.names}`
);
// Find the location of the selected item.
let optionElement = this.backgroundOptionsElement.children[
selectedIndex
] as HTMLElement;
let optionRect = optionElement.getBoundingClientRect();
let parentRect = this.innerElement.getBoundingClientRect();
return [
optionRect.left - parentRect.left,
optionRect.top - parentRect.top,
getAllocatedWidthInPx(optionElement),
getAllocatedHeightInPx(optionElement),
];
}
onItemClick(event: MouseEvent, name: string): void {
// If this item was already selected, the new value may be `None`
if (this.state.selectedName === name) {
if (this.state.allow_none) {
this.state.selectedName = null;
} else {
return;
}
} else {
this.state.selectedName = name;
}
// Update the marker
this.animateToCurrentTarget();
// Notify the backend
this.sendMessageToBackend({
name: this.state.selectedName,
});
// Eat the event
event.stopPropagation();
}
buildContent(deltaState: DeltaState<SwitcherBarState>): HTMLElement {
let result = document.createElement("div");
result.classList.add("rio-switcher-bar-options");
result.style.gap = `${this.state.spacing}rem`;
let names = deltaState.names ?? this.state.names;
let icons = firstDefined(deltaState.icons, this.state.icons);
// Iterate over both
for (let i = 0; i < names.length; i++) {
let name = names[i];
let optionElement = document.createElement("div");
optionElement.classList.add("rio-switcher-bar-option");
result.appendChild(optionElement);
// Icon
if (icons !== null && icons[i] !== null) {
let iconContainer = document.createElement("div");
iconContainer.classList.add("rio-switcher-bar-icon");
optionElement.appendChild(iconContainer);
applyIcon(iconContainer, icons[i]!);
}
// Text
let textElement = document.createElement("div");
optionElement.appendChild(textElement);
textElement.textContent = name;
// Detect clicks
optionElement.addEventListener("click", (event) =>
this.onItemClick(event, name)
);
}
return result;
}
updateElement(
deltaState: DeltaState<SwitcherBarState>,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
// Have the options changed?
if (deltaState.names !== undefined || deltaState.icons !== undefined) {
this.innerElement.innerHTML = "";
this.markerElement.innerHTML = "";
// Background options
this.backgroundOptionsElement = this.buildContent(deltaState);
this.innerElement.appendChild(this.backgroundOptionsElement);
// Marker
this.innerElement.appendChild(this.markerElement);
// Marker options
this.markerOptionsElement = this.buildContent(deltaState);
this.markerElement.appendChild(this.markerOptionsElement);
// Pass on all available space to the marker options
requestAnimationFrame(() => {
this.markerOptionsElement.style.width = `${getAllocatedWidthInPx(
this.backgroundOptionsElement
)}px`;
this.markerOptionsElement.style.height = `${getAllocatedHeightInPx(
this.backgroundOptionsElement
)}px`;
// Update the CSS
this.updateCssToMatchState();
});
}
// Color
if (deltaState.color !== undefined) {
applySwitcheroo(
this.markerElement,
deltaState.color === "keep" ? "bump" : deltaState.color
);
}
// Orientation
if (deltaState.orientation !== undefined) {
let flexDirection =
deltaState.orientation == "vertical" ? "column" : "row";
this.element.style.flexDirection = flexDirection;
this.backgroundOptionsElement.style.flexDirection = flexDirection;
this.markerOptionsElement.style.flexDirection = flexDirection;
}
// Spacing
if (deltaState.spacing !== undefined) {
this.backgroundOptionsElement.style.gap = `${deltaState.spacing}rem`;
this.markerOptionsElement.style.gap = `${deltaState.spacing}rem`;
}
// If the selection has changed make sure to move the marker
if (deltaState.selectedName !== undefined) {
if (this.isInitialized) {
if (deltaState.selectedName !== this.state.selectedName) {
this.state.selectedName = deltaState.selectedName;
this.state.names = deltaState.names ?? this.state.names;
this.animateToCurrentTarget();
}
} else if (deltaState.selectedName === null) {
this.fadeTween.teleportTo(0);
} else {
this.fadeTween.teleportTo(1);
requestAnimationFrame(() => {
this.markerAtAnimationStart = this.markerAtAnimationEnd =
this.getMarkerTarget()!;
let animatedPosition =
this.state.orientation == "horizontal"
? this.markerAtAnimationEnd[0]
: this.markerAtAnimationEnd[1];
this.moveTween.teleportTo(animatedPosition);
this.moveTween.update();
this.updateCssToMatchState();
});
}
}
// Any future updates are not the first
this.isInitialized = true;
}
}