mirror of
https://github.com/rio-labs/rio.git
synced 2026-01-01 10:49:58 -06:00
366 lines
12 KiB
TypeScript
366 lines
12 KiB
TypeScript
import { ComponentBase, ComponentState, DeltaState } from "./componentBase";
|
|
import { componentsById } from "../componentManagement";
|
|
import { getDisplayableChildren } from "../devToolsTreeWalk";
|
|
import { Highlighter } from "../highlighter";
|
|
import { Debouncer } from "../debouncer";
|
|
import { markEventAsHandled } from "../eventHandling";
|
|
import { pixelsPerRem } from "../app";
|
|
import { getAllocatedHeightInPx, getAllocatedWidthInPx } from "../utils";
|
|
|
|
export type LayoutDisplayState = ComponentState & {
|
|
_type_: "LayoutDisplay-builtin";
|
|
component_id: number;
|
|
max_requested_height: number;
|
|
};
|
|
|
|
export class LayoutDisplayComponent extends ComponentBase<LayoutDisplayState> {
|
|
// Represents the target component's parent. It matches the aspect ratio of
|
|
// the parent and is centered within this component.
|
|
parentElement: HTMLElement;
|
|
|
|
// Helper class for highlighting components
|
|
highlighter: Highlighter;
|
|
|
|
// Keep track of which element the user is currently hovering over
|
|
parentIsHovered: boolean = false;
|
|
hoveredChild: HTMLElement | null = null;
|
|
|
|
// The display has to update when any of the displayed components change
|
|
// size. This are the parameters used to connect/disconnect event handlers
|
|
// for this purpose.
|
|
childrenToWatch: [HTMLElement, () => void][] = [];
|
|
|
|
onChangeLimiter: Debouncer;
|
|
|
|
createElement(): HTMLElement {
|
|
// Initialize the HTML
|
|
let element = document.createElement("div");
|
|
element.classList.add("rio-layout-display");
|
|
|
|
this.parentElement = document.createElement("div");
|
|
this.parentElement.classList.add("rio-layout-display-parent");
|
|
element.appendChild(this.parentElement);
|
|
|
|
// Create the highlighter
|
|
this.highlighter = new Highlighter();
|
|
|
|
// Listen to pointer events
|
|
this.parentElement.onpointerenter = () => {
|
|
this.parentIsHovered = true;
|
|
this.updateHighlighter();
|
|
};
|
|
|
|
this.parentElement.onpointerleave = () => {
|
|
this.parentIsHovered = false;
|
|
this.updateHighlighter();
|
|
};
|
|
|
|
this.parentElement.ondblclick = (event) => {
|
|
markEventAsHandled(event);
|
|
|
|
// Try to find the parent
|
|
let targetComponent: ComponentBase =
|
|
componentsById[this.state.component_id];
|
|
if (targetComponent === undefined) {
|
|
return;
|
|
}
|
|
|
|
if (targetComponent.parent === null) {
|
|
return;
|
|
}
|
|
|
|
// Switch to it
|
|
this.setStateAndNotifyBackend({
|
|
component_id: targetComponent.parent.id,
|
|
});
|
|
};
|
|
|
|
// Create a rate-limited version of the notifyBackendOfChange function
|
|
this.onChangeLimiter = new Debouncer({
|
|
callback: this._notifyBackendOfChange.bind(this),
|
|
});
|
|
|
|
// Initialize the content
|
|
setTimeout(() => {
|
|
this.updateContent();
|
|
}, 0);
|
|
|
|
// Done
|
|
return element;
|
|
}
|
|
|
|
onDestruction(): void {
|
|
super.onDestruction();
|
|
|
|
// Destroy the highlighter
|
|
this.highlighter.destroy();
|
|
}
|
|
|
|
updateElement(
|
|
deltaState: DeltaState<LayoutDisplayState>,
|
|
latentComponents: Set<ComponentBase>
|
|
): void {
|
|
super.updateElement(deltaState, latentComponents);
|
|
|
|
// Has the target component changed?
|
|
if (deltaState.component_id !== undefined) {
|
|
// Update the content
|
|
//
|
|
// This is necessary because the layout update may not trigger a
|
|
// content update if none of the watched children have changed.
|
|
//
|
|
// Also don't do it straight away, because layouting must happen
|
|
// first, and the other components may not even have had time to
|
|
// update yet.
|
|
setTimeout(() => {
|
|
this.updateContent();
|
|
}, 0);
|
|
}
|
|
|
|
// Has the maximum height changed?
|
|
if (deltaState.max_requested_height !== undefined) {
|
|
this.parentElement.style.maxHeight = `${deltaState.max_requested_height}rem`;
|
|
}
|
|
}
|
|
|
|
_notifyBackendOfChange(): void {
|
|
this.sendMessageToBackend({
|
|
type: "layoutChange",
|
|
});
|
|
}
|
|
|
|
disconnectEventHandlers(): void {
|
|
for (let [element, handler] of this.childrenToWatch) {
|
|
element.removeEventListener("resize", handler);
|
|
}
|
|
|
|
this.childrenToWatch = [];
|
|
}
|
|
|
|
listenForSizeChange(element: HTMLElement, handler: () => void): void {
|
|
element.addEventListener("resize", handler);
|
|
}
|
|
|
|
/// Called by the global dev tools connector when a re-layout was just
|
|
/// performed.
|
|
public onLayoutChange(): void {
|
|
// Update the content
|
|
setTimeout(() => {
|
|
this.updateContent();
|
|
}, 0);
|
|
|
|
// Tell the backend about it
|
|
this.onChangeLimiter.call();
|
|
}
|
|
|
|
updateContent(): void {
|
|
// Remove any previous content
|
|
this.parentElement.innerHTML = "";
|
|
|
|
// No longer care about any currently watched children
|
|
this.disconnectEventHandlers();
|
|
|
|
// Get a reference to the target component
|
|
let targetComponent: ComponentBase =
|
|
componentsById[this.state.component_id]!;
|
|
|
|
if (targetComponent === undefined) {
|
|
return;
|
|
}
|
|
|
|
// Look up the parent
|
|
let parentComponent = targetComponent.parent;
|
|
let parentLayout: number[];
|
|
|
|
if (parentComponent === null) {
|
|
parentLayout = [0, 0, window.innerWidth, window.innerHeight];
|
|
} else {
|
|
let parentRect = parentComponent.element.getBoundingClientRect();
|
|
parentLayout = [
|
|
parentRect.left,
|
|
parentRect.top,
|
|
getAllocatedWidthInPx(parentComponent.element),
|
|
getAllocatedHeightInPx(parentComponent.element),
|
|
];
|
|
|
|
this.listenForSizeChange(
|
|
parentComponent.element,
|
|
this.onLayoutChange.bind(this)
|
|
);
|
|
}
|
|
let [
|
|
parentLeftInViewport,
|
|
parentTopInViewport,
|
|
parentAllocatedWidth,
|
|
parentAllocatedHeight,
|
|
] = parentLayout;
|
|
|
|
// Find all siblings
|
|
let children: ComponentBase[];
|
|
|
|
if (parentComponent === null) {
|
|
children = [targetComponent];
|
|
} else {
|
|
children = getDisplayableChildren(parentComponent);
|
|
}
|
|
|
|
// Size the parent element
|
|
let selfAspect =
|
|
getAllocatedWidthInPx(this.element) /
|
|
getAllocatedHeightInPx(this.element);
|
|
let parentAspect = parentAllocatedWidth / parentAllocatedHeight;
|
|
|
|
this.parentElement.style.aspectRatio = `${parentAspect}`;
|
|
|
|
if (selfAspect < parentAspect) {
|
|
this.parentElement.style.width = "100%";
|
|
this.parentElement.style.height = "auto";
|
|
} else {
|
|
this.parentElement.style.width = "auto";
|
|
this.parentElement.style.height = "100%";
|
|
}
|
|
|
|
// Decide on a scale. Display everything as large as possible, while
|
|
// fitting it into the allocated space and without distorting the aspect
|
|
// ratio.
|
|
let scalePerX = 100 / parentAllocatedWidth;
|
|
let scalePerY = 100 / parentAllocatedHeight;
|
|
|
|
// Add all children
|
|
for (let childComponent of children) {
|
|
// Watch this child
|
|
this.listenForSizeChange(
|
|
childComponent.element,
|
|
this.onLayoutChange.bind(this)
|
|
);
|
|
|
|
// Create the HTML representation
|
|
let childElement = document.createElement("div");
|
|
childElement.classList.add("rio-layout-display-child");
|
|
this.parentElement.appendChild(childElement);
|
|
|
|
let marginElement = document.createElement("div");
|
|
marginElement.classList.add("rio-layout-display-margin");
|
|
this.parentElement.appendChild(marginElement);
|
|
|
|
// Is this the selected component?
|
|
let isTarget = childComponent.id === targetComponent.id;
|
|
if (isTarget) {
|
|
childElement.classList.add("rio-layout-display-target");
|
|
}
|
|
|
|
// Label the child
|
|
childElement.innerText = childComponent.state._python_type_;
|
|
|
|
// Position the child
|
|
let childRect = childComponent.element.getBoundingClientRect();
|
|
|
|
let childLeft = childRect.left - parentLeftInViewport;
|
|
let childTop = childRect.top - parentTopInViewport;
|
|
|
|
childElement.style.left = `${childLeft * scalePerX}%`;
|
|
childElement.style.top = `${childTop * scalePerY}%`;
|
|
|
|
// Size the child
|
|
childElement.style.width = `${
|
|
getAllocatedWidthInPx(childComponent.element) * scalePerX
|
|
}%`;
|
|
childElement.style.height = `${
|
|
getAllocatedHeightInPx(childComponent.element) * scalePerY
|
|
}%`;
|
|
|
|
// Position the margin
|
|
let margins = childComponent.state._margin_;
|
|
let marginLeft = childLeft - margins[0] * pixelsPerRem;
|
|
let marginTop = childTop - margins[1] * pixelsPerRem;
|
|
|
|
marginElement.style.left = `${marginLeft * scalePerX}%`;
|
|
marginElement.style.top = `${marginTop * scalePerY}%`;
|
|
|
|
// Size the margin
|
|
marginElement.style.width = `${
|
|
(childRect.width + (margins[0] + margins[2]) * pixelsPerRem) *
|
|
scalePerX
|
|
}%`;
|
|
|
|
marginElement.style.height = `${
|
|
(childRect.height + (margins[1] + margins[3]) * pixelsPerRem) *
|
|
scalePerY
|
|
}%`;
|
|
|
|
// Clicking selects the component
|
|
if (!isTarget) {
|
|
childElement.onclick = (event) => {
|
|
markEventAsHandled(event);
|
|
|
|
// Update the state
|
|
this.setStateAndNotifyBackend({
|
|
component_id: childComponent.id,
|
|
});
|
|
};
|
|
}
|
|
|
|
// Double clicking switches to the component's children
|
|
childElement.ondblclick = (event) => {
|
|
markEventAsHandled(event);
|
|
|
|
// Does this component have any children?
|
|
let childChildren = getDisplayableChildren(childComponent);
|
|
|
|
if (childChildren.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Update the state
|
|
this.setStateAndNotifyBackend({
|
|
component_id: childChildren[0].id,
|
|
});
|
|
};
|
|
|
|
// Hovering highlights it
|
|
childElement.onpointerenter = () => {
|
|
this.hoveredChild = childComponent.element;
|
|
this.updateHighlighter();
|
|
};
|
|
|
|
childElement.onpointerleave = () => {
|
|
if (this.hoveredChild !== childComponent.element) {
|
|
return;
|
|
}
|
|
|
|
this.hoveredChild = null;
|
|
this.updateHighlighter();
|
|
};
|
|
}
|
|
}
|
|
|
|
updateHighlighter(): void {
|
|
// If a child is hovered, move the highlighter to it
|
|
if (this.hoveredChild !== null) {
|
|
this.highlighter.moveTo(this.hoveredChild);
|
|
return;
|
|
}
|
|
|
|
// Otherwise, if the parent is hovered, highlight it
|
|
if (this.parentIsHovered) {
|
|
let targetComponent: ComponentBase =
|
|
componentsById[this.state.component_id]!;
|
|
|
|
if (targetComponent === undefined) {
|
|
this.highlighter.moveTo(null);
|
|
return;
|
|
}
|
|
|
|
if (targetComponent.parent === null) {
|
|
this.highlighter.moveTo(null);
|
|
} else {
|
|
this.highlighter.moveTo(targetComponent.parent.element);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Otherwise, hide the highlighter
|
|
this.highlighter.moveTo(null);
|
|
}
|
|
}
|