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

450 lines
17 KiB
TypeScript

import { AspectRatioContainerComponent } from './components/aspectRatioContainer';
import { BuildFailedComponent } from './components/buildFailed';
import { ButtonComponent, IconButtonComponent } from './components/buttons';
import { CalendarComponent } from './components/calendar';
import { CardComponent } from './components/card';
import { CheckboxComponent } from './components/checkbox';
import { ClassContainerComponent } from './components/classContainer';
import { CodeBlockComponent } from './components/codeBlock';
import { CodeExplorerComponent } from './components/codeExplorer';
import { ColorPickerComponent } from './components/colorPicker';
import { ColumnComponent, RowComponent } from './components/linearContainers';
import { ComponentBase, ComponentState } from './components/componentBase';
import { ComponentId } from './dataModels';
import { ComponentPickerComponent } from './components/componentPicker';
import { ComponentTreeComponent } from './components/componentTree';
import { CustomListItemComponent } from './components/customListItem';
import { devToolsConnector } from './app';
import { DevToolsConnectorComponent } from './components/devToolsConnector';
import { DialogContainerComponent } from './components/dialog_container';
import { DrawerComponent } from './components/drawer';
import { DropdownComponent } from './components/dropdown';
import { FlowComponent as FlowContainerComponent } from './components/flowContainer';
import { FundamentalRootComponent } from './components/fundamentalRootComponent';
import { GridComponent } from './components/grid';
import { HeadingListItemComponent } from './components/headingListItem';
import { HighLevelComponent as HighLevelComponent } from './components/highLevelComponent';
import { HtmlComponent } from './components/html';
import { IconComponent } from './components/icon';
import { ImageComponent } from './components/image';
import { KeyEventListenerComponent } from './components/keyEventListener';
import { LayoutDisplayComponent } from './components/layoutDisplay';
import { LinkComponent } from './components/link';
import { ListViewComponent } from './components/listView';
import { MarkdownComponent } from './components/markdown';
import { MediaPlayerComponent } from './components/mediaPlayer';
import { MouseEventListenerComponent } from './components/mouseEventListener';
import { MultiLineTextInputComponent } from './components/multiLineTextInput';
import { NodeInputComponent } from './components/nodeInput';
import { NodeOutputComponent } from './components/nodeOutput';
import { OverlayComponent } from './components/overlay';
import { PlotComponent } from './components/plot';
import { PopupComponent } from './components/popup';
import { ProgressBarComponent } from './components/progressBar';
import { ProgressCircleComponent } from './components/progressCircle';
import { RectangleComponent } from './components/rectangle';
import { reprElement, scrollToUrlFragment } from './utils';
import { RevealerComponent } from './components/revealer';
import { ScrollContainerComponent } from './components/scrollContainer';
import { ScrollTargetComponent } from './components/scrollTarget';
import { SeparatorComponent } from './components/separator';
import { SeparatorListItemComponent } from './components/separatorListItem';
import { SliderComponent } from './components/slider';
import { SlideshowComponent } from './components/slideshow';
import { StackComponent } from './components/stack';
import { SwitchComponent } from './components/switch';
import { SwitcherBarComponent } from './components/switcherBar';
import { SwitcherComponent } from './components/switcher';
import { TableComponent } from './components/table';
import { TextComponent } from './components/text';
import { TextInputComponent } from './components/textInput';
import { ThemeContextSwitcherComponent } from './components/themeContextSwitcher';
import { TooltipComponent } from './components/tooltip';
import { callRemoteMethodDiscardResponse } from './rpc';
const COMPONENT_CLASSES = {
'AspectRatioContainer-builtin': AspectRatioContainerComponent,
'BuildFailed-builtin': BuildFailedComponent,
'Button-builtin': ButtonComponent,
'Calendar-builtin': CalendarComponent,
'Card-builtin': CardComponent,
'Checkbox-builtin': CheckboxComponent,
'ClassContainer-builtin': ClassContainerComponent,
'CodeBlock-builtin': CodeBlockComponent,
'CodeExplorer-builtin': CodeExplorerComponent,
'ColorPicker-builtin': ColorPickerComponent,
'Column-builtin': ColumnComponent,
'ComponentPicker-builtin': ComponentPickerComponent,
'ComponentTree-builtin': ComponentTreeComponent,
'CustomListItem-builtin': CustomListItemComponent,
'DevToolsConnector-builtin': DevToolsConnectorComponent,
'DialogContainer-builtin': DialogContainerComponent,
'Drawer-builtin': DrawerComponent,
'Dropdown-builtin': DropdownComponent,
'FlowContainer-builtin': FlowContainerComponent,
'FundamentalRootComponent-builtin': FundamentalRootComponent,
'Grid-builtin': GridComponent,
'HeadingListItem-builtin': HeadingListItemComponent,
'HighLevelComponent-builtin': HighLevelComponent,
'Html-builtin': HtmlComponent,
'Icon-builtin': IconComponent,
'IconButton-builtin': IconButtonComponent,
'Image-builtin': ImageComponent,
'KeyEventListener-builtin': KeyEventListenerComponent,
'LayoutDisplay-builtin': LayoutDisplayComponent,
'Link-builtin': LinkComponent,
'ListView-builtin': ListViewComponent,
'Markdown-builtin': MarkdownComponent,
'MediaPlayer-builtin': MediaPlayerComponent,
'MouseEventListener-builtin': MouseEventListenerComponent,
'MultiLineTextInput-builtin': MultiLineTextInputComponent,
'NodeInput-builtin': NodeInputComponent,
'NodeOutput-builtin': NodeOutputComponent,
'Overlay-builtin': OverlayComponent,
'Plot-builtin': PlotComponent,
'Popup-builtin': PopupComponent,
'ProgressBar-builtin': ProgressBarComponent,
'ProgressCircle-builtin': ProgressCircleComponent,
'Rectangle-builtin': RectangleComponent,
'Revealer-builtin': RevealerComponent,
'Row-builtin': RowComponent,
'ScrollContainer-builtin': ScrollContainerComponent,
'ScrollTarget-builtin': ScrollTargetComponent,
'Separator-builtin': SeparatorComponent,
'SeparatorListItem-builtin': SeparatorListItemComponent,
'Slider-builtin': SliderComponent,
'Slideshow-builtin': SlideshowComponent,
'Stack-builtin': StackComponent,
'Switch-builtin': SwitchComponent,
'Switcher-builtin': SwitcherComponent,
'SwitcherBar-builtin': SwitcherBarComponent,
'Table-builtin': TableComponent,
'Text-builtin': TextComponent,
'TextInput-builtin': TextInputComponent,
'ThemeContextSwitcher-builtin': ThemeContextSwitcherComponent,
'Tooltip-builtin': TooltipComponent,
};
globalThis.COMPONENT_CLASSES = COMPONENT_CLASSES;
export const componentsById: { [id: ComponentId]: ComponentBase | undefined } =
{};
export const componentsByElement = new Map<HTMLElement, ComponentBase>();
let fundamentalRootComponent: FundamentalRootComponent | null = null;
export function getRootComponent(): FundamentalRootComponent {
if (fundamentalRootComponent === null) {
throw new Error('There is no root component yet');
}
return fundamentalRootComponent;
}
export function getComponentByElement(element: Element): ComponentBase {
let instance = tryGetComponentByElement(element);
if (instance === null) {
// Just displaying the element itself isn't quite enough information for
// debugging. We'll go up the tree until we find an element that belongs
// to a component, and include that in the error message.
let elem: Element | null = element.parentElement;
while (elem) {
instance = tryGetComponentByElement(elem);
if (instance !== null) {
throw `Element ${reprElement(
element
)} does not correspond to a component. It is a child element of ${instance.toString()}`;
}
elem = elem.parentElement;
}
throw `Element ${reprElement(
element
)} does not correspond to a component (and none of its parent elements correspond to a component, either)`;
}
return instance;
}
globalThis.componentsById = componentsById; // For debugging
globalThis.getInstanceByElement = getComponentByElement; // For debugging
export function tryGetComponentByElement(
element: Element
): ComponentBase | null {
let component = componentsByElement.get(element as HTMLElement);
if (component !== undefined) {
return component;
}
// Components may create additional HTML elements for layouting purposes
// (alignment, scrolling, ...), so check if this is such an element
if (element instanceof HTMLElement) {
let ownerId = element.dataset.ownerId;
if (ownerId !== undefined) {
component = componentsById[ownerId];
if (component !== undefined) {
return component;
}
}
}
return null;
}
export function isComponentElement(element: Element): boolean {
return componentsByElement.has(element as HTMLElement);
}
export function getParentComponentElement(
element: HTMLElement
): HTMLElement | null {
let curElement = element.parentElement;
while (curElement !== null) {
if (isComponentElement(curElement)) {
return curElement;
}
curElement = curElement.parentElement;
}
return null;
}
/// Given a state, return the ids of all its children
export function getChildIds(state: ComponentState): ComponentId[] {
let result: ComponentId[] = [];
let propertyNamesWithChildren =
globalThis.CHILD_ATTRIBUTE_NAMES[state['_type_']!] || [];
for (let propertyName of propertyNamesWithChildren) {
let propertyValue = state[propertyName];
if (Array.isArray(propertyValue)) {
result.push(...propertyValue);
} else if (propertyValue !== null && propertyValue !== undefined) {
result.push(propertyValue);
}
}
return result;
}
export function updateComponentStates(
deltaStates: { [id: string]: ComponentState },
rootComponentId: ComponentId | null
): void {
// Modifying the DOM makes the keyboard focus get lost. Remember which
// element had focus so we can restore it later.
let focusedElement = document.activeElement;
// Find the component that this HTMLElement belongs to
while (focusedElement !== null && !isComponentElement(focusedElement)) {
focusedElement = focusedElement.parentElement;
}
let focusedComponent =
focusedElement === null
? null
: getComponentByElement(focusedElement as HTMLElement);
// Create a set to hold all latent components, so they aren't garbage
// collected while updating the DOM.
let latentComponents = new Set<ComponentBase>();
// Keep track of all components whose `_grow_` changed, because their
// parents have to be notified so they can update their CSS
let growChangedComponents: ComponentBase[] = [];
// Make sure all components mentioned in the message have a corresponding
// HTML element
for (let componentIdAsString in deltaStates) {
let deltaState = deltaStates[componentIdAsString];
let component = componentsById[componentIdAsString];
// This is a reused component, no need to instantiate a new one
if (component) {
// Check if its `_grow_` changed
if (deltaState._grow_ !== undefined) {
if (
deltaState._grow_[0] !== component.state._grow_[0] ||
deltaState._grow_[1] !== component.state._grow_[1]
) {
growChangedComponents.push(component);
}
}
continue;
}
// Get the class for this component
const componentClass = COMPONENT_CLASSES[deltaState._type_!];
// Make sure the component type is valid (Just helpful for debugging)
if (!componentClass) {
throw `Encountered unknown component type: ${deltaState._type_}`;
}
// Create an instance for this component
let newComponent: ComponentBase = new componentClass(
parseInt(componentIdAsString),
deltaState
);
// Register the component for quick and easy lookup
componentsById[componentIdAsString] = newComponent;
componentsByElement.set(newComponent.element, newComponent);
// Store the component's class name in the element. Used for debugging.
newComponent.element.setAttribute(
'dbg-py-class',
deltaState._python_type_!
);
newComponent.element.setAttribute('dbg-id', componentIdAsString);
// Set the component's key, if it has one. Used for debugging.
let key = deltaState['key'];
if (key !== undefined) {
newComponent.element.setAttribute('dbg-key', `${key}`);
}
}
// Some components, like Overlays, need access to the root component. If it
// changed, assign it to our global variable.
if (rootComponentId !== null) {
fundamentalRootComponent = componentsById[
rootComponentId
] as FundamentalRootComponent;
}
// Update all components mentioned in the message
for (let id in deltaStates) {
let deltaState = deltaStates[id];
let component: ComponentBase = componentsById[id]!;
// Perform updates specific to this component type
component.updateElement(deltaState, latentComponents);
// Update the component's state
component.state = {
...component.state,
...deltaState,
};
}
// Notify the parents of all elements whose `_grow_` changed to update their
// CSS
let parents = new Set<ComponentBase>();
for (let child of growChangedComponents) {
parents.add(child.parent!);
}
for (let parent of parents) {
parent.onChildGrowChanged();
}
// Restore the keyboard focus
if (focusedComponent !== null) {
restoreKeyboardFocus(focusedComponent, latentComponents);
}
// Remove the latent components
for (let component of latentComponents) {
// Dialog containers aren't part of the component tree, so they falsely
// appear as latent. Don't destroy them.
if (component instanceof DialogContainerComponent) {
continue;
}
recursivelyDeleteComponent(component);
}
// If this is the first time, check if there's an #url-fragment and scroll
// to it
if (rootComponentId !== null) {
let rootComponent = componentsById[rootComponentId]!;
document.body.appendChild(rootComponent.element);
scrollToUrlFragment('instant');
}
// Notify the dev tools, if any
if (devToolsConnector !== null) {
devToolsConnector.afterComponentStateChange(deltaStates);
}
}
export function recursivelyDeleteComponent(component: ComponentBase): void {
let to_do = [component];
for (let comp of to_do) {
// Make sure the children will be cleaned up as well
to_do.push(...comp.children);
// If this component had any dialogs attached, they must also go
for (let dialog_container of comp.ownedDialogs) {
to_do.push(dialog_container);
// Inform Python about the destruction of the dialog
callRemoteMethodDiscardResponse('dialogClosed', {
dialogRootComponentId: dialog_container.id,
});
}
// Inform the component of its impending doom
comp.onDestruction();
// Remove it from the global lookup tables
delete componentsById[comp.id];
componentsByElement.delete(comp.element);
// And finally, remove it from the DOM
comp.element.remove();
}
}
function canHaveKeyboardFocus(instance: ComponentBase): boolean {
// @ts-expect-error
return typeof instance.grabKeyboardFocus === 'function';
}
function restoreKeyboardFocus(
focusedComponent: ComponentBase,
latentComponents: Set<ComponentBase>
): void {
// The elements that are about to die still know the id of the parent from
// which they were just removed. We'll go up the tree until we find a parent
// that can accept the keyboard focus.
//
// Keep in mind that we have to traverse the component tree all the way up to
// the root. Because even if a component still has a parent, the parent itself
// might be about to die.
let rootComponent = getRootComponent();
let current = focusedComponent;
let winner: ComponentBase | null = null;
while (current !== rootComponent) {
// If this component is dead, no child of it can get the keyboard focus
if (latentComponents.has(current)) {
winner = null;
}
// If we don't currently know of a focusable (and live) component, check
// if this one fits the bill
else if (winner === null && canHaveKeyboardFocus(current)) {
winner = current;
}
current = current.parent!;
}
// We made it to the root. Do we have a winner?
if (winner !== null) {
// @ts-expect-error
winner.grabKeyboardFocus();
}
}