import { componentsById } from '../componentManagement'; import { applyIcon } from '../designApplication'; import { ComponentBase, ComponentState } 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 { state: Required; private highlighter = new Highlighter(); private nodesByComponent: WeakMap = 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!.componentIdsToComponentTrees.set(this.id, 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!.componentIdsToComponentTrees.delete(this.id); // Destroy the highlighter this.highlighter.destroy(); } updateElement( deltaState: ComponentTreeState, latentComponents: Set ): 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 / 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', }); } 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'); } 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: 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 { // Make a Promise that will resolve once the user has clicked something let resolvePromise: (value: any) => void; let donePicking = new Promise((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('mousemove', onMouseMove, true); window.removeEventListener('click', onClick, true); window.removeEventListener('mousedown', markEventAsHandled, true); window.removeEventListener('mouseup', markEventAsHandled, true); resolvePromise(undefined); }; document.documentElement.classList.add('picking-component'); window.addEventListener('mousemove', onMouseMove, true); window.addEventListener('click', onClick, true); window.addEventListener('mousedown', markEventAsHandled, true); window.addEventListener('mouseup', 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; } }