Files
rio/frontend/code/components/componentBase.ts
2024-08-04 15:34:45 +02:00

556 lines
20 KiB
TypeScript

import { componentsById, getComponentByElement } from '../componentManagement';
import { callRemoteMethodDiscardResponse } from '../rpc';
import {
EventHandler,
DragHandler,
DragHandlerArguments,
ClickHandlerArguments,
ClickHandler,
} from '../eventHandling';
import { ComponentId } from '../dataModels';
import { insertWrapperElement, replaceElement } from '../utils';
import { devToolsConnector } from '../app';
import { DialogContainerComponent } from './dialog_container';
/// Base for all component states. Updates received from the backend are
/// partial, hence most properties may be undefined.
export type ComponentState = {
// The component type's unique id. Crucial so the client knows what kind of
// component to spawn.
_type_?: string;
// Debugging information. Useful both for developing rio itself, and also
// displayed to developers in Rio's dev tools
_python_type_?: string;
// Debugging information
_key_?: string | number | null;
// How much space to leave on the left, top, right, bottom
_margin_?: [number, number, number, number];
// Explicit size request, if any
_size_?: [number, number];
// Alignment of the component within its parent, if any
_align_?: [number | null, number | null];
// Scrolling behavior
// SCROLLING-REWORK _scroll_?: [RioScrollBehavior, RioScrollBehavior];
// Whether the component would like to receive additional space if there is
// any left over
_grow_?: [boolean, boolean];
// Debugging information: The dev tools may not display components to the
// developer if they're considered internal
_rio_internal_?: boolean;
};
/// Base class for all components
///
/// Note: Components that can have the keyboard focus must also implement a
/// `grabKeyboardFocus(): void` method.
export abstract class ComponentBase {
id: ComponentId;
element: HTMLElement;
state: Required<ComponentState>;
// Reference to the parent component. If the component is about to be
// removed from the component tree (i.e. it's in `latent-components`), this
// still references the *last* parent component. `null` is only for newly
// created components and the root component.
parent: ComponentBase | null = null;
children = new Set<ComponentBase>();
_eventHandlers = new Set<EventHandler>();
private marginElement: HTMLElement | null = null;
private outerAlignElement: HTMLElement | null = null;
private innerAlignElement: HTMLElement | null = null;
private outerScrollElement: HTMLElement | null = null;
private centerScrollElement: HTMLElement | null = null;
private innerScrollElement: HTMLElement | null = null;
// Any dialogs attached to this component. When this component disappears,
// the dialogs go with it.
public ownedDialogs: DialogContainerComponent[] = [];
constructor(id: ComponentId, state: Required<ComponentState>) {
this.id = id;
this.state = state;
this.element = this.createElement();
this.element.classList.add('rio-component');
}
// Layouting (alignment, scrolling, etc) may require extra HTML elements,
// which will be created on demand. This property always points to the
// current outermost element. So when a component is moved around in the
// DOM, make sure to use `outerElement` instead of `element`.
get outerElement(): HTMLElement {
if (this.marginElement !== null) {
return this.marginElement;
}
if (this.outerScrollElement !== null) {
return this.outerScrollElement;
}
if (this.outerAlignElement !== null) {
return this.outerAlignElement;
}
return this.element;
}
get alignmentElement(): HTMLElement | null {
return this.outerAlignElement;
}
/// Given a partial state update, this function updates the component's HTML
/// element to reflect the new state.
///
/// The `element` parameter is identical to `this.element`. It's passed as
/// an argument because it's more efficient than calling `this.element`.
updateElement(
deltaState: ComponentState,
latentComponents: Set<ComponentBase>
): void {
if (deltaState._size_ !== undefined) {
this.element.style.minWidth = `${deltaState._size_[0]}rem`;
this.element.style.minHeight = `${deltaState._size_[1]}rem`;
}
if (deltaState._align_ !== undefined) {
this._updateAlign(deltaState._align_);
}
// SCROLLING-REWORK
// if (deltaState._scroll_ !== undefined) {
// this._updateScroll(deltaState._scroll_);
// }
if (deltaState._margin_ !== undefined) {
this._updateMargin(deltaState._margin_);
}
}
onChildGrowChanged(): void {}
private _updateAlign(align: [number | null, number | null]): void {
if (align[0] === null && align[1] === null) {
// Remove the alignElement if we have one
if (this.outerAlignElement !== null) {
replaceElement(
this.outerAlignElement,
this.innerAlignElement!.firstChild!
);
this.outerAlignElement = null;
this.innerAlignElement = null;
}
} else {
// Create the alignElement if we don't have one already
if (this.outerAlignElement === null) {
this.innerAlignElement = insertWrapperElement(this.element);
this.outerAlignElement = insertWrapperElement(
this.innerAlignElement
);
this.innerAlignElement.classList.add('rio-align-inner');
this.outerAlignElement.classList.add('rio-align-outer');
this.outerAlignElement.dataset.ownerId = `${this.id}`;
}
let transform = '';
if (align[0] === null) {
this.innerAlignElement!.style.removeProperty('left');
this.innerAlignElement!.style.width = '100%';
this.innerAlignElement!.classList.add('stretch-child-x');
} else {
this.innerAlignElement!.style.left = `${align[0] * 100}%`;
this.innerAlignElement!.style.width = 'min-content';
this.innerAlignElement!.classList.remove('stretch-child-x');
transform += `translateX(-${align[0] * 100}%) `;
}
if (align[1] === null) {
this.innerAlignElement!.style.removeProperty('top');
this.innerAlignElement!.style.height = '100%';
this.innerAlignElement!.classList.add('stretch-child-y');
} else {
this.innerAlignElement!.style.top = `${align[1] * 100}%`;
this.innerAlignElement!.style.height = 'min-content';
this.innerAlignElement!.classList.remove('stretch-child-y');
transform += `translateY(-${align[1] * 100}%) `;
}
this.innerAlignElement!.style.transform = transform;
}
}
// SCROLLING-REWORK
// private _updateScroll(
// scroll: [RioScrollBehavior, RioScrollBehavior]
// ): void {
// if (scroll[0] === 'never' && scroll[1] === 'never') {
// // Remove the scrollElement if we have one
// if (this.outerScrollElement !== null) {
// replaceElement(
// this.outerScrollElement,
// this.outerScrollElement.firstChild!
// );
// this.outerScrollElement = null;
// }
// } else {
// // Create the scrollElement if we don't have one already
// if (this.outerScrollElement === null) {
// this.innerScrollElement = insertWrapperElement(
// this.outerAlignElement ?? this.element
// );
// this.centerScrollElement = insertWrapperElement(
// this.innerScrollElement
// );
// this.outerScrollElement = insertWrapperElement(
// this.centerScrollElement
// );
// this.outerScrollElement.dataset.ownerId = `${this.id}`;
// this.outerScrollElement.className =
// 'rio-scroll-helper rio-scroll';
// }
// this.outerScrollElement.dataset.scrollX = scroll[0];
// this.outerScrollElement.dataset.scrollY = scroll[1];
// }
// }
private _updateMargin(margin: [number, number, number, number]): void {
if (
margin[0] === 0 &&
margin[1] === 0 &&
margin[2] === 0 &&
margin[3] === 0
) {
// Remove the marginElement if we have one
if (this.marginElement !== null) {
replaceElement(
this.marginElement,
this.marginElement.firstChild!
);
this.marginElement = null;
}
} else {
// Create the marginElement if we don't have one already
if (this.marginElement === null) {
this.marginElement = insertWrapperElement(
this.outerScrollElement ??
this.outerAlignElement ??
this.element
);
this.marginElement.classList.add('rio-margin');
this.marginElement.dataset.ownerId = `${this.id}`;
}
// Margins cause weird problems (for example, they can stick out of
// the parent element), so we use padding instead
this.marginElement.style.paddingLeft = `${margin[0]}rem`;
this.marginElement.style.paddingTop = `${margin[1]}rem`;
this.marginElement.style.paddingRight = `${margin[2]}rem`;
this.marginElement.style.paddingBottom = `${margin[3]}rem`;
}
}
private unparent(latentComponents: Set<ComponentBase>): void {
// Remove this component from its parent
console.assert(this.parent !== null);
this.parent!.children.delete(this);
latentComponents.add(this);
}
private registerChild(
latentComponents: Set<ComponentBase>,
child: ComponentBase
): void {
// Remove the child from its previous parent
if (child.parent !== null) {
child.parent.children.delete(child);
}
// Add it to this component
child.parent = this;
this.children.add(child);
latentComponents.delete(child);
}
/// Appends the given child component at the end of the given HTML element.
/// Does not remove or modify any existing children. If `childId` is
/// `undefined`, does nothing.
appendChild(
latentComponents: Set<ComponentBase>,
childId: ComponentId | undefined,
parentElement: HTMLElement = this.element
): void {
// If undefined, do nothing
if (childId === undefined) {
return;
}
// Add the child
let child = componentsById[childId]!;
parentElement.appendChild(child.outerElement);
this.registerChild(latentComponents, child);
}
/// Replaces the child of the given HTML element with the given child. The
/// element must have zero or one children. If `childId` is `null`, removes
/// the current child. If `childId` is `undefined`, does nothing.
replaceOnlyChild(
latentComponents: Set<ComponentBase>,
childId: null | undefined | ComponentId,
parentElement: HTMLElement = this.element
): void {
// If undefined, do nothing
if (childId === undefined) {
return;
}
// If null, remove the current child
let currentChildElement: Element | null = null;
for (let child of iterChildElements(parentElement)) {
currentChildElement = child;
break;
}
if (childId === null) {
if (currentChildElement !== null) {
let child = getComponentByElement(currentChildElement);
currentChildElement.remove();
child.unparent(latentComponents);
}
console.assert(parentElement.firstElementChild === null);
return;
}
// If a child already exists, either move it to the latent container or
// leave it alone if it's already the correct element
if (currentChildElement !== null) {
let child = getComponentByElement(currentChildElement);
// Don't reparent the child if not necessary. This way things like
// keyboard focus are preserved
if (child.id === childId) {
return;
}
currentChildElement.remove();
child.unparent(latentComponents);
}
// Add the replacement component
let child = componentsById[childId]!;
parentElement.appendChild(child.outerElement);
this.registerChild(latentComponents, child);
}
/// Replaces all children of the given HTML element with the given children.
/// If `childIds` is `undefined`, does nothing.
///
/// If `wrapInDivs` is true, each child is wrapped in a `<div>` element.
/// This also requires any existing children to be wrapped in `<div>`s.
replaceChildren(
latentComponents: Set<ComponentBase>,
childIds: undefined | ComponentId[],
parentElement: HTMLElement = this.element,
wrapInDivs: boolean = false
): void {
// If undefined, do nothing
if (childIds === undefined) {
return;
}
let childElementIter = iterChildElements(parentElement);
let curElement = childElementIter.next().value;
let children = childIds.map((id) => componentsById[id]!);
let curIndex = 0;
// Since children are being moved between parents, it's possible for
// some empty wrappers to persist. In that case `unwrap` will return
// `null`.
let wrap: (element: HTMLElement) => Element;
let unwrap: (element: Element) => HTMLElement | null;
if (wrapInDivs) {
wrap = (element: HTMLElement) => {
let wrapper = document.createElement('div');
wrapper.classList.add('rio-child-wrapper');
wrapper.appendChild(element);
return wrapper;
};
unwrap = (element: Element) =>
element.firstElementChild as HTMLElement | null;
} else {
wrap = (element: HTMLElement) => element;
unwrap = (element: Element) => element as HTMLElement;
}
while (true) {
// If there are no more children in the DOM element, add the
// remaining children
if (curElement === null) {
while (curIndex < children.length) {
let child = children[curIndex];
parentElement.appendChild(wrap(child.outerElement));
this.registerChild(latentComponents, child);
curIndex++;
}
break;
}
// If there are no more children in the message, remove the
// remaining DOM children
if (curIndex >= children.length) {
while (curElement !== null) {
curElement.remove();
let childElement = unwrap(curElement);
if (childElement !== null) {
let child = getComponentByElement(childElement);
child.unparent(latentComponents);
}
curElement = childElementIter.next().value;
}
break;
}
// If this was just an empty wrapper element, remove it and move on
// to the next element
let childElement = unwrap(curElement);
if (childElement === null) {
curElement.remove();
curElement = childElementIter.next().value;
continue;
}
// If this element is the correct element, move on
let curChild = getComponentByElement(childElement);
let expectedChild = children[curIndex];
if (curChild === expectedChild) {
curElement = childElementIter.next().value;
curIndex++;
continue;
}
// This element is not the correct element, insert the correct one
// instead
parentElement.insertBefore(
wrap(expectedChild.outerElement),
curElement
);
this.registerChild(latentComponents, expectedChild);
curIndex++;
}
}
/// Creates the HTML element associated with this component. This function does
/// not attach the element to the DOM, but merely returns it.
protected abstract createElement(): HTMLElement;
/// This method is called when a component is about to be removed from the
/// component tree. It can be used for cleaning up event handlers and helper
/// HTML elements (like popups).
onDestruction(): void {
for (let handler of this._eventHandlers) {
handler.disconnect();
}
}
/// Send a message to the python instance corresponding to this component. The
/// message is an arbitrary JSON object and will be passed to the instance's
/// `_on_message` method.
sendMessageToBackend(message: object): void {
callRemoteMethodDiscardResponse('componentMessage', {
componentId: this.id,
payload: message,
});
}
_setStateDontNotifyBackend(deltaState: object): void {
// Trigger an update
this.updateElement(deltaState, null as any as Set<ComponentBase>);
// Set the state
this.state = {
...this.state,
...deltaState,
};
// Notify the dev tools, if any
if (devToolsConnector !== null) {
devToolsConnector.afterComponentStateChange({
[this.id]: deltaState,
});
}
}
setStateAndNotifyBackend(deltaState: object): void {
// Set the state. This also updates the component
this._setStateDontNotifyBackend(deltaState);
// Notify the backend
callRemoteMethodDiscardResponse('componentStateUpdate', {
componentId: this.id,
deltaState: deltaState,
});
}
addClickHandler(args: ClickHandlerArguments): ClickHandler {
return new ClickHandler(this, args);
}
addDragHandler(args: DragHandlerArguments): DragHandler {
return new DragHandler(this, args);
}
toString(): string {
let class_name = this.constructor.name;
return `<${class_name} id:${this.id}>`;
}
}
globalThis.RIO_COMPONENT_BASE = ComponentBase;
/// Iterates over an element's children, but ignores elements that have the
/// `rio-not-a-child-component` class. This is used by e.g. the RippleEffect.
function* iterChildElements(parentElement: Element) {
// Since `replaceChildren` removes elements from the DOM, it messes up the
// iteration for us. So we'll first store the elements in an array, and then
// yield them.
//
// Yes, I know this function is pretty weird, but the upside is that
// `replaceChildren` is neater in exchange.
let children: Element[] = [];
let element = parentElement.firstElementChild;
while (element !== null) {
if (!element.classList.contains('rio-not-a-child-component')) {
children.push(element);
}
element = element.nextElementSibling;
}
for (let element of children) {
yield element;
}
return null; // Return instead of yield to shut up the type checker
}