mirror of
https://github.com/rio-labs/rio.git
synced 2025-12-30 17:59:51 -06:00
441 lines
17 KiB
TypeScript
441 lines
17 KiB
TypeScript
import { ButtonComponent, IconButtonComponent } from "./components/buttons";
|
|
import { CalendarComponent } from "./components/calendar";
|
|
import { callRemoteMethodDiscardResponse } from "./rpc";
|
|
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/dialogContainer";
|
|
import { DrawerComponent } from "./components/drawer";
|
|
import { DropdownComponent } from "./components/dropdown";
|
|
import { ErrorPlaceholderComponent } from "./components/errorPlaceholder";
|
|
import { FilePickerAreaComponent } from "./components/filePickerArea";
|
|
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 { 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 { PointerEventListenerComponent } from "./components/pointerEventListener";
|
|
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 { WebviewComponent } from "./components/webview";
|
|
import { GraphEditorComponent } from "./components/graphEditor/graphEditor";
|
|
|
|
const COMPONENT_CLASSES = {
|
|
"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,
|
|
"ErrorPlaceholder-builtin": ErrorPlaceholderComponent,
|
|
"FilePickerArea-builtin": FilePickerAreaComponent,
|
|
"FlowContainer-builtin": FlowContainerComponent,
|
|
"FundamentalRootComponent-builtin": FundamentalRootComponent,
|
|
"GraphEditor-builtin": GraphEditorComponent,
|
|
"Grid-builtin": GridComponent,
|
|
"HeadingListItem-builtin": HeadingListItemComponent,
|
|
"HighLevelComponent-builtin": HighLevelComponent,
|
|
"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,
|
|
"PointerEventListener-builtin": PointerEventListenerComponent,
|
|
"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,
|
|
"Webview-builtin": WebviewComponent,
|
|
};
|
|
|
|
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) {
|
|
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);
|
|
|
|
// 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
|
|
component.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();
|
|
}
|
|
}
|