mirror of
https://github.com/rio-labs/rio.git
synced 2025-12-31 10:19:43 -06:00
332 lines
11 KiB
TypeScript
332 lines
11 KiB
TypeScript
import { pixelsPerRem } from "../app";
|
|
import { componentsById } from "../componentManagement";
|
|
import { ComponentId } from "../dataModels";
|
|
import {
|
|
getAllocatedHeightInPx,
|
|
getAllocatedWidthInPx,
|
|
OnlyResizeObserver,
|
|
zip,
|
|
} from "../utils";
|
|
import { ComponentBase, ComponentState, DeltaState } from "./componentBase";
|
|
|
|
export type LinearContainerState = ComponentState & {
|
|
_type_: "Row-builtin" | "Column-builtin";
|
|
children: ComponentId[];
|
|
spacing: number;
|
|
proportions: "homogeneous" | number[] | null;
|
|
};
|
|
|
|
// The size of the invisible spacer element. It must be large enough to account
|
|
// for small inaccuracies in the child elements' sizes. (For example, if the
|
|
// spacer is only 1 pixel large and 2 child elements are 0.5 pixels larger than
|
|
// they should be, then the spacer gets reduced to 0 pixels and can no longer do
|
|
// its job.)
|
|
const PROPORTIONS_SPACER_SIZE = 50;
|
|
|
|
export abstract class LinearContainer extends ComponentBase<LinearContainerState> {
|
|
index: 0 | 1; // 0 for Rows, 1 for Columns
|
|
sizeAttribute: "width" | "height"; // 'width' for Rows, 'height' for Columns
|
|
|
|
// All this stuff is needed for the `proportions`.
|
|
//
|
|
// When proportions are enabled, we must execute JS code whenever the
|
|
// natural size of a child element changes. We have
|
|
// `naturalSizeObservers.ts` for that, but wrapping every single child
|
|
// element in one of those would be horribly inefficient. Quick summary of
|
|
// how it's done:
|
|
//
|
|
// - Make the flexbox slightly larger than it needs to be
|
|
// - Add an invisible spacer element at the end to fill up this extra space
|
|
// - Calculate the `flex-grow` of every child element so that it ends up
|
|
// with the desired proportion
|
|
// - Whenever a child's natural size changes, the spacer will grow or
|
|
// shrink. We can detect this with a ResizeObserver.
|
|
private helperElement: HTMLElement;
|
|
private childContainer: HTMLElement;
|
|
private proportionsSpacer: HTMLElement | null = null;
|
|
private proportionNumbers: number[] = [];
|
|
private totalProportions: number = 0;
|
|
private childNaturalSizes: number[] = [];
|
|
private spacerResizeObserver: OnlyResizeObserver | null = null;
|
|
|
|
onDestruction(): void {
|
|
super.onDestruction();
|
|
|
|
if (this.spacerResizeObserver !== null) {
|
|
this.spacerResizeObserver.disconnect();
|
|
}
|
|
}
|
|
|
|
createElement(): HTMLElement {
|
|
let element = document.createElement("div");
|
|
element.classList.add("rio-linear-container");
|
|
|
|
this.helperElement = document.createElement("div");
|
|
element.appendChild(this.helperElement);
|
|
|
|
this.childContainer = document.createElement("div");
|
|
this.helperElement.appendChild(this.childContainer);
|
|
|
|
return element;
|
|
}
|
|
|
|
updateElement(
|
|
deltaState: DeltaState<LinearContainerState>,
|
|
latentComponents: Set<ComponentBase>
|
|
): void {
|
|
super.updateElement(deltaState, latentComponents);
|
|
|
|
// Children
|
|
if (deltaState.children !== undefined) {
|
|
this.replaceChildren(
|
|
latentComponents,
|
|
deltaState.children,
|
|
this.childContainer,
|
|
true
|
|
);
|
|
|
|
// Make sure the `proportionsSpacer` is at the end
|
|
if (this.proportionsSpacer !== null) {
|
|
this.childContainer.appendChild(this.proportionsSpacer);
|
|
}
|
|
}
|
|
|
|
// Spacing
|
|
if (deltaState.spacing !== undefined) {
|
|
this.childContainer.style.gap = `${deltaState.spacing}rem`;
|
|
}
|
|
|
|
// Proportions
|
|
if (deltaState.proportions !== undefined) {
|
|
if (deltaState.proportions === null) {
|
|
if (this.proportionsSpacer !== null) {
|
|
this.element.classList.remove("has-proportions");
|
|
|
|
this.helperElement.style.removeProperty(
|
|
`min-${this.sizeAttribute}`
|
|
);
|
|
this.childContainer.style.removeProperty(
|
|
this.sizeAttribute
|
|
);
|
|
|
|
this.spacerResizeObserver!.disconnect();
|
|
this.spacerResizeObserver = null;
|
|
|
|
this.proportionsSpacer.remove();
|
|
this.proportionsSpacer = null;
|
|
}
|
|
} else {
|
|
if (this.proportionsSpacer === null) {
|
|
this.element.classList.add("has-proportions");
|
|
|
|
this.proportionsSpacer = document.createElement("div");
|
|
this.proportionsSpacer.classList.add(
|
|
"rio-not-a-child-component"
|
|
);
|
|
this.proportionsSpacer.style.flexGrow = `${PROPORTIONS_SPACER_SIZE}`;
|
|
this.childContainer.appendChild(this.proportionsSpacer);
|
|
|
|
this.spacerResizeObserver = new OnlyResizeObserver(
|
|
this.proportionsSpacer,
|
|
this._onChildNaturalSizeChanged.bind(this)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update the CSS if necessary
|
|
if (
|
|
deltaState.children !== undefined ||
|
|
deltaState.proportions !== undefined ||
|
|
deltaState.spacing !== undefined
|
|
) {
|
|
Object.assign(this.state, deltaState);
|
|
|
|
if (this.state.proportions === null) {
|
|
this.updateChildGrows();
|
|
} else {
|
|
// Not entirely sure why this delay is needed, but we suspect
|
|
// it's because ResizeObservers can only trigger once per frame
|
|
// or something like that. So it triggers too early (before the
|
|
// child components did `updateElement`) and then doesn't run
|
|
// again.
|
|
//
|
|
// Without this workaround proportions were often wrong, and it
|
|
// has definitely improved the situation immensely, but I doubt
|
|
// that the underlying problem is really solved.
|
|
requestAnimationFrame(() => {
|
|
this.updateMinSize();
|
|
this.updateChildProportions();
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
onChildGrowChanged(): void {
|
|
if (this.state.proportions === null) {
|
|
this.updateChildGrows();
|
|
}
|
|
}
|
|
|
|
private updateChildGrows(): void {
|
|
// Set the children's `flex-grow`
|
|
let hasGrowers = false;
|
|
for (let [index, childId] of this.state.children.entries()) {
|
|
let childComponent = componentsById[childId]!;
|
|
let childWrapper = this.childContainer.children[
|
|
index
|
|
] as HTMLElement;
|
|
|
|
if (childComponent.state._grow_[this.index]) {
|
|
hasGrowers = true;
|
|
childWrapper.style.flexGrow = "1";
|
|
} else {
|
|
childWrapper.style.flexGrow = "0";
|
|
}
|
|
}
|
|
|
|
// If nobody wants to grow, all of them do
|
|
if (!hasGrowers) {
|
|
for (let childWrapper of this.childContainer.children) {
|
|
(childWrapper as HTMLElement).style.flexGrow = "1";
|
|
}
|
|
}
|
|
}
|
|
|
|
private updateMinSize(): void {
|
|
if (this.children.size === 0) {
|
|
this.proportionNumbers = [];
|
|
this.totalProportions = 0;
|
|
this.childNaturalSizes = [];
|
|
|
|
this.helperElement.style.setProperty(
|
|
`min-${this.sizeAttribute}`,
|
|
"0"
|
|
);
|
|
return;
|
|
}
|
|
|
|
this.spacerResizeObserver!.disable();
|
|
|
|
// Get every child's natural size
|
|
this.childContainer.style.setProperty(
|
|
this.sizeAttribute,
|
|
"min-content"
|
|
);
|
|
this.childContainer.style.setProperty(`min-${this.sizeAttribute}`, "0");
|
|
|
|
this.childNaturalSizes = [];
|
|
for (let child of this.childContainer.children) {
|
|
let size =
|
|
this.index === 0
|
|
? getAllocatedWidthInPx(child as HTMLElement)
|
|
: getAllocatedHeightInPx(child as HTMLElement);
|
|
|
|
this.childNaturalSizes.push(size);
|
|
}
|
|
this.childNaturalSizes.pop(); // The last one's the spacer, remove it
|
|
|
|
this.childContainer.style.setProperty(
|
|
this.sizeAttribute,
|
|
`calc(100% + ${PROPORTIONS_SPACER_SIZE}px + ${this.state.spacing}rem)`
|
|
);
|
|
this.childContainer.style.removeProperty(`min-${this.sizeAttribute}`);
|
|
|
|
// Sum up the proportions
|
|
this.proportionNumbers =
|
|
this.state.proportions === "homogeneous"
|
|
? new Array(this.children.size).fill(1)
|
|
: this.state.proportions!;
|
|
|
|
this.totalProportions = 0;
|
|
for (let proportion of this.proportionNumbers) {
|
|
this.totalProportions += proportion;
|
|
}
|
|
|
|
// Calculate the minimum size we need to fit all children
|
|
let pixelPerProportion = 0;
|
|
for (let [naturalSize, proportion] of zip(
|
|
this.childNaturalSizes,
|
|
this.proportionNumbers
|
|
)) {
|
|
pixelPerProportion = Math.max(
|
|
pixelPerProportion,
|
|
naturalSize / proportion
|
|
);
|
|
}
|
|
|
|
let minSize =
|
|
pixelPerProportion * this.totalProportions +
|
|
this.state.spacing * pixelsPerRem * (this.children.size - 1);
|
|
|
|
this.helperElement.style.setProperty(
|
|
`min-${this.sizeAttribute}`,
|
|
`${minSize}px`
|
|
);
|
|
|
|
this.spacerResizeObserver!.enable();
|
|
}
|
|
|
|
private updateChildProportions(): void {
|
|
this.spacerResizeObserver!.disable();
|
|
|
|
let allocatedSize =
|
|
this.index === 0
|
|
? getAllocatedWidthInPx(this.element)
|
|
: getAllocatedHeightInPx(this.element);
|
|
|
|
let availableSpace =
|
|
allocatedSize -
|
|
this.state.spacing * pixelsPerRem * (this.children.size - 1);
|
|
|
|
let i = 0;
|
|
for (let childElement of this.childContainer
|
|
.children as HTMLCollectionOf<HTMLElement>) {
|
|
// Skip the last child, that's the spacer
|
|
if (i >= this.proportionNumbers.length) {
|
|
break;
|
|
}
|
|
|
|
let desiredSize =
|
|
availableSpace *
|
|
(this.proportionNumbers[i] / this.totalProportions);
|
|
|
|
// Calculate how much this element needs to grow. Conveniently,
|
|
// plugging that into `flex-grow` will give us the desired size.
|
|
let extraSizeNeeded = desiredSize - this.childNaturalSizes[i];
|
|
childElement.style.flexGrow = `${extraSizeNeeded}`;
|
|
|
|
i++;
|
|
}
|
|
|
|
this.spacerResizeObserver!.enable();
|
|
}
|
|
|
|
private _onChildNaturalSizeChanged(): void {
|
|
this.updateMinSize();
|
|
this.updateChildProportions();
|
|
}
|
|
}
|
|
|
|
export class RowComponent extends LinearContainer {
|
|
index = 0 as 0;
|
|
sizeAttribute = "width" as "width";
|
|
|
|
createElement(): HTMLElement {
|
|
let element = super.createElement();
|
|
element.classList.add("rio-row");
|
|
return element;
|
|
}
|
|
}
|
|
|
|
export class ColumnComponent extends LinearContainer {
|
|
index = 1 as 1;
|
|
sizeAttribute = "height" as "height";
|
|
|
|
createElement(): HTMLElement {
|
|
let element = super.createElement();
|
|
element.classList.add("rio-column");
|
|
return element;
|
|
}
|
|
}
|