From dfd71f365eaabcc6a1a882881d0938743b985d4e Mon Sep 17 00:00:00 2001 From: Aran-Fey Date: Tue, 2 Jul 2024 20:52:05 +0200 Subject: [PATCH] implement proportions --- frontend/code/components/linearContainers.ts | 223 +++++++++++++++++-- frontend/code/components/listView.ts | 33 +-- frontend/code/rippleEffect.ts | 5 +- frontend/code/utils.ts | 39 ++++ frontend/css/style.scss | 119 +++++++--- 5 files changed, 347 insertions(+), 72 deletions(-) diff --git a/frontend/code/components/linearContainers.ts b/frontend/code/components/linearContainers.ts index 12d0492c..9b552572 100644 --- a/frontend/code/components/linearContainers.ts +++ b/frontend/code/components/linearContainers.ts @@ -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; 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(); diff --git a/frontend/code/components/listView.ts b/frontend/code/components/listView.ts index 3feb8cb2..face3afe 100644 --- a/frontend/code/components/listView.ts +++ b/frontend/code/components/listView.ts @@ -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) { - 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 ): 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 ( diff --git a/frontend/code/rippleEffect.ts b/frontend/code/rippleEffect.ts index 02eb3832..1ef3b28a 100644 --- a/frontend/code/rippleEffect.ts +++ b/frontend/code/rippleEffect.ts @@ -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 diff --git a/frontend/code/utils.ts b/frontend/code/utils.ts index db599bbd..d5dfcddc 100644 --- a/frontend/code/utils.ts +++ b/frontend/code/utils.ts @@ -46,6 +46,45 @@ export class AsyncQueue { } } +/// 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; } diff --git a/frontend/css/style.scss b/frontend/css/style.scss index de31977c..16f3baf7 100644 --- a/frontend/css/style.scss +++ b/frontend/css/style.scss @@ -392,7 +392,9 @@ select { background-color: transparent; opacity: 0; - transition: opacity 0.3s ease-in-out, background-color 1s ease-in-out; + transition: + opacity 0.3s ease-in-out, + background-color 1s ease-in-out; & > * { transform: translateY(-5rem); @@ -439,17 +441,39 @@ select { } } -// Row, Column & ListView +// Row & Column .rio-linear-child-container { pointer-events: none; - display: flex; - align-items: stretch; + + @include single-container(); + + & > * > * { + display: flex; + align-items: stretch; + + // Stretch to fill the parent + min-width: 100%; + min-height: 100%; + } } -.rio-column { +.rio-column > * > * { flex-direction: column; } +// Row & Column with proportions +.rio-row.has-proportions > * { + // Cut off the spacer element + overflow-x: hidden; + width: 100%; +} + +.rio-column.has-proportions > * { + // Cut off the spacer element + overflow-y: hidden; + height: 100%; +} + // Grid .rio-grid { pointer-events: none; @@ -822,7 +846,9 @@ $rio-input-box-small-label-spacing-top: 0.5rem; outline: 0.15rem solid var(--rio-global-disabled-bg-variant); - transition: all 0.3s ease-in-out, outline 0.15s linear; + transition: + all 0.3s ease-in-out, + outline 0.15s linear; } svg { @@ -1156,7 +1182,9 @@ $rio-input-box-small-label-spacing-top: 0.5rem; // Preserve the text color outside of the switcheroo application, as some // styles depend on it. --outer-text-color: var(--rio-local-text-color); - transition: color 0.1s ease-in-out, border-color 0.1s ease-in-out; + transition: + color 0.1s ease-in-out, + border-color 0.1s ease-in-out; @include single-container; @@ -1169,7 +1197,9 @@ $rio-input-box-small-label-spacing-top: 0.5rem; background-color: var(--rio-local-bg); box-shadow: 0 0 0 transparent; - transition: background-color 0.1s ease-in-out, box-shadow 0.2s ease-in-out; + transition: + background-color 0.1s ease-in-out, + box-shadow 0.2s ease-in-out; } .rio-buttonstyle-major:hover:not(.rio-switcheroo-disabled) { @@ -1319,7 +1349,9 @@ $rio-input-box-small-label-spacing-top: 0.5rem; opacity: 0; transform: translateY(-50%); - transition: opacity 0.45s ease-in-out, transform 0.35s ease; + transition: + opacity 0.45s ease-in-out, + transform 0.35s ease; } .rio-revealer-open > * > .rio-revealer-content-inner { @@ -1428,8 +1460,10 @@ $rio-input-box-small-label-spacing-top: 0.5rem; background-color: var(--rio-local-level-2-bg); opacity: 0%; - transition: left var(--rio-slider-position-transition-time) ease-in-out, - width 0.15s ease-in-out, height 0.15s ease-in-out, + transition: + left var(--rio-slider-position-transition-time) ease-in-out, + width 0.15s ease-in-out, + height 0.15s ease-in-out, opacity 0.15s ease-in-out; } @@ -1454,7 +1488,8 @@ $rio-input-box-small-label-spacing-top: 0.5rem; box-shadow: 0 0.1rem 0.2rem var(--rio-global-shadow-color); - transition: left var(--rio-slider-position-transition-time) ease-in-out, + transition: + left var(--rio-slider-position-transition-time) ease-in-out, background-color 0.1s ease-in-out; } @@ -1607,7 +1642,9 @@ $rio-input-box-small-label-spacing-top: 0.5rem; border-radius: 50%; transform: translate(-50%, -50%); - transition: width 0.2s ease-in-out, height 0.2s ease-in-out; + transition: + width 0.2s ease-in-out, + height 0.2s ease-in-out; } .rio-media-player-timeline:hover .rio-media-player-timeline-knob { @@ -2189,7 +2226,9 @@ $rio-input-box-small-label-spacing-top: 0.5rem; padding: 0.3rem; border-radius: 0.5rem; - transition: opacity 0.1s ease-in-out, color 0.1s ease-in-out, + transition: + opacity 0.1s ease-in-out, + color 0.1s ease-in-out, background-color 0.1s ease-in-out; } @@ -2215,7 +2254,9 @@ $rio-input-box-small-label-spacing-top: 0.5rem; linear-gradient(45deg, var(--checker-color) 25%, transparent 25%); background-size: var(--checker-size) var(--checker-size); - background-position: 0 0, 0 0, + background-position: + 0 0, + 0 0, calc(var(--checker-size) * -0.5) calc(var(--checker-size) * -0.5), calc(var(--checker-size) * 0.5) calc(var(--checker-size) * 0.5); } @@ -2385,7 +2426,9 @@ $rio-input-box-small-label-spacing-top: 0.5rem; background-color: var(--rio-local-bg); box-shadow: 0 0 0 var(--rio-global-shadow-color); - transition: box-shadow 0.15s ease-out, background-color 0.1s ease-out; + transition: + box-shadow 0.15s ease-out, + background-color 0.1s ease-out; } .rio-card-elevate-on-hover:hover { @@ -2436,7 +2479,9 @@ $rio-input-box-small-label-spacing-top: 0.5rem; color: var(--rio-local-text-color); - transition: background-color 0.1s ease-out, color 0.1s ease-out; + transition: + background-color 0.1s ease-out, + color 0.1s ease-out; } .rio-switcher-bar-option > .rio-switcher-bar-icon { @@ -2867,8 +2912,11 @@ $rio-input-box-small-label-spacing-top: 0.5rem; position: fixed; z-index: $z-index-dev-tools-highlighter; - transition: left 0.3s ease-in-out, top 0.3s ease-in-out, - width 0.3s ease-in-out, height 0.3s ease-in-out; + transition: + left 0.3s ease-in-out, + top 0.3s ease-in-out, + width 0.3s ease-in-out, + height 0.3s ease-in-out; } @keyframes pulse { @@ -3005,7 +3053,8 @@ html.picking-component * { } .rio-switcher-resizer { - transition: min-width var(--rio-switcher-transition-time) ease-in-out, + transition: + min-width var(--rio-switcher-transition-time) ease-in-out, min-height var(--rio-switcher-transition-time) ease-in-out; } @@ -3152,8 +3201,12 @@ html.picking-component * { opacity: 0; - transition: opacity 0.3s ease-in-out, left 0.3s ease-in-out, - top 0.3s ease-in-out, width 0.3s ease-in-out, height 0.3s ease-in-out; + transition: + opacity 0.3s ease-in-out, + left 0.3s ease-in-out, + top 0.3s ease-in-out, + width 0.3s ease-in-out, + height 0.3s ease-in-out; } .rio-code-explorer-highlighter::after { @@ -3224,8 +3277,11 @@ html.picking-component * { transform: translate(-50%, -50%); - transition: top var(--rio-ripple-duration), left var(--rio-ripple-duration), - width var(--rio-ripple-duration), height var(--rio-ripple-duration), + transition: + top var(--rio-ripple-duration), + left var(--rio-ripple-duration), + width var(--rio-ripple-duration), + height var(--rio-ripple-duration), opacity var(--rio-ripple-duration); } @@ -3245,14 +3301,17 @@ html.picking-component * { transform: scale(0); opacity: 0; - transition: transform 0.2s linear, opacity 0.1s ease-in-out; + transition: + transform 0.2s linear, + opacity 0.1s ease-in-out; } .rio-popup-animation-scale.rio-popup-manager-open { transform: scale(1); opacity: 1; - transition: transform 0.2s $transition-timing-overshoot, + transition: + transform 0.2s $transition-timing-overshoot, opacity 0.1s ease-in-out; } @@ -3407,7 +3466,9 @@ html.picking-component * { border-radius: var(--rio-global-corner-radius-small); opacity: 0.5; - transition: background-color 0.1s ease-out, box-shadow 0.15s ease-out; + transition: + background-color 0.1s ease-out, + box-shadow 0.15s ease-out; } .rio-layout-display-child:not(.rio-layout-display-target) { @@ -3472,7 +3533,9 @@ html.picking-component * { width: 1.4rem; height: 1.4rem; - transition: border-width 0.2s ease-in-out, border-color 0.2s ease-in-out, + transition: + border-width 0.2s ease-in-out, + border-color 0.2s ease-in-out, background-color 0.2s ease-in-out; }