implement proportions

This commit is contained in:
Aran-Fey
2024-07-02 20:52:05 +02:00
parent 794a31306d
commit dfd71f365e
5 changed files with 347 additions and 72 deletions
+201 -22
View File
@@ -1,24 +1,65 @@
import { componentsById } from '../componentManagement';
import { ComponentId } from '../dataModels';
import { getNaturalSizeInPixels } from '../utils';
import { OnlyResizeObserver, zip } from '../utils';
import { ComponentBase, ComponentState } from './componentBase';
export type LinearContainerState = ComponentState & {
_type_: 'Row-builtin' | 'Column-builtin' | 'ListView-builtin';
_type_: 'Row-builtin' | 'Column-builtin';
children?: ComponentId[];
spacing?: number;
proportions?: 'homogeneous' | number[] | null;
};
const PROPORTIONS_SPACER_SIZE = 30;
export abstract class LinearContainer extends ComponentBase {
state: Required<LinearContainerState>;
index = -1; // 0 for Rows, 1 for Columns
sizeAttribute = ''; // '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 selfResizeObserver: OnlyResizeObserver | null = null;
private spacerResizeObserver: OnlyResizeObserver | null = null;
onDestruction(): void {
if (this.selfResizeObserver !== null) {
this.selfResizeObserver.disconnect();
}
if (this.spacerResizeObserver !== null) {
this.spacerResizeObserver.disconnect();
}
}
createElement(): HTMLElement {
let element = document.createElement('div');
element.classList.add('rio-linear-child-container');
this.helperElement = document.createElement('div');
element.appendChild(this.helperElement);
this.childContainer = document.createElement('div');
this.helperElement.appendChild(this.childContainer);
return element;
}
@@ -33,30 +74,85 @@ export abstract class LinearContainer extends ComponentBase {
this.replaceChildren(
latentComponents,
deltaState.children,
this.element,
this.childContainer,
true
);
// Make sure the `proportionsSpacer` is at the end
if (this.proportionsSpacer !== null) {
this.element.appendChild(this.proportionsSpacer);
}
}
// Spacing
if (deltaState.spacing !== undefined) {
this.element.style.gap = `${deltaState.spacing}rem`;
this.childContainer.style.gap = `${deltaState.spacing}rem`;
}
// Update the CSS depending on whether we have proportions or not
Object.assign(this.state, deltaState);
this.updateCSS();
// 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.selfResizeObserver!.disconnect();
this.selfResizeObserver = null;
this.proportionsSpacer.remove();
this.proportionsSpacer = null;
}
} else {
if (this.proportionsSpacer === null) {
this.element.classList.add('has-proportions');
this.selfResizeObserver = new OnlyResizeObserver(
this.childContainer,
this.onSizeChanged.bind(this)
);
// Add the spacer element
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
) {
let proportions = deltaState.proportions ?? this.state.proportions;
if (proportions === null) {
this.updateChildGrows();
} else {
this.onProportionsChanged();
}
}
}
onChildGrowChanged(): void {
this.updateCSS();
}
private updateCSS(): void {
if (this.state.proportions === null) {
this.updateChildGrows();
} else {
this.updateProportions();
}
}
@@ -65,7 +161,9 @@ export abstract class LinearContainer extends ComponentBase {
let hasGrowers = false;
for (let [index, childId] of this.state.children.entries()) {
let childComponent = componentsById[childId]!;
let childWrapper = this.element.children[index] as HTMLElement;
let childWrapper = this.childContainer.children[
index
] as HTMLElement;
if (childComponent.state._grow_[this.index]) {
hasGrowers = true;
@@ -77,33 +175,114 @@ export abstract class LinearContainer extends ComponentBase {
// If nobody wants to grow, all of them do
if (!hasGrowers) {
for (let childWrapper of this.element.children) {
for (let childWrapper of this.childContainer.children) {
(childWrapper as HTMLElement).style.flexGrow = '1';
}
}
}
private updateProportions(): void {
let proportions: number[] =
private onProportionsChanged(): void {
if (this.state.children.length === 0) {
this.proportionNumbers = [];
this.totalProportions = 0;
this.childNaturalSizes = [];
this.helperElement.style[
this.index === 0 ? 'minWidth' : 'minHeight'
] = '0';
return;
}
this.spacerResizeObserver!.disable();
// Get every child's natural size
this.childContainer.style[this.sizeAttribute] = 'min-content';
this.childNaturalSizes = [];
for (let child of this.childContainer.children) {
let size = child.getBoundingClientRect()[this.sizeAttribute];
this.childNaturalSizes.push(size);
}
this.childNaturalSizes.pop(); // The last one's the spacer, remove it
this.childContainer.style[this.sizeAttribute] =
`calc(100% + ${PROPORTIONS_SPACER_SIZE}px)`;
// Sum up the proportions
this.proportionNumbers =
this.state.proportions === 'homogeneous'
? new Array(this.children.size).fill(1)
: this.state.proportions!;
let naturalSizes = this.state.children.map(
(childId) =>
getNaturalSizeInPixels(componentsById[childId]!.outerElement)[
this.index
]
);
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 containerMinSize = pixelPerProportion * this.totalProportions;
this.helperElement.style[this.index === 0 ? 'minWidth' : 'minHeight'] =
`${containerMinSize}px`;
this.spacerResizeObserver!.enable();
}
private onSizeChanged(): void {
this.spacerResizeObserver!.disable();
let rect = this.element.getBoundingClientRect();
let size = rect[this.sizeAttribute];
let i = 0;
for (let childElement of this.childContainer.children) {
if (i >= this.proportionNumbers.length) {
break;
}
let desiredSize =
(size * this.proportionNumbers[i]) / this.totalProportions;
(childElement as HTMLElement).style.flexGrow = `${
desiredSize - this.childNaturalSizes[i]
}`;
i++;
}
this.spacerResizeObserver!.enable();
}
private _onChildNaturalSizeChanged(): void {
this.onProportionsChanged();
this.onSizeChanged();
}
}
export class RowComponent extends LinearContainer {
index = 0;
sizeAttribute = 'width';
createElement(): HTMLElement {
let element = super.createElement();
element.classList.add('rio-row');
return element;
}
}
export class ColumnComponent extends LinearContainer {
index = 1;
sizeAttribute = 'height';
createElement(): HTMLElement {
let element = super.createElement();
+12 -21
View File
@@ -1,30 +1,24 @@
import { componentsByElement } from '../componentManagement';
import { ComponentId } from '../dataModels';
import { ComponentBase } from './componentBase';
import { ComponentBase, ComponentState } from './componentBase';
import { CustomListItemComponent } from './customListItem';
import { HeadingListItemComponent } from './headingListItem';
import {
ColumnComponent,
LinearContainer,
LinearContainerState,
} from './linearContainers';
import { SeparatorListItemComponent } from './separatorListItem';
export class ListViewComponent extends LinearContainer {
constructor(id: ComponentId, state: Required<LinearContainerState>) {
state.spacing = 0;
state.proportions = null;
super(id, state);
}
export type ListViewState = ComponentState & {
_type_: 'ListView-builtin';
children?: ComponentId[];
};
export class ListViewComponent extends ComponentBase {
createElement(): HTMLElement {
let element = super.createElement();
let element = document.createElement('div');
element.classList.add('rio-list-view');
return element;
}
updateElement(
deltaState: LinearContainerState,
deltaState: ListViewState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
@@ -38,17 +32,14 @@ export class ListViewComponent extends LinearContainer {
true
);
// Clear everybody's position
for (let child of this.element.children) {
let element = child.firstElementChild as HTMLElement;
element.style.left = '0';
element.style.top = '0';
}
// Update the styles of the children
this._updateChildStyles();
}
onChildGrowChanged(): void {
this._updateChildStyles();
}
_isGroupedListItemWorker(comp: ComponentBase): boolean {
// Is this a recognized list item type?
if (
+4 -1
View File
@@ -48,7 +48,10 @@ export class RippleEffect {
// Spawn two elements: one for the animation, and one with `overflow:
// hidden`
let rippleContainer = document.createElement('div');
rippleContainer.classList.add('rio-ripple-container');
rippleContainer.classList.add(
'rio-ripple-container',
'rio-not-a-child-component'
);
rippleContainer.style.setProperty(
'--rio-ripple-color',
this.rippleCssColor
+39
View File
@@ -46,6 +46,45 @@ export class AsyncQueue<T> {
}
}
/// A ResizeObserver that doesn't invoke the callback function when it's
/// created, only on actual resizes.
export class OnlyResizeObserver {
private element: Element;
private callback: () => void;
private ignoreNextCall: boolean = true;
private resizeObserver: ResizeObserver;
constructor(element: Element, callback: () => void) {
this.element = element;
this.callback = callback;
this.resizeObserver = new ResizeObserver(this._callback.bind(this));
this.resizeObserver.observe(element);
}
public disable(): void {
this.resizeObserver.disconnect();
}
public enable(): void {
this.ignoreNextCall = true;
this.resizeObserver.observe(this.element);
}
public disconnect(): void {
this.resizeObserver.disconnect();
}
private _callback(): void {
if (this.ignoreNextCall) {
this.ignoreNextCall = false;
return;
}
this.callback();
}
}
export function commitCss(element: HTMLElement): void {
element.offsetHeight;
}