UNPKG

@pmndrs/uikit

Version:

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

272 lines (271 loc) 11.8 kB
import { batch, signal } from '@preact/signals-core'; import { Display, Edge, FlexDirection, Overflow } from 'yoga-layout/load'; import { setter } from './setter.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 class FlexNode { component; children = []; yogaNode; layoutChangeListeners = new Set(); customLayouting; active = signal(false); constructor(component) { this.component = component; 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(); }; }, component.abortSignal); abortableEffect(() => { if (!this.active.value) { return; } const internalAbort = new AbortController(); const unsubscribe = component.properties.subscribePropertyKeys((key) => { if (!hasImmediateProperty(key)) { return; } abortableEffect(() => { setter[key](component.root.value, this.yogaNode, component.properties.value[key]); this.component.root.peek().requestCalculateLayout(); }, internalAbort.signal); }); return () => { unsubscribe(); internalAbort.abort(); }; }, component.abortSignal); abortableEffect(() => { const parentContainer = component.parentContainer.value; if (parentContainer == null) { return; } parentContainer.node.addChild(this); return () => parentContainer.node.removeChild(this); }, component.abortSignal); } setCustomLayouting(layouting) { this.customLayouting = layouting; this.updateMeasureFunction(); } updateMeasureFunction() { if (this.customLayouting == null || !this.active.value) { return; } setMeasureFunc(this.yogaNode, this.customLayouting.measure); this.component.root.peek().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.component.root.peek().requestCalculateLayout(); } removeChild(node) { const i = this.children.indexOf(node); if (i === -1) { return; } this.children.splice(i, 1); this.component.root.peek().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; if (this.customLayouting != null && this.component.properties.peek()[parentDirectionVertical ? 'minHeight' : 'minWidth'] === 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 (this.component.properties.peek().flexShrink == null) { const hasHeight = this.component.properties.peek().height != null; this.yogaNode.setFlexShrink(hasHeight && parentDirectionVertical ? 0 : undefined); } /** ---- END ---- */ //commiting the children let groupChildren; this.children.sort((child1, child2) => { groupChildren ??= child1.component.parent?.children; if (groupChildren == null) { return 0; } const group1 = child1.component; const group2 = child2.component; 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()); } } updateMeasurements(displayed, parentWidth, parentHeight) { if (this.yogaNode == null) { throw new Error(`update measurements cannot be called without a yoga node`); } this.component.overflow.value = this.yogaNode.getOverflow(); displayed &&= this.yogaNode.getDisplay() != Display.None; this.component.displayed.value = displayed; const width = this.yogaNode.getComputedWidth(); const height = this.yogaNode.getComputedHeight(); updateVector2Signal(this.component.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.component.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.component.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.component.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.component.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.component.maxScrollPosition, xScrollable ? maxScrollX : undefined, yScrollable ? maxScrollY : undefined); updateVector2Signal(this.component.scrollable, xScrollable, yScrollable); } else { updateVector2Signal(this.component.maxScrollPosition, undefined, undefined); updateVector2Signal(this.component.scrollable, false, false); } const overflowVisible = this.component.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, widthMode, height, heightMode) => { const result = func(width, widthMode, height, heightMode); //this is necassary because rounding values down will lead to unnecassary text line breaks result.width = Math.ceil(result.width * PointScaleFactor) / PointScaleFactor; result.height = Math.ceil(result.height * PointScaleFactor) / PointScaleFactor; return result; }); 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']; }