mirror of
https://github.com/rio-labs/rio.git
synced 2025-12-31 10:19:43 -06:00
506 lines
17 KiB
TypeScript
506 lines
17 KiB
TypeScript
import { componentsById } from "../componentManagement";
|
|
import { applyIcon } from "../designApplication";
|
|
import { ComponentBase, ComponentState, DeltaState } from "./componentBase";
|
|
import { Highlighter } from "../highlighter";
|
|
import {
|
|
getDisplayableChildren,
|
|
getDisplayedRootComponent,
|
|
} from "../devToolsTreeWalk";
|
|
import { markEventAsHandled } from "../eventHandling";
|
|
import { devToolsConnector } from "../app";
|
|
import { findComponentUnderMouse } from "../utils";
|
|
|
|
export type ComponentTreeState = ComponentState & {
|
|
_type_: "ComponentTree-builtin";
|
|
component_id: number;
|
|
};
|
|
|
|
export class ComponentTreeComponent extends ComponentBase<ComponentTreeState> {
|
|
private highlighter = new Highlighter();
|
|
|
|
private nodesByComponent: WeakMap<ComponentBase, HTMLElement> =
|
|
new WeakMap();
|
|
|
|
createElement(): HTMLElement {
|
|
// Register this component with the global dev tools component, so it
|
|
// receives updates when a component's state changes.
|
|
console.assert(devToolsConnector !== null, "devToolsConnector is null");
|
|
devToolsConnector!.componentTreeComponent = this;
|
|
|
|
// Spawn the HTML
|
|
let element = document.createElement("div");
|
|
element.classList.add("rio-dev-tools-component-tree");
|
|
|
|
// Populate. This needs to lookup the root component, which isn't in the
|
|
// tree yet.
|
|
setTimeout(() => {
|
|
this.buildTree();
|
|
}, 0);
|
|
|
|
return element;
|
|
}
|
|
|
|
onDestruction(): void {
|
|
super.onDestruction();
|
|
|
|
// Unregister this component from the global dev tools component
|
|
console.assert(devToolsConnector !== null, "devToolsConnector is null");
|
|
devToolsConnector!.componentTreeComponent = null;
|
|
|
|
// Destroy the highlighter
|
|
this.highlighter.destroy();
|
|
}
|
|
|
|
updateElement(
|
|
deltaState: 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.
|
|
private 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;
|
|
}
|
|
|
|
private setSelectedComponent(component: ComponentBase): void {
|
|
// Select the component
|
|
this.setStateAndNotifyBackend({
|
|
component_id: component.id,
|
|
});
|
|
|
|
// Expand all parent nodes
|
|
let parent = component.parent;
|
|
while (parent !== null) {
|
|
this.setNodeExpanded(parent, true, false);
|
|
parent = parent.parent;
|
|
}
|
|
|
|
// Highlight the tree item
|
|
this.highlightSelectedComponent();
|
|
|
|
// Expand the node
|
|
this.setNodeExpanded(component, true);
|
|
|
|
// Scroll to the node
|
|
this.getNodeFor(component).scrollIntoView({
|
|
behavior: "smooth",
|
|
block: "center",
|
|
inline: "center",
|
|
});
|
|
|
|
// Scroll to the element
|
|
component.element.scrollIntoView({
|
|
behavior: "smooth",
|
|
block: "nearest",
|
|
inline: "nearest",
|
|
});
|
|
}
|
|
|
|
private 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);
|
|
}
|
|
|
|
private 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;
|
|
}
|
|
|
|
private 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");
|
|
|
|
// Expand/collapse on click
|
|
iconElement.addEventListener("click", (event: Event) => {
|
|
this.setNodeExpanded(
|
|
component,
|
|
!this.getNodeExpanded(component)
|
|
);
|
|
markEventAsHandled(event);
|
|
});
|
|
}
|
|
|
|
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 iconsAndTooltips: [string, string][] = [];
|
|
|
|
// Icon: Container
|
|
if (children.length <= 1) {
|
|
} else if (children.length > 9) {
|
|
iconsAndTooltips.push([
|
|
"material/filter_9_plus",
|
|
`${children.length} children`,
|
|
]);
|
|
} else {
|
|
iconsAndTooltips.push([
|
|
`material/filter_${children.length}`,
|
|
`${children.length} children`,
|
|
]);
|
|
}
|
|
|
|
// Icon: Key
|
|
if (component.state._key_ !== null) {
|
|
iconsAndTooltips.push([
|
|
"material/key",
|
|
`Key: ${component.state._key_}`,
|
|
]);
|
|
}
|
|
|
|
let spacer = document.createElement("div");
|
|
spacer.style.flexGrow = "1";
|
|
header.appendChild(spacer);
|
|
|
|
for (let [icon, tooltip] of iconsAndTooltips) {
|
|
let iconElement = document.createElement("div");
|
|
iconElement.style.display = "flex"; // Centers the SVG vertically
|
|
header.appendChild(iconElement);
|
|
|
|
iconElement.title = tooltip;
|
|
applyIcon(iconElement, icon);
|
|
}
|
|
|
|
// Click...
|
|
header.addEventListener("click", (event) => {
|
|
markEventAsHandled(event);
|
|
this.setSelectedComponent(component);
|
|
});
|
|
|
|
// 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;
|
|
}
|
|
|
|
private 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;
|
|
}
|
|
|
|
private 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
private 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"
|
|
);
|
|
}
|
|
}
|
|
|
|
private nodeNeedsRebuild(
|
|
component: ComponentBase,
|
|
deltaState: 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;
|
|
}
|
|
|
|
private 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);
|
|
}
|
|
|
|
/// Lets the user select a component in the `ComponentTree` by clicking on
|
|
/// it in the DOM.
|
|
public async pickComponent(): Promise<void> {
|
|
// Make a Promise that will resolve once the user has clicked something
|
|
let resolvePromise: (value: any) => void;
|
|
let donePicking = new Promise<void>((resolve) => {
|
|
resolvePromise = resolve;
|
|
});
|
|
|
|
// Whenever the mouse moves over a different component, move the
|
|
// highlighter there
|
|
let lastHoveredComponent: ComponentBase | null = null;
|
|
|
|
let onMouseMove = (event: MouseEvent) => {
|
|
markEventAsHandled(event);
|
|
|
|
let componentUnderMouse = findComponentUnderMouse(event);
|
|
|
|
if (componentUnderMouse === lastHoveredComponent) {
|
|
return;
|
|
}
|
|
lastHoveredComponent = componentUnderMouse;
|
|
|
|
if (
|
|
componentUnderMouse !== null &&
|
|
this.componentCanBeSelected(componentUnderMouse)
|
|
) {
|
|
this.highlighter.moveTo(componentUnderMouse.element);
|
|
} else {
|
|
this.highlighter.moveTo(null);
|
|
}
|
|
};
|
|
|
|
// On click, select the component under the cursor
|
|
let onClick = (event: MouseEvent) => {
|
|
markEventAsHandled(event);
|
|
|
|
let componentUnderMouse = findComponentUnderMouse(event);
|
|
|
|
if (
|
|
componentUnderMouse !== null &&
|
|
this.componentCanBeSelected(componentUnderMouse)
|
|
) {
|
|
this.setSelectedComponent(componentUnderMouse);
|
|
}
|
|
|
|
document.documentElement.style.removeProperty("pointer");
|
|
this.highlighter.moveTo(null);
|
|
|
|
document.documentElement.classList.remove("picking-component");
|
|
window.removeEventListener("pointermove", onMouseMove, true);
|
|
window.removeEventListener("click", onClick, true);
|
|
window.removeEventListener("pointerdown", markEventAsHandled, true);
|
|
window.removeEventListener("pointerup", markEventAsHandled, true);
|
|
|
|
resolvePromise(undefined);
|
|
};
|
|
|
|
document.documentElement.classList.add("picking-component");
|
|
window.addEventListener("pointermove", onMouseMove, true);
|
|
window.addEventListener("click", onClick, true);
|
|
window.addEventListener("pointerdown", markEventAsHandled, true);
|
|
window.addEventListener("pointerup", markEventAsHandled, true);
|
|
|
|
await donePicking;
|
|
}
|
|
|
|
// Returns whether the given component can be selected. (Components in the
|
|
// dev sidebar can't be selected.)
|
|
private componentCanBeSelected(component: ComponentBase): boolean {
|
|
return this.nodesByComponent.get(component) !== undefined;
|
|
}
|
|
}
|