import { componentsById, getRootScroller } from '../componentManagement'; import { applyIcon } from '../designApplication'; import { ComponentId } from '../dataModels'; import { ComponentBase, ComponentState } from './componentBase'; import { DevToolsConnectorComponent } from './devToolsConnector'; export type ComponentTreeState = ComponentState & { _type_: 'ComponentTree-builtin'; }; export class ComponentTreeComponent extends ComponentBase { state: Required; /// When a component should be highlighted to the user, this HTML element /// does the highlighting. private highlighterElement: HTMLElement; private selectedComponentId: ComponentId | null = null; private nodesByComponent: WeakMap = 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 dbg: DevToolsConnectorComponent = globalThis.RIO_DEV_TOOLS; console.assert(dbg !== null); dbg.componentTree = 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. requestAnimationFrame(() => { this.buildTree(); }); // Add the highlighter and hide it this.highlighterElement = document.createElement('div'); this.highlighterElement.classList.add( 'rio-dev-tools-component-highlighter' ); document.body.appendChild(this.highlighterElement); this.moveHighlighterTo(null); return element; } onDestruction(): void { // Unregister this component from the global dev tools component let dbg: DevToolsConnectorComponent = globalThis.RIO_DEV_TOOLS; console.assert(dbg !== null); dbg.componentTree = null; // Remove any highlighters this.highlighterElement.remove(); } updateElement( deltaState: ComponentState, latentComponents: Set ): void {} /// 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 = this.selectedComponentId === null ? undefined : componentsById[this.selectedComponentId]; if (selectedComponent !== undefined) { return selectedComponent; } // Default to the root let result = this.getDisplayedRootComponent(); this.setSelectedComponent(result); return result; } /// Stores the currently selected component, without updating any UI. Also /// notifies the backend. setSelectedComponent(component: ComponentBase) { this.selectedComponentId = component.id; this.sendMessageToBackend({ selectedComponentId: this.selectedComponentId, }); } /// Many of the spawned components are internal to Rio and shouldn't be /// displayed to the user. This function makes that determination. shouldDisplayComponent(comp: ComponentBase): boolean { return !comp.state._rio_internal_; } _drillDown(comp: ComponentBase): ComponentBase[] { // Is this component displayable? if (this.shouldDisplayComponent(comp)) { return [comp]; } // No, drill down let result: ComponentBase[] = []; for (let child of comp.children) { result.push(...this._drillDown(child)); } return result; } /// Given a component, return all of its children which should be displayed /// in the tree. getDisplayableChildren(comp: ComponentBase): ComponentBase[] { let result: ComponentBase[] = []; // Keep drilling down until a component which should be displayed // is encountered for (let child of comp.children) { result.push(...this._drillDown(child)); } return result; } /// Return the root component, but take care to discard any rio internal /// components. getDisplayedRootComponent(): ComponentBase { let rootScroller = getRootScroller(); let userRoot = componentsById[rootScroller.state.content]!; return userRoot; } buildTree(): void { // Get the rootmost displayed component let rootComponent = this.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 = this.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) => { event.stopPropagation(); // Select the component this.setSelectedComponent(component); // 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 let componentElement = component.element; componentElement.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest', }); }); // Highlight the actual component when the element is hovered header.addEventListener('mouseover', (event) => { this.moveHighlighterTo(component); event.stopPropagation(); }); // Remove any highlighters when the element is unhovered node.addEventListener('mouseout', (event) => { this.moveHighlighterTo(null); event.stopPropagation(); }); return node; } /// Transition the highlighter to the given component. If the component is /// `null`, transition it out. moveHighlighterTo(component: ComponentBase | null) { // If no component is to be highlighted, make the highlighter the size // of the window, effectively hiding it. Overshoot by a bit to make sure // the highlighter's pulse animation doesn't make it visible by // accident. let left, top, width, height; if (component === null) { left = -10; top = -10; width = window.innerWidth + 20; height = window.innerHeight + 20; } else { let componentElement = component.element; let rect = componentElement.getBoundingClientRect(); left = rect.left; top = rect.top; width = rect.width; height = rect.height; } // Move the highlighter this.highlighterElement.style.top = `${top}px`; this.highlighterElement.style.left = `${left}px`; this.highlighterElement.style.width = `${width}px`; this.highlighterElement.style.height = `${height}px`; } 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 = this.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(); } public afterComponentStateChange(deltaStates: { [componentId: string]: { [key: string]: any }; }) { // Look for components whose children 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); } }