Files
rio/frontend/code/componentManagement.ts
2024-04-12 22:02:07 +02:00

557 lines
20 KiB
TypeScript

import { AlignComponent } from './components/align';
import { BuildFailedComponent } from './components/buildFailed';
import { ButtonComponent } from './components/button';
import { CardComponent } from './components/card';
import { ClassContainerComponent } from './components/classContainer';
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 './models';
import { ComponentTreeComponent } from './components/componentTree';
import { CustomListItemComponent } from './components/customListItem';
import { DebuggerConnectorComponent } from './components/debuggerConnector';
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 { HtmlComponent } from './components/html';
import { IconComponent } from './components/icon';
import { ImageComponent } from './components/image';
import { KeyEventListenerComponent } from './components/keyEventListener';
import { LinkComponent } from './components/link';
import { ListViewComponent } from './components/listView';
import { MarginComponent } from './components/margin';
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 { PlaceholderComponent } from './components/placeholder';
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 } 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 { updateLayout } from './layouting';
const COMPONENT_CLASSES = {
'Align-builtin': AlignComponent,
'BuildFailed-builtin': BuildFailedComponent,
'Button-builtin': ButtonComponent,
'Card-builtin': CardComponent,
'ClassContainer-builtin': ClassContainerComponent,
'CodeExplorer-builtin': CodeExplorerComponent,
'ColorPicker-builtin': ColorPickerComponent,
'Column-builtin': ColumnComponent,
'ComponentTree-builtin': ComponentTreeComponent,
'CustomListItem-builtin': CustomListItemComponent,
'DebuggerConnector-builtin': DebuggerConnectorComponent,
'Drawer-builtin': DrawerComponent,
'Dropdown-builtin': DropdownComponent,
'FlowContainer-builtin': FlowContainerComponent,
'FundamentalRootComponent-builtin': FundamentalRootComponent,
'Grid-builtin': GridComponent,
'HeadingListItem-builtin': HeadingListItemComponent,
'Html-builtin': HtmlComponent,
'Icon-builtin': IconComponent,
'Image-builtin': ImageComponent,
'KeyEventListener-builtin': KeyEventListenerComponent,
'Link-builtin': LinkComponent,
'ListView-builtin': ListViewComponent,
'Margin-builtin': MarginComponent,
'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,
Placeholder: PlaceholderComponent,
};
globalThis.COMPONENT_CLASSES = COMPONENT_CLASSES;
export const componentsById: { [id: ComponentId]: ComponentBase | undefined } =
{};
export const componentsByElement = new Map<HTMLElement, ComponentBase>();
export function getRootComponent(): FundamentalRootComponent {
let element = document.body.querySelector(
'.rio-fundamental-root-component'
);
console.assert(
element !== null,
"Couldn't find the root component in the document body"
);
return componentsByElement.get(
element as HTMLElement
) as FundamentalRootComponent;
}
export function getRootScroller(): ScrollContainerComponent {
let rootComponent = getRootComponent();
return componentsById[
rootComponent.state.content
] as ScrollContainerComponent;
}
globalThis.getRootScroller = getRootScroller; // Used to scroll up after navigating to a different page
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 {
return componentsByElement.get(element as HTMLElement) ?? null;
}
export function isComponentElement(element: Element): boolean {
return componentsByElement.has(element as HTMLElement);
}
export function getParentComponentElementIncludingInjected(
element: HTMLElement
): HTMLElement | null {
let curElement = element.parentElement;
while (curElement !== null) {
if (isComponentElement(curElement)) {
return curElement;
}
curElement = curElement.parentElement;
}
return null;
}
function getCurrentComponentState(
id: ComponentId,
deltaState: ComponentState
): ComponentState {
let instance = componentsById[id];
if (instance === undefined) {
return deltaState;
}
return {
...instance.state,
...deltaState,
};
}
function createLayoutComponentStates(
componentId: ComponentId,
message: { [id: string]: ComponentState }
): ComponentId {
let deltaState = message[componentId] || {};
let entireState = getCurrentComponentState(componentId, deltaState);
let resultId = componentId;
// Margin
let margin = entireState['_margin_']!;
if (margin === undefined) {
console.error(`Got incomplete state for component ${componentId}`);
}
if (
margin[0] !== 0 ||
margin[1] !== 0 ||
margin[2] !== 0 ||
margin[3] !== 0
) {
let marginId = (componentId * -10) as ComponentId;
message[marginId] = {
_type_: 'Margin-builtin',
_python_type_: 'Margin (injected)',
_key_: null,
_margin_: [0, 0, 0, 0],
_size_: [0, 0],
_grow_: entireState['_grow_'],
_rio_internal_: true,
// @ts-ignore
content: resultId,
margin_left: margin[0],
margin_top: margin[1],
margin_right: margin[2],
margin_bottom: margin[3],
};
resultId = marginId;
}
// Align
let align = entireState['_align_']!;
if (align === undefined) {
console.error(`Got incomplete state for component ${componentId}`);
}
if (align[0] !== null || align[1] !== null) {
let alignId = (componentId * -10 - 1) as ComponentId;
message[alignId] = {
_type_: 'Align-builtin',
_python_type_: 'Align (injected)',
_key_: null,
_margin_: [0, 0, 0, 0],
_size_: entireState['_size_'],
_grow_: entireState['_grow_'],
_rio_internal_: true,
// @ts-ignore
content: resultId,
align_x: align[0],
align_y: align[1],
};
resultId = alignId;
}
return resultId;
}
/// 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;
}
function replaceChildrenWithLayoutComponents(
deltaState: ComponentState,
childIds: Set<ComponentId>,
message: { [id: string]: ComponentState }
): void {
let propertyNamesWithChildren =
globalThis.CHILD_ATTRIBUTE_NAMES[deltaState['_type_']!] || [];
function uninjectedId(id: ComponentId): ComponentId {
if (id >= 0) {
return id;
}
return Math.floor(id / -10) as ComponentId;
}
for (let propertyName of propertyNamesWithChildren) {
let propertyValue = deltaState[propertyName] as
| ComponentId[]
| ComponentId
| null
| undefined;
if (Array.isArray(propertyValue)) {
deltaState[propertyName] = propertyValue.map(
(childId: ComponentId): ComponentId => {
childId = uninjectedId(childId);
childIds.add(childId);
return createLayoutComponentStates(childId, message);
}
);
} else if (propertyValue !== null && propertyValue !== undefined) {
let childId = uninjectedId(propertyValue);
deltaState[propertyName] = createLayoutComponentStates(
childId,
message
);
childIds.add(childId);
}
}
}
function preprocessDeltaStates(message: {
[id: string]: ComponentState;
}): void {
// Fortunately the root component is created internally by the server, so we
// don't need to worry about it having a margin or alignment.
let originalComponentIds = Object.keys(message).map((id) =>
parseInt(id)
) as ComponentId[];
// Keep track of which components have their parents in the message
let childIds: Set<ComponentId> = new Set();
// Walk over all components in the message and inject layout components. The
// message is modified in-place, so take care to have a copy of all keys
// (`originalComponentIds`)
for (let componentId of originalComponentIds) {
replaceChildrenWithLayoutComponents(
message[componentId],
childIds,
message
);
}
// Find all components which have had a layout component injected, and make
// sure their parents are updated to point to the new component.
for (let componentId of originalComponentIds) {
// Child of another component in the message
if (childIds.has(componentId)) {
continue;
}
// The parent isn't contained in the message. Find and add it.
let child = componentsById[componentId];
if (child === undefined) {
continue;
}
let parent = child.getParentExcludingInjected();
if (parent === null) {
continue;
}
let newParentState = { ...parent.state };
replaceChildrenWithLayoutComponents(newParentState, childIds, message);
message[parent.id] = newParentState;
}
}
export function updateComponentStates(
deltaStates: { [id: string]: ComponentState },
rootComponentId: ComponentId | null
): void {
// Preprocess the message. This converts `_align_` and `_margin_` properties
// into actual components, amongst other things.
preprocessDeltaStates(deltaStates);
// 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 HTML element to hold all latent components, so they aren't
// garbage collected while updating the DOM.
let latentComponents = new Set<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) {
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}`);
}
}
// 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);
// If the component's width or height has changed, request a re-layout.
let width_changed =
Math.abs(deltaState._size_![0] - component.state._size_[0]) > 1e-6;
let height_changed =
Math.abs(deltaState._size_![1] - component.state._size_[1]) > 1e-6;
if (width_changed || height_changed) {
console.log(
`Triggering re-layout because component #${id} changed size: ${component.state._size_} -> ${deltaState._size_}`
);
component.makeLayoutDirty();
}
// Update the component's state
component.state = {
...component.state,
...deltaState,
};
}
// Set the root component if necessary
if (rootComponentId !== null) {
let rootElement = componentsById[rootComponentId]!.element;
document.body.appendChild(rootElement);
}
// Restore the keyboard focus
if (focusedComponent !== null) {
restoreKeyboardFocus(focusedComponent, latentComponents);
}
// Remove the latent components
for (let component of latentComponents) {
// Destruct the component and all its children
let queue = [component];
for (let comp of queue) {
queue.push(...comp.children);
comp.onDestruction();
delete componentsById[comp.id];
componentsByElement.delete(comp.element);
}
}
// Update the layout
updateLayout();
}
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();
}
}