diff --git a/.storybook/stories/List.stories.tsx b/.storybook/stories/List.stories.tsx index b29662a..d9c0ea6 100644 --- a/.storybook/stories/List.stories.tsx +++ b/.storybook/stories/List.stories.tsx @@ -1,23 +1,26 @@ import React from 'react' import { ComponentStory, ComponentMeta } from '@storybook/react' +import { a } from '@react-spring/three' -import { Flex, Box } from '../../src' +import { Flex, Box, SpringBox } from '../../src' import { Setup } from '../Setup' const List = ({ width, height }: { width: number; height: number }) => { return ( - - {new Array(8).fill(undefined).map((_, i) => ( - - {(width, height) => ( - - - - - )} - - ))} - + + + {new Array(8).fill(undefined).map((_, i) => ( + + {(width, height) => ( + + + + + )} + + ))} + + ) } diff --git a/.storybook/stories/NestedBoxes.stories.tsx b/.storybook/stories/NestedBoxes.stories.tsx index f1895fd..d523edf 100644 --- a/.storybook/stories/NestedBoxes.stories.tsx +++ b/.storybook/stories/NestedBoxes.stories.tsx @@ -1,4 +1,4 @@ -import { Box, Flex } from '../../src' +import { AutomaticBox, Box, Flex } from '../../src' import React, { Suspense } from 'react' import { ComponentMeta, ComponentStory } from '@storybook/react' import { Box as Cube } from '@react-three/drei' @@ -9,26 +9,28 @@ import { Setup } from '../Setup' const Block = ({ color1, color2 }: { color1: Color; color2: Color }) => { return ( - + - + - - + + - + - + ) } const NestedBoxes = ({ width, height }: { width: number; height: number }) => { return ( - - - - + + + + + + ) } diff --git a/.storybook/stories/Rotation.stories.tsx b/.storybook/stories/Rotation.stories.tsx index 9d85a30..65231c3 100644 --- a/.storybook/stories/Rotation.stories.tsx +++ b/.storybook/stories/Rotation.stories.tsx @@ -1,4 +1,4 @@ -import { Box, Flex } from '../../src' +import { Box, AutomaticBox, ReferenceGroup, Flex } from '../../src' import React, { Suspense } from 'react' import { ComponentMeta, ComponentStory } from '@storybook/react' @@ -25,28 +25,37 @@ const Rotation = ({ const width = 3 const height = 1 return ( - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + ) } diff --git a/package.json b/package.json index 3480df3..77eb01f 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "prettier": "^2.0.5", "react": "^17.0.2", "react-dom": "^17.0.2", + "@react-spring/three": "^9.2.4", "rimraf": "^3.0.2", "rollup": "^2.26.10", "rollup-plugin-filesize": "^9.1.1", @@ -113,6 +114,7 @@ "peerDependencies": { "@react-three/fiber": ">=6.0", "react": "^17.0.2", + "@react-spring/three": "^9.2.4", "three": ">=0.126" } } diff --git a/src/Box.tsx b/src/Box.tsx index 8ce17a7..fc8618f 100644 --- a/src/Box.tsx +++ b/src/Box.tsx @@ -1,247 +1,98 @@ -import React, { useLayoutEffect, useRef, useMemo, useState } from 'react' -import * as THREE from 'three' -import Yoga from 'yoga-layout-prebuilt' -import { ReactThreeFiber, useFrame } from '@react-three/fiber' +import React, { forwardRef, ReactNode, useCallback, useMemo, useState, useContext, createContext } from 'react' import mergeRefs from 'react-merge-refs' +import { boxNodeContext } from './context' +import { R3FlexProps, useProps } from './props' -import { setYogaProperties, rmUndefFromObj } from './util' -import { boxContext, flexContext, SharedBoxContext } from './context' -import { R3FlexProps } from './props' -import { useReflow, useContext } from './hooks' - -export type BoxProps = { - centerAnchor?: boolean - children: React.ReactNode | ((width: number, height: number, centerAnchor?: boolean) => React.ReactNode) -} & R3FlexProps & - Omit, 'children'> - -function BoxImpl( - { - // Non-flex props - children, - centerAnchor, - - // flex props - flexDirection, - flexDir, - dir, - - alignContent, - alignItems, - alignSelf, - align, - - justifyContent, - justify, - - flexBasis, - basis, - flexGrow, - grow, - - flexShrink, - shrink, - - flexWrap, - wrap, - - margin, - m, - marginBottom, - marginLeft, - marginRight, - marginTop, - mb, - ml, - mr, - mt, - - padding, - p, - paddingBottom, - paddingLeft, - paddingRight, - paddingTop, - pb, - pl, - pr, - pt, - - height, - width, - - maxHeight, - maxWidth, - minHeight, - minWidth, - - // other - ...props - }: BoxProps, - ref: React.Ref -) { - // must memoize or the object literal will cause every dependent of flexProps to rerender everytime - const flexProps: R3FlexProps = useMemo(() => { - const _flexProps = { - flexDirection, - flexDir, - dir, - - alignContent, - alignItems, - alignSelf, - align, - - justifyContent, - justify, - - flexBasis, - basis, - flexGrow, - grow, - flexShrink, - shrink, - - flexWrap, - wrap, - - margin, - m, - marginBottom, - marginLeft, - marginRight, - marginTop, - mb, - ml, - mr, - mt, - - padding, - p, - paddingBottom, - paddingLeft, - paddingRight, - paddingTop, - pb, - pl, - pr, - pt, - - height, - width, - - maxHeight, - maxWidth, - minHeight, - minWidth, - } - - rmUndefFromObj(_flexProps) - return _flexProps - }, [ - align, - alignContent, - alignItems, - alignSelf, - dir, - flexBasis, - basis, - flexDir, - flexDirection, - flexGrow, - grow, - flexShrink, - shrink, - flexWrap, - height, - justify, - justifyContent, - m, - margin, - marginBottom, - marginLeft, - marginRight, - marginTop, - maxHeight, - maxWidth, - mb, - minHeight, - minWidth, - ml, - mr, - mt, - p, - padding, - paddingBottom, - paddingLeft, - paddingRight, - paddingTop, - pb, - pl, - pr, - pt, - width, - wrap, - ]) - - const { registerBox, unregisterBox, scaleFactor } = useContext(flexContext) - const { node: parent } = useContext(boxContext) - const group = useRef() - const node = useMemo(() => Yoga.Node.create(), []) - const reflow = useReflow() - - useLayoutEffect(() => { - setYogaProperties(node, flexProps, scaleFactor) - }, [flexProps, node, scaleFactor]) - - // Make child known to the parents yoga instance *before* it calculates layout - useLayoutEffect(() => { - if (!group.current || !parent) return - - parent.insertChild(node, parent.getChildCount()) - registerBox(node, group.current, flexProps, centerAnchor) - - // Remove child on unmount - return () => { - parent.removeChild(node) - unregisterBox(node) - } - }, [node, parent, flexProps, centerAnchor, registerBox, unregisterBox]) - - // We need to reflow if props change - useLayoutEffect(() => { - reflow() - }, [children, flexProps, reflow]) - - const [size, setSize] = useState<[number, number]>([0, 0]) - const epsilon = 1 / scaleFactor - useFrame(() => { - const width = - (typeof flexProps.width === 'number' ? flexProps.width : null) || node.getComputedWidth().valueOf() / scaleFactor - const height = - (typeof flexProps.height === 'number' ? flexProps.height : null) || - node.getComputedHeight().valueOf() / scaleFactor - - if (Math.abs(width - size[0]) > epsilon || Math.abs(height - size[1]) > epsilon) { - setSize([width, height]) - } - }) - - const sharedBoxContext = useMemo(() => ({ node, size, centerAnchor }), [node, size, centerAnchor]) - - return ( - - - {typeof children === 'function' ? children(size[0], size[1], centerAnchor) : children} - - - ) -} +import { useBox, usePropsSyncSize } from '.' +import { FrameValue } from '@react-spring/core' +import type * as Fiber from '@react-three/fiber' /** * Box container for 3D Objects. * For containing Boxes use ``. */ -export const Box = React.forwardRef(BoxImpl) +export const Box = forwardRef< + ReactNode, + { + onUpdateTransformation?: (x: number, y: number, width: number, height: number) => void + centerAnchor?: boolean + children: ((width: number, height: number) => React.ReactNode) | React.ReactNode + index?: number + } & R3FlexProps & + Fiber.GroupProps +>( + ( + { + // Non-flex props + children, + centerAnchor, + + onUpdateTransformation, + index, + + // other + ...props + }, + ref + ) => { + // must memoize or the object literal will cause every dependent of flexProps to rerender everytime + const [flexProps, groupProps] = useProps(props) + + const [[x, y, width, height], setTransformation] = useState<[number, number, number, number]>([0, 0, 0, 0]) + + const node = useBox( + flexProps, + centerAnchor, + index, + useCallback( + (x: number, y: number, width: number, height: number) => { + onUpdateTransformation && onUpdateTransformation(x, y, width, height) + setTransformation([x, y, width, height]) + }, + [onUpdateTransformation] + ) + ) + + const size = useMemoArray<[number, number]>([width, height]) + + return ( + + + + {useMemo( + () => (typeof children === 'function' ? children(width, height) : children), + [width, height, children] + )} + + + + ) + } +) + +export const AutomaticBox = forwardRef< + ReactNode, + { + onUpdateTransformation?: (x: number, y: number, width: number, height: number) => void + centerAnchor?: boolean + children: ((width: number, height: number) => React.ReactNode) | React.ReactNode + index?: number + } & R3FlexProps & + Fiber.GroupProps +>((props, ref) => { + const [overwrittenProps, setRef] = usePropsSyncSize(props) + const mergedReds = useMemo(() => mergeRefs([ref, setRef]), [ref, setRef]) + return +}) + +export function useMemoArray>(array: T): T { + return useMemo(() => array, [array]) +} + +export const boxSizeContext = createContext<[number | FrameValue, number | FrameValue]>(null as any) + +export function useFlexSize() { + return useContext(boxSizeContext) +} Box.displayName = 'Box' diff --git a/src/Flex.tsx b/src/Flex.tsx index 44d30b4..89e620d 100644 --- a/src/Flex.tsx +++ b/src/Flex.tsx @@ -1,21 +1,11 @@ import React, { useLayoutEffect, useMemo, useCallback, PropsWithChildren, useRef } from 'react' import Yoga, { YogaNode } from 'yoga-layout-prebuilt' -import * as THREE from 'three' -import { useFrame, useThree, ReactThreeFiber } from '@react-three/fiber' -import mergeRefs from 'react-merge-refs' -import { - setYogaProperties, - rmUndefFromObj, - vectorFromObject, - Axis, - getDepthAxis, - getFlex2DSize, - getOBBSize, - getRootShift, -} from './util' -import { boxContext, flexContext, SharedFlexContext, SharedBoxContext } from './context' +import { setYogaProperties, rmUndefFromObj, Axis, getDepthAxis, getFlex2DSize, getAxis } from './util' +import { boxNodeContext, flexContext, SharedFlexContext } from './context' import type { R3FlexProps, FlexYogaDirection, FlexPlane } from './props' +import { Group } from 'three' +import { useProps } from '.' export type FlexProps = PropsWithChildren< Partial<{ @@ -26,348 +16,226 @@ export type FlexProps = PropsWithChildren< yogaDirection: FlexYogaDirection plane: FlexPlane scaleFactor?: number + maxUps?: number onReflow?: (totalWidth: number, totalHeight: number) => void - disableSizeRecalc?: boolean - /** Centers flex container in position. - * - * !NB center is based on provided flex size, not on the actual content */ - centerAnchor?: boolean }> & - R3FlexProps & - Omit, 'children'> + R3FlexProps > interface BoxesItem { node: YogaNode - group: THREE.Group - flexProps: R3FlexProps - centerAnchor: boolean + parent: YogaNode + yogaIndex: number + reactIndex: number | undefined + centerAnchor?: boolean + onUpdateTransformation?: (x: number, y: number, width: number, height: number) => void } -function FlexImpl( - { - // Non flex props - size = [1, 1, 1], - yogaDirection = 'ltr', - plane = 'xy', - children, - scaleFactor = 100, - onReflow, - disableSizeRecalc, - centerAnchor: rootCenterAnchor, - - // flex props - - flexDirection, - flexDir, - dir, - - alignContent, - alignItems, - alignSelf, - align, - - justifyContent, - justify, - - flexBasis, - basis, - flexGrow, - grow, - flexShrink, - shrink, - - flexWrap, - wrap, - - margin, - m, - marginBottom, - marginLeft, - marginRight, - marginTop, - mb, - ml, - mr, - mt, - - padding, - p, - paddingBottom, - paddingLeft, - paddingRight, - paddingTop, - pb, - pl, - pr, - pt, - - height, - width, - - maxHeight, - maxWidth, - minHeight, - minWidth, - - // other - ...props - }: FlexProps, - ref: React.Ref -) { +/** + * Flex container. Can contain Boxes + */ +export function Flex({ + // Non flex props + size = [1, 1, 1], + yogaDirection = 'ltr', + plane = 'xy', + children, + scaleFactor = 100, + onReflow, + maxUps, + + // other + ...props +}: FlexProps) { // must memoize or the object literal will cause every dependent of flexProps to rerender everytime - const flexProps: R3FlexProps = useMemo(() => { - const _flexProps = { - flexDirection, - flexDir, - dir, - - alignContent, - alignItems, - alignSelf, - align, - - justifyContent, - justify, - - flexBasis, - basis, - flexGrow, - grow, - flexShrink, - shrink, - - flexWrap, - wrap, - - margin, - m, - marginBottom, - marginLeft, - marginRight, - marginTop, - mb, - ml, - mr, - mt, + const [flexProps] = useProps(props) - padding, - p, - paddingBottom, - paddingLeft, - paddingRight, - paddingTop, - pb, - pl, - pr, - pt, + const reflowRef = useRef<() => void>(null as any) - height, - width, + // Mechanism for invalidating and recalculating layout + const reflowTimeout = useRef(undefined) - maxHeight, - maxWidth, - minHeight, - minWidth, + const requestReflow = useCallback(() => { + if (reflowTimeout.current == null) { + reflowTimeout.current = window.setTimeout(() => { + reflowRef.current() + reflowTimeout.current = undefined + }, 1000 / (maxUps ?? 10)) } - - rmUndefFromObj(_flexProps) - return _flexProps - }, [ - align, - alignContent, - alignItems, - alignSelf, - dir, - flexBasis, - basis, - flexDir, - flexDirection, - flexGrow, - grow, - flexShrink, - shrink, - flexWrap, - height, - justify, - justifyContent, - m, - margin, - marginBottom, - marginLeft, - marginRight, - marginTop, - maxHeight, - maxWidth, - mb, - minHeight, - minWidth, - ml, - mr, - mt, - p, - padding, - paddingBottom, - paddingLeft, - paddingRight, - paddingTop, - pb, - pl, - pr, - pt, - width, - wrap, - ]) - - const rootGroup = useRef() + }, [maxUps]) // Keeps track of the yoga nodes of the children and the related wrapper groups const boxesRef = useRef([]) + const dirtyParents = useRef>(new Set()) + const registerBox = useCallback( - (node: YogaNode, group: THREE.Group, flexProps: R3FlexProps, centerAnchor: boolean = false) => { + (node: YogaNode, parent: YogaNode) => { + boxesRef.current.push({ node, reactIndex: undefined, yogaIndex: -1, parent }) + dirtyParents.current.add(parent) + requestReflow() + }, + [requestReflow] + ) + const updateBox = useCallback( + ( + node: YogaNode, + index: number | undefined, + onUpdateTransformation: (x: number, y: number, width: number, height: number) => void, + centerAnchor?: boolean + ) => { + const i = boxesRef.current.findIndex((b) => b.node === node) + if (i !== -1) { + boxesRef.current[i] = { + ...boxesRef.current[i], + reactIndex: index, + onUpdateTransformation, + centerAnchor, + } + dirtyParents.current.add(boxesRef.current[i].parent) + requestReflow() + } else { + console.warn(`unable to unregister box (node could not be found)`) + } + }, + [requestReflow] + ) + const unregisterBox = useCallback( + (node: YogaNode) => { const i = boxesRef.current.findIndex((b) => b.node === node) if (i !== -1) { + const { parent, node } = boxesRef.current[i] boxesRef.current.splice(i, 1) + parent.removeChild(node) + dirtyParents.current.add(parent) + requestReflow() + } else { + console.warn(`unable to unregister box (node could not be found)`) } - boxesRef.current.push({ group, node, flexProps, centerAnchor }) }, - [] + [requestReflow] ) - const unregisterBox = useCallback((node: YogaNode) => { - const i = boxesRef.current.findIndex((b) => b.node === node) - if (i !== -1) { - boxesRef.current.splice(i, 1) - } - }, []) // Reference to the yoga native node const node = useMemo(() => Yoga.Node.create(), []) + useLayoutEffect(() => { - setYogaProperties(node, flexProps, scaleFactor) - }, [node, flexProps, scaleFactor]) + reflowRef.current = () => { + // Common variables for reflow + const mainAxis = plane[0] as Axis + const crossAxis = plane[1] as Axis + const depthAxis = getDepthAxis(plane) + const [flexWidth, flexHeight] = getFlex2DSize(size, plane) + const yogaDirection_ = + yogaDirection === 'ltr' ? Yoga.DIRECTION_LTR : yogaDirection === 'rtl' ? Yoga.DIRECTION_RTL : yogaDirection + + dirtyParents.current.forEach((parent) => updateRealBoxIndices(boxesRef.current, parent)) + dirtyParents.current.clear() + + // Perform yoga layout calculation + node.calculateLayout(flexWidth * scaleFactor, flexHeight * scaleFactor, yogaDirection_) + + let minX = 0 + let maxX = 0 + let minY = 0 + let maxY = 0 + + // Reposition after recalculation + boxesRef.current.forEach(({ node, centerAnchor, onUpdateTransformation }) => { + const { left, top, width, height } = node.getComputedLayout() + + const axesValues = [left + (centerAnchor ? width / 2 : 0), -(top + (centerAnchor ? height / 2 : 0)), 0] + const axes: Array = [mainAxis, crossAxis, depthAxis] + + onUpdateTransformation && + onUpdateTransformation( + NaNToZero(getAxis('x', axes, axesValues)) / scaleFactor, + NaNToZero(getAxis('y', axes, axesValues)) / scaleFactor, + NaNToZero(width) / scaleFactor, + NaNToZero(height) / scaleFactor + ) + + minX = Math.min(minX, left) + minY = Math.min(minY, top) + maxX = Math.max(maxX, left + width) + maxY = Math.max(maxY, top + height) + }) - // Mechanism for invalidating and recalculating layout - const { invalidate } = useThree() - const dirtyRef = useRef(true) - const requestReflow = useCallback(() => { - dirtyRef.current = true - invalidate() - }, [invalidate]) + // Call the reflow event to update resulting size + onReflow && onReflow((maxX - minX) / scaleFactor, (maxY - minY) / scaleFactor) + } + requestReflow() + }, [requestReflow, node, onReflow, size, plane, yogaDirection, scaleFactor]) - // We need to reflow everything if flex props changes useLayoutEffect(() => { + setYogaProperties(node, flexProps, scaleFactor) + // We need to reflow everything if flex props changes requestReflow() - }, [children, flexProps, requestReflow]) - - // Common variables for reflow - const boundingBox = useMemo(() => new THREE.Box3(), []) - const vec = useMemo(() => new THREE.Vector3(), []) - const mainAxis = plane[0] as Axis - const crossAxis = plane[1] as Axis - const depthAxis = getDepthAxis(plane) - const [flexWidth, flexHeight] = getFlex2DSize(size, plane) - const yogaDirection_ = - yogaDirection === 'ltr' ? Yoga.DIRECTION_LTR : yogaDirection === 'rtl' ? Yoga.DIRECTION_RTL : yogaDirection + }, [node, flexProps, scaleFactor, requestReflow]) // Shared context for flex and box const sharedFlexContext = useMemo( () => ({ + plane, requestReflow, registerBox, + updateBox, unregisterBox, scaleFactor, }), - [requestReflow, registerBox, unregisterBox, scaleFactor] + [plane, requestReflow, registerBox, unregisterBox, scaleFactor, updateBox] ) - const sharedBoxContext = useMemo( - () => ({ node, size: [flexWidth, flexHeight], centerAnchor: rootCenterAnchor }), - [node, flexWidth, flexHeight, rootCenterAnchor] - ) - - // Handles the reflow procedure - function reflow() { - if (!disableSizeRecalc) { - // Recalc all the sizes - boxesRef.current.forEach(({ group, node, flexProps }) => { - const scaledWidth = typeof flexProps.width === 'number' ? flexProps.width * scaleFactor : flexProps.width - const scaledHeight = typeof flexProps.height === 'number' ? flexProps.height * scaleFactor : flexProps.height - - if (scaledWidth !== undefined && scaledHeight !== undefined) { - // Forced size, no need to calculate bounding box - node.setWidth(scaledWidth) - node.setHeight(scaledHeight) - } else if (node.getChildCount() === 0) { - // No size specified, calculate size - if (rootGroup.current) { - getOBBSize(group, rootGroup.current, boundingBox, vec) - } else { - // rootGroup ref is missing for some reason, let's just use usual bounding box - boundingBox.setFromObject(group).getSize(vec) - } - - node.setWidth(scaledWidth || vec[mainAxis] * scaleFactor) - node.setHeight(scaledHeight || vec[crossAxis] * scaleFactor) - } - }) - } - - // Perform yoga layout calculation - node.calculateLayout(flexWidth * scaleFactor, flexHeight * scaleFactor, yogaDirection_) - - const rootWidth = node.getComputedWidth() - const rootHeight = node.getComputedHeight() - - let minX = 0 - let maxX = 0 - let minY = 0 - let maxY = 0 - - // Reposition after recalculation - boxesRef.current.forEach(({ group, node, centerAnchor }) => { - const { left, top, width, height } = node.getComputedLayout() - const [mainAxisShift, crossAxisShift] = getRootShift(rootCenterAnchor, rootWidth, rootHeight, node) - - const position = vectorFromObject({ - [mainAxis]: (mainAxisShift + left + (centerAnchor ? width / 2 : 0)) / scaleFactor, - [crossAxis]: -(crossAxisShift + top + (centerAnchor ? height / 2 : 0)) / scaleFactor, - [depthAxis]: 0, - } as any) - - minX = Math.min(minX, left) - minY = Math.min(minY, top) - maxX = Math.max(maxX, left + width) - maxY = Math.max(maxY, top + height) - group.position.copy(position) - }) - - // Call the reflow event to update resulting size - onReflow && onReflow((maxX - minX) / scaleFactor, (maxY - minY) / scaleFactor) - - // Ask react-three-fiber to perform a render (invalidateFrameLoop) - invalidate() - } + return ( + + {children} + + ) +} - // We check if we have to reflow every frame - // This way we can batch the reflow if we have multiple reflow requests - useFrame(() => { - if (dirtyRef.current) { - dirtyRef.current = false - reflow() +/** + * aligns react index with an ordered continous yogaIndex + * @param boxesItems all boxes + * @param parent the parent in which the reordering should happen + */ +function updateRealBoxIndices(boxesItems: Array, parent: YogaNode): void { + //could be done without the filter more efficiently with another data structure (e.g. map with parent as key) + sortIndex(boxesItems.filter(({ parent: boxParent }) => boxParent === parent)).forEach((box, index) => { + if (box.yogaIndex != index) { + if (box.yogaIndex != -1) { + parent.removeChild(box.node) + } + parent.insertChild(box.node, index) + box.yogaIndex = index } }) +} - return ( - - - {children} - - +function sortIndex(boxes: Array): Array { + //split array + const { unindexed, indexed } = boxes.reduce<{ indexed: Array; unindexed: Array }>( + ({ indexed, unindexed }, box) => ({ + indexed: box.reactIndex != null ? [...indexed, box] : indexed, + unindexed: box.reactIndex == null ? [...unindexed, box] : unindexed, + }), + { indexed: [], unindexed: [] } ) + //sort after react Index + const result = indexed.sort(({ reactIndex: r1 }, { reactIndex: r2 }) => r1! - r2!) + //fillup array + let i = 0 + let nextUnindexed = unindexed.shift() + while (nextUnindexed != null) { + const boxAtIndex = result[i] + if (boxAtIndex == null || (boxAtIndex.reactIndex != null && boxAtIndex.reactIndex > i)) { + result.splice(i, 0, nextUnindexed) + nextUnindexed = unindexed.shift() + } + i++ + } + return result } -/** - * Flex container. Can contain Boxes - */ -export const Flex = React.forwardRef(FlexImpl) +function NaNToZero(val: number) { + return isNaN(val) ? 0 : val +} Flex.displayName = 'Flex' diff --git a/src/ReferenceGroup.tsx b/src/ReferenceGroup.tsx new file mode 100644 index 0000000..0dc85db --- /dev/null +++ b/src/ReferenceGroup.tsx @@ -0,0 +1,15 @@ +import * as Fiber from '@react-three/fiber' +import React, { forwardRef, useMemo, useState } from 'react' +import { Group } from 'three' +import mergeRefs from 'react-merge-refs' +import { referenceGroupContext } from './context' + +export const ReferenceGroup = forwardRef(({ children, ...props }, ref) => { + const [group, setRef] = useState(null) + const mergedReds = useMemo(() => mergeRefs([ref, setRef]), [ref, setRef]) + return ( + + {children} + + ) +}) diff --git a/src/SpringBox.tsx b/src/SpringBox.tsx new file mode 100644 index 0000000..431db0f --- /dev/null +++ b/src/SpringBox.tsx @@ -0,0 +1,68 @@ +import React, { forwardRef, ReactNode, useMemo } from 'react' +import { boxSizeContext, usePropsSyncSize, useSpringBox } from '.' +import { R3FlexProps, useProps } from './props' +import { useMemoArray } from './Box' +import { FrameValue, a, AnimatedProps } from '@react-spring/three' +import { boxNodeContext } from './context' +import * as Fiber from '@react-three/fiber' +import mergeRefs from 'react-merge-refs' + +export const SpringBox = forwardRef< + ReactNode, + { + onUpdateTransformation?: (x: number, y: number, width: number, height: number) => void + centerAnchor?: boolean + children: ((width: FrameValue, height: FrameValue) => React.ReactNode) | React.ReactNode + index?: number + } & R3FlexProps & + AnimatedProps +>( + ( + { + // Non-flex props + children, + centerAnchor, + + onUpdateTransformation, + index, + + // other + ...props + }, + ref + ) => { + const [flexProps, groupProps] = useProps>(props) + + const { node, x, y, width, height } = useSpringBox(flexProps, centerAnchor, index, onUpdateTransformation) + + const size = useMemoArray<[FrameValue, FrameValue]>([width, height]) + + return ( + + + + {useMemo( + () => (typeof children === 'function' ? children(width, height) : children), + [width, height, children] + )} + + + + ) + } +) + +export const AutomaticSpringBox = forwardRef< + ReactNode, + { + onUpdateTransformation?: (x: number, y: number, width: number, height: number) => void + centerAnchor?: boolean + children: ((width: number, height: number) => React.ReactNode) | React.ReactNode + index?: number + } & R3FlexProps & + Fiber.GroupProps +>((props, ref) => { + const [overwrittenProps, setRef] = usePropsSyncSize(props) + const mergedReds = useMemo(() => mergeRefs([ref, setRef]), [ref, setRef]) + return +}) diff --git a/src/context.ts b/src/context.ts index 03c242d..eb85200 100644 --- a/src/context.ts +++ b/src/context.ts @@ -1,23 +1,35 @@ import { createContext } from 'react' -import { YogaNode } from 'yoga-layout-prebuilt' import { Group } from 'three' -import { R3FlexProps } from './props' +import { YogaNode } from 'yoga-layout-prebuilt' +import { FlexPlane, R3FlexProps } from './props' export interface SharedFlexContext { scaleFactor: number + plane: FlexPlane requestReflow(): void - registerBox(node: YogaNode, group: Group, flexProps: R3FlexProps, centerAnchor?: boolean): void + registerBox(node: YogaNode, parent: YogaNode): void + updateBox( + node: YogaNode, + index: number | undefined, + onUpdateTransformation: (x: number, y: number, width: number, height: number) => void, + centerAnchor?: boolean + ): void unregisterBox(node: YogaNode): void notInitialized?: boolean } const initialSharedFlexContext: SharedFlexContext = { + plane: 'xy', scaleFactor: 100, requestReflow() { console.warn('Flex not initialized! Please report') }, registerBox() { console.warn('Flex not initialized! Please report') + return 0 + }, + updateBox() { + console.warn('Flex not initialized! Please report') }, unregisterBox() { console.warn('Flex not initialized! Please report') @@ -27,17 +39,6 @@ const initialSharedFlexContext: SharedFlexContext = { export const flexContext = createContext(initialSharedFlexContext) -export interface SharedBoxContext { - node: YogaNode | null - size: [number, number] - centerAnchor?: boolean - notInitialized?: boolean -} - -const initialSharedBoxContext: SharedBoxContext = { - node: null, - size: [0, 0], - notInitialized: true, -} +export const boxNodeContext = createContext(null) -export const boxContext = createContext(initialSharedBoxContext) +export const referenceGroupContext = createContext(null as any) diff --git a/src/hooks.ts b/src/hooks.ts index 79ae372..ab76df8 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -1,82 +1,66 @@ -import { useCallback, useContext as useContextImpl, useMemo } from 'react' -import { Mesh, Vector3 } from 'three' -import { flexContext, boxContext } from './context' +import { useCallback, useContext as useContextImpl, useMemo, useState } from 'react' +import { Box3, Object3D, Vector3 } from 'three' +import { flexContext, boxNodeContext, referenceGroupContext } from './context' +import { Axis, getOBBSize } from './util' -export function useContext(context: React.Context) { +export function useContext(context: React.Context) { let result = useContextImpl(context) - if (result.notInitialized) { + if (result == null) { console.warn('You must place this hook/component under a component!') } return result } -export function useReflow() { - const { requestReflow } = useContext(flexContext) - return requestReflow -} - -/** - * @returns [width, height, centerAnchor] - */ -export function useFlexSize() { - const { - size: [width, height], - centerAnchor, - } = useContext(boxContext) - const value = useMemo(() => [width, height, centerAnchor] as const, [width, height, centerAnchor]) - return value -} - export function useFlexNode() { - const { node } = useContext(boxContext) - return node + return useContext(boxNodeContext) } -/** - * explicitly set the size of the box's yoga node - * @requires that the surrounding Flex-Element has `disableSizeRecalc` set to `true` - */ -export function useSetSize(): (width: number, height: number) => void { - const { requestReflow, scaleFactor } = useContext(flexContext) - const node = useFlexNode() +const boundingBox = new Box3() +const vec = new Vector3() - const sync = useCallback( - (width: number, height: number) => { - if (node == null) { - throw new Error('yoga node is null. sync size is impossible') +export function usePropsSyncSize( + flexProps: T +): [T & { width?: number; height?: number }, (ref: Object3D | undefined) => void] { + const [overwrittenProps, setSize] = usePropsSetSize(flexProps) + const { plane } = useContext(flexContext) + const referenceGroup = useContextImpl(referenceGroupContext) + const setRef = useCallback( + (ref: Object3D | undefined) => { + if (ref == null) { + setSize([undefined, undefined]) + } else { + getOBBSize(ref, referenceGroup, boundingBox, vec) + const mainAxis = plane[0] as Axis + const crossAxis = plane[1] as Axis + setSize([vec[mainAxis], vec[crossAxis]]) } - node.setWidth(width * scaleFactor) - node.setHeight(height * scaleFactor) - requestReflow() }, - [node, requestReflow] + [setSize, referenceGroup, plane] ) - - return sync + return [overwrittenProps, setRef] } -const helperVector = new Vector3() - /** - * explicitly sync the yoga node size with a mesh's geometry and uniform global scale + * explicitly set the size of the box's yoga node * @requires that the surrounding Flex-Element has `disableSizeRecalc` set to `true` */ -export function useSyncGeometrySize(): (mesh: Mesh) => void { - const setSize = useSetSize() - return useCallback( - (mesh: Mesh) => { - mesh.updateMatrixWorld() - helperVector.setFromMatrixScale(mesh.matrixWorld) - - //since the scale is in global space but the box boundings are in local space, scaling can't be translated, thus a uniform scaling is required to have this work properly - if (Math.abs(helperVector.x - helperVector.y) > 0.001 || Math.abs(helperVector.y - helperVector.z) > 0.001) { - throw new Error('object was not scaled uniformly') +export function usePropsSetSize( + flexProps: T +): [T & { width?: number; height?: number }, (size: [width: number | undefined, height: number | undefined]) => void] { + const [[width, height], setSize] = useState<[width: number | undefined, height: number | undefined]>([ + undefined, + undefined, + ]) + const overwrittenProps = useMemo(() => { + if (width != null && height != null) { + return { + width, + height, + ...flexProps, } - const worldScale = helperVector.x - mesh.geometry.computeBoundingBox() - const box = mesh.geometry.boundingBox! - setSize((box.max.x - box.min.x) * worldScale, (box.max.y - box.min.y) * worldScale) - }, - [setSize] - ) + } else { + return flexProps + } + }, [flexProps, width, height]) + return [overwrittenProps, setSize] } diff --git a/src/index.ts b/src/index.ts index ccfc616..e24932d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,9 @@ -export * from './Box' -export * from './Flex' export * from './props' export * from './hooks' +export * from './useBox' +export * from './useSpringBox' +export * from './ReferenceGroup' +export * from './Flex' +export * from './Box' +export * from './SpringBox' export type { Axis } from './util' diff --git a/src/props.ts b/src/props.ts index b46b226..cbf45f6 100644 --- a/src/props.ts +++ b/src/props.ts @@ -1,4 +1,13 @@ -import { YogaFlexDirection, YogaAlign, YogaJustifyContent, YogaFlexWrap, YogaDirection } from 'yoga-layout-prebuilt' +import { + YogaFlexDirection, + YogaAlign, + YogaJustifyContent, + YogaFlexWrap, + YogaDirection, + YogaMeasureMode, +} from 'yoga-layout-prebuilt' +import { rmUndefFromObj } from './util' +import { useMemo } from 'react' export type FlexYogaDirection = YogaDirection | 'ltr' | 'rtl' export type FlexPlane = 'xy' | 'yz' | 'xz' @@ -117,4 +126,195 @@ export type R3FlexProps = Partial<{ marginBottom: Value // Shorthand for marginBottom mb: Value + + measureFunc: ( + width: number, + widthMeasureMode: YogaMeasureMode, + height: number, + heightMeasureMode: YogaMeasureMode + ) => { width?: number; height?: number } | null + + aspectRatio: number }> + +export function useProps({ + flexDirection, + flexDir, + dir, + + alignContent, + alignItems, + alignSelf, + align, + + justifyContent, + justify, + + flexBasis, + basis, + flexGrow, + grow, + + flexShrink, + shrink, + + flexWrap, + wrap, + + margin, + m, + marginBottom, + marginLeft, + marginRight, + marginTop, + mb, + ml, + mr, + mt, + + padding, + p, + paddingBottom, + paddingLeft, + paddingRight, + paddingTop, + pb, + pl, + pr, + pt, + + height, + width, + + maxHeight, + maxWidth, + minHeight, + minWidth, + + measureFunc, + aspectRatio, + + // other + ...props +}: R3FlexProps & T): [R3FlexProps, typeof props] { + return [ + useMemo(() => { + const result = { + flexDirection, + flexDir, + dir, + + alignContent, + alignItems, + alignSelf, + align, + + justifyContent, + justify, + + flexBasis, + basis, + flexGrow, + grow, + + flexShrink, + shrink, + + flexWrap, + wrap, + + margin, + m, + marginBottom, + marginLeft, + marginRight, + marginTop, + mb, + ml, + mr, + mt, + + padding, + p, + paddingBottom, + paddingLeft, + paddingRight, + paddingTop, + pb, + pl, + pr, + pt, + + height, + width, + + maxHeight, + maxWidth, + minHeight, + minWidth, + + measureFunc, + aspectRatio, + } + rmUndefFromObj(result) + return result + }, [ + flexDirection, + flexDir, + dir, + + alignContent, + alignItems, + alignSelf, + align, + + justifyContent, + justify, + + flexBasis, + basis, + flexGrow, + grow, + + flexShrink, + shrink, + + flexWrap, + wrap, + + margin, + m, + marginBottom, + marginLeft, + marginRight, + marginTop, + mb, + ml, + mr, + mt, + + padding, + p, + paddingBottom, + paddingLeft, + paddingRight, + paddingTop, + pb, + pl, + pr, + pt, + + height, + width, + + maxHeight, + maxWidth, + minHeight, + minWidth, + + measureFunc, + aspectRatio, + ]), + props, + ] +} diff --git a/src/useBox.ts b/src/useBox.ts new file mode 100644 index 0000000..cc61fa1 --- /dev/null +++ b/src/useBox.ts @@ -0,0 +1,36 @@ +import { useContext, useMemo, useLayoutEffect } from 'react' +import Yoga, { YogaNode } from 'yoga-layout-prebuilt' +import { R3FlexProps, useFlexNode } from '.' +import { flexContext } from './context' +import { setYogaProperties } from './util' + +export function useBox( + flexProps: R3FlexProps | undefined, + centerAnchor: boolean | undefined, + index: number | undefined, + onUpdateTransformation: (x: number, y: number, width: number, height: number) => void +): YogaNode { + const { registerBox, unregisterBox, updateBox, scaleFactor, requestReflow } = useContext(flexContext) + const parent = useFlexNode() + const node = useMemo(() => Yoga.Node.create(), []) + + useLayoutEffect(() => { + setYogaProperties(node, flexProps ?? {}, scaleFactor) + requestReflow() + }, [flexProps, node, scaleFactor, requestReflow]) + + //register and unregister box + useLayoutEffect(() => { + if (!parent) return + registerBox(node, parent) + return () => unregisterBox(node) + }, [node, parent, registerBox, unregisterBox]) + + //update box properties + useLayoutEffect( + () => updateBox(node, index, onUpdateTransformation, centerAnchor), + [node, index, centerAnchor, onUpdateTransformation, updateBox] + ) + + return node +} diff --git a/src/useSpringBox.ts b/src/useSpringBox.ts new file mode 100644 index 0000000..56a1892 --- /dev/null +++ b/src/useSpringBox.ts @@ -0,0 +1,40 @@ +import { useCallback } from 'react' +import { SpringConfig, useSpring } from '@react-spring/three' +import { useBox } from './useBox' +import { R3FlexProps } from '.' + +export function useSpringBox( + flexProps: R3FlexProps | undefined, + centerAnchor: boolean | undefined, + index: number | undefined, + onUpdateTransformation?: (x: number, y: number, width: number, height: number) => void, + config?: SpringConfig +) { + const [spring, api] = useSpring( + { + x: 0, + y: 0, + width: 0, + height: 0, + config, + }, + [config] + ) + + const update = useCallback( + (x: number, y: number, width: number, height: number) => { + onUpdateTransformation && onUpdateTransformation(x, y, width, height) + api.start({ + x, + y, + width, + height, + }) + }, + [api, onUpdateTransformation] + ) + + const node = useBox(flexProps, centerAnchor, index, update) + + return { node, ...spring } +} diff --git a/src/util.ts b/src/util.ts index d2ad6bf..7cad5c1 100644 --- a/src/util.ts +++ b/src/util.ts @@ -34,6 +34,10 @@ export const setYogaProperties = (node: YogaNode, props: R3FlexProps, scaleFacto case 'basis': case 'flexBasis': return node.setFlexBasis(value) + case 'width': + return node.setWidth(value) + case 'height': + return node.setHeight(value) default: return (node[`set${capitalize(name)}` as keyof YogaNode] as any)(value) @@ -44,6 +48,10 @@ export const setYogaProperties = (node: YogaNode, props: R3FlexProps, scaleFacto case 'basis': case 'flexBasis': return node.setFlexBasis(scaledValue) + case 'width': + return node.setWidth(scaledValue) + case 'height': + return node.setHeight(scaledValue) case 'grow': case 'flexGrow': return node.setFlexGrow(scaledValue) @@ -90,19 +98,31 @@ export const setYogaProperties = (node: YogaNode, props: R3FlexProps, scaleFacto case 'marginBottom': case 'mb': return node.setMargin(Yoga.EDGE_BOTTOM, scaledValue) - + case 'aspectRatio': + return node.setAspectRatio(value) default: return (node[`set${capitalize(name)}` as keyof YogaNode] as any)(scaledValue) } + } else if (typeof value === 'function') { + switch (name) { + case 'measureFunc': + return node.setMeasureFunc(value) + } } }) } -export const vectorFromObject = ({ x, y, z }: { x: number; y: number; z: number }) => new Vector3(x, y, z) - export type Axis = 'x' | 'y' | 'z' export const axes: Axis[] = ['x', 'y', 'z'] +export function getAxis(searchAxis: Axis, axes: Array, values: Array) { + const index = axes.findIndex((axis) => axis === searchAxis) + if (index == -1) { + throw new Error(`unable to find axis "${searchAxis}" in [${axes.join(', ')}] `) + } + return values[index] +} + export function getDepthAxis(plane: FlexPlane) { switch (plane) { case 'xy': @@ -137,38 +157,26 @@ export const rmUndefFromObj = (obj: Record) => * * NB: This doesn't work when object itself is rotated (well, for now) */ -export const getOBBSize = (object: Object3D, root: Object3D, bb: Box3, size: Vector3) => { - object.updateMatrix() - const oldMatrix = object.matrix - const oldMatrixAutoUpdate = object.matrixAutoUpdate - - root.updateMatrixWorld() - const m = new Matrix4().copy(root.matrixWorld).invert() - object.matrix = m - // to prevent matrix being reassigned - object.matrixAutoUpdate = false - root.updateMatrixWorld() - - bb.setFromObject(object).getSize(size) - - object.matrix = oldMatrix - object.matrixAutoUpdate = oldMatrixAutoUpdate - root.updateMatrixWorld() -} - -const getIsTopLevelChild = (node: YogaNode) => !node.getParent()?.getParent() - -/** @returns [mainAxisShift, crossAxisShift] */ -export const getRootShift = ( - rootCenterAnchor: boolean | undefined, - rootWidth: number, - rootHeight: number, - node: YogaNode -) => { - if (!rootCenterAnchor || !getIsTopLevelChild(node)) { - return [0, 0] +export const getOBBSize = (object: Object3D, root: Object3D | null, bb: Box3, size: Vector3) => { + if (root == null) { + bb.setFromObject(object).getSize(size) + } else { + object.updateMatrix() + const oldMatrix = object.matrix + const oldMatrixAutoUpdate = object.matrixAutoUpdate + + root.updateMatrixWorld() + const m = new Matrix4().copy(root.matrixWorld).invert() + //this also inverts all transformations by "object" + object.matrix = m + // to prevent matrix being reassigned + object.matrixAutoUpdate = false + root.updateMatrixWorld() + + bb.setFromObject(object).getSize(size) + + object.matrix = oldMatrix + object.matrixAutoUpdate = oldMatrixAutoUpdate + root.updateMatrixWorld() } - const mainAxisShift = -rootWidth / 2 - const crossAxisShift = -rootHeight / 2 - return [mainAxisShift, crossAxisShift] as const } diff --git a/yarn.lock b/yarn.lock index a06dbc0..26bfa1a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1622,6 +1622,51 @@ prop-types "^15.6.1" react-lifecycles-compat "^3.0.4" +"@react-spring/animated@~9.2.5-beta.0": + version "9.2.5" + resolved "https://registry.yarnpkg.com/@react-spring/animated/-/animated-9.2.5.tgz#7c52e4845b572459a922bc8f5b07122293eede07" + integrity sha512-SDIgozNdxQ8xj4xbrF9aDsU/xyZ1WlnbnLo6BAvTciVIwp4Zhbt8keISQ2bq3THDjPxQbRTzxry8pSW2qUwXaw== + dependencies: + "@react-spring/shared" "~9.2.5-beta.0" + "@react-spring/types" "~9.2.5-beta.0" + +"@react-spring/core@~9.2.5-beta.0": + version "9.2.5" + resolved "https://registry.yarnpkg.com/@react-spring/core/-/core-9.2.5.tgz#fd0cae8e291467dcb94d5dc4eabe43e07cca9697" + integrity sha512-3pQOA1QyEu3/8tEfZ0DGklPULyM+bXqJE0JJ0S0lBUivd2MvxhVbJzqoHKxdoHI8CsVSFWMNwwJQ4Vd/XpAk8w== + dependencies: + "@react-spring/animated" "~9.2.5-beta.0" + "@react-spring/shared" "~9.2.5-beta.0" + "@react-spring/types" "~9.2.5-beta.0" + +"@react-spring/rafz@~9.2.5-beta.0": + version "9.2.5" + resolved "https://registry.yarnpkg.com/@react-spring/rafz/-/rafz-9.2.5.tgz#517b6bf6407dd791719e5aae11c18fd321c08af1" + integrity sha512-FZdbgcBMF1DM/eCnHZ28nHUG984gqcZHWlz2aIfj5TikPTzgVYDECCW/Pvt3ncHLTxikjYn2wvDV3/Q68yqv8A== + +"@react-spring/shared@~9.2.5-beta.0": + version "9.2.5" + resolved "https://registry.yarnpkg.com/@react-spring/shared/-/shared-9.2.5.tgz#ce96cd1063bd644e820b19d9f3ebce8f6077b872" + integrity sha512-kutUl8PN0xBSXBmpnHJVFDsgOCFP448syiCcRfdSsryO8kVfJOcSNT4BzIqmzDCWto/neBQJs2iEhKOZTfwQnA== + dependencies: + "@react-spring/rafz" "~9.2.5-beta.0" + "@react-spring/types" "~9.2.5-beta.0" + +"@react-spring/three@^9.2.4": + version "9.2.5" + resolved "https://registry.yarnpkg.com/@react-spring/three/-/three-9.2.5.tgz#1b66dfe4ca3982a3800410608e11894424940802" + integrity sha512-FtrxN6KDVMYaymMvwxNNZAJinLQHeKJ5TMQK+GqfLkFuJyNjUgiAThnAWYrp76iZrSR3AxOqNkZ36YZ7cjwNjA== + dependencies: + "@react-spring/animated" "~9.2.5-beta.0" + "@react-spring/core" "~9.2.5-beta.0" + "@react-spring/shared" "~9.2.5-beta.0" + "@react-spring/types" "~9.2.5-beta.0" + +"@react-spring/types@~9.2.5-beta.0": + version "9.2.5" + resolved "https://registry.yarnpkg.com/@react-spring/types/-/types-9.2.5.tgz#14eeca9ed7d5beed8c3fc943ee8f365c5c9fa635" + integrity sha512-ayitxzSUGO4MTQ6VOeNgUviTV/8nxjwGq6Ie+pFgv6JUlOecwdzo2/apEeHN6ae9tbcxQJx6nuDw/yb590M8Uw== + "@react-three/drei@^7.3.1": version "7.3.1" resolved "https://registry.yarnpkg.com/@react-three/drei/-/drei-7.3.1.tgz#df35632f41e4b23f8b68b887ed9d5af7a9ebf36a"