diff --git a/lib/ArcSegment.tsx b/lib/ArcSegment.tsx index 4469366..042fbd4 100644 --- a/lib/ArcSegment.tsx +++ b/lib/ArcSegment.tsx @@ -1,11 +1,9 @@ -import React, { useEffect, useImperativeHandle, useMemo } from 'react'; +import React from 'react'; import Two from 'two.js'; -import { useTwo } from './Context'; - import type { ArcSegment as Instance } from 'two.js/src/shapes/arc-segment'; import { PathProps } from './Path'; import { type EventHandlers } from './Properties'; -import { EVENT_HANDLER_NAMES } from './Events'; +import { useTwoObject } from './useTwoObject'; export type ArcSegmentProps = | PathProps @@ -13,6 +11,7 @@ export type ArcSegmentProps = | 'endAngle' | 'innerRadius' | 'outerRadius'; + type ComponentProps = React.PropsWithChildren< { [K in Extract]?: Instance[K]; @@ -26,84 +25,11 @@ type ComponentProps = React.PropsWithChildren< export type RefArcSegment = Instance; export const ArcSegment = React.forwardRef( - ({ x, y, resolution, ...props }, forwardedRef) => { - const { parent, registerEventShape, unregisterEventShape } = useTwo(); - - // Create the instance synchronously so it's available for refs immediately - const arcSegment = useMemo( - () => new Two.ArcSegment(0, 0, 0, 0, 0, 0, resolution), - [resolution] - ); - - // Extract event handlers from props - const { eventHandlers, shapeProps } = useMemo(() => { - const eventHandlers: Partial = {}; - const shapeProps: Record = {}; - - for (const key in props) { - if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - // An explicitly `undefined` handler means "not interactive", so it - // must not count toward the registered handler set. - const handler = props[key as keyof EventHandlers]; - if (handler !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - eventHandlers[key as keyof EventHandlers] = handler as any; - } - } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - shapeProps[key] = (props as any)[key]; - } - } - - return { eventHandlers, shapeProps }; - }, [props]); - - useEffect(() => { - if (parent) { - parent.add(arcSegment); - return () => { - parent.remove(arcSegment); - }; - } - }, [parent, arcSegment]); - - useEffect(() => { - // Update position - if (typeof x === 'number') arcSegment.translation.x = x; - if (typeof y === 'number') arcSegment.translation.y = y; - - // Update other properties (excluding event handlers) - for (const key in shapeProps) { - if (key in arcSegment) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (arcSegment as any)[key] = (shapeProps as any)[key]; - } - } - }, [shapeProps, arcSegment, x, y]); - - // Unregister on unmount only - useEffect(() => { - return () => { - unregisterEventShape(arcSegment); - }; - }, [arcSegment, unregisterEventShape]); - - // Register / update event handlers - useEffect(() => { - if (Object.keys(eventHandlers).length > 0) { - registerEventShape(arcSegment, eventHandlers, parent ?? undefined); - } else { - unregisterEventShape(arcSegment); - } - }, [ - arcSegment, - registerEventShape, - unregisterEventShape, - parent, - eventHandlers, - ]); - - useImperativeHandle(forwardedRef, () => arcSegment, [arcSegment]); + (props, forwardedRef) => { + useTwoObject(props, forwardedRef, { + factory: (p) => new Two.ArcSegment(0, 0, 0, 0, 0, 0, p.resolution), + constructionProps: ['resolution'], + }); return <>; } diff --git a/lib/Circle.tsx b/lib/Circle.tsx index 361f89e..ce944fb 100644 --- a/lib/Circle.tsx +++ b/lib/Circle.tsx @@ -1,11 +1,9 @@ -import React, { useEffect, useImperativeHandle, useMemo } from 'react'; +import React from 'react'; import Two from 'two.js'; -import { useTwo } from './Context'; - import type { Circle as Instance } from 'two.js/src/shapes/circle'; import { PathProps } from './Path'; import { type EventHandlers } from './Properties'; -import { EVENT_HANDLER_NAMES } from './Events'; +import { useTwoObject } from './useTwoObject'; export type CircleProps = PathProps | 'radius'; type ComponentProps = React.PropsWithChildren< @@ -21,85 +19,11 @@ type ComponentProps = React.PropsWithChildren< export type RefCircle = Instance; export const Circle = React.forwardRef( - ({ x, y, resolution, ...props }, forwardedRef) => { - const { parent, registerEventShape, unregisterEventShape } = useTwo(); - - // Create the instance synchronously so it's available for refs immediately - const circle = useMemo( - () => new Two.Circle(0, 0, 0, resolution), - [resolution] - ); - - // Extract event handlers from props - const { eventHandlers, shapeProps } = useMemo(() => { - const eventHandlers: Partial = {}; - const shapeProps: Record = {}; - - for (const key in props) { - if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - // An explicitly `undefined` handler means "not interactive", so it - // must not count toward the registered handler set. - const handler = props[key as keyof EventHandlers]; - if (handler !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - eventHandlers[key as keyof EventHandlers] = handler as any; - } - } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - shapeProps[key] = (props as any)[key]; - } - } - - return { eventHandlers, shapeProps }; - }, [props]); - - useEffect(() => { - // Update position - if (typeof x === 'number') circle.translation.x = x; - if (typeof y === 'number') circle.translation.y = y; - - // Update other properties (excluding event handlers) - for (const key in shapeProps) { - if (key in circle) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (circle as any)[key] = (shapeProps as any)[key]; - } - } - }, [circle, shapeProps, x, y]); - - useEffect(() => { - if (parent) { - parent.add(circle); - - return () => { - parent.remove(circle); - }; - } - }, [parent, circle]); - - // Unregister on unmount only - useEffect(() => { - return () => { - unregisterEventShape(circle); - }; - }, [circle, unregisterEventShape]); - - // Register / update event handlers - useEffect(() => { - if (Object.keys(eventHandlers).length > 0) { - registerEventShape(circle, eventHandlers, parent ?? undefined); - } else { - unregisterEventShape(circle); - } - }, [ - circle, - registerEventShape, - unregisterEventShape, - parent, - eventHandlers, - ]); - - useImperativeHandle(forwardedRef, () => circle, [circle]); + (props, forwardedRef) => { + useTwoObject(props, forwardedRef, { + factory: (p) => new Two.Circle(0, 0, 0, p.resolution), + constructionProps: ['resolution'], + }); return <>; } diff --git a/lib/Context.ts b/lib/Context.ts index 714c03d..127ff3b 100644 --- a/lib/Context.ts +++ b/lib/Context.ts @@ -17,8 +17,13 @@ export interface TwoCoreContextValue { export interface TwoParentContextValue { parent: Group | null; + attachChild?: (child: Shape | Group) => void; + detachChild?: (child: Shape | Group) => void; + registerChildOrder?: (child: Shape | Group) => void; } +export const ChildSlotContext = createContext(null); + export interface TwoSizeContextValue { width: number; height: number; diff --git a/lib/Ellipse.tsx b/lib/Ellipse.tsx index 2031a80..d509f17 100644 --- a/lib/Ellipse.tsx +++ b/lib/Ellipse.tsx @@ -1,11 +1,9 @@ -import React, { useEffect, useImperativeHandle, useMemo } from 'react'; +import React from 'react'; import Two from 'two.js'; -import { useTwo } from './Context'; - import type { Ellipse as Instance } from 'two.js/src/shapes/ellipse'; import { PathProps } from './Path'; import { type EventHandlers } from './Properties'; -import { EVENT_HANDLER_NAMES } from './Events'; +import { useTwoObject } from './useTwoObject'; export type EllipseProps = PathProps | 'width' | 'height'; type ComponentProps = React.PropsWithChildren< @@ -20,86 +18,12 @@ type ComponentProps = React.PropsWithChildren< export type RefEllipse = Instance; -export const Ellipse = React.forwardRef( - ({ x, y, resolution, ...props }, forwardedRef) => { - const { parent, registerEventShape, unregisterEventShape } = useTwo(); - - // Create the instance synchronously so it's available for refs immediately - const ellipse = useMemo( - () => new Two.Ellipse(0, 0, 0, 0, resolution), - [resolution] - ); - - // Extract event handlers from props - const { eventHandlers, shapeProps } = useMemo(() => { - const eventHandlers: Partial = {}; - const shapeProps: Record = {}; - - for (const key in props) { - if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - // An explicitly `undefined` handler means "not interactive", so it - // must not count toward the registered handler set. - const handler = props[key as keyof EventHandlers]; - if (handler !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - eventHandlers[key as keyof EventHandlers] = handler as any; - } - } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - shapeProps[key] = (props as any)[key]; - } - } - - return { eventHandlers, shapeProps }; - }, [props]); - - useEffect(() => { - if (parent) { - parent.add(ellipse); - - return () => { - parent.remove(ellipse); - }; - } - }, [parent, ellipse]); - - useEffect(() => { - // Update position - if (typeof x === 'number') ellipse.translation.x = x; - if (typeof y === 'number') ellipse.translation.y = y; - - // Update other properties (excluding event handlers) - for (const key in shapeProps) { - if (key in ellipse) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (ellipse as any)[key] = (shapeProps as any)[key]; - } - } - }, [ellipse, x, y, shapeProps]); - - // Unregister on unmount only - useEffect(() => { - return () => { - unregisterEventShape(ellipse); - }; - }, [ellipse, unregisterEventShape]); - - // Register / update event handlers - useEffect(() => { - if (Object.keys(eventHandlers).length > 0) { - registerEventShape(ellipse, eventHandlers, parent ?? undefined); - } else { - unregisterEventShape(ellipse); - } - }, [ - ellipse, - registerEventShape, - unregisterEventShape, - parent, - eventHandlers, - ]); - - useImperativeHandle(forwardedRef, () => ellipse, [ellipse]); +export const Ellipse = React.forwardRef( + (props, forwardedRef) => { + useTwoObject(props, forwardedRef, { + factory: (p) => new Two.Ellipse(0, 0, 0, 0, p.resolution), + constructionProps: ['resolution'], + }); return <>; } diff --git a/lib/Group.tsx b/lib/Group.tsx index f00f2a6..7185a1a 100644 --- a/lib/Group.tsx +++ b/lib/Group.tsx @@ -1,10 +1,8 @@ -import React, { useEffect, useImperativeHandle, useMemo } from 'react'; -import Two from 'two.js'; -import { Context, TwoParentContext, TwoSizeContext, useTwo } from './Context'; - +import React from 'react'; +import { Context, TwoParentContext, TwoSizeContext } from './Context'; import type { Group as Instance } from 'two.js/src/group'; import { ShapeProps, type EventHandlers } from './Properties'; -import { EVENT_HANDLER_NAMES } from './Events'; +import { useTwoGroup } from './useTwoObject'; export type GroupProps = | ShapeProps @@ -35,127 +33,20 @@ type ComponentProps = React.PropsWithChildren< export type RefGroup = Instance; export const Group = React.forwardRef( - ({ x, y, ...props }, forwardedRef) => { - const { - two, - parent, - width, - height, - registerEventShape, - unregisterEventShape, - hitTestPoint, - } = useTwo(); - - // Create the instance synchronously so it's available for refs immediately - const group = useMemo(() => new Two.Group(), []); - - // Extract event handlers from props - const { eventHandlers, shapeProps } = useMemo(() => { - const eventHandlers: Partial = {}; - const shapeProps: Record = {}; - - for (const key in props) { - if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - // An explicitly `undefined` handler means "not interactive", so it - // must not count toward the registered handler set. - const handler = props[key as keyof EventHandlers]; - if (handler !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - eventHandlers[key as keyof EventHandlers] = handler as any; - } - } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - shapeProps[key] = (props as any)[key]; - } - } - - return { eventHandlers, shapeProps }; - }, [props]); - - useEffect(() => { - if (parent) { - parent.add(group); - - return () => { - parent.remove(group); - }; - } - }, [parent, group]); - - useEffect(() => { - // Update position - if (typeof x === 'number') group.translation.x = x; - if (typeof y === 'number') group.translation.y = y; - - const args = { ...shapeProps }; - delete args.children; // Allow react to handle children - - // Update other properties (excluding event handlers) - for (const key in args) { - if (key in group) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (group as any)[key] = (args as any)[key]; - } - } - }, [group, x, y, shapeProps]); - - // Unregister on unmount only - useEffect(() => { - return () => { - unregisterEventShape(group); - }; - }, [group, unregisterEventShape]); - - // Register / update event handlers - useEffect(() => { - if (Object.keys(eventHandlers).length > 0) { - registerEventShape(group, eventHandlers, parent ?? undefined); - } else { - unregisterEventShape(group); - } - }, [ - group, - registerEventShape, - unregisterEventShape, - parent, - eventHandlers, - ]); - - useImperativeHandle(forwardedRef, () => group, [group]); - - const coreValue = useMemo( - () => ({ - two, - registerEventShape, - unregisterEventShape, - hitTestPoint, - }), - [two, registerEventShape, unregisterEventShape, hitTestPoint] - ); - - const parentValue = useMemo( - () => ({ - parent: group, - }), - [group] - ); - - const sizeValue = useMemo( - () => ({ - width, - height, - }), - [width, height] + (props, forwardedRef) => { + const { coreValue, parentValue, sizeValue, renderChildren } = useTwoGroup( + props, + forwardedRef, ); return ( - {props.children} + {renderChildren()} ); - } + }, ); diff --git a/lib/Image.tsx b/lib/Image.tsx index 61b75a3..7398306 100644 --- a/lib/Image.tsx +++ b/lib/Image.tsx @@ -1,12 +1,10 @@ -import React, { useEffect, useImperativeHandle, useMemo } from 'react'; +import React from 'react'; import Two from 'two.js'; -import { useTwo } from './Context'; - import type { Image as Instance } from 'two.js/src/effects/image'; import { RectangleProps } from './Rectangle'; import type { Texture } from 'two.js/src/effects/texture'; import { applyOrigin, type EventHandlers, type OriginProp } from './Properties'; -import { EVENT_HANDLER_NAMES } from './Events'; +import { useTwoObject } from './useTwoObject'; export type ImageProps = RectangleProps | 'mode' | 'texture'; @@ -28,88 +26,31 @@ type ComponentProps = React.PropsWithChildren< export type RefImage = Instance; export const Image = React.forwardRef( - ({ mode, src, texture, x, y, origin, ...props }, forwardedRef) => { - const { parent, registerEventShape, unregisterEventShape } = useTwo(); - - // Create the instance synchronously so it's available for refs immediately - const image = useMemo(() => new Two.Image(src), [src]); - - // Extract event handlers from props - const { eventHandlers, shapeProps } = useMemo(() => { - const eventHandlers: Partial = {}; - const shapeProps: Record = {}; - - for (const key in props) { - if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - // An explicitly `undefined` handler means "not interactive", so it - // must not count toward the registered handler set. - const handler = props[key as keyof EventHandlers]; - if (handler !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - eventHandlers[key as keyof EventHandlers] = handler as any; - } - } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - shapeProps[key] = (props as any)[key]; + (props, forwardedRef) => { + useTwoObject(props, forwardedRef, { + factory: (p) => new Two.Image(p.src), + constructionProps: ['src'], + specialProps: ['mode', 'texture', 'origin'], + applySpecialProps: (image, currentProps, changed, removed) => { + if ('origin' in changed) { + applyOrigin(image, currentProps.origin); + } else if (removed.includes('origin')) { + image.origin.set(0, 0); } - } - - return { eventHandlers, shapeProps }; - }, [props]); - - useEffect(() => { - if (parent) { - parent.add(image); - - return () => { - parent.remove(image); - }; - } - }, [parent, image]); - useEffect(() => { - if (typeof mode !== 'undefined') image.mode = mode; - if (typeof texture !== 'undefined') image.texture = texture; - - // Update position - if (typeof x === 'number') image.translation.x = x; - if (typeof y === 'number') image.translation.y = y; - - // Update origin - applyOrigin(image, origin); - - // Update other properties (excluding event handlers) - for (const key in shapeProps) { - if (key in image) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (image as any)[key] = (shapeProps as any)[key]; + if ('mode' in changed && currentProps.mode !== undefined) { + image.mode = currentProps.mode; + } else if (removed.includes('mode')) { + image.mode = 'fill'; } - } - }, [image, shapeProps, mode, texture, x, y, origin]); - // Unregister on unmount only - useEffect(() => { - return () => { - unregisterEventShape(image); - }; - }, [image, unregisterEventShape]); - - // Register / update event handlers - useEffect(() => { - if (Object.keys(eventHandlers).length > 0) { - registerEventShape(image, eventHandlers, parent ?? undefined); - } else { - unregisterEventShape(image); - } - }, [ - image, - registerEventShape, - unregisterEventShape, - parent, - eventHandlers, - ]); - - useImperativeHandle(forwardedRef, () => image, [image]); + if ('texture' in changed && currentProps.texture !== undefined) { + image.texture = currentProps.texture; + } else if (removed.includes('texture')) { + image.texture = null as unknown as Texture; + } + }, + }); return <>; } diff --git a/lib/ImageSequence.tsx b/lib/ImageSequence.tsx index 8ffad2a..7186385 100644 --- a/lib/ImageSequence.tsx +++ b/lib/ImageSequence.tsx @@ -1,12 +1,10 @@ -import React, { useEffect, useImperativeHandle, useMemo } from 'react'; +import React from 'react'; import Two from 'two.js'; -import { useTwo } from './Context'; - import type { ImageSequence as Instance } from 'two.js/src/effects/image-sequence'; import { RectangleProps } from './Rectangle'; import type { Texture } from 'two.js/src/effects/texture'; import { applyOrigin, type EventHandlers, type OriginProp } from './Properties'; -import { EVENT_HANDLER_NAMES } from './Events'; +import { useTwoObject } from './useTwoObject'; export type ImageSequenceProps = | RectangleProps @@ -34,91 +32,28 @@ type ComponentProps = React.PropsWithChildren< export type RefImageSequence = Instance; export const ImageSequence = React.forwardRef( - ({ src, x, y, origin, autoPlay, ...props }, forwardedRef) => { - const { parent, registerEventShape, unregisterEventShape } = useTwo(); - - // Create the instance synchronously so it's available for refs immediately - const imageSequence = useMemo(() => new Two.ImageSequence(src), [src]); - - // Extract event handlers from props - const { eventHandlers, shapeProps } = useMemo(() => { - const eventHandlers: Partial = {}; - const shapeProps: Record = {}; - - for (const key in props) { - if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - // An explicitly `undefined` handler means "not interactive", so it - // must not count toward the registered handler set. - const handler = props[key as keyof EventHandlers]; - if (handler !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - eventHandlers[key as keyof EventHandlers] = handler as any; - } - } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - shapeProps[key] = (props as any)[key]; + (props, forwardedRef) => { + useTwoObject(props, forwardedRef, { + factory: (p) => new Two.ImageSequence(p.src), + constructionProps: ['src'], + specialProps: ['autoPlay', 'origin'], + applySpecialProps: (seq, currentProps, changed, removed) => { + if ('origin' in changed) { + applyOrigin(seq, currentProps.origin); + } else if (removed.includes('origin')) { + seq.origin.set(0, 0); } - } - - return { eventHandlers, shapeProps }; - }, [props]); - - useEffect(() => { - if (parent) { - parent.add(imageSequence); - - return () => { - parent.remove(imageSequence); - }; - } - }, [parent, imageSequence]); - useEffect(() => { - if (autoPlay) { - imageSequence.play(); - } else { - imageSequence.pause(); - } - - // Update position - if (typeof x === 'number') imageSequence.translation.x = x; - if (typeof y === 'number') imageSequence.translation.y = y; - - // Update origin - applyOrigin(imageSequence, origin); - - // Update other properties (excluding event handlers) - for (const key in shapeProps) { - if (key in imageSequence) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (imageSequence as any)[key] = (shapeProps as any)[key]; + if (currentProps.autoPlay) { + seq.play(); + } else { + seq.pause(); } - } - }, [shapeProps, imageSequence, x, y, origin, autoPlay]); - - // Unregister on unmount only - useEffect(() => { - return () => { - unregisterEventShape(imageSequence); - }; - }, [imageSequence, unregisterEventShape]); - - // Register / update event handlers - useEffect(() => { - if (Object.keys(eventHandlers).length > 0) { - registerEventShape(imageSequence, eventHandlers, parent ?? undefined); - } else { - unregisterEventShape(imageSequence); - } - }, [ - imageSequence, - registerEventShape, - unregisterEventShape, - parent, - eventHandlers, - ]); - - useImperativeHandle(forwardedRef, () => imageSequence, [imageSequence]); + }, + disposeOwned: (seq) => { + seq.pause(); + }, + }); return <>; } diff --git a/lib/Line.tsx b/lib/Line.tsx index a93b2cc..394c86b 100644 --- a/lib/Line.tsx +++ b/lib/Line.tsx @@ -1,106 +1,73 @@ -import React, { useEffect, useImperativeHandle, useMemo } from 'react'; +import React from 'react'; import Two from 'two.js'; -import { useTwo } from './Context'; - import type { Line as Instance } from 'two.js/src/shapes/line'; import { PathProps } from './Path'; -import { type EventHandlers } from './Properties'; -import { EVENT_HANDLER_NAMES } from './Events'; +import { applyVector, type EventHandlers, type VectorProp } from './Properties'; +import { useTwoObject } from './useTwoObject'; export type LineProps = PathProps | 'left' | 'right'; type ComponentProps = React.PropsWithChildren< { - [K in Extract]?: Instance[K]; + [K in Extract]?: K extends 'left' | 'right' + ? VectorProp + : Instance[K]; } & { x1?: number; y1?: number; x2?: number; y2?: number; + left?: VectorProp; + right?: VectorProp; } & Partial >; export type RefLine = Instance; export const Line = React.forwardRef( - ({ x1, y1, x2, y2, ...props }, forwardedRef) => { - const { parent, registerEventShape, unregisterEventShape } = useTwo(); - - // Create the instance synchronously so it's available for refs immediately - const line = useMemo(() => new Two.Line(), []); - - // Extract event handlers from props - const { eventHandlers, shapeProps } = useMemo(() => { - const eventHandlers: Partial = {}; - const shapeProps: Record = {}; - - for (const key in props) { - if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - // An explicitly `undefined` handler means "not interactive", so it - // must not count toward the registered handler set. - const handler = props[key as keyof EventHandlers]; - if (handler !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - eventHandlers[key as keyof EventHandlers] = handler as any; - } - } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - shapeProps[key] = (props as any)[key]; + (props, forwardedRef) => { + useTwoObject(props, forwardedRef, { + factory: () => new Two.Line(), + specialProps: ['x1', 'y1', 'x2', 'y2', 'left', 'right'], + applySpecialProps: (line, currentProps, changed, removed) => { + if ('left' in changed) { + applyVector(line.left, currentProps.left); + } + if ('right' in changed) { + applyVector(line.right, currentProps.right); } - } - - return { eventHandlers, shapeProps }; - }, [props]); - - useEffect(() => { - if (parent) { - parent.add(line); - - return () => { - parent.remove(line); - }; - } - }, [parent, line]); - - useEffect(() => { - // Update vertices - if (typeof x1 === 'number') line.left.x = x1; - if (typeof y1 === 'number') line.left.y = y1; - if (typeof x2 === 'number') line.right.x = x2; - if (typeof y2 === 'number') line.right.y = y2; + if ('x1' in changed) { + line.left.x = typeof currentProps.x1 === 'number' ? currentProps.x1 : 0; + } else if (removed.includes('x1') && !('left' in currentProps)) { + line.left.x = 0; + } - // Update other properties (excluding event handlers) - for (const key in shapeProps) { - if (key in line) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (line as any)[key] = (shapeProps as any)[key]; + if ('y1' in changed) { + line.left.y = typeof currentProps.y1 === 'number' ? currentProps.y1 : 0; + } else if (removed.includes('y1') && !('left' in currentProps)) { + line.left.y = 0; } - } - }, [shapeProps, line, x1, y1, x2, y2]); - // Unregister on unmount only - useEffect(() => { - return () => { - unregisterEventShape(line); - }; - }, [line, unregisterEventShape]); + if ('x2' in changed) { + line.right.x = typeof currentProps.x2 === 'number' ? currentProps.x2 : 0; + } else if (removed.includes('x2') && !('right' in currentProps)) { + line.right.x = 0; + } - // Register / update event handlers - useEffect(() => { - if (Object.keys(eventHandlers).length > 0) { - registerEventShape(line, eventHandlers, parent ?? undefined); - } else { - unregisterEventShape(line); - } - }, [ - line, - registerEventShape, - unregisterEventShape, - parent, - eventHandlers, - ]); + if ('y2' in changed) { + line.right.y = typeof currentProps.y2 === 'number' ? currentProps.y2 : 0; + } else if (removed.includes('y2') && !('right' in currentProps)) { + line.right.y = 0; + } - useImperativeHandle(forwardedRef, () => line, [line]); + if (removed.includes('left') && !('x1' in currentProps) && !('y1' in currentProps)) { + line.left.set(0, 0); + } + if (removed.includes('right') && !('x2' in currentProps) && !('y2' in currentProps)) { + line.right.set(0, 0); + } + }, + }); return <>; } diff --git a/lib/LinearGradient.tsx b/lib/LinearGradient.tsx index 581db22..86d6b29 100644 --- a/lib/LinearGradient.tsx +++ b/lib/LinearGradient.tsx @@ -1,53 +1,78 @@ -import React, { useImperativeHandle, useEffect, useMemo } from 'react'; +import React from 'react'; import Two from 'two.js'; - import type { LinearGradient as Instance } from 'two.js/src/effects/linear-gradient'; -import { GradientProps } from './Properties'; +import { applyVector, GradientProps, type VectorProp } from './Properties'; +import { useTwoObject } from './useTwoObject'; export type LinearGradientProps = GradientProps | 'left' | 'right'; type ComponentProps = React.PropsWithChildren< { - [K in Extract]?: Instance[K]; + [K in Extract]?: K extends 'left' | 'right' + ? VectorProp + : Instance[K]; } & { x1?: number; y1?: number; x2?: number; y2?: number; + left?: VectorProp; + right?: VectorProp; } >; export type RefLinearGradient = Instance; export const LinearGradient = React.forwardRef( - ({ x1, y1, x2, y2, ...props }, forwardedRef) => { - const gradient = useMemo(() => new Two.LinearGradient(), []); - - useEffect(() => { - if (typeof x1 === 'number') { - gradient.left.x = x1; - } - if (typeof y1 === 'number') { - gradient.left.y = y1; - } - if (typeof x2 === 'number') { - gradient.right.x = x2; - } - if (typeof y2 === 'number') { - gradient.right.y = y2; - } - - // Update other properties (excluding event handlers) - for (const key in props) { - if (key in gradient) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (gradient as any)[key] = (props as any)[key]; - } - } - }, [gradient, x1, y1, x2, y2, props]); - - useImperativeHandle(forwardedRef, () => gradient, [gradient]); - - return null; // No visual representation + (props, forwardedRef) => { + useTwoObject(props as Record, forwardedRef, { + factory: () => new Two.LinearGradient(), + isSceneObject: false, + specialProps: ['x1', 'y1', 'x2', 'y2', 'left', 'right'], + applySpecialProps: (gradient, currentProps, changed, removed) => { + const p = currentProps as unknown as ComponentProps; + const grad = gradient as unknown as Instance; + + if ('left' in changed) { + applyVector(grad.left, p.left); + } + if ('right' in changed) { + applyVector(grad.right, p.right); + } + + if ('x1' in changed) { + grad.left.x = typeof p.x1 === 'number' ? p.x1 : 0; + } else if (removed.includes('x1') && !('left' in p)) { + grad.left.x = 0; + } + + if ('y1' in changed) { + grad.left.y = typeof p.y1 === 'number' ? p.y1 : 0; + } else if (removed.includes('y1') && !('left' in p)) { + grad.left.y = 0; + } + + if ('x2' in changed) { + grad.right.x = typeof p.x2 === 'number' ? p.x2 : 0; + } else if (removed.includes('x2') && !('right' in p)) { + grad.right.x = 0; + } + + if ('y2' in changed) { + grad.right.y = typeof p.y2 === 'number' ? p.y2 : 0; + } else if (removed.includes('y2') && !('right' in p)) { + grad.right.y = 0; + } + + if (removed.includes('left') && !('x1' in p) && !('y1' in p)) { + grad.left.set(0, 0); + } + if (removed.includes('right') && !('x2' in p) && !('y2' in p)) { + grad.right.set(0, 0); + } + }, + }); + + return null; } ); diff --git a/lib/Path.tsx b/lib/Path.tsx index ff8e083..e75170b 100644 --- a/lib/Path.tsx +++ b/lib/Path.tsx @@ -1,10 +1,8 @@ -import React, { useEffect, useImperativeHandle, useMemo } from 'react'; +import React from 'react'; import Two from 'two.js'; -import { useTwo } from './Context'; - import type { Path as Instance } from 'two.js/src/path'; import { ShapeProps, type EventHandlers } from './Properties'; -import { EVENT_HANDLER_NAMES } from './Events'; +import { useTwoObject } from './useTwoObject'; export type PathProps = | ShapeProps @@ -39,87 +37,19 @@ type ComponentProps = React.PropsWithChildren< export type RefPath = Instance; export const Path = React.forwardRef( - ({ manual, x, y, ...props }, forwardedRef) => { - const { parent, registerEventShape, unregisterEventShape } = useTwo(); - - // Create the instance synchronously so it's available for refs immediately - const path = useMemo(() => new Two.Path(), []); - - // Extract event handlers from props - const { eventHandlers, shapeProps } = useMemo(() => { - const eventHandlers: Partial = {}; - const shapeProps: Record = {}; - - for (const key in props) { - if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - // An explicitly `undefined` handler means "not interactive", so it - // must not count toward the registered handler set. - const handler = props[key as keyof EventHandlers]; - if (handler !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - eventHandlers[key as keyof EventHandlers] = handler as any; - } - } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - shapeProps[key] = (props as any)[key]; - } - } - - return { eventHandlers, shapeProps }; - }, [props]); - - useEffect(() => { - if (parent) { - parent.add(path); - - return () => { - parent.remove(path); - }; - } - }, [parent, path]); - - // Unregister on unmount only - useEffect(() => { - return () => { - unregisterEventShape(path); - }; - }, [path, unregisterEventShape]); - - // Register / update event handlers - useEffect(() => { - if (Object.keys(eventHandlers).length > 0) { - registerEventShape(path, eventHandlers, parent ?? undefined); - } else { - unregisterEventShape(path); - } - }, [ - path, - registerEventShape, - unregisterEventShape, - parent, - eventHandlers, - ]); - - useEffect(() => { - // Update position - if (typeof x === 'number') path.translation.x = x; - if (typeof y === 'number') path.translation.y = y; - - if (typeof manual !== 'undefined') { - path.automatic = !manual; - } - - // Update other properties (excluding event handlers) - for (const key in shapeProps) { - if (key in path) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (path as any)[key] = (shapeProps as any)[key]; + (props, forwardedRef) => { + useTwoObject(props, forwardedRef, { + factory: () => new Two.Path(), + specialProps: ['manual'], + applySpecialProps: (path, currentProps, changed, removed) => { + if ('manual' in changed) { + path.automatic = !currentProps.manual; + } else if (removed.includes('manual')) { + path.automatic = true; } - } - }, [shapeProps, path, x, y, manual]); - - useImperativeHandle(forwardedRef, () => path, [path]); + }, + }); return <>; - } + }, ); diff --git a/lib/Points.tsx b/lib/Points.tsx index cc992b2..d54118a 100644 --- a/lib/Points.tsx +++ b/lib/Points.tsx @@ -1,10 +1,8 @@ -import React, { useEffect, useImperativeHandle, useMemo } from 'react'; +import React from 'react'; import Two from 'two.js'; -import { useTwo } from './Context'; - import type { Points as Instance } from 'two.js/src/shapes/points'; import { ShapeProps, type EventHandlers } from './Properties'; -import { EVENT_HANDLER_NAMES } from './Events'; +import { useTwoObject } from './useTwoObject'; export type PointsProps = | ShapeProps @@ -32,83 +30,11 @@ type ComponentProps = React.PropsWithChildren< export type RefPoints = Instance; export const Points = React.forwardRef( - ({ x, y, ...props }, forwardedRef) => { - const { parent, registerEventShape, unregisterEventShape } = useTwo(); - - // Create the instance synchronously so it's available for refs immediately - const points = useMemo(() => new Two.Points(), []); - - // Extract event handlers from props - const { eventHandlers, shapeProps } = useMemo(() => { - const eventHandlers: Partial = {}; - const shapeProps: Record = {}; - - for (const key in props) { - if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - // An explicitly `undefined` handler means "not interactive", so it - // must not count toward the registered handler set. - const handler = props[key as keyof EventHandlers]; - if (handler !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - eventHandlers[key as keyof EventHandlers] = handler as any; - } - } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - shapeProps[key] = (props as any)[key]; - } - } - - return { eventHandlers, shapeProps }; - }, [props]); - - useEffect(() => { - if (parent) { - parent.add(points); - - return () => { - parent.remove(points); - }; - } - }, [parent, points]); - - useEffect(() => { - // Update position - if (typeof x === 'number') points.translation.x = x; - if (typeof y === 'number') points.translation.y = y; - - // Update other properties (excluding event handlers) - for (const key in shapeProps) { - if (key in points) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (points as any)[key] = (shapeProps as any)[key]; - } - } - }, [shapeProps, points, x, y]); - - // Unregister on unmount only - useEffect(() => { - return () => { - unregisterEventShape(points); - }; - }, [points, unregisterEventShape]); - - // Register / update event handlers - useEffect(() => { - if (Object.keys(eventHandlers).length > 0) { - registerEventShape(points, eventHandlers, parent ?? undefined); - } else { - unregisterEventShape(points); - } - }, [ - points, - registerEventShape, - unregisterEventShape, - parent, - eventHandlers, - ]); - - useImperativeHandle(forwardedRef, () => points, [points]); + (props, forwardedRef) => { + useTwoObject(props, forwardedRef, { + factory: () => new Two.Points(), + }); return <>; - } + }, ); diff --git a/lib/Polygon.tsx b/lib/Polygon.tsx index 293c337..569f61e 100644 --- a/lib/Polygon.tsx +++ b/lib/Polygon.tsx @@ -1,11 +1,9 @@ -import React, { useEffect, useImperativeHandle, useMemo } from 'react'; +import React from 'react'; import Two from 'two.js'; -import { useTwo } from './Context'; - import type { Polygon as Instance } from 'two.js/src/shapes/polygon'; import { PathProps } from './Path'; import { type EventHandlers } from './Properties'; -import { EVENT_HANDLER_NAMES } from './Events'; +import { useTwoObject } from './useTwoObject'; export type PolygonProps = PathProps | 'width' | 'height' | 'sides' | 'radius'; type ComponentProps = React.PropsWithChildren< @@ -21,82 +19,11 @@ type ComponentProps = React.PropsWithChildren< export type RefPolygon = Instance; export const Polygon = React.forwardRef( - ({ x, y, ...props }, forwardedRef) => { - const { parent, registerEventShape, unregisterEventShape } = useTwo(); - - // Create the instance synchronously so it's available for refs immediately - const polygon = useMemo(() => new Two.Polygon(), []); - - // Extract event handlers from props - const { eventHandlers, shapeProps } = useMemo(() => { - const eventHandlers: Partial = {}; - const shapeProps: Record = {}; - - for (const key in props) { - if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - // An explicitly `undefined` handler means "not interactive", so it - // must not count toward the registered handler set. - const handler = props[key as keyof EventHandlers]; - if (handler !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - eventHandlers[key as keyof EventHandlers] = handler as any; - } - } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - shapeProps[key] = (props as any)[key]; - } - } - - return { eventHandlers, shapeProps }; - }, [props]); - - useEffect(() => { - if (parent) { - parent.add(polygon); - - return () => { - parent.remove(polygon); - }; - } - }, [parent, polygon]); - - useEffect(() => { - // Update position - if (typeof x === 'number') polygon.translation.x = x; - if (typeof y === 'number') polygon.translation.y = y; - - // Update other properties (excluding event handlers) - for (const key in shapeProps) { - if (key in polygon) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (polygon as any)[key] = (shapeProps as any)[key]; - } - } - }, [shapeProps, polygon, x, y]); - - // Unregister on unmount only - useEffect(() => { - return () => { - unregisterEventShape(polygon); - }; - }, [polygon, unregisterEventShape]); - - // Register / update event handlers - useEffect(() => { - if (Object.keys(eventHandlers).length > 0) { - registerEventShape(polygon, eventHandlers, parent ?? undefined); - } else { - unregisterEventShape(polygon); - } - }, [ - polygon, - registerEventShape, - unregisterEventShape, - parent, - eventHandlers, - ]); - - useImperativeHandle(forwardedRef, () => polygon, [polygon]); + (props, forwardedRef) => { + useTwoObject(props, forwardedRef, { + factory: (p) => new Two.Polygon(0, 0, p.radius, p.sides), + constructionProps: ['sides'], + }); return <>; } diff --git a/lib/Properties.ts b/lib/Properties.ts index f7609b9..9285691 100644 --- a/lib/Properties.ts +++ b/lib/Properties.ts @@ -36,12 +36,41 @@ export const GRADIENT_PROPERTIES = [ 'stops', ] as const; -export type OriginProp = +export type VectorProp = | Vector | { x?: number; y?: number } | readonly [number, number] | [number, number]; +export type OriginProp = VectorProp; + +export type ScaleProp = + | number + | Vector + | { x?: number; y?: number } + | readonly [number, number] + | [number, number]; + +/** + * Normalizes and applies vector coordinates onto a Two.js Vector instance. + */ +export function applyVector( + target: Vector, + source: VectorProp | undefined +): void { + if (typeof source === 'undefined') return; + + if (source instanceof Two.Vector) { + target.copy(source); + } else if (Array.isArray(source) && source.length >= 2) { + target.set(source[0], source[1]); + } else if (typeof source === 'object' && source !== null) { + const obj = source as { x?: number; y?: number }; + if (typeof obj.x === 'number') target.x = obj.x; + if (typeof obj.y === 'number') target.y = obj.y; + } +} + /** * Normalizes and applies origin coordinates onto a Two.js shape instance. */ @@ -62,4 +91,34 @@ export function applyOrigin( } } +/** + * Normalizes and applies scale onto a Two.js object. + * Protects against object assignments that would otherwise cause _matrix to produce NaN. + */ +export function applyScale( + instance: { scale: number | Vector }, + scale: ScaleProp | undefined +): void { + if (typeof scale === 'undefined') return; + if (typeof scale === 'number') { + instance.scale = scale; + } else if (scale instanceof Two.Vector) { + instance.scale = scale; + } else if (Array.isArray(scale) && scale.length >= 2) { + if (instance.scale instanceof Two.Vector) { + instance.scale.set(scale[0], scale[1]); + } else { + instance.scale = new Two.Vector(scale[0], scale[1]); + } + } else if (typeof scale === 'object' && scale !== null) { + const obj = scale as { x?: number; y?: number }; + const sx = typeof obj.x === 'number' ? obj.x : 1; + const sy = typeof obj.y === 'number' ? obj.y : sx; + if (instance.scale instanceof Two.Vector) { + instance.scale.set(sx, sy); + } else { + instance.scale = new Two.Vector(sx, sy); + } + } +} diff --git a/lib/Provider.tsx b/lib/Provider.tsx index 5e48cce..8787f47 100644 --- a/lib/Provider.tsx +++ b/lib/Provider.tsx @@ -11,11 +11,14 @@ import Two from 'two.js'; import type { Shape } from 'two.js/src/shape'; import type { Group } from 'two.js/src/group'; import { + ChildSlotContext, TwoCoreContext, TwoParentContext, TwoSizeContext, useTwo, + type TwoParentContextValue, } from './Context'; +import { reconcileSceneOrder } from './reconciliation'; import type { EventHandlers } from './Events'; import { clientToWorldPoint, @@ -134,12 +137,35 @@ export const Provider = React.forwardRef< const eventShapes = useRef>(new Map()); const hoveredShapes = useRef>(new Set()); const capturedShape = useRef(null); + const childrenOrderRef = useRef>([]); + const attachedChildrenRef = useRef>(new Set()); const [twoState, setTwoState] = useState(two); const [parentState, setParentState] = useState(parent); const [width, setWidth] = useState(0); const [height, setHeight] = useState(0); + const attachChild = useCallback((child: Shape | Group) => { + attachedChildrenRef.current.add(child); + parentState?.add(child); + }, [parentState]); + + const detachChild = useCallback((child: Shape | Group) => { + attachedChildrenRef.current.delete(child); + parentState?.remove(child); + }, [parentState]); + + const registerChildOrder = useCallback((child: Shape | Group) => { + childrenOrderRef.current.push(child); + }, []); + + useLayoutEffect(() => { + if (parentState && childrenOrderRef.current.length > 0) { + reconcileSceneOrder(parentState, childrenOrderRef.current); + childrenOrderRef.current = []; + } + }); + // Separate Two.js constructor props from DOM element props const { twoProps, domProps } = useMemo(() => { const twoProps: Record = {}; @@ -226,6 +252,10 @@ export const Provider = React.forwardRef< setWidth(two.width); setHeight(two.height); + const shapes = eventShapes.current; + const hovered = hoveredShapes.current; + const attached = attachedChildrenRef.current; + return () => { two.renderer.domElement.parentElement?.removeChild( two.renderer.domElement, @@ -238,6 +268,11 @@ export const Provider = React.forwardRef< Two.Instances.splice(index, 1); } two.clear(); + shapes.clear(); + hovered.clear(); + capturedShape.current = null; + attached.clear(); + childrenOrderRef.current = []; }; } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -587,11 +622,14 @@ export const Provider = React.forwardRef< [twoState, registerEventShape, unregisterEventShape, hitTestPoint], ); - const parentValue = useMemo( + const parentValue = useMemo( () => ({ parent: parentState, + attachChild, + detachChild, + registerChildOrder, }), - [parentState], + [parentState, attachChild, detachChild, registerChildOrder], ); const sizeValue = useMemo( @@ -607,7 +645,14 @@ export const Provider = React.forwardRef<
- {props.children} + {React.Children.map(props.children, (child, index) => { + if (!React.isValidElement(child)) return child; + return ( + + {child} + + ); + })}
diff --git a/lib/RadialGradient.tsx b/lib/RadialGradient.tsx index b0b38ae..4c638bd 100644 --- a/lib/RadialGradient.tsx +++ b/lib/RadialGradient.tsx @@ -1,46 +1,78 @@ -import React, { useImperativeHandle, useEffect, useMemo } from 'react'; +import React from 'react'; import Two from 'two.js'; - import type { RadialGradient as Instance } from 'two.js/src/effects/radial-gradient'; -import { GradientProps } from './Properties'; +import { applyVector, GradientProps, type VectorProp } from './Properties'; +import { useTwoObject } from './useTwoObject'; export type RadialGradientProps = GradientProps | 'center' | 'radius' | 'focal'; type ComponentProps = React.PropsWithChildren< { - [K in Extract]?: Instance[K]; + [K in Extract]?: K extends 'center' | 'focal' + ? VectorProp + : Instance[K]; } & { x?: number; y?: number; focalX?: number; focalY?: number; + center?: VectorProp; + focal?: VectorProp; } >; export type RefRadialGradient = Instance; export const RadialGradient = React.forwardRef( - ({ x, y, focalX, focalY, ...props }, forwardedRef) => { - const radialGradient = useMemo(() => new Two.RadialGradient(), []); + (props, forwardedRef) => { + useTwoObject(props as Record, forwardedRef, { + factory: () => new Two.RadialGradient(), + isSceneObject: false, + specialProps: ['x', 'y', 'focalX', 'focalY', 'center', 'focal'], + applySpecialProps: (gradient, currentProps, changed, removed) => { + const p = currentProps as unknown as ComponentProps; + const grad = gradient as unknown as Instance; + + if ('center' in changed) { + applyVector(grad.center, p.center); + } + if ('focal' in changed) { + applyVector(grad.focal, p.focal); + } + + if ('x' in changed) { + grad.center.x = typeof p.x === 'number' ? p.x : 0; + } else if (removed.includes('x') && !('center' in p)) { + grad.center.x = 0; + } - useEffect(() => { - if (typeof x === 'number') radialGradient.center.x = x; - if (typeof y === 'number') radialGradient.center.y = y; + if ('y' in changed) { + grad.center.y = typeof p.y === 'number' ? p.y : 0; + } else if (removed.includes('y') && !('center' in p)) { + grad.center.y = 0; + } - if (typeof focalX === 'number') radialGradient.focal.x = focalX; - if (typeof focalY === 'number') radialGradient.focal.y = focalY; + if ('focalX' in changed) { + grad.focal.x = typeof p.focalX === 'number' ? p.focalX : 0; + } else if (removed.includes('focalX') && !('focal' in p)) { + grad.focal.x = 0; + } - // Update other properties (excluding event handlers) - for (const key in props) { - if (key in radialGradient) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (radialGradient as any)[key] = (props as any)[key]; + if ('focalY' in changed) { + grad.focal.y = typeof p.focalY === 'number' ? p.focalY : 0; + } else if (removed.includes('focalY') && !('focal' in p)) { + grad.focal.y = 0; } - } - }, [props, radialGradient, x, y, focalX, focalY]); - useImperativeHandle(forwardedRef, () => radialGradient, [radialGradient]); + if (removed.includes('center') && !('x' in p) && !('y' in p)) { + grad.center.set(0, 0); + } + if (removed.includes('focal') && !('focalX' in p) && !('focalY' in p)) { + grad.focal.set(0, 0); + } + }, + }); - return null; // No visual representation + return null; } ); diff --git a/lib/Rectangle.tsx b/lib/Rectangle.tsx index 71301f5..dc89203 100644 --- a/lib/Rectangle.tsx +++ b/lib/Rectangle.tsx @@ -1,11 +1,9 @@ -import React, { useEffect, useImperativeHandle, useMemo } from 'react'; +import React from 'react'; import Two from 'two.js'; -import { useTwo } from './Context'; - import type { Rectangle as Instance } from 'two.js/src/shapes/rectangle'; import { PathProps } from './Path'; import { applyOrigin, type EventHandlers, type OriginProp } from './Properties'; -import { EVENT_HANDLER_NAMES } from './Events'; +import { useTwoObject } from './useTwoObject'; export type RectangleProps = PathProps | 'width' | 'height' | 'origin'; type ComponentProps = React.PropsWithChildren< @@ -23,85 +21,18 @@ type ComponentProps = React.PropsWithChildren< export type RefRectangle = Instance; export const Rectangle = React.forwardRef( - ({ x, y, origin, ...props }, forwardedRef) => { - const { parent, registerEventShape, unregisterEventShape } = useTwo(); - - // Create the instance synchronously so it's available for refs immediately - const rectangle = useMemo(() => new Two.Rectangle(), []); - - // Extract event handlers from props - const { eventHandlers, shapeProps } = useMemo(() => { - const eventHandlers: Partial = {}; - const shapeProps: Record = {}; - - for (const key in props) { - if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - // An explicitly `undefined` handler means "not interactive", so it - // must not count toward the registered handler set. - const handler = props[key as keyof EventHandlers]; - if (handler !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - eventHandlers[key as keyof EventHandlers] = handler as any; - } - } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - shapeProps[key] = (props as any)[key]; - } - } - - return { eventHandlers, shapeProps }; - }, [props]); - - useEffect(() => { - if (parent) { - parent.add(rectangle); - - return () => { - parent.remove(rectangle); - }; - } - }, [parent, rectangle]); - - useEffect(() => { - // Update position - if (typeof x === 'number') rectangle.translation.x = x; - if (typeof y === 'number') rectangle.translation.y = y; - - // Update origin - applyOrigin(rectangle, origin); - - // Update other properties (excluding event handlers) - for (const key in shapeProps) { - if (key in rectangle) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (rectangle as any)[key] = (shapeProps as any)[key]; + (props, forwardedRef) => { + useTwoObject(props, forwardedRef, { + factory: () => new Two.Rectangle(), + specialProps: ['origin'], + applySpecialProps: (rectangle, currentProps, changed, removed) => { + if ('origin' in changed) { + applyOrigin(rectangle, currentProps.origin); + } else if (removed.includes('origin')) { + rectangle.origin.set(0, 0); } - } - }, [shapeProps, rectangle, x, y, origin]); - - // Unregister on unmount only - useEffect(() => { - return () => { - unregisterEventShape(rectangle); - }; - }, [rectangle, unregisterEventShape]); - - // Register / update event handlers - useEffect(() => { - if (Object.keys(eventHandlers).length > 0) { - registerEventShape(rectangle, eventHandlers, parent ?? undefined); - } else { - unregisterEventShape(rectangle); - } - }, [ - rectangle, - registerEventShape, - unregisterEventShape, - parent, - eventHandlers, - ]); - - useImperativeHandle(forwardedRef, () => rectangle, [rectangle]); + }, + }); return <>; } diff --git a/lib/RoundedRectangle.tsx b/lib/RoundedRectangle.tsx index 57f1199..14c4551 100644 --- a/lib/RoundedRectangle.tsx +++ b/lib/RoundedRectangle.tsx @@ -1,11 +1,9 @@ -import React, { useEffect, useImperativeHandle, useMemo } from 'react'; +import React from 'react'; import Two from 'two.js'; -import { useTwo } from './Context'; - import type { RoundedRectangle as Instance } from 'two.js/src/shapes/rounded-rectangle'; import { PathProps } from './Path'; import { type EventHandlers } from './Properties'; -import { EVENT_HANDLER_NAMES } from './Events'; +import { useTwoObject } from './useTwoObject'; export type RoundedRectangleProps = PathProps | 'width' | 'height' | 'radius'; type ComponentProps = React.PropsWithChildren< @@ -20,88 +18,10 @@ type ComponentProps = React.PropsWithChildren< export type RefRoundedRectangle = Instance; export const RoundedRectangle = React.forwardRef( - ({ x, y, ...props }, forwardedRef) => { - const { parent, registerEventShape, unregisterEventShape } = useTwo(); - - // Create the instance synchronously so it's available for refs immediately - const roundedRectangle = useMemo(() => new Two.RoundedRectangle(), []); - - // Extract event handlers from props - const { eventHandlers, shapeProps } = useMemo(() => { - const eventHandlers: Partial = {}; - const shapeProps: Record = {}; - - for (const key in props) { - if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - // An explicitly `undefined` handler means "not interactive", so it - // must not count toward the registered handler set. - const handler = props[key as keyof EventHandlers]; - if (handler !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - eventHandlers[key as keyof EventHandlers] = handler as any; - } - } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - shapeProps[key] = (props as any)[key]; - } - } - - return { eventHandlers, shapeProps }; - }, [props]); - - useEffect(() => { - if (parent) { - parent.add(roundedRectangle); - - return () => { - parent.remove(roundedRectangle); - }; - } - }, [parent, roundedRectangle]); - - useEffect(() => { - // Update position - if (typeof x === 'number') roundedRectangle.translation.x = x; - if (typeof y === 'number') roundedRectangle.translation.y = y; - - // Update other properties (excluding event handlers) - for (const key in shapeProps) { - if (key in roundedRectangle) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (roundedRectangle as any)[key] = (shapeProps as any)[key]; - } - } - }, [shapeProps, roundedRectangle, x, y]); - - // Unregister on unmount only - useEffect(() => { - return () => { - unregisterEventShape(roundedRectangle); - }; - }, [roundedRectangle, unregisterEventShape]); - - // Register / update event handlers - useEffect(() => { - if (Object.keys(eventHandlers).length > 0) { - registerEventShape( - roundedRectangle, - eventHandlers, - parent ?? undefined - ); - } else { - unregisterEventShape(roundedRectangle); - } - }, [ - roundedRectangle, - registerEventShape, - unregisterEventShape, - parent, - eventHandlers, - ]); - - useImperativeHandle(forwardedRef, () => roundedRectangle, [ - roundedRectangle, - ]); + (props, forwardedRef) => { + useTwoObject(props, forwardedRef, { + factory: () => new Two.RoundedRectangle(), + }); return <>; } diff --git a/lib/SVG.tsx b/lib/SVG.tsx index f5b41b0..08a489b 100644 --- a/lib/SVG.tsx +++ b/lib/SVG.tsx @@ -1,11 +1,9 @@ -import React, { useEffect, useImperativeHandle, useMemo, useRef } from 'react'; -import Two from 'two.js'; +import React, { useEffect, useRef } from 'react'; import { Context, TwoParentContext, TwoSizeContext, useTwo } from './Context'; - import type { Group as Instance } from 'two.js/src/group'; import { type EventHandlers } from './Properties'; -import { EVENT_HANDLER_NAMES } from './Events'; import type { GroupProps } from './Group'; +import { useTwoGroup } from './useTwoObject'; export type SVGProps = GroupProps; @@ -14,42 +12,34 @@ type ComponentProps = React.PropsWithChildren< [K in Extract]?: Instance[K]; } & ( | { - // Source (one required) - src: string; // URL to .svg file - content?: never; // Inline SVG markup string + src: string; + content?: never; } | { - src?: never; // URL to .svg file - content: string; // Inline SVG markup string + src?: never; + content: string; } ) & { x?: number; y?: number; onLoad?: (group: Instance, svg: SVGElement | SVGElement[]) => void; onError?: (error: Error) => void; - shallow?: boolean; // Flatten groups when interpreting + shallow?: boolean; } & Partial >; export type RefSVG = Instance; export const SVG = React.forwardRef( - ({ x, y, src, content, onLoad, onError, ...props }, forwardedRef) => { - const { - two, - parent, - width, - height, - registerEventShape, - unregisterEventShape, - hitTestPoint, - } = useTwo(); - const svg = useMemo(() => new Two.Group(), []); - const ref = useRef(null); + (props, forwardedRef) => { + const { two } = useTwo(); + const { src, content, onLoad, onError, ...restProps } = props; + const onLoadRef = useRef(onLoad); const onErrorRef = useRef(onError); + const rafRef = useRef(null); const lastLoadedSource = useRef<{ - two: Two | null; + two: unknown; key: string | null; }>({ two: null, key: null }); @@ -61,44 +51,15 @@ export const SVG = React.forwardRef( onErrorRef.current = onError; }, [onError]); - // Extract event handlers from props - const { eventHandlers, shapeProps } = useMemo(() => { - const eventHandlers: Partial = {}; - const shapeProps: Record = {}; - - for (const key in props) { - if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - // An explicitly `undefined` handler means "not interactive", so it - // must not count toward the registered handler set. - const handler = props[key as keyof EventHandlers]; - if (handler !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - eventHandlers[key as keyof EventHandlers] = handler as any; - } - } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - shapeProps[key] = (props as any)[key]; - } - } - - return { eventHandlers, shapeProps }; - }, [props]); - - // Hoist instance for async access - useEffect(() => { - ref.current = svg; - }, [svg]); - - // Add group to parent - useEffect(() => { - if (parent && svg) { - parent.add(svg); - - return () => { - parent.remove(svg); - }; - } - }, [svg, parent]); + const { + instance: svg, + coreValue, + parentValue, + sizeValue, + renderChildren, + } = useTwoGroup(restProps, forwardedRef, { + specialProps: ['shallow'], + }); // Validate props useEffect(() => { @@ -124,7 +85,6 @@ export const SVG = React.forwardRef( const currentKey = source; const last = lastLoadedSource.current; - // Skip reload if the source and Two instance are unchanged if (last.two === two && last.key === currentKey) { return; } @@ -133,26 +93,27 @@ export const SVG = React.forwardRef( lastLoadedSource.current = { two, key: currentKey }; try { - // two.load() returns a Group immediately (empty initially) - // and populates it asynchronously via callback two.load( source, - (loadedGroup: Instance, svg: SVGElement | SVGElement[]) => { + (loadedGroup: Instance, svgElement: SVGElement | SVGElement[]) => { if (!mounted) return; - ref.current?.add(loadedGroup.children); - // Invoke user callback if provided + svg.add(loadedGroup.children); + const handleLoad = onLoadRef.current; if (handleLoad) { try { - // Wait until next frame once Two.js has computed - // all necessary rendering / bounding box updates - requestAnimationFrame(() => handleLoad(ref.current!, svg)); + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + } + rafRef.current = requestAnimationFrame(() => { + rafRef.current = null; + if (mounted) { + handleLoad(svg, svgElement); + } + }); } catch (err) { - console.error( - '[react-two.js] Error in SVG onLoad callback:', - err - ); + console.error('[react-two.js] Error in SVG onLoad callback:', err); } } } @@ -179,88 +140,21 @@ export const SVG = React.forwardRef( } return () => { - // Note: Two.js XHR requests cannot be cancelled - // We track mounted state to prevent setState on unmounted component mounted = false; - // Reset last loaded key so the same source can be reloaded after cleanup - lastLoadedSource.current = { two: null, key: null }; - // Remove previously added children - ref.current?.remove(ref.current.children); - }; - }, [two, src, content]); - - // Update position and properties - useEffect(() => { - // Update position - if (typeof x === 'number') svg.translation.x = x; - if (typeof y === 'number') svg.translation.y = y; - - const args = { ...shapeProps }; - delete args.children; // Allow react to handle children - - // Update other properties (excluding event handlers) - for (const key in args) { - if (key in svg) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (svg as any)[key] = (args as any)[key]; + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; } - } - }, [svg, x, y, shapeProps]); - - // Unregister on unmount only - useEffect(() => { - return () => { - unregisterEventShape(svg); + lastLoadedSource.current = { two: null, key: null }; + svg.remove(svg.children); }; - }, [svg, unregisterEventShape]); - - // Register / update event handlers - useEffect(() => { - if (Object.keys(eventHandlers).length > 0) { - registerEventShape(svg, eventHandlers, parent ?? undefined); - } else { - unregisterEventShape(svg); - } - }, [ - svg, - registerEventShape, - unregisterEventShape, - parent, - eventHandlers, - ]); - - useImperativeHandle(forwardedRef, () => svg, [svg]); - - const coreValue = useMemo( - () => ({ - two, - registerEventShape, - unregisterEventShape, - hitTestPoint, - }), - [two, registerEventShape, unregisterEventShape, hitTestPoint] - ); - - const parentValue = useMemo( - () => ({ - parent: svg, - }), - [svg] - ); - - const sizeValue = useMemo( - () => ({ - width, - height, - }), - [width, height] - ); + }, [two, src, content, svg]); return ( - {props.children} + {renderChildren()} diff --git a/lib/Sprite.tsx b/lib/Sprite.tsx index 7e77872..3f379c4 100644 --- a/lib/Sprite.tsx +++ b/lib/Sprite.tsx @@ -1,12 +1,10 @@ -import React, { useEffect, useImperativeHandle, useMemo } from 'react'; +import React from 'react'; import Two from 'two.js'; -import { useTwo } from './Context'; - import type { Sprite as Instance } from 'two.js/src/effects/sprite'; import type { Texture } from 'two.js/src/effects/texture'; import { RectangleProps } from './Rectangle'; import { applyOrigin, type EventHandlers, type OriginProp } from './Properties'; -import { EVENT_HANDLER_NAMES } from './Events'; +import { useTwoObject } from './useTwoObject'; export type SpriteProps = | RectangleProps @@ -36,91 +34,28 @@ type ComponentProps = React.PropsWithChildren< export type RefSprite = Instance; export const Sprite = React.forwardRef( - ({ src, x, y, origin, autoPlay, ...props }, forwardedRef) => { - const { parent, registerEventShape, unregisterEventShape } = useTwo(); - - // Create the instance synchronously so it's available for refs immediately - const sprite = useMemo(() => new Two.Sprite(src), [src]); - - // Extract event handlers from props - const { eventHandlers, shapeProps } = useMemo(() => { - const eventHandlers: Partial = {}; - const shapeProps: Record = {}; + (props, forwardedRef) => { + useTwoObject(props, forwardedRef, { + factory: (p) => new Two.Sprite(p.src), + constructionProps: ['src'], + specialProps: ['autoPlay', 'origin'], + applySpecialProps: (sprite, currentProps, changed, removed) => { + if ('origin' in changed) { + applyOrigin(sprite, currentProps.origin); + } else if (removed.includes('origin')) { + sprite.origin.set(0, 0); + } - for (const key in props) { - if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - // An explicitly `undefined` handler means "not interactive", so it - // must not count toward the registered handler set. - const handler = props[key as keyof EventHandlers]; - if (handler !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - eventHandlers[key as keyof EventHandlers] = handler as any; - } + if (currentProps.autoPlay) { + sprite.play(); } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - shapeProps[key] = (props as any)[key]; + sprite.pause(); } - } - - return { eventHandlers, shapeProps }; - }, [props]); - - useEffect(() => { - if (parent) { - parent.add(sprite); - - return () => { - parent.remove(sprite); - }; - } - }, [parent, sprite]); - - useEffect(() => { - // Update position - if (typeof x === 'number') sprite.translation.x = x; - if (typeof y === 'number') sprite.translation.y = y; - - // Update origin - applyOrigin(sprite, origin); - - if (autoPlay) { - sprite.play(); - } else { + }, + disposeOwned: (sprite) => { sprite.pause(); - } - - // Update other properties (excluding event handlers) - for (const key in shapeProps) { - if (key in sprite) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (sprite as any)[key] = (shapeProps as any)[key]; - } - } - }, [shapeProps, sprite, x, y, origin, autoPlay]); - - // Unregister on unmount only - useEffect(() => { - return () => { - unregisterEventShape(sprite); - }; - }, [sprite, unregisterEventShape]); - - // Register / update event handlers - useEffect(() => { - if (Object.keys(eventHandlers).length > 0) { - registerEventShape(sprite, eventHandlers, parent ?? undefined); - } else { - unregisterEventShape(sprite); - } - }, [ - sprite, - registerEventShape, - unregisterEventShape, - parent, - eventHandlers, - ]); - - useImperativeHandle(forwardedRef, () => sprite, [sprite]); + }, + }); return <>; } diff --git a/lib/Star.tsx b/lib/Star.tsx index 88c5a3b..d8e55ba 100644 --- a/lib/Star.tsx +++ b/lib/Star.tsx @@ -1,11 +1,9 @@ -import React, { useEffect, useImperativeHandle, useMemo } from 'react'; +import React from 'react'; import Two from 'two.js'; -import { useTwo } from './Context'; - import type { Star as Instance } from 'two.js/src/shapes/star'; import { PathProps } from './Path'; import { type EventHandlers } from './Properties'; -import { EVENT_HANDLER_NAMES } from './Events'; +import { useTwoObject } from './useTwoObject'; export type StarProps = PathProps | 'innerRadius' | 'outerRadius' | 'sides'; type ComponentProps = React.PropsWithChildren< @@ -20,82 +18,10 @@ type ComponentProps = React.PropsWithChildren< export type RefStar = Instance; export const Star = React.forwardRef( - ({ x, y, ...props }, forwardedRef) => { - const { parent, registerEventShape, unregisterEventShape } = useTwo(); - - // Create the instance synchronously so it's available for refs immediately - const star = useMemo(() => new Two.Star(), []); - - // Extract event handlers from props - const { eventHandlers, shapeProps } = useMemo(() => { - const eventHandlers: Partial = {}; - const shapeProps: Record = {}; - - for (const key in props) { - if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - // An explicitly `undefined` handler means "not interactive", so it - // must not count toward the registered handler set. - const handler = props[key as keyof EventHandlers]; - if (handler !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - eventHandlers[key as keyof EventHandlers] = handler as any; - } - } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - shapeProps[key] = (props as any)[key]; - } - } - - return { eventHandlers, shapeProps }; - }, [props]); - - useEffect(() => { - if (parent) { - parent.add(star); - - return () => { - parent.remove(star); - }; - } - }, [parent, star]); - - useEffect(() => { - // Update position - if (typeof x === 'number') star.translation.x = x; - if (typeof y === 'number') star.translation.y = y; - - // Update other properties (excluding event handlers) - for (const key in shapeProps) { - if (key in star) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (star as any)[key] = (shapeProps as any)[key]; - } - } - }, [shapeProps, star, x, y]); - - // Unregister on unmount only - useEffect(() => { - return () => { - unregisterEventShape(star); - }; - }, [star, unregisterEventShape]); - - // Register / update event handlers - useEffect(() => { - if (Object.keys(eventHandlers).length > 0) { - registerEventShape(star, eventHandlers, parent ?? undefined); - } else { - unregisterEventShape(star); - } - }, [ - star, - registerEventShape, - unregisterEventShape, - parent, - eventHandlers, - ]); - - useImperativeHandle(forwardedRef, () => star, [star]); + (props, forwardedRef) => { + useTwoObject(props, forwardedRef, { + factory: () => new Two.Star(), + }); return <>; } diff --git a/lib/Text.tsx b/lib/Text.tsx index af386f4..55db443 100644 --- a/lib/Text.tsx +++ b/lib/Text.tsx @@ -1,10 +1,8 @@ -import React, { useEffect, useImperativeHandle, useMemo } from 'react'; +import React from 'react'; import Two from 'two.js'; -import { useTwo } from './Context'; - import type { Text as Instance } from 'two.js/src/text'; import { ShapeProps, type EventHandlers } from './Properties'; -import { EVENT_HANDLER_NAMES } from './Events'; +import { useTwoObject } from './useTwoObject'; export type TextProps = | ShapeProps @@ -27,6 +25,7 @@ export type TextProps = | 'mask' | 'clip' | 'strokeAttenuation'; + type ComponentProps = React.PropsWithChildren< { [K in Extract]?: Instance[K]; @@ -39,82 +38,10 @@ type ComponentProps = React.PropsWithChildren< export type RefText = Instance; export const Text = React.forwardRef( - ({ x, y, ...props }, forwardedRef) => { - const { parent, registerEventShape, unregisterEventShape } = useTwo(); - - // Create the instance synchronously so it's available for refs immediately - const text = useMemo(() => new Two.Text(), []); - - // Extract event handlers from props - const { eventHandlers, shapeProps } = useMemo(() => { - const eventHandlers: Partial = {}; - const shapeProps: Record = {}; - - for (const key in props) { - if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - // An explicitly `undefined` handler means "not interactive", so it - // must not count toward the registered handler set. - const handler = props[key as keyof EventHandlers]; - if (handler !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - eventHandlers[key as keyof EventHandlers] = handler as any; - } - } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - shapeProps[key] = (props as any)[key]; - } - } - - return { eventHandlers, shapeProps }; - }, [props]); - - useEffect(() => { - if (parent) { - parent.add(text); - - return () => { - parent.remove(text); - }; - } - }, [parent, text]); - - useEffect(() => { - // Update position - if (typeof x === 'number') text.translation.x = x; - if (typeof y === 'number') text.translation.y = y; - - // Update other properties (excluding event handlers) - for (const key in shapeProps) { - if (key in text) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (text as any)[key] = (shapeProps as any)[key]; - } - } - }, [shapeProps, text, x, y]); - - // Unregister on unmount only - useEffect(() => { - return () => { - unregisterEventShape(text); - }; - }, [text, unregisterEventShape]); - - // Register / update event handlers - useEffect(() => { - if (Object.keys(eventHandlers).length > 0) { - registerEventShape(text, eventHandlers, parent ?? undefined); - } else { - unregisterEventShape(text); - } - }, [ - text, - registerEventShape, - unregisterEventShape, - parent, - eventHandlers, - ]); - - useImperativeHandle(forwardedRef, () => text, [text]); + (props, forwardedRef) => { + useTwoObject(props, forwardedRef, { + factory: () => new Two.Text(), + }); return <>; } diff --git a/lib/Texture.tsx b/lib/Texture.tsx index b120037..d752aa6 100644 --- a/lib/Texture.tsx +++ b/lib/Texture.tsx @@ -1,8 +1,8 @@ -import React, { useImperativeHandle, useEffect, useMemo } from 'react'; +import React from 'react'; import Two from 'two.js'; - import type { Texture as Instance } from 'two.js/src/effects/texture'; import { ElementProps } from './Properties'; +import { useTwoObject } from './useTwoObject'; export type TextureProps = | ElementProps @@ -12,33 +12,31 @@ export type TextureProps = | 'scale' | 'offset' | 'image'; + +export type TextureSource = + | string + | HTMLImageElement + | HTMLCanvasElement + | HTMLVideoElement; + type ComponentProps = React.PropsWithChildren< { [K in Extract]?: Instance[K]; } & { - src?: string | HTMLImageElement | HTMLCanvasElement | HTMLVideoElement; + src?: TextureSource; } >; export type RefTexture = Instance; export const Texture = React.forwardRef( - ({ src, ...props }, forwardedRef) => { - // Create the instance synchronously so it's available for refs immediately - const texture = useMemo(() => new Two.Texture(src), [src]); - - useEffect(() => { - // Update other properties (excluding event handlers) - for (const key in props) { - if (key in texture) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (texture as any)[key] = (props as any)[key]; - } - } - }, [props, texture]); - - useImperativeHandle(forwardedRef, () => texture, [texture]); + (props, forwardedRef) => { + useTwoObject(props as Record, forwardedRef, { + factory: (p) => new Two.Texture(p.src as TextureSource), + constructionProps: ['src'], + isSceneObject: false, + }); - return null; // No visual representation + return null; } ); diff --git a/lib/main.ts b/lib/main.ts index 8d8cbc1..e55e796 100644 --- a/lib/main.ts +++ b/lib/main.ts @@ -34,6 +34,10 @@ export { RadialGradient, type RefRadialGradient, type RadialGradientProps } from // Texture exports export { Texture, type RefTexture, type TextureProps } from './Texture'; +// Lifecycle & Reconciliation exports +export { useTwoObject, useTwoGroup } from './useTwoObject'; +export { TWO_DEFAULT_PROPS } from './reconciliation'; + // Event exports export type { TwoEvent, EventHandler, EventHandlers } from './Events'; @@ -43,12 +47,16 @@ export type { ShapeProps, GradientProps, OriginProp, + VectorProp, + ScaleProp, } from './Properties'; export { ELEMENT_PROPERTIES, SHAPE_PROPERTIES, GRADIENT_PROPERTIES, applyOrigin, + applyVector, + applyScale, } from './Properties'; // Property matrix exports diff --git a/lib/reconciliation.ts b/lib/reconciliation.ts new file mode 100644 index 0000000..36c4c7d --- /dev/null +++ b/lib/reconciliation.ts @@ -0,0 +1,211 @@ +import type { Shape } from 'two.js/src/shape'; +import type { Group } from 'two.js/src/group'; + +/** + * Standard default values for Two.js properties. + * Used when a previously specified React prop is removed or set to undefined. + */ +export const TWO_DEFAULT_PROPS: Record = { + // Shape / Path styling + fill: '#fff', + stroke: '#000', + linewidth: 1, + opacity: 1, + visible: true, + cap: 'round', + join: 'round', + miter: 4, + closed: true, + curved: false, + automatic: true, + beginning: 0, + ending: 1, + dashes: [], + + // Positioning & transforms + x: 0, + y: 0, + rotation: 0, + scale: 1, + skewX: 0, + skewY: 0, + + // Masking & clipping + mask: null, + clip: false, + strokeAttenuation: true, + + // General element + className: '', + + // Points defaults + sizeAttenuation: false, + + // Gradient defaults + spread: 'pad', + units: 'objectBoundingBox', + + // Text defaults + value: '', + family: 'sans-serif', + size: 13, + leading: 17, + alignment: 'middle', + baseline: 'middle', + style: 'normal', + weight: 'normal', + decoration: 'none', + direction: 'ltr', + + // Line endpoints + x1: 0, + y1: 0, + x2: 0, + y2: 0, + + // Circle / Rectangle dimensions + radius: 0, + width: 0, + height: 0, + + // Special flags + manual: false, +}; + +/** + * Capture default property values present on a newly instantiated Two.js object. + */ +export function captureDefaultProps( + instance: Record +): Record { + const defaults: Record = { ...TWO_DEFAULT_PROPS }; + + for (const key of Object.keys(TWO_DEFAULT_PROPS)) { + if (key in instance && instance[key] !== undefined) { + defaults[key] = instance[key]; + } + } + + // Also capture instance-specific properties if defined + if ('translation' in instance && instance.translation) { + const translation = instance.translation as { x?: number; y?: number }; + defaults.x = translation.x ?? 0; + defaults.y = translation.y ?? 0; + } + + return defaults; +} + +/** + * Result of diffing incoming props against previously applied props. + */ +export interface PropDiff

{ + changed: Partial

; + removed: Array; + hasChanges: boolean; +} + +/** + * Diffs incoming props against previously applied props. + * - Properties with new/changed values are in `changed`. + * - Properties present in `prevProps` but missing/undefined in `nextProps` are in `removed`. + * - Unchanged properties are omitted. + */ +export function diffProps

>( + prevProps: P, + nextProps: P, + ignoredKeys: Set = new Set() +): PropDiff

{ + const changed: Partial

= {}; + const removed: Array = []; + + // Check for changed or newly added props + for (const key in nextProps) { + if (ignoredKeys.has(key)) continue; + + const nextVal = nextProps[key]; + const prevVal = prevProps[key]; + + if (nextVal !== prevVal) { + if (nextVal === undefined) { + // Setting to undefined is treated as prop removal + if (key in prevProps && prevVal !== undefined) { + removed.push(key as keyof P); + } + } else { + changed[key as keyof P] = nextVal; + } + } + } + + // Check for removed props + for (const key in prevProps) { + if (ignoredKeys.has(key)) continue; + + if (!(key in nextProps) && prevProps[key] !== undefined) { + if (!removed.includes(key as keyof P)) { + removed.push(key as keyof P); + } + } + } + + const hasChanges = + Object.keys(changed).length > 0 || removed.length > 0; + + return { changed, removed, hasChanges }; +} + +/** + * Helper to safely reorder children inside a Two.js parent Group + * to match a given target order array. + */ +export function reconcileSceneOrder( + parent: Group, + targetOrder: Array +): void { + if (!parent || !parent.children || targetOrder.length === 0) return; + + const children = parent.children as unknown as Array; + if (children.length <= 1) return; + + // Build target position map + const targetMap = new Map(); + targetOrder.forEach((item, idx) => { + targetMap.set(item, idx); + }); + + let needsReorder = false; + let lastSeenIndex = -1; + + for (let i = 0; i < children.length; i++) { + const child = children[i]; + const targetIdx = targetMap.get(child); + if (targetIdx !== undefined) { + if (targetIdx < lastSeenIndex) { + needsReorder = true; + break; + } + lastSeenIndex = targetIdx; + } + } + + if (!needsReorder) return; + + // Stable sort children array according to targetMap + children.sort((a, b) => { + const indexA = targetMap.get(a); + const indexB = targetMap.get(b); + + if (indexA !== undefined && indexB !== undefined) { + return indexA - indexB; + } + if (indexA !== undefined) return -1; + if (indexB !== undefined) return 1; + return 0; + }); + + // Flag Two.js order update so renderer knows the scenegraph order changed + if (typeof (parent as unknown as { _flagOrder?: boolean })._flagOrder !== 'undefined') { + (parent as unknown as { _flagOrder: boolean })._flagOrder = true; + } +} diff --git a/lib/useTwoObject.ts b/lib/useTwoObject.ts new file mode 100644 index 0000000..aa448a5 --- /dev/null +++ b/lib/useTwoObject.ts @@ -0,0 +1,566 @@ +import React, { + useCallback, + useContext, + useEffect, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, +} from 'react'; +import Two from 'two.js'; +import type { Shape } from 'two.js/src/shape'; +import type { Group } from 'two.js/src/group'; +import type { Vector } from 'two.js/src/vector'; +import { + ChildSlotContext, + useTwo, + type TwoCoreContextValue, + type TwoParentContextValue, + type TwoSizeContextValue, +} from './Context'; +import { EVENT_HANDLER_NAMES, type EventHandlers } from './Events'; +import { + applyScale, + applyVector, + type ScaleProp, + type VectorProp, +} from './Properties'; +import { + captureDefaultProps, + diffProps, + reconcileSceneOrder, + TWO_DEFAULT_PROPS, +} from './reconciliation'; + +export type TwoSceneItem = Shape | Group; + +const IGNORED_PROP_KEYS = new Set([ + 'children', + 'key', + 'ref', + ...EVENT_HANDLER_NAMES, +]); + +export interface TwoObjectConfig< + T, + P extends Record +> { + /** + * Factory function to instantiate the Two.js object. + */ + factory: (props: P) => T; + + /** + * Prop keys that require destroying and recreating the object if changed + * (e.g. ['resolution'], ['sides'], ['spokes'], ['src']). + */ + constructionProps?: Array; + + /** + * Prop keys that should not be assigned directly to instance[key] + * (e.g. ['x', 'y', 'x1', 'y1', 'manual', 'autoPlay']). + */ + specialProps?: Array; + + /** + * Custom handler to apply changed/removed special props to the instance. + */ + applySpecialProps?: ( + instance: T, + props: P, + changed: Partial

, + removed: Array + ) => void; + + /** + * Custom disposal logic for owned resources (e.g. internal textures). + */ + disposeOwned?: (instance: T) => void; + + /** + * Whether this object is added to parent.children. Defaults to true. + * False for non-scene objects like LinearGradient, RadialGradient, Texture. + */ + isSceneObject?: boolean; +} + +export interface PositionProps { + x?: number; + y?: number; + position?: VectorProp; + translation?: VectorProp; +} + +/** + * Standard handler for applying position (x, y, position, translation) to translation. + */ +export function applyDefaultPositionProps( + instance: T, + props: PositionProps, + changed: PositionProps, + removed: Array +): void { + const inst = instance as unknown as Record; + if ('translation' in inst && inst.translation) { + const translation = inst.translation as Vector; + + // Check position / translation vector props first + if ('position' in changed && changed.position !== undefined) { + applyVector(translation, changed.position); + } else if ('translation' in changed && changed.translation !== undefined) { + applyVector(translation, changed.translation); + } + + const hasExplicitPos = props.position !== undefined && props.position !== null; + const hasExplicitTrans = props.translation !== undefined && props.translation !== null; + + // Individual x, y props take precedence or apply discretely + if ('x' in changed) { + translation.x = typeof props.x === 'number' ? props.x : 0; + } else if (removed.includes('x') && !hasExplicitPos && !hasExplicitTrans) { + translation.x = 0; + } + + if ('y' in changed) { + translation.y = typeof props.y === 'number' ? props.y : 0; + } else if (removed.includes('y') && !hasExplicitPos && !hasExplicitTrans) { + translation.y = 0; + } + + // If position or translation was removed and no explicit x or y is set + if ( + (removed.includes('position') || removed.includes('translation')) && + typeof props.x !== 'number' && + typeof props.y !== 'number' && + !hasExplicitPos && + !hasExplicitTrans + ) { + translation.set(0, 0); + } + } +} + +/** + * Shared lifecycle hook for Two.js scene objects and effects. + */ +export function useTwoObject< + T, + P extends Record +>( + props: P, + forwardedRef: React.ForwardedRef, + config: TwoObjectConfig +): { + instance: T; + eventHandlers: Partial; + shapeProps: Record; +} { + const { + parent, + attachChild, + detachChild, + registerChildOrder, + registerEventShape, + unregisterEventShape, + } = useTwo(); + + // Listen to ChildSlotContext to guarantee re-render when sibling order changes, + // even if this component was wrapped in React.memo + useContext(ChildSlotContext); + + const isSceneObject = config.isSceneObject !== false; + const constructionProps = config.constructionProps ?? []; + const specialPropsSet = useMemo( + () => + new Set([ + 'x', + 'y', + 'position', + 'translation', + 'scale', + ...((config.specialProps as string[]) ?? []), + ]), + [config.specialProps] + ); + + // Extract event handlers vs shape props + const { eventHandlers, shapeProps } = useMemo(() => { + const handlers: Partial = {}; + const shape: Record = {}; + + for (const key in props) { + if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { + const handler = props[key as keyof EventHandlers]; + if (handler !== undefined) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + handlers[key as keyof EventHandlers] = handler as any; + } + } else { + shape[key] = props[key]; + } + } + + return { eventHandlers: handlers, shapeProps: shape }; + }, [props]); + + // Track construction props to detect when recreation is necessary + const lastConstructionPropsRef = useRef>({}); + const instanceRef = useRef(null); + const defaultPropsRef = useRef>({}); + const prevPropsRef = useRef>({}); + const isInitialMountRef = useRef(true); + const pendingReplacementRef = useRef<{ + oldInstance: T; + newInstance: T; + } | null>(null); + + // Check if any construction-only prop changed + let needsRecreation = instanceRef.current === null; + if (!needsRecreation) { + for (const cp of constructionProps) { + if (props[cp] !== lastConstructionPropsRef.current[cp as string]) { + needsRecreation = true; + break; + } + } + } + + // Create or recreate instance synchronously so it's ready during render / ref forwarding. + // Note: All scene-graph mutations and resource disposals are deferred to commit-phase layout effects. + if (needsRecreation) { + const oldInstance = instanceRef.current; + const newInstance = config.factory(props); + + if (oldInstance) { + pendingReplacementRef.current = { oldInstance, newInstance }; + } + + instanceRef.current = newInstance; + defaultPropsRef.current = captureDefaultProps( + newInstance as unknown as Record + ); + + // Save construction props snapshot + const cSnapshot: Record = {}; + for (const cp of constructionProps) { + cSnapshot[cp as string] = props[cp]; + } + lastConstructionPropsRef.current = cSnapshot; + + // Reset prevProps so all properties are applied to the new instance + prevPropsRef.current = {}; + } + + const instance = instanceRef.current!; + + // Forward ref + useImperativeHandle(forwardedRef, () => instance, [instance]); + + // Apply discrete property updates and resets + useLayoutEffect(() => { + const prev = prevPropsRef.current; + const current = shapeProps; + + const { changed, removed, hasChanges } = diffProps( + prev, + current, + IGNORED_PROP_KEYS + ); + + if (hasChanges || isInitialMountRef.current) { + const instRecord = instance as unknown as Record; + + // 1. Apply changed properties + for (const key in changed) { + if (!specialPropsSet.has(key)) { + if (key in instRecord) { + instRecord[key] = changed[key]; + } + } + } + + // 2. Reset removed properties to Two.js defaults + for (const key of removed) { + if (!specialPropsSet.has(key as string)) { + if (key in instRecord) { + const defaultVal = + defaultPropsRef.current[key as string] ?? + TWO_DEFAULT_PROPS[key as string]; + if (defaultVal !== undefined) { + instRecord[key as string] = defaultVal; + } + } + } + } + + // 3. Handle default position props (x, y, position, translation) + applyDefaultPositionProps( + instance, + props as PositionProps, + changed as PositionProps, + removed as string[] + ); + + // 4. Handle scale prop safely + if ('scale' in changed) { + applyScale( + instance as unknown as { scale: number | Vector }, + (changed as { scale?: ScaleProp }).scale + ); + } else if (removed.includes('scale')) { + const defaultScale = (defaultPropsRef.current.scale ?? 1) as ScaleProp; + applyScale( + instance as unknown as { scale: number | Vector }, + defaultScale + ); + } + + // 5. Custom special props callback + if (config.applySpecialProps) { + config.applySpecialProps( + instance, + props, + changed as Partial

, + removed as Array + ); + } + + prevPropsRef.current = { ...current }; + isInitialMountRef.current = false; + } + }); + + const configRef = useRef(config); + configRef.current = config; + + // Scene-graph attachment, in-place replacement, and reparenting lifecycle + useLayoutEffect(() => { + const pending = pendingReplacementRef.current; + + if (pending && pending.newInstance === instance) { + pendingReplacementRef.current = null; + const { oldInstance } = pending; + + if (isSceneObject) { + let replacedInPlace = false; + if (parent) { + const children = parent.children as unknown as Array; + const idx = children.indexOf(oldInstance); + if (idx !== -1) { + // Replace in-place at the exact same child index + children.splice(idx, 1, instance); + (instance as unknown as { parent?: Group }).parent = parent; + if (typeof (parent as unknown as { _flagOrder?: boolean })._flagOrder !== 'undefined') { + (parent as unknown as { _flagOrder: boolean })._flagOrder = true; + } + replacedInPlace = true; + } + } + + if (!replacedInPlace) { + // When a construction-prop change coincides with a parent change, + // oldInstance is still in the previous parent's children. + // Detach oldInstance from its previous parent so it is not orphaned. + const oldParent = (oldInstance as unknown as { parent?: Group }).parent; + if (oldParent) { + oldParent.remove(oldInstance as unknown as Shape); + (oldInstance as unknown as { parent?: Group }).parent = undefined; + } + + // Attach replacement to the current parent + if (parent) { + const sceneItem = instance as unknown as Shape | Group; + if (attachChild) { + attachChild(sceneItem); + } else { + parent.add(sceneItem as unknown as Shape); + } + } + } + } + + // Cleanup old instance owned resources and event handlers in commit phase + unregisterEventShape(oldInstance as unknown as Shape | Group); + configRef.current.disposeOwned?.(oldInstance); + } else if (isSceneObject && parent) { + const sceneItem = instance as unknown as Shape | Group; + if (attachChild) { + attachChild(sceneItem); + } else { + parent.add(sceneItem as unknown as Shape); + } + } + + return () => { + // If this instance is about to be replaced by a pending newInstance on the same parent, + // do not remove it here; the pending replacement will splice the new instance in-place. + const nextPending = pendingReplacementRef.current; + if ( + nextPending && + nextPending.oldInstance === instance && + isSceneObject && + (instance as unknown as { parent?: Group }).parent === parent + ) { + return; + } + + if (isSceneObject && parent) { + const sceneItem = instance as unknown as Shape | Group; + if (detachChild) { + detachChild(sceneItem); + } else { + parent.remove(sceneItem as unknown as Shape); + } + } + }; + }, [instance, parent, isSceneObject, attachChild, detachChild, unregisterEventShape]); + + // Sibling order registration (runs on every commit in document order) + useLayoutEffect(() => { + if (!isSceneObject || !parent) return; + + if (registerChildOrder) { + registerChildOrder(instance as unknown as Shape | Group); + } + }); + + // Event handler registration and cleanup + useEffect(() => { + const eventItem = instance as unknown as Shape | Group; + if (Object.keys(eventHandlers).length > 0) { + registerEventShape(eventItem, eventHandlers, parent ?? undefined); + } else { + unregisterEventShape(eventItem); + } + + return () => { + unregisterEventShape(eventItem); + }; + }, [instance, parent, eventHandlers, registerEventShape, unregisterEventShape]); + + // Cleanup on unmount (strict mode safe) + useEffect(() => { + return () => { + configRef.current.disposeOwned?.(instance); + }; + }, [instance]); + + return { instance, eventHandlers, shapeProps }; +} + +export interface UseTwoGroupResult { + instance: Group; + coreValue: TwoCoreContextValue; + parentValue: TwoParentContextValue; + sizeValue: TwoSizeContextValue; + renderChildren: () => React.ReactNode; +} + +/** + * Shared hook for Group components to coordinate child ordering and context propagation. + */ +export function useTwoGroup

>( + props: React.PropsWithChildren

, + forwardedRef: React.ForwardedRef, + config?: Partial> +): UseTwoGroupResult { + const { two, width, height, registerEventShape, unregisterEventShape, hitTestPoint } = + useTwo(); + + const childrenOrderRef = useRef>([]); + const attachedChildrenRef = useRef>(new Set()); + + // Attach a child to this group + const attachChild = useCallback((child: Shape | Group) => { + attachedChildrenRef.current.add(child); + groupRef.current?.add(child); + }, []); + + // Detach a child from this group + const detachChild = useCallback((child: Shape | Group) => { + attachedChildrenRef.current.delete(child); + groupRef.current?.remove(child); + }, []); + + // Register child order during commit + const registerChildOrder = useCallback((child: Shape | Group) => { + childrenOrderRef.current.push(child); + }, []); + + const groupRef = useRef(null); + + const { instance } = useTwoObject( + props as P, + forwardedRef, + { + factory: () => new Two.Group(), + ...config, + } + ); + + groupRef.current = instance; + + // Reconcile children order after all child layout effects run + useLayoutEffect(() => { + if (childrenOrderRef.current.length > 0) { + reconcileSceneOrder(instance, childrenOrderRef.current); + childrenOrderRef.current = []; + } + }); + + // Clean up all attached children on unmount + useEffect(() => { + const attached = attachedChildrenRef.current; + return () => { + attached.clear(); + childrenOrderRef.current = []; + }; + }, []); + + const coreValue = useMemo( + () => ({ + two, + registerEventShape, + unregisterEventShape, + hitTestPoint, + }), + [two, registerEventShape, unregisterEventShape, hitTestPoint] + ); + + const parentValue = useMemo( + () => ({ + parent: instance, + attachChild, + detachChild, + registerChildOrder, + }), + [instance, attachChild, detachChild, registerChildOrder] + ); + + const sizeValue = useMemo( + () => ({ + width, + height, + }), + [width, height] + ); + + const renderChildren = useCallback(() => { + return React.Children.map(props.children, (child, index) => { + if (!React.isValidElement(child)) return child; + return React.createElement( + ChildSlotContext.Provider, + { value: index, key: child.key ?? index }, + child + ); + }); + }, [props.children]); + + return { + instance, + coreValue, + parentValue, + sizeValue, + renderChildren, + }; +} diff --git a/tests/reconciliation.test.tsx b/tests/reconciliation.test.tsx new file mode 100644 index 0000000..4837b5b --- /dev/null +++ b/tests/reconciliation.test.tsx @@ -0,0 +1,817 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; +import { render } from '@testing-library/react'; +import Two from 'two.js'; +import { + Canvas, + Group, + Circle, + Rectangle, + Polygon, + Text, + Line, + LinearGradient, + RadialGradient, + useTwo, + type RefCircle, + type RefGroup, + type RefRectangle, + type RefPolygon, + type RefLine, + type RefLinearGradient, + type RefRadialGradient, + type VectorProp, + type ScaleProp, +} from '../lib/main'; +import { TwoParentContext } from '../lib/Context'; +import { TWO_DEFAULT_PROPS } from '../lib/reconciliation'; + +beforeEach(() => { + HTMLCanvasElement.prototype.getContext = vi.fn().mockReturnValue({ + fillRect: vi.fn(), + clearRect: vi.fn(), + getImageData: vi.fn().mockReturnValue({ data: [] }), + putImageData: vi.fn(), + createImageData: vi.fn().mockReturnValue([]), + setTransform: vi.fn(), + drawImage: vi.fn(), + save: vi.fn(), + fillText: vi.fn(), + restore: vi.fn(), + beginPath: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + closePath: vi.fn(), + stroke: vi.fn(), + translate: vi.fn(), + scale: vi.fn(), + rotate: vi.fn(), + arc: vi.fn(), + fill: vi.fn(), + measureText: vi.fn().mockReturnValue({ width: 0 }), + transform: vi.fn(), + rect: vi.fn(), + clip: vi.fn(), + }); +}); + +describe('Two.js Scene Graph Reconciliation (Issue #29)', () => { + describe('1. Initial Mount & Order', () => { + it('mounts children into parent.children in JSX document order', () => { + const groupRef = React.createRef(); + const circleRef = React.createRef(); + const rectRef = React.createRef(); + const polyRef = React.createRef(); + + render( + + + + + + + + ); + + const group = groupRef.current!; + expect(group).not.toBeNull(); + expect(group.children.length).toBe(3); + expect(group.children[0]).toBe(circleRef.current); + expect(group.children[1]).toBe(rectRef.current); + expect(group.children[2]).toBe(polyRef.current); + }); + }); + + describe('2. Sibling Reordering', () => { + it('reorders parent.children when keyed JSX siblings reorder', () => { + const groupRef = React.createRef(); + const refs: Record = {}; + + function Item({ id }: { id: string }) { + return ( + { + if (el) refs[id] = el; + }} + radius={10} + /> + ); + } + + function App({ order }: { order: string[] }) { + return ( + + + {order.map((id) => ( + + ))} + + + ); + } + + const { rerender } = render(); + const group = groupRef.current!; + + expect(group.children.map((c: { id: string }) => c.id)).toEqual([ + refs['a'].id, + refs['b'].id, + refs['c'].id, + ]); + + // Reorder to ['c', 'a', 'b'] + rerender(); + + expect(group.children.map((c: { id: string }) => c.id)).toEqual([ + refs['c'].id, + refs['a'].id, + refs['b'].id, + ]); + + // Reorder to ['b', 'c', 'a'] + rerender(); + + expect(group.children.map((c: { id: string }) => c.id)).toEqual([ + refs['b'].id, + refs['c'].id, + refs['a'].id, + ]); + }); + + it('reorders parent.children even when sibling components are wrapped in React.memo', () => { + const groupRef = React.createRef(); + const refs: Record = {}; + + const MemoItem = React.memo(({ id }: { id: string }) => { + return ( + { + if (el) refs[id] = el; + }} + radius={15} + /> + ); + }); + + function App({ order }: { order: string[] }) { + return ( + + + {order.map((id) => ( + + ))} + + + ); + } + + const { rerender } = render(); + const group = groupRef.current!; + + expect(group.children.map((c: { id: string }) => c.id)).toEqual([ + refs['x'].id, + refs['y'].id, + refs['z'].id, + ]); + + // Reorder memoized siblings + rerender(); + + expect(group.children.map((c: { id: string }) => c.id)).toEqual([ + refs['z'].id, + refs['x'].id, + refs['y'].id, + ]); + }); + }); + + describe('3. Reparenting', () => { + it('moves a keyed object between groups, removing from old parent and inserting into new parent', () => { + const g1Ref = React.createRef(); + const g2Ref = React.createRef(); + const circleRef = React.createRef(); + + function App({ targetGroup }: { targetGroup: 'g1' | 'g2' }) { + return ( + + + + {targetGroup === 'g1' && ( + + )} + + + + {targetGroup === 'g2' && ( + + )} + + + + ); + } + + const { rerender } = render(); + const g1 = g1Ref.current!; + const g2 = g2Ref.current!; + const circle1 = circleRef.current!; + + expect(g1.children).toContain(circle1); + expect(g2.children).not.toContain(circle1); + expect(circle1.parent).toBe(g1); + + // Move circle from g1 to g2 + rerender(); + const circle2 = circleRef.current!; + + expect(g1.children).not.toContain(circle1); + expect(g2.children).toContain(circle2); + expect(circle2.parent).toBe(g2); + // It should be at index 1 between the two rectangles + expect(g2.children[1]).toBe(circle2); + }); + + it('reparents the same component instance when parent context changes dynamically', () => { + const circleRef = React.createRef(); + const g1 = new Two.Group(); + const g2 = new Two.Group(); + + function App({ parentGroup }: { parentGroup: Two.Group }) { + return ( + + + + + + ); + } + + const { rerender } = render(); + const circle = circleRef.current!; + + expect(g1.children).toContain(circle); + expect(g2.children).not.toContain(circle); + expect(circle.parent).toBe(g1); + + // Change parent to g2 without unmounting Circle + rerender(); + + expect(circleRef.current).toBe(circle); // Same instance! + expect(g1.children).not.toContain(circle); + expect(g2.children).toContain(circle); + expect(circle.parent).toBe(g2); + }); + + it('detaches old instance from previous parent when recreation coincides with parent change', () => { + const circleRef = React.createRef(); + const g1 = new Two.Group(); + const g2 = new Two.Group(); + + function App({ parentGroup, resolution }: { parentGroup: Two.Group; resolution: number }) { + return ( + + + + + + ); + } + + const { rerender } = render(); + const oldCircle = circleRef.current!; + + expect(g1.children).toContain(oldCircle); + expect(g2.children).not.toContain(oldCircle); + expect(oldCircle.parent).toBe(g1); + + // Simultaneously change parent to g2 AND change construction-only prop resolution (12 -> 36) + rerender(); + const newCircle = circleRef.current!; + + expect(newCircle).not.toBe(oldCircle); + // Old circle must be cleanly detached from g1 and not orphaned! + expect(g1.children).not.toContain(oldCircle); + expect(g1.children).toHaveLength(0); + // New circle must be attached to g2 + expect(g2.children).toContain(newCircle); + expect(newCircle.parent).toBe(g2); + }); + }); + + describe('4. Discrete Prop Updates', () => { + it('updates only changed properties without reassigning unchanged properties', () => { + const circleRef = React.createRef(); + + function App({ x, fill }: { x: number; fill: string }) { + return ( + + + + ); + } + + const { rerender } = render(); + const circle = circleRef.current!; + + expect(circle.translation.x).toBe(100); + expect(circle.translation.y).toBe(50); + expect(circle.fill).toBe('red'); + expect(circle.stroke).toBe('#333'); + + // Clear Two.js internal dirty flags + circle._flagStroke = false; + circle._flagFill = false; + + // Update only x (position) + rerender(); + + expect(circle.translation.x).toBe(150); + expect(circle.fill).toBe('red'); + // Neither fill nor stroke were reassigned + expect(circle._flagStroke).toBe(false); + expect(circle._flagFill).toBe(false); + }); + }); + + describe('5. Prop Removal & Reset Semantics', () => { + it('restores Two.js default values when props are removed or set to undefined', () => { + const circleRef = React.createRef(); + + interface ShapeProps { + x?: number; + y?: number; + fill?: string; + stroke?: string; + linewidth?: number; + opacity?: number; + } + + function App({ shapeProps }: { shapeProps: ShapeProps }) { + return ( + + + + ); + } + + const { rerender } = render( + + ); + + const circle = circleRef.current!; + expect(circle.translation.x).toBe(200); + expect(circle.translation.y).toBe(300); + expect(circle.fill).toBe('#ff0000'); + expect(circle.stroke).toBe('#00ff00'); + expect(circle.linewidth).toBe(10); + expect(circle.opacity).toBe(0.4); + + // Now remove fill, stroke, linewidth, opacity, and position + rerender(); + + expect(circle.translation.x).toBe(0); + expect(circle.translation.y).toBe(0); + expect(circle.fill).toBe(TWO_DEFAULT_PROPS.fill); + expect(circle.stroke).toBe(TWO_DEFAULT_PROPS.stroke); + expect(circle.linewidth).toBe(TWO_DEFAULT_PROPS.linewidth); + expect(circle.opacity).toBe(TWO_DEFAULT_PROPS.opacity); + }); + + it('restores default values on Text component when props are removed', () => { + const textRef = React.createRef(); + + function App({ value, size }: { value?: string; size?: number }) { + return ( + + + + ); + } + + const { rerender } = render(); + const text = textRef.current!; + + expect(text.value).toBe('Hello World'); + expect(text.size).toBe(32); + + // Remove size prop + rerender(); + expect(text.value).toBe('Hello World'); + expect(text.size).toBe(TWO_DEFAULT_PROPS.size); + + // Remove value prop + rerender(); + expect(text.value).toBe(TWO_DEFAULT_PROPS.value); + }); + }); + + describe('6. Construction-Only Prop Replacement', () => { + it('recreates instance safely in-place at the exact child index when construction props change', () => { + const groupRef = React.createRef(); + const circleRef = React.createRef(); + + function App({ resolution }: { resolution: number }) { + return ( + + + + + + + + ); + } + + const { rerender } = render(); + const group = groupRef.current!; + const oldCircle = circleRef.current!; + + expect(group.children[1]).toBe(oldCircle); + expect(oldCircle.fill).toBe('orange'); + + // Change construction prop (resolution 12 -> 36) + rerender(); + const newCircle = circleRef.current!; + + expect(newCircle).not.toBe(oldCircle); + expect(group.children.length).toBe(3); + // Replaced in-place at exact child index 1 between the two rectangles! + expect(group.children[1]).toBe(newCircle); + expect(newCircle.fill).toBe('orange'); + + // Subsequent recreation (36 -> 48) also keeps the exact same position + rerender(); + const thirdCircle = circleRef.current!; + + expect(thirdCircle).not.toBe(newCircle); + expect(group.children.length).toBe(3); + expect(group.children[1]).toBe(thirdCircle); + }); + + it('defers instance replacement and disposal to commit phase rather than render phase', () => { + const groupRef = React.createRef(); + let renderPhaseChildrenLengthDuringRecreation = -1; + + function Observer({ resolution }: { resolution: number }) { + // Inspect parent group during render phase + const group = groupRef.current; + if (group && resolution > 12) { + renderPhaseChildrenLengthDuringRecreation = group.children.length; + } + return ( + + ); + } + + function App({ resolution }: { resolution: number }) { + return ( + + + + + + + + ); + } + + const { rerender } = render(); + const group = groupRef.current!; + expect(group.children.length).toBe(3); + + rerender(); + // During render phase of recreation, group.children was NOT mutated or duplicated + expect(renderPhaseChildrenLengthDuringRecreation).toBe(3); + expect(group.children.length).toBe(3); + }); + }); + + describe('7. Strict Mode & Resource Lifecycle', () => { + it('handles StrictMode double-mount without leaking duplicate children or event handlers', () => { + const probe: { hitTestPoint?: (x: number, y: number) => boolean } = {}; + + function Probe() { + const { hitTestPoint } = useTwo(); + probe.hitTestPoint = hitTestPoint; + return null; + } + + const groupRef = React.createRef(); + + render( + + + + {}} + /> + + + + + ); + + const group = groupRef.current!; + expect(group.children.length).toBe(1); + expect(probe.hitTestPoint!(400, 300)).toBe(true); + expect(probe.hitTestPoint!(10, 10)).toBe(false); + }); + + it('unmounts cleanly and unregisters handlers', () => { + const probe: { hitTestPoint?: (x: number, y: number) => boolean } = {}; + + function Probe() { + const { hitTestPoint } = useTwo(); + probe.hitTestPoint = hitTestPoint; + return null; + } + + function App({ show }: { show: boolean }) { + return ( + + {show && ( + {}} + /> + )} + + + ); + } + + const { rerender } = render(); + expect(probe.hitTestPoint!(400, 300)).toBe(true); + + rerender(); + expect(probe.hitTestPoint!(400, 300)).toBe(false); + }); + + it('distinguishes owned resources from shared resources on unmount', () => { + // Create a shared Texture instance outside of the shape + const sharedTexture = new Two.Texture(); + let textureDisposed = false; + + // Wrap texture to detect if dispose was incorrectly called + (sharedTexture as unknown as { dispose?: () => void }).dispose = () => { + textureDisposed = true; + }; + + function App({ show }: { show: boolean }) { + return ( + + {show && } + + ); + } + + const { rerender } = render(); + // Unmount the shape + rerender(); + + // Shared texture must NOT be disposed + expect(textureDisposed).toBe(false); + }); + }); + + describe('9. Extended Prop Normalization and Default Resets', () => { + it('normalizes position and translation props (objects, tuples, Two.Vector) without throwing', () => { + const circleRef = React.createRef(); + + function App({ + position, + translation, + }: { + position?: VectorProp; + translation?: VectorProp; + }) { + return ( + + + + ); + } + + // Object literal + const { rerender } = render(); + const circle = circleRef.current!; + expect(circle.translation.x).toBe(120); + expect(circle.translation.y).toBe(80); + + // Tuple + rerender(); + expect(circle.translation.x).toBe(45); + expect(circle.translation.y).toBe(65); + + // Two.Vector + const v = new Two.Vector(200, 300); + rerender(); + expect(circle.translation.x).toBe(200); + expect(circle.translation.y).toBe(300); + + // Removal resets to (0, 0) + rerender(); + expect(circle.translation.x).toBe(0); + expect(circle.translation.y).toBe(0); + }); + + it('normalizes scale tuples and objects safely without matrix NaN', () => { + const circleRef = React.createRef(); + + function App({ scale }: { scale?: ScaleProp }) { + return ( + + + + ); + } + + // Tuple scale + const { rerender } = render(); + const circle = circleRef.current!; + expect(circle.scale).toBeInstanceOf(Two.Vector); + expect((circle.scale as Two.Vector).x).toBe(2); + expect((circle.scale as Two.Vector).y).toBe(3); + + // Ensure _matrix update does not produce NaN + const internalCircle = circle as unknown as { + _update: () => void; + _matrix: { elements: Float32Array }; + }; + internalCircle._update(); + for (const el of internalCircle._matrix.elements) { + expect(Number.isNaN(el)).toBe(false); + } + + // Object scale + rerender(); + expect((circle.scale as Two.Vector).x).toBe(0.5); + expect((circle.scale as Two.Vector).y).toBe(1.5); + internalCircle._update(); + for (const el of internalCircle._matrix.elements) { + expect(Number.isNaN(el)).toBe(false); + } + + // Scalar number scale + rerender(); + expect(circle.scale).toBe(4); + + // Removal resets to default scale 1 + rerender(); + expect(circle.scale).toBe(1); + }); + + it('resets removed properties to TWO_DEFAULT_PROPS (mask, clip, strokeAttenuation, skew)', () => { + const circleRef = React.createRef(); + const maskShape = new Two.Rectangle(0, 0, 50, 50); + + function App({ + mask, + clip, + strokeAttenuation, + skewX, + skewY, + }: { + mask?: Two.Shape | null; + clip?: boolean; + strokeAttenuation?: boolean; + skewX?: number; + skewY?: number; + }) { + return ( + + + + ); + } + + const { rerender } = render( + + ); + const circle = circleRef.current!; + expect(circle.mask).toBe(maskShape); + expect(circle.clip).toBe(true); + expect(circle.strokeAttenuation).toBe(false); + expect(circle.skewX).toBe(0.5); + expect(circle.skewY).toBe(0.25); + + // Remove all props -> must reset to Two.js defaults! + rerender(); + expect(circle.mask).toBeNull(); + expect(circle.clip).toBe(false); + expect(circle.strokeAttenuation).toBe(true); + expect(circle.skewX).toBe(0); + expect(circle.skewY).toBe(0); + }); + + it('normalizes vector endpoints on Line, LinearGradient, and RadialGradient', () => { + const lineRef = React.createRef(); + const linearRef = React.createRef(); + const radialRef = React.createRef(); + + function App({ + lineLeft, + lineRight, + linearLeft, + linearRight, + radialCenter, + radialFocal, + }: { + lineLeft?: VectorProp; + lineRight?: VectorProp; + linearLeft?: VectorProp; + linearRight?: VectorProp; + radialCenter?: VectorProp; + radialFocal?: VectorProp; + }) { + return ( + + + + + + ); + } + + const { rerender } = render( + + ); + + const line = lineRef.current!; + expect(line.left.x).toBe(10); + expect(line.left.y).toBe(20); + expect(line.right.x).toBe(100); + expect(line.right.y).toBe(200); + + const linear = linearRef.current!; + expect(linear.left.x).toBe(5); + expect(linear.left.y).toBe(10); + expect(linear.right.x).toBe(80); + expect(linear.right.y).toBe(90); + + const radial = radialRef.current!; + expect(radial.center.x).toBe(40); + expect(radial.center.y).toBe(50); + expect(radial.focal.x).toBe(20); + expect(radial.focal.y).toBe(30); + + // Removal resets + rerender(); + expect(line.left.x).toBe(0); + expect(line.left.y).toBe(0); + expect(line.right.x).toBe(0); + expect(line.right.y).toBe(0); + expect(linear.left.x).toBe(0); + expect(linear.left.y).toBe(0); + expect(radial.center.x).toBe(0); + expect(radial.center.y).toBe(0); + }); + }); +});