UNPKG

@pmndrs/uikit

Version:

Build performant 3D user interfaces with Three.js and yoga.

275 lines (274 loc) 12 kB
import { batch, signal, untracked } from '@preact/signals-core'; import { Display, Edge, FlexDirection, Overflow } from 'yoga-layout/load'; import { setter } from './setter.js'; import { setupImmediateProperties } from '../properties/immediate.js'; import { PointScaleFactor, createYogaNode } from './yoga.js'; import { abortableEffect } from '../utils.js'; function hasImmediateProperty(key) { if (key === 'measureFunc') { return true; } return key in setter; } export function createFlexNodeState() { return { node: signal(), size: signal(undefined), relativeCenter: signal(undefined), borderInset: signal(undefined), overflow: signal(Overflow.Visible), displayed: signal(false), scrollable: signal([false, false]), paddingInset: signal(undefined), maxScrollPosition: signal([undefined, undefined]), }; } export class FlexNode { state; propertiesSignal; object; objectVisibileDefault; children = []; yogaNode; layoutChangeListeners = new Set(); customLayouting; active = signal(false); objectVisible = false; constructor(state, propertiesSignal, object, objectVisibileDefault, abortSignal) { this.state = state; this.propertiesSignal = propertiesSignal; this.object = object; this.objectVisibileDefault = objectVisibileDefault; abortableEffect(() => { const yogaNode = createYogaNode(); if (yogaNode == null) { return; } this.yogaNode = yogaNode; this.active.value = true; this.updateMeasureFunction(); return () => { this.yogaNode?.getParent()?.removeChild(this.yogaNode); this.yogaNode?.free(); }; }, abortSignal); setupImmediateProperties(propertiesSignal, this.active, hasImmediateProperty, (key, value) => { setter[key](this.yogaNode, value); this.state.root.requestCalculateLayout(); }, abortSignal); } setCustomLayouting(layouting) { this.customLayouting = layouting; this.updateMeasureFunction(); } updateMeasureFunction() { if (this.customLayouting == null || !this.active.value) { return; } setMeasureFunc(this.yogaNode, this.customLayouting.measure); this.state.root.requestCalculateLayout(); } /** * use requestCalculateLayout instead */ calculateLayout() { if (this.yogaNode == null) { return; } this.commit(this.yogaNode.getFlexDirection()); this.yogaNode.calculateLayout(undefined, undefined); batch(() => this.updateMeasurements(true, undefined, undefined)); } addChild(node) { this.children.push(node); this.state.root.requestCalculateLayout(); } removeChild(node) { const i = this.children.indexOf(node); if (i === -1) { return; } this.children.splice(i, 1); this.state.root.requestCalculateLayout(); } commit(parentDirection) { if (this.yogaNode == null) { throw new Error(`commit cannot be called without a yoga node`); } /** ---- START : adaptation of yoga's behavior to align more to the web behavior ---- */ const parentDirectionVertical = parentDirection === FlexDirection.Column || parentDirection === FlexDirection.ColumnReverse; const properties = this.propertiesSignal.peek(); if (this.customLayouting != null && untracked(() => properties.read(parentDirectionVertical ? 'minHeight' : 'minWidth', undefined)) === undefined) { this.yogaNode[parentDirectionVertical ? 'setMinHeight' : 'setMinWidth'](parentDirectionVertical ? this.customLayouting.minHeight : this.customLayouting.minWidth); } //see: https://codepen.io/Gettinqdown-Dev/pen/wvZLKBm //-> on the web if the parent has flexdireciton column, elements dont shrink below flexBasis if (untracked(() => properties.read('flexShrink', undefined)) == null) { const hasHeight = untracked(() => properties.read('height', undefined)) != null; this.yogaNode.setFlexShrink(hasHeight && parentDirectionVertical ? 0 : undefined); } /** ---- END ---- */ //commiting the children let groupChildren; this.children.sort((child1, child2) => { groupChildren ??= child1.object.parent?.children; if (groupChildren == null) { return 0; } const group1 = child1.object; const group2 = child2.object; const i1 = groupChildren.indexOf(group1); if (i1 === -1) { throw new Error(`parent mismatch`); } const i2 = groupChildren.indexOf(group2); if (i2 === -1) { throw new Error(`parent mismatch`); } return i1 - i2; }); let i = 0; let oldChildNode = this.yogaNode.getChild(i); let correctChild = this.children[i]; while (correctChild != null || oldChildNode != null) { if (correctChild != null && oldChildNode != null && yogaNodeEqual(oldChildNode, assertNodeNotNull(correctChild.yogaNode))) { correctChild = this.children[++i]; oldChildNode = this.yogaNode.getChild(i); continue; } //either remove, insert, or replace if (oldChildNode != null) { //either remove or replace this.yogaNode.removeChild(oldChildNode); } if (correctChild != null) { //either insert or replace const node = assertNodeNotNull(correctChild.yogaNode); node.getParent()?.removeChild(node); this.yogaNode.insertChild(node, i); correctChild = this.children[++i]; } //the yoga node MUST be updated via getChild even for insert since the returned value is somehow bound to the index oldChildNode = this.yogaNode.getChild(i); } //recursively executing commit in children const childrenLength = this.children.length; for (let i = 0; i < childrenLength; i++) { this.children[i].commit(this.yogaNode.getFlexDirection()); } this.objectVisible = this.objectVisibileDefault || this.children.some((child) => child.objectVisible); this.object.visible = this.objectVisible; } updateMeasurements(displayed, parentWidth, parentHeight) { if (this.yogaNode == null) { throw new Error(`update measurements cannot be called without a yoga node`); } this.state.overflow.value = this.yogaNode.getOverflow(); displayed &&= this.yogaNode.getDisplay() === Display.Flex; this.state.displayed.value = displayed; const width = this.yogaNode.getComputedWidth(); const height = this.yogaNode.getComputedHeight(); updateVector2Signal(this.state.size, width, height); parentWidth ??= width; parentHeight ??= height; const x = this.yogaNode.getComputedLeft(); const y = this.yogaNode.getComputedTop(); const relativeCenterX = x + width * 0.5 - parentWidth * 0.5; const relativeCenterY = -(y + height * 0.5 - parentHeight * 0.5); updateVector2Signal(this.state.relativeCenter, relativeCenterX, relativeCenterY); const paddingTop = this.yogaNode.getComputedPadding(Edge.Top); const paddingLeft = this.yogaNode.getComputedPadding(Edge.Left); const paddingRight = this.yogaNode.getComputedPadding(Edge.Right); const paddingBottom = this.yogaNode.getComputedPadding(Edge.Bottom); updateInsetSignal(this.state.paddingInset, paddingTop, paddingRight, paddingBottom, paddingLeft); const borderTop = this.yogaNode.getComputedBorder(Edge.Top); const borderRight = this.yogaNode.getComputedBorder(Edge.Right); const borderBottom = this.yogaNode.getComputedBorder(Edge.Bottom); const borderLeft = this.yogaNode.getComputedBorder(Edge.Left); updateInsetSignal(this.state.borderInset, borderTop, borderRight, borderBottom, borderLeft); for (const layoutChangeListener of this.layoutChangeListeners) { layoutChangeListener(); } const childrenLength = this.children.length; let maxContentWidth = 0; let maxContentHeight = 0; for (let i = 0; i < childrenLength; i++) { const [contentWidth, contentHeight] = this.children[i].updateMeasurements(displayed, width, height); maxContentWidth = Math.max(maxContentWidth, contentWidth); maxContentHeight = Math.max(maxContentHeight, contentHeight); } maxContentWidth -= borderLeft; maxContentHeight -= borderTop; if (this.state.overflow.value === Overflow.Scroll) { maxContentWidth += paddingRight; maxContentHeight += paddingLeft; const widthWithoutBorder = width - borderLeft - borderRight; const heightWithoutBorder = height - borderTop - borderBottom; const maxScrollX = maxContentWidth - widthWithoutBorder; const maxScrollY = maxContentHeight - heightWithoutBorder; const xScrollable = maxScrollX > 0.5; const yScrollable = maxScrollY > 0.5; updateVector2Signal(this.state.maxScrollPosition, xScrollable ? maxScrollX : undefined, yScrollable ? maxScrollY : undefined); updateVector2Signal(this.state.scrollable, xScrollable, yScrollable); } else { updateVector2Signal(this.state.maxScrollPosition, undefined, undefined); updateVector2Signal(this.state.scrollable, false, false); } const overflowVisible = this.state.overflow.value === Overflow.Visible; return [ x + Math.max(width, overflowVisible ? maxContentWidth : 0), y + Math.max(height, overflowVisible ? maxContentHeight : 0), ]; } addLayoutChangeListener(listener) { this.layoutChangeListeners.add(listener); return () => void this.layoutChangeListeners.delete(listener); } } export function setMeasureFunc(node, func) { if (func == null) { node.setMeasureFunc(null); return; } node.setMeasureFunc((width, wMode, height, hMode) => { const result = func(width, wMode, height, hMode); return { //why + 2? we use a offset of + 1 to prevent precision errors but + 1 causes the following issue in yoga with the default pixelSize of 0.01 therefore +2 //https://github.com/facebook/yoga/issues/1651 width: Math.ceil(result.width * PointScaleFactor + 2) / PointScaleFactor, height: Math.ceil(result.height * PointScaleFactor + 2) / PointScaleFactor, }; }); node.markDirty(); } function updateVector2Signal(signal, x, y) { if (signal.value != null) { const [oldX, oldY] = signal.value; if (oldX === x && oldY === y) { return; } } signal.value = [x, y]; } function updateInsetSignal(signal, top, right, bottom, left) { if (signal.value != null) { const [oldTop, oldRight, oldBottom, oldLeft] = signal.value; if (oldTop == top && oldRight == right && oldBottom == bottom && oldLeft == left) { return; } } signal.value = [top, right, bottom, left]; } function assertNodeNotNull(val) { if (val == null) { throw new Error(`commit cannot be called with a children that miss a yoga node`); } return val; } function yogaNodeEqual(n1, n2) { return n1['M']['O'] === n2['M']['O']; }