Files
rio/frontend/code/components/componentTree.ts
2024-06-22 21:58:55 +02:00

407 lines
13 KiB
TypeScript

import { componentsById } from '../componentManagement';
import { applyIcon } from '../designApplication';
import { ComponentBase, ComponentState } from './componentBase';
import { DevToolsConnectorComponent } from './devToolsConnector';
import { Highlighter } from '../highlighter';
import {
getDisplayableChildren,
getDisplayedRootComponent,
} from '../devToolsTreeWalk';
import { markEventAsHandled } from '../eventHandling';
export type ComponentTreeState = ComponentState & {
_type_: 'ComponentTree-builtin';
component_id?: number;
};
export class ComponentTreeComponent extends ComponentBase {
state: Required<ComponentTreeState>;
private highlighter: Highlighter;
private nodesByComponent: WeakMap<ComponentBase, HTMLElement> =
new WeakMap();
// FIXME: Dead entries are never removed from `nodesByComponentId`
createElement(): HTMLElement {
// Register this component with the global dev tools component, so it
// receives updates when a component's state changes.
let devTools: DevToolsConnectorComponent = globalThis.RIO_DEV_TOOLS;
console.assert(devTools !== null);
devTools.componentIdsToComponentTrees.set(this.id, this);
// Spawn the HTML
let element = document.createElement('div');
element.classList.add('rio-dev-tools-component-tree');
// Create the highlighter
this.highlighter = new Highlighter();
// Populate. This needs to lookup the root component, which isn't in the
// tree yet.
setTimeout(() => {
this.buildTree();
}, 0);
return element;
}
onDestruction(): void {
// Unregister this component from the global dev tools component
let devTools: DevToolsConnectorComponent = globalThis.RIO_DEV_TOOLS;
console.assert(devTools !== null);
devTools.componentIdsToComponentTrees.delete(this.id);
// Destroy the highlighter
this.highlighter.destroy();
}
updateElement(
deltaState: ComponentTreeState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
if (deltaState.component_id !== undefined) {
// Highlight the tree item
//
// This might have to lookup other components. Let the state update
// finish first.
setTimeout(() => {
this.highlightSelectedComponent();
}, 0);
}
}
/// Returns the currently selected component. This will impute a sensible
/// default if the selected component no longer exists.
getSelectedComponent(): ComponentBase {
// Does the previously selected component still exist?
let selectedComponent: ComponentBase | undefined =
componentsById[this.state.component_id];
if (selectedComponent !== undefined) {
return selectedComponent;
}
// Default to the root component
let result = getDisplayedRootComponent();
this.setStateAndNotifyBackend({
component_id: result.id,
});
return result;
}
buildTree(): void {
// Get the rootmost displayed component
let rootComponent = getDisplayedRootComponent();
// Build a node for it
this.element.appendChild(this.getNodeFor(rootComponent));
// Attempting to immediately access the just spawned items fails,
// apparently because the browser needs to get control first. Any
// further actions will happen with a delay.
setTimeout(() => {
// Don't start off with a fully collapsed tree
if (!this.getNodeExpanded(rootComponent)) {
this.setNodeExpanded(rootComponent, true);
}
// Highlight the selected component
this.highlightSelectedComponent();
}, 0);
}
getNodeFor(component: ComponentBase): HTMLElement {
let node = this.nodesByComponent.get(component);
if (node !== undefined) {
return node;
}
node = this.buildNode(component);
this.nodesByComponent.set(component, node);
return node;
}
buildNode(component: ComponentBase): HTMLElement {
// Create the element for this item
let node = document.createElement('div');
node.id = `rio-dev-tools-component-tree-item-${component.id}`;
node.classList.add('rio-dev-tools-component-tree-item');
// Create the header
let children = getDisplayableChildren(component);
let header = document.createElement('div');
header.classList.add('rio-dev-tools-component-tree-item-header');
header.textContent = component.state._python_type_;
// Expander arrow, or at least a placeholder for it
let iconElement = document.createElement('div');
iconElement.style.display = 'flex'; // Centers the SVG vertically
header.insertBefore(iconElement, header.firstChild);
if (children.length > 0) {
applyIcon(
iconElement,
'material/keyboard-arrow-right',
'currentColor'
);
}
node.appendChild(header);
// Add the children
let childrenContainer = document.createElement('div');
childrenContainer.classList.add(
'rio-dev-tools-component-tree-item-children'
);
node.appendChild(childrenContainer);
for (let childInfo of children) {
childrenContainer.appendChild(this.getNodeFor(childInfo));
}
// Expand the node, or not
let expanded = this.getNodeExpanded(component);
node.dataset.expanded = `${expanded}`;
node.dataset.hasChildren = `${children.length > 0}`;
// Add icons to give additional information
let icons: string[] = [];
// Icon: Container
if (children.length <= 1) {
} else if (children.length > 9) {
icons.push('material/filter-9-plus');
} else {
icons.push(`material/filter-${children.length}`);
}
// Icon: Key
if (component.state._key_ !== null) {
icons.push('material/key');
}
let spacer = document.createElement('div');
spacer.style.flexGrow = '1';
header.appendChild(spacer);
for (let icon of icons) {
let iconElement = document.createElement('div');
iconElement.style.display = 'flex'; // Centers the SVG vertically
header.appendChild(iconElement);
applyIcon(iconElement, icon, 'currentColor');
}
// Click...
header.addEventListener('click', (event) => {
markEventAsHandled(event);
// Select the component
this.setStateAndNotifyBackend({
component_id: component.id,
});
// Highlight the tree item
this.highlightSelectedComponent();
// Expand / collapse the node's children
let expanded = this.getNodeExpanded(component);
this.setNodeExpanded(component, !expanded);
// Scroll to the element
component.element.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'nearest',
});
});
// Highlight the actual component when the element is hovered
header.addEventListener('mouseover', (event) => {
this.highlighter.moveTo(component.element);
markEventAsHandled(event);
});
// Remove any highlighters when the element is unhovered
node.addEventListener('mouseout', (event) => {
this.highlighter.moveTo(null);
markEventAsHandled(event);
});
return node;
}
getNodeExpanded(instance: ComponentBase): boolean {
// This is monkey-patched directly in the instance to preserve it across
// dev-tools rebuilds.
// @ts-ignore
return instance._rio_dev_tools_tree_expanded_ === true;
}
setNodeExpanded(
component: ComponentBase,
expanded: boolean,
allowRecursion: boolean = true
) {
// Monkey-patch the new value in the instance
// @ts-ignore
component._rio_dev_tools_tree_expanded_ = expanded;
// Get the node element for this instance
let element = document.getElementById(
`rio-dev-tools-component-tree-item-${component.id}`
);
// Expand / collapse its children.
//
// Only do so if there are children, as the additional empty spacing
// looks dumb otherwise.
let children = getDisplayableChildren(component);
if (element !== null) {
element.dataset.expanded = `${expanded}`;
}
// If expanding, and the node only has a single child, expand that child
// as well
if (allowRecursion && expanded) {
if (children.length === 1) {
this.setNodeExpanded(children[0], true);
}
}
}
highlightSelectedComponent() {
// Unhighlight all previously highlighted items
for (let element of Array.from(
document.querySelectorAll(
'.rio-dev-tools-component-tree-item-header-weakly-selected, .rio-dev-tools-component-tree-item-header-strongly-selected'
)
)) {
element.classList.remove(
'rio-dev-tools-component-tree-item-header-weakly-selected',
'rio-dev-tools-component-tree-item-header-strongly-selected'
);
}
// Get the selected component
let selectedComponent = this.getSelectedComponent();
// Find all tree items
let treeItems: HTMLElement[] = [];
let cur: HTMLElement | null = document.getElementById(
`rio-dev-tools-component-tree-item-${selectedComponent.id}`
) as HTMLElement;
while (
cur !== null &&
cur.classList.contains('rio-dev-tools-component-tree-item')
) {
treeItems.push(cur.firstElementChild as HTMLElement);
cur = cur.parentElement!.parentElement;
}
// Strongly select the leafmost item
treeItems[0].classList.add(
'rio-dev-tools-component-tree-item-header-strongly-selected'
);
// Weakly select the rest
for (let i = 1; i < treeItems.length; i++) {
treeItems[i].classList.add(
'rio-dev-tools-component-tree-item-header-weakly-selected'
);
}
}
nodeNeedsRebuild(
component: ComponentBase,
deltaState: ComponentState
): boolean {
if ('key' in deltaState) {
return true;
}
let propertyNamesWithChildren: string[] =
globalThis.CHILD_ATTRIBUTE_NAMES[component.state['_type_']!] || [];
for (let propertyName of propertyNamesWithChildren) {
if (propertyName in deltaState) {
return true;
}
}
return false;
}
rebuildNode(node: HTMLElement, component: ComponentBase): void {
let newNode = this.buildNode(component);
this.nodesByComponent.set(component, newNode);
node.parentElement!.insertBefore(newNode, node);
node.remove();
}
/// Called by the global dev tools component when a component's state
/// changes.
public afterComponentStateChange(deltaStates: {
[componentId: string]: { [key: string]: any };
}) {
// Look for components whose children have changed and rebuild their
// nodes
for (let [componentIdString, deltaState] of Object.entries(
deltaStates
)) {
let component = componentsById[componentIdString]!;
// If we haven't created a node for this component yet, there's no
// need to update it
let node = this.nodesByComponent.get(component);
if (node === undefined) {
continue;
}
if (this.nodeNeedsRebuild(component, deltaState)) {
this.rebuildNode(node, component);
}
}
// The component tree has been modified. Browsers struggle to retrieve
// the new elements immediately, so wait a bit.
setTimeout(() => {
// Flash all changed components
for (let componentId in deltaStates) {
// Get the element. Not everything will show up, since some
// components aren't displayed in the tree (internals).
let element = document.getElementById(
`rio-dev-tools-component-tree-item-rio-id-${componentId}`
);
if (element === null) {
continue;
}
let elementHeader = element.firstElementChild as HTMLElement;
// Flash the font to indicate a change
elementHeader.classList.add(
'rio-dev-tools-component-tree-flash'
);
setTimeout(() => {
elementHeader.classList.remove(
'rio-dev-tools-component-tree-flash'
);
}, 5000);
}
}, 0);
}
}