Files
rio/frontend/code/components/switcherBar.ts
2024-07-30 20:55:55 +02:00

382 lines
13 KiB
TypeScript

import { ComponentBase, ComponentState } 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 } 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 {
state: Required<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
let backgroundOptionsRect =
this.backgroundOptionsElement.getBoundingClientRect();
this.markerOptionsElement.style.width = `${backgroundOptionsRect.width}px`;
this.markerOptionsElement.style.height = `${backgroundOptionsRect.height}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);
// Find the location of the selected item.
let optionElement =
this.backgroundOptionsElement.children[selectedIndex];
let optionRect = optionElement.getBoundingClientRect();
let parentRect = this.innerElement.getBoundingClientRect();
return [
optionRect.left - parentRect.left,
optionRect.top - parentRect.top,
optionRect.width,
optionRect.height,
];
}
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: 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: 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
setTimeout(() => {
let backgroundOptionsRect =
this.backgroundOptionsElement.getBoundingClientRect();
this.markerOptionsElement.style.width = `${backgroundOptionsRect.width}px`;
this.markerOptionsElement.style.height = `${backgroundOptionsRect.height}px`;
// Update the CSS
this.updateCssToMatchState();
}, 100);
}
// 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.animateToCurrentTarget();
}
} else if (deltaState.selectedName === null) {
this.fadeTween.teleportTo(0);
} else {
this.fadeTween.teleportTo(1);
setTimeout(() => {
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();
}, 100);
}
}
// Any future updates are not the first
this.isInitialized = true;
}
}