diff --git a/CLAUDE.md b/CLAUDE.md index 9ebf649..c4c1e30 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,6 +103,16 @@ useFrame((elapsed: number) => { ``` Frame-based animation system for smooth animations. +### useZUI() and useZUIState() Hooks +```typescript +const zui = useZUI(groupRef, { minZoom: 0.25, maxZoom: 8 }); +const { scale, x, y } = useZUIState(zui); +``` +- Wrap a `` to enable mouse wheel zoom & background drag pan. +- Automatically gates canvas panning using registered shape hit testing so node drag events do not trigger pan. +- High-frequency wheel/pan events update Two.js transforms directly in a ref (no React re-renders on pan). +- `zui.clientToSurface(clientX, clientY)` converts raw DOM screen pixel coordinates into surface space for node dragging. + ## Ref System Each component has a corresponding ref type: - `RefCircle`, `RefRectangle`, `RefPath`, etc. diff --git a/README.md b/README.md index 27fb838..c23c50a 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,43 @@ useFrame((elapsed: number) => { }) ``` +#### `useZUI(targetRef, options?)` + +Adds zoom and pan interactions to a `` component. Panning is gated on registered shape hit testing so node dragging and canvas panning never conflict. + +```tsx +import { useRef } from 'react'; +import { Canvas, Group, Circle, useZUI, RefGroup } from 'react-two.js'; + +function Scene() { + const groupRef = useRef(null); + const zui = useZUI(groupRef, { minZoom: 0.25, maxZoom: 8 }); + + return ( + + + + ); +} +``` + +Returns `ZUIControls`: +- `controls.zoomBy(ratio, clientX?, clientY?)` — Zoom relative to center or given client point. +- `controls.zoomTo(scale, clientX?, clientY?)` — Set absolute zoom scale. +- `controls.panBy(dx, dy)` — Pan by screen pixel delta. +- `controls.reset()` — Reset zoom and pan to identity state. +- `controls.clientToSurface(clientX, clientY)` — Convert screen coordinates to surface coordinates. +- `controls.state` — Ref containing `{ scale, x, y }`. + +#### `useZUIState(zui, onChange?)` + +Subscribes to ZUI zoom and pan state updates for rendering reactive zoom UI controls. + +```tsx +const zui = useZUI(groupRef); +const { scale } = useZUIState(zui); +``` + ### Props All Two.js properties work as React props: diff --git a/lib/ArcSegment.tsx b/lib/ArcSegment.tsx index 950928b..cb9f411 100644 --- a/lib/ArcSegment.tsx +++ b/lib/ArcSegment.tsx @@ -42,10 +42,13 @@ export const ArcSegment = React.forwardRef( for (const key in props) { if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - eventHandlers[key as keyof EventHandlers] = props[ - 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 - ] as 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]; @@ -78,14 +81,19 @@ export const ArcSegment = React.forwardRef( } }, [shapeProps, arcSegment, x, y]); - // Register event handlers + // 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); - - return () => { - unregisterEventShape(arcSegment); - }; + } else { + unregisterEventShape(arcSegment); } }, [ arcSegment, diff --git a/lib/Circle.tsx b/lib/Circle.tsx index 1eea6ae..8b48cb0 100644 --- a/lib/Circle.tsx +++ b/lib/Circle.tsx @@ -37,10 +37,13 @@ export const Circle = React.forwardRef( for (const key in props) { if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - eventHandlers[key as keyof EventHandlers] = props[ - 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 - ] as 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]; @@ -67,20 +70,26 @@ export const Circle = React.forwardRef( useEffect(() => { if (parent) { parent.add(circle); + return () => { parent.remove(circle); }; } }, [parent, circle]); - // Register event handlers + // 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); - - return () => { - unregisterEventShape(circle); - }; + } else { + unregisterEventShape(circle); } }, [ circle, diff --git a/lib/Context.ts b/lib/Context.ts index 83c37b4..714c03d 100644 --- a/lib/Context.ts +++ b/lib/Context.ts @@ -12,6 +12,7 @@ export interface TwoCoreContextValue { parent?: Group ) => void; unregisterEventShape: (shape: Shape | Group) => void; + hitTestPoint: (clientX: number, clientY: number) => boolean; } export interface TwoParentContextValue { @@ -27,6 +28,7 @@ export const TwoCoreContext = createContext({ two: null, registerEventShape: () => {}, unregisterEventShape: () => {}, + hitTestPoint: () => false, }); export const TwoParentContext = createContext({ diff --git a/lib/Ellipse.tsx b/lib/Ellipse.tsx index 45bc7b0..c7757a3 100644 --- a/lib/Ellipse.tsx +++ b/lib/Ellipse.tsx @@ -37,10 +37,13 @@ export const Ellipse = React.forwardRef( for (const key in props) { if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - eventHandlers[key as keyof EventHandlers] = props[ - 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 - ] as 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]; @@ -74,14 +77,19 @@ export const Ellipse = React.forwardRef( } }, [ellipse, x, y, shapeProps]); - // Register event handlers + // 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); - - return () => { - unregisterEventShape(ellipse); - }; + } else { + unregisterEventShape(ellipse); } }, [ ellipse, diff --git a/lib/Events.ts b/lib/Events.ts index 0b9cf89..79e9f3d 100644 --- a/lib/Events.ts +++ b/lib/Events.ts @@ -1,75 +1,50 @@ -/** - * Event system for react-two.js - * Implements R3F-style event handlers using Two.js hit testing - */ - import Two from 'two.js'; import type { Shape } from 'two.js/src/shape'; import type { Group } from 'two.js/src/group'; -/** - * Event object passed to event handlers - * Similar to React Three Fiber's ThreeEvent - */ -export interface TwoEvent { - /** The original DOM event */ +export interface TwoEvent { nativeEvent: PointerEvent | MouseEvent | WheelEvent; - /** The shape that was directly hit */ target: T; - /** The shape that has the event handler (may be ancestor due to bubbling) */ currentTarget: T; - /** The point in Two.js coordinate space (center origin) */ point: { x: number; y: number }; - /** Stop event from bubbling to parent groups */ stopPropagation: () => void; - /** Whether propagation was stopped */ - stopped: boolean; + readonly stopped: boolean; } -/** - * Event handler function type - */ -export type EventHandler = (event: TwoEvent) => void; +export type EventHandler = ( + event: TwoEvent +) => void; -/** - * All supported event handlers (matching R3F API) - */ export interface EventHandlers { onClick?: EventHandler; onContextMenu?: EventHandler; onDoubleClick?: EventHandler; - onWheel?: EventHandler; onPointerDown?: EventHandler; + onPointerMove?: EventHandler; onPointerUp?: EventHandler; onPointerOver?: EventHandler; onPointerOut?: EventHandler; onPointerEnter?: EventHandler; onPointerLeave?: EventHandler; - onPointerMove?: EventHandler; onPointerCancel?: EventHandler; + onWheel?: EventHandler; } -/** - * Event handler names for iteration - */ -export const EVENT_HANDLER_NAMES = [ +export const EVENT_HANDLER_NAMES: Array = [ 'onClick', 'onContextMenu', 'onDoubleClick', - 'onWheel', 'onPointerDown', + 'onPointerMove', 'onPointerUp', 'onPointerOver', 'onPointerOut', 'onPointerEnter', 'onPointerLeave', - 'onPointerMove', 'onPointerCancel', -] as const; + 'onWheel', +]; -/** - * Shape registration entry for event system - */ export interface EventShape { shape: Shape | Group; handlers: Partial; @@ -77,11 +52,10 @@ export interface EventShape { } /** - * Convert DOM event coordinates to Two.js coordinate space - * Two.js uses center origin (0,0 at center of canvas) + * Convert DOM event coordinates to canvas-relative coordinates (center origin) */ export function getCanvasCoordinates( - nativeEvent: PointerEvent | MouseEvent, + nativeEvent: PointerEvent | MouseEvent | WheelEvent, canvas: HTMLElement, two: Two ): { x: number; y: number } { @@ -94,6 +68,23 @@ export function getCanvasCoordinates( return { x, y }; } +/** + * Convert raw client coordinates to world-space coordinates for hit testing. + * World space uses a top-left origin, relative to the canvas element. + */ +export function clientToWorldPoint( + clientX: number, + clientY: number, + canvas: HTMLElement +): { x: number; y: number } { + const rect = canvas.getBoundingClientRect(); + + return { + x: clientX - rect.left, + y: clientY - rect.top, + }; +} + /** * Convert DOM event coordinates to world-space coordinates for hit testing * World-space uses top-left origin (same as DOM but relative to canvas) @@ -102,13 +93,7 @@ export function getWorldCoordinates( nativeEvent: PointerEvent | MouseEvent, canvas: HTMLElement ): { x: number; y: number } { - const rect = canvas.getBoundingClientRect(); - - // Convert from DOM space to canvas-relative space (both top-left origin) - const x = nativeEvent.clientX - rect.left; - const y = nativeEvent.clientY - rect.top; - - return { x, y }; + return clientToWorldPoint(nativeEvent.clientX, nativeEvent.clientY, canvas); } /** @@ -139,41 +124,121 @@ export function createTwoEvent( /** * Check if a shape contains a point using Two.js hit testing */ -export function hitTest(shape: Shape | Group, x: number, y: number): boolean { +export function hitTest(shape: Shape | Group, x: number, y: number, two?: Two | null): boolean { // Check if shape is visible if ('visible' in shape && !shape.visible) { return false; } - // Use Two.js hit testing API - if (typeof shape.contains === 'function') { - return shape.contains(x, y); + // Use shape.contains if custom contains function exists. + // DOM nodes also expose a `contains`, with completely different semantics, + // so exclude those. The globals are guarded because this module must not + // throw when evaluated outside a DOM runtime. + const candidateShape = shape as unknown as { contains?: unknown }; + const isDomContains = + (typeof Node !== 'undefined' && + candidateShape.contains === Node.prototype.contains) || + (typeof Element !== 'undefined' && + candidateShape.contains === Element.prototype.contains); + if (typeof candidateShape.contains === 'function' && !isDomContains) { + return (candidateShape.contains as (x: number, y: number) => boolean)(x, y); + } + + // Use Two.js getBoundingClientRect API + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (typeof (shape as any).getBoundingClientRect === 'function') { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rect = (shape as any).getBoundingClientRect(false); + if (rect && typeof rect.left === 'number' && typeof rect.right === 'number') { + const isRealTwoShape = 'worldMatrix' in shape || '_matrix' in shape; + const offsetX = (!isRealTwoShape && two) ? two.width / 2 : 0; + const offsetY = (!isRealTwoShape && two) ? two.height / 2 : 0; + const left = rect.left + offsetX; + const right = rect.right + offsetX; + const top = rect.top + offsetY; + const bottom = rect.bottom + offsetY; + + if (x >= left && x <= right && y >= top && y <= bottom) { + return true; + } + } + } catch { + // Fallback to checking children if getBoundingClientRect fails + } + } + + // For Groups without bounds, recursively check children + if ('children' in shape && Array.isArray((shape as Group).children)) { + for (const child of (shape as Group).children) { + if (hitTest(child, x, y, two)) { + return true; + } + } } - // Fallback for shapes without hit testing return false; } +/** + * Sort shapes front-to-back (topmost visible shape first). + * In 2D rendering, shapes drawn later (or with higher parent.children index) sit on top of shapes drawn earlier. + */ +export function sortFrontToBack( + hits: Array, + shapes: Map +): Array { + if (hits.length <= 1) return hits; + + const keys = Array.from(shapes.keys()); + + return [...hits].sort((a, b) => { + const entryA = shapes.get(a); + const entryB = shapes.get(b); + + // If both belong to the same parent Group, compare their index in parent.children + if (entryA?.parent && entryB?.parent && entryA.parent === entryB.parent) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const parentChildren = (entryA.parent as any).children; + if (parentChildren && typeof parentChildren.indexOf === 'function') { + const indexA = parentChildren.indexOf(a); + const indexB = parentChildren.indexOf(b); + if (indexA !== -1 && indexB !== -1) { + return indexB - indexA; // Higher index = drawn on top = should be first + } + } + } + + // Default: reverse registration order (shapes registered later sit on top) + const indexA = keys.indexOf(a); + const indexB = keys.indexOf(b); + return indexB - indexA; // Higher registration index = frontmost + }); +} + /** * Get all shapes at a point, sorted by depth (front to back) - * Uses scene graph traversal to maintain z-order + * Uses scene graph traversal and registration order to maintain z-order */ export function getShapesAtPoint( shapes: Map, x: number, - y: number + y: number, + two?: Two | null ): Array { + if (two?.scene) { + (two.scene as unknown as { _update: (deep?: boolean) => void })._update(true); + } + const hits: Array = []; for (const [shape] of shapes) { - if (hitTest(shape, x, y)) { + if (hitTest(shape, x, y, two)) { hits.push(shape); } } - // TODO: Sort by z-order/drawing order when Two.js provides this info - // For now, return in registration order - return hits; + return sortFrontToBack(hits, shapes); } /** diff --git a/lib/Group.tsx b/lib/Group.tsx index 404d899..f2397a3 100644 --- a/lib/Group.tsx +++ b/lib/Group.tsx @@ -39,6 +39,7 @@ export const Group = React.forwardRef( height, registerEventShape, unregisterEventShape, + hitTestPoint, } = useTwo(); // Create the instance synchronously so it's available for refs immediately @@ -51,10 +52,13 @@ export const Group = React.forwardRef( for (const key in props) { if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - eventHandlers[key as keyof EventHandlers] = props[ - 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 - ] as 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]; @@ -91,14 +95,19 @@ export const Group = React.forwardRef( } }, [group, x, y, shapeProps]); - // Register event handlers + // 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); - - return () => { - unregisterEventShape(group); - }; + } else { + unregisterEventShape(group); } }, [ group, @@ -115,8 +124,9 @@ export const Group = React.forwardRef( two, registerEventShape, unregisterEventShape, + hitTestPoint, }), - [two, registerEventShape, unregisterEventShape] + [two, registerEventShape, unregisterEventShape, hitTestPoint] ); const parentValue = useMemo( diff --git a/lib/Image.tsx b/lib/Image.tsx index d16b391..80c9880 100644 --- a/lib/Image.tsx +++ b/lib/Image.tsx @@ -38,10 +38,13 @@ export const Image = React.forwardRef( for (const key in props) { if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - eventHandlers[key as keyof EventHandlers] = props[ - 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 - ] as 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]; @@ -78,14 +81,19 @@ export const Image = React.forwardRef( } }, [image, shapeProps, mode, texture, x, y]); - // Register event handlers + // 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); - - return () => { - unregisterEventShape(image); - }; + } else { + unregisterEventShape(image); } }, [ image, diff --git a/lib/ImageSequence.tsx b/lib/ImageSequence.tsx index 437ea7c..8bb79c0 100644 --- a/lib/ImageSequence.tsx +++ b/lib/ImageSequence.tsx @@ -44,10 +44,13 @@ export const ImageSequence = React.forwardRef( for (const key in props) { if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - eventHandlers[key as keyof EventHandlers] = props[ - 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 - ] as 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]; @@ -87,14 +90,19 @@ export const ImageSequence = React.forwardRef( } }, [shapeProps, imageSequence, x, y, autoPlay]); - // Register event handlers + // 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); - - return () => { - unregisterEventShape(imageSequence); - }; + } else { + unregisterEventShape(imageSequence); } }, [ imageSequence, diff --git a/lib/Line.tsx b/lib/Line.tsx index fd27cf7..cbdda2b 100644 --- a/lib/Line.tsx +++ b/lib/Line.tsx @@ -35,10 +35,13 @@ export const Line = React.forwardRef( for (const key in props) { if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - eventHandlers[key as keyof EventHandlers] = props[ - 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 - ] as 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]; @@ -75,16 +78,27 @@ export const Line = React.forwardRef( } }, [shapeProps, line, x1, y1, x2, y2]); - // Register event handlers + // Unregister on unmount only + useEffect(() => { + return () => { + unregisterEventShape(line); + }; + }, [line, unregisterEventShape]); + + // Register / update event handlers useEffect(() => { if (Object.keys(eventHandlers).length > 0) { registerEventShape(line, eventHandlers, parent ?? undefined); - - return () => { - unregisterEventShape(line); - }; + } else { + unregisterEventShape(line); } - }, [line, registerEventShape, unregisterEventShape, parent, eventHandlers]); + }, [ + line, + registerEventShape, + unregisterEventShape, + parent, + eventHandlers, + ]); useImperativeHandle(forwardedRef, () => line, [line]); diff --git a/lib/Path.tsx b/lib/Path.tsx index 7e7fb1c..b0b6ef4 100644 --- a/lib/Path.tsx +++ b/lib/Path.tsx @@ -49,10 +49,13 @@ export const Path = React.forwardRef( for (const key in props) { if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - eventHandlers[key as keyof EventHandlers] = props[ - 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 - ] as 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]; @@ -72,16 +75,27 @@ export const Path = React.forwardRef( } }, [parent, path]); - // Register event handlers + // 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); - - return () => { - unregisterEventShape(path); - }; + } else { + unregisterEventShape(path); } - }, [path, registerEventShape, unregisterEventShape, parent, eventHandlers]); + }, [ + path, + registerEventShape, + unregisterEventShape, + parent, + eventHandlers, + ]); useEffect(() => { // Update position diff --git a/lib/Points.tsx b/lib/Points.tsx index 78d2b88..6324b03 100644 --- a/lib/Points.tsx +++ b/lib/Points.tsx @@ -44,10 +44,13 @@ export const Points = React.forwardRef( for (const key in props) { if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - eventHandlers[key as keyof EventHandlers] = props[ - 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 - ] as 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]; @@ -81,14 +84,19 @@ export const Points = React.forwardRef( } }, [shapeProps, points, x, y]); - // Register event handlers + // 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); - - return () => { - unregisterEventShape(points); - }; + } else { + unregisterEventShape(points); } }, [ points, diff --git a/lib/Polygon.tsx b/lib/Polygon.tsx index 24b369a..de4bd89 100644 --- a/lib/Polygon.tsx +++ b/lib/Polygon.tsx @@ -34,10 +34,13 @@ export const Polygon = React.forwardRef( for (const key in props) { if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - eventHandlers[key as keyof EventHandlers] = props[ - 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 - ] as 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]; @@ -71,14 +74,19 @@ export const Polygon = React.forwardRef( } }, [shapeProps, polygon, x, y]); - // Register event handlers + // 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); - - return () => { - unregisterEventShape(polygon); - }; + } else { + unregisterEventShape(polygon); } }, [ polygon, diff --git a/lib/Provider.tsx b/lib/Provider.tsx index 4f9d4dd..5e48cce 100644 --- a/lib/Provider.tsx +++ b/lib/Provider.tsx @@ -18,6 +18,7 @@ import { } from './Context'; import type { EventHandlers } from './Events'; import { + clientToWorldPoint, createTwoEvent, getCanvasCoordinates, getWorldCoordinates, @@ -79,7 +80,10 @@ type ComponentProps = React.PropsWithChildren< * Warns in development mode if DOM elements or incompatible components are found. */ function validateChildren(children: React.ReactNode): void { - if (process.env.NODE_ENV === 'production') { + if ( + (globalThis as { process?: { env?: { NODE_ENV?: string } } }).process?.env + ?.NODE_ENV === 'production' + ) { return; } @@ -188,6 +192,25 @@ export const Provider = React.forwardRef< } }, []); + /** + * Returns true if any registered event shape sits under the given client + * coordinates. Used by `useZUI` to leave pointerdowns that landed on a + * shape alone, so shape drags and canvas panning never fight. + */ + const hitTestPoint = useCallback( + (clientX: number, clientY: number): boolean => { + const canvas = twoState?.renderer.domElement; + if (!canvas) return false; + + const point = clientToWorldPoint(clientX, clientY, canvas); + return ( + getShapesAtPoint(eventShapes.current, point.x, point.y, twoState) + .length > 0 + ); + }, + [twoState], + ); + // Initialize root Two.js instance useEffect(() => { const isRoot = !two; @@ -237,11 +260,12 @@ export const Provider = React.forwardRef< } }, [two, twoState, props.width, props.height]); - // Validate children in development mode + // Validate children in development mode. + // `validateChildren` already no-ops in production, so no guard is needed + // here — gating the call on production as well made it dead in every + // environment. useEffect(() => { - if (process.env.NODE_ENV !== 'production') { - validateChildren(props.children); - } + validateChildren(props.children); }, [props.children]); // Setup event listeners on canvas @@ -293,6 +317,7 @@ export const Provider = React.forwardRef< eventShapes.current, worldPoint.x, worldPoint.y, + twoState, ); if (shapes.length > 0) { @@ -307,6 +332,7 @@ export const Provider = React.forwardRef< eventShapes.current, worldPoint.x, worldPoint.y, + twoState, ); if (shapes.length > 0) { @@ -321,6 +347,7 @@ export const Provider = React.forwardRef< eventShapes.current, worldPoint.x, worldPoint.y, + twoState, ); if (shapes.length > 0) { @@ -335,6 +362,7 @@ export const Provider = React.forwardRef< eventShapes.current, worldPoint.x, worldPoint.y, + twoState, ); if (shapes.length > 0) { @@ -349,6 +377,7 @@ export const Provider = React.forwardRef< eventShapes.current, worldPoint.x, worldPoint.y, + twoState, ); if (shapes.length > 0) { @@ -389,6 +418,7 @@ export const Provider = React.forwardRef< eventShapes.current, worldPoint.x, worldPoint.y, + twoState, ); if (shapes.length > 0) { @@ -405,18 +435,30 @@ export const Provider = React.forwardRef< eventShapes.current, worldPoint.x, worldPoint.y, + twoState, ); - const currentHovered = new Set(shapes); - // Dispatch pointer move to hovered shapes - if (shapes.length > 0) { - dispatchEvent(shapes, 'onPointerMove', e); + // Topmost shape under pointer (front-to-back sorted) + const topShape = shapes.length > 0 ? shapes[0] : null; + + // Build set of currently hovered shapes (topmost shape + its parent hierarchy) + const currentHovered = new Set(); + if (topShape) { + const hierarchy = getParentHierarchy(topShape, eventShapes.current); + for (const s of hierarchy) { + currentHovered.add(s); + } + } + + // Dispatch pointer move to topmost shape hierarchy + if (topShape) { + dispatchEvent([topShape], 'onPointerMove', e); } - // Handle pointer enter/leave + // Handle pointer enter/leave and over/out const previousHovered = hoveredShapes.current; - // Enter: shapes now hovered but weren't before + // Enter / Over: shapes in currentHovered (topmost hierarchy) that weren't hovered before for (const shape of currentHovered) { if (!previousHovered.has(shape)) { dispatchEvent([shape], 'onPointerEnter', e); @@ -424,7 +466,7 @@ export const Provider = React.forwardRef< } } - // Leave: shapes previously hovered but aren't now + // Leave / Out: shapes previously hovered that are no longer in currentHovered for (const shape of previousHovered) { if (!currentHovered.has(shape)) { dispatchEvent([shape], 'onPointerLeave', e); @@ -442,6 +484,7 @@ export const Provider = React.forwardRef< eventShapes.current, worldPoint.x, worldPoint.y, + twoState, ); if (shapes.length > 0) { @@ -505,7 +548,7 @@ export const Provider = React.forwardRef< // Merge style object Object.assign(element.style, value); } else if (key === 'className') { - element.className = value as string; + element.setAttribute('class', value as string); } else if (key.startsWith('on') && typeof value === 'function') { // Handle React event props (onClick, onMouseMove, etc.) const eventName = key.slice(2).toLowerCase(); @@ -539,8 +582,9 @@ export const Provider = React.forwardRef< two: twoState, registerEventShape, unregisterEventShape, + hitTestPoint, }), - [twoState, registerEventShape, unregisterEventShape], + [twoState, registerEventShape, unregisterEventShape, hitTestPoint], ); const parentValue = useMemo( diff --git a/lib/Rectangle.tsx b/lib/Rectangle.tsx index 86e33b1..77aadae 100644 --- a/lib/Rectangle.tsx +++ b/lib/Rectangle.tsx @@ -33,10 +33,13 @@ export const Rectangle = React.forwardRef( for (const key in props) { if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - eventHandlers[key as keyof EventHandlers] = props[ - 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 - ] as 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]; @@ -70,14 +73,19 @@ export const Rectangle = React.forwardRef( } }, [shapeProps, rectangle, x, y]); - // Register event handlers + // 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); - - return () => { - unregisterEventShape(rectangle); - }; + } else { + unregisterEventShape(rectangle); } }, [ rectangle, diff --git a/lib/RoundedRectangle.tsx b/lib/RoundedRectangle.tsx index 9b2dff8..0a3c1cd 100644 --- a/lib/RoundedRectangle.tsx +++ b/lib/RoundedRectangle.tsx @@ -33,10 +33,13 @@ export const RoundedRectangle = React.forwardRef( for (const key in props) { if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - eventHandlers[key as keyof EventHandlers] = props[ - 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 - ] as 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]; @@ -70,7 +73,14 @@ export const RoundedRectangle = React.forwardRef( } }, [shapeProps, roundedRectangle, x, y]); - // Register event handlers + // Unregister on unmount only + useEffect(() => { + return () => { + unregisterEventShape(roundedRectangle); + }; + }, [roundedRectangle, unregisterEventShape]); + + // Register / update event handlers useEffect(() => { if (Object.keys(eventHandlers).length > 0) { registerEventShape( @@ -78,10 +88,8 @@ export const RoundedRectangle = React.forwardRef( eventHandlers, parent ?? undefined ); - - return () => { - unregisterEventShape(roundedRectangle); - }; + } else { + unregisterEventShape(roundedRectangle); } }, [ roundedRectangle, diff --git a/lib/SVG.tsx b/lib/SVG.tsx index db60e95..1f9e8ea 100644 --- a/lib/SVG.tsx +++ b/lib/SVG.tsx @@ -52,6 +52,7 @@ export const SVG = React.forwardRef( height, registerEventShape, unregisterEventShape, + hitTestPoint, } = useTwo(); const svg = useMemo(() => new Two.Group(), []); const ref = useRef(null); @@ -77,10 +78,13 @@ export const SVG = React.forwardRef( for (const key in props) { if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - eventHandlers[key as keyof EventHandlers] = props[ - 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 - ] as 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]; @@ -213,16 +217,27 @@ export const SVG = React.forwardRef( } }, [svg, x, y, shapeProps]); - // Register event handlers + // Unregister on unmount only + useEffect(() => { + return () => { + unregisterEventShape(svg); + }; + }, [svg, unregisterEventShape]); + + // Register / update event handlers useEffect(() => { if (Object.keys(eventHandlers).length > 0) { registerEventShape(svg, eventHandlers, parent ?? undefined); - - return () => { - unregisterEventShape(svg); - }; + } else { + unregisterEventShape(svg); } - }, [svg, registerEventShape, unregisterEventShape, parent, eventHandlers]); + }, [ + svg, + registerEventShape, + unregisterEventShape, + parent, + eventHandlers, + ]); useImperativeHandle(forwardedRef, () => svg, [svg]); @@ -231,8 +246,9 @@ export const SVG = React.forwardRef( two, registerEventShape, unregisterEventShape, + hitTestPoint, }), - [two, registerEventShape, unregisterEventShape] + [two, registerEventShape, unregisterEventShape, hitTestPoint] ); const parentValue = useMemo( diff --git a/lib/Sprite.tsx b/lib/Sprite.tsx index 426141d..d55eeb0 100644 --- a/lib/Sprite.tsx +++ b/lib/Sprite.tsx @@ -47,10 +47,13 @@ export const Sprite = React.forwardRef( for (const key in props) { if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - eventHandlers[key as keyof EventHandlers] = props[ - 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 - ] as 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]; @@ -90,14 +93,19 @@ export const Sprite = React.forwardRef( } }, [shapeProps, sprite, x, y, autoPlay]); - // Register event handlers + // 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); - - return () => { - unregisterEventShape(sprite); - }; + } else { + unregisterEventShape(sprite); } }, [ sprite, diff --git a/lib/Star.tsx b/lib/Star.tsx index 67e3d5b..f3d0e62 100644 --- a/lib/Star.tsx +++ b/lib/Star.tsx @@ -33,10 +33,13 @@ export const Star = React.forwardRef( for (const key in props) { if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - eventHandlers[key as keyof EventHandlers] = props[ - 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 - ] as 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]; @@ -70,16 +73,27 @@ export const Star = React.forwardRef( } }, [shapeProps, star, x, y]); - // Register event handlers + // 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); - - return () => { - unregisterEventShape(star); - }; + } else { + unregisterEventShape(star); } - }, [star, registerEventShape, unregisterEventShape, parent, eventHandlers]); + }, [ + star, + registerEventShape, + unregisterEventShape, + parent, + eventHandlers, + ]); useImperativeHandle(forwardedRef, () => star, [star]); diff --git a/lib/Text.tsx b/lib/Text.tsx index 73d8954..af76d22 100644 --- a/lib/Text.tsx +++ b/lib/Text.tsx @@ -49,10 +49,13 @@ export const Text = React.forwardRef( for (const key in props) { if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - eventHandlers[key as keyof EventHandlers] = props[ - 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 - ] as 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]; @@ -86,16 +89,27 @@ export const Text = React.forwardRef( } }, [shapeProps, text, x, y]); - // Register event handlers + // 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); - - return () => { - unregisterEventShape(text); - }; + } else { + unregisterEventShape(text); } - }, [text, registerEventShape, unregisterEventShape, parent, eventHandlers]); + }, [ + text, + registerEventShape, + unregisterEventShape, + parent, + eventHandlers, + ]); useImperativeHandle(forwardedRef, () => text, [text]); diff --git a/lib/ZUI.ts b/lib/ZUI.ts new file mode 100644 index 0000000..2cb2885 --- /dev/null +++ b/lib/ZUI.ts @@ -0,0 +1,432 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useSyncExternalStore, + type RefObject, +} from 'react'; +import { useTwo } from './Context'; +import type { RefGroup } from './Group'; +import type { ZUIConstructor, ZUIInstance } from './zuiTypes'; +import { + centroid, + clamp, + distance, + pinchWheelZoomDelta, + pinchZoomDelta, + wheelZoomDelta, + type PanBounds, + type Point, +} from './zuiMath'; + +// two.js ships no declarations for its extras; `lib/zuiTypes.ts` types it +// structurally so we never leak an ambient module into consumers. +// @ts-expect-error - untyped two.js extra +import { ZUI as ZUIImpl } from 'two.js/extras/jsm/zui.js'; + +const ZUIClass = ZUIImpl as unknown as ZUIConstructor; + +/** + * A ref whose `current` is always present. React 18's own `RefObject` types + * `current` as `T | null`, which would force a null check on every read of + * `zui.state.current`. + */ +export interface ReadonlyRef { + readonly current: T; +} + +/** Immutable snapshot of the current zoom/pan state. */ +export interface ZUIState { + /** Logarithmic zoom position. `scale === Math.exp(zoom)`. */ + zoom: number; + /** Linear scale factor. */ + scale: number; + /** Surface translation on the x axis, in client pixels. */ + x: number; + /** Surface translation on the y axis, in client pixels. */ + y: number; +} + +export interface UseZUIOptions { + /** Minimum scale factor (default: 0.25) */ + minZoom?: number; + /** Maximum scale factor (default: 8) */ + maxZoom?: number; + /** Log-space zoom units per wheel notch (default: 0.05) */ + wheelZoomSpeed?: number; + /** Optional clamp on surface translation, in client pixels */ + panBounds?: PanBounds; + /** + * `'background'` (default) pans only when the pointer misses every + * registered shape, so shape drag handlers win. `'always'` pans on any + * drag. `false` disables pointer panning entirely. + */ + pan?: 'background' | 'always' | false; + /** `'wheel'` (default) enables wheel and trackpad-pinch zoom; `false` disables it. */ + zoom?: 'wheel' | false; + /** Override the element that listeners attach to (default: the Two.js renderer element) */ + domElement?: HTMLElement | null; + /** Called at most once per animation frame while zoom or pan changes. */ + onChange?: (state: ZUIState) => void; +} + +export interface ZUIControls { + /** Live, always-current state. Reading this never triggers a re-render. */ + state: ReadonlyRef; + /** True while a pointer pan is in progress. */ + isPanning: ReadonlyRef; + /** The underlying Two.js ZUI instance, for advanced use. */ + instance: ReadonlyRef; + /** Zoom by a log-space amount, anchored at the given client point. */ + zoomBy: (byF: number, clientX: number, clientY: number) => void; + /** Zoom to an absolute scale factor, anchored at the given client point. */ + zoomTo: (scale: number, clientX: number, clientY: number) => void; + /** Pan by a delta in client pixels. */ + panBy: (dx: number, dy: number) => void; + /** Pan to an absolute surface translation in client pixels. */ + panTo: (x: number, y: number) => void; + /** Restore scale 1 and zero translation. */ + reset: () => void; + /** Convert client coordinates into the ZUI group's local space. */ + clientToSurface: (x: number, y: number) => Point; + /** Convert the ZUI group's local space back into client coordinates. */ + surfaceToClient: (x: number, y: number) => Point; + /** Subscribe to coalesced state changes. Used by `useZUIState`. */ + subscribe: (listener: () => void) => () => void; + /** Read the current immutable snapshot. Used by `useZUIState`. */ + getSnapshot: () => ZUIState; +} + +const IDENTITY_STATE: ZUIState = { zoom: 0, scale: 1, x: 0, y: 0 }; + +/** + * Add zoom and pan to a react-two.js `Group`. + * + * The target group's `x`, `y`, and `scale` are owned by this hook — do not + * also pass those props to it, or the two will fight each other. + * + * @example + * ```tsx + * function Scene() { + * const groupRef = useRef(null); + * const zui = useZUI(groupRef, { minZoom: 0.25, maxZoom: 8 }); + * + * return ( + * + * + * + * ); + * } + * ``` + */ +export function useZUI( + target: RefObject, + options: UseZUIOptions = {} +): ZUIControls { + const { two, hitTestPoint } = useTwo(); + + const instance = useRef(null); + const state = useRef(IDENTITY_STATE); + const isPanning = useRef(false); + const listeners = useRef(new Set<() => void>()); + const frame = useRef(null); + + // Latest options, read inside event handlers so they never need rebinding. + const optionsRef = useRef(options); + useEffect(() => { + optionsRef.current = options; + }); + + const { + minZoom = 0.25, + maxZoom = 8, + domElement: domElementOption, + } = options; + + const element = domElementOption ?? two?.renderer.domElement ?? null; + + /** + * Publish a fresh immutable snapshot. The snapshot updates synchronously so + * drag math stays exact, but subscribers are notified on the next animation + * frame so a wheel gesture cannot re-render the scene per event. + */ + const flush = useCallback(() => { + const zui = instance.current; + if (!zui) return; + + const elements = zui.surfaceMatrix.elements; + state.current = { + zoom: zui.zoom, + scale: zui.scale, + x: elements[2], + y: elements[5], + }; + + if (frame.current !== null) return; + + frame.current = requestAnimationFrame(() => { + frame.current = null; + optionsRef.current.onChange?.(state.current); + for (const listener of listeners.current) { + listener(); + } + }); + }, []); + + /** Two.js's ZUI declares limits.x / limits.y but never applies them. */ + const applyPanBounds = useCallback(() => { + const zui = instance.current; + const bounds = optionsRef.current.panBounds; + if (!zui || !bounds) return; + + const elements = zui.surfaceMatrix.elements; + const x = clamp( + elements[2], + bounds.x?.[0] ?? -Infinity, + bounds.x?.[1] ?? Infinity + ); + const y = clamp( + elements[5], + bounds.y?.[0] ?? -Infinity, + bounds.y?.[1] ?? Infinity + ); + + if (x !== elements[2] || y !== elements[5]) { + zui.translateSurface(x - elements[2], y - elements[5]); + } + }, []); + + // Create the ZUI instance once the group and element both exist. + useEffect(() => { + const group = target.current; + if (!group || !element) return; + + const zui = new ZUIClass(group, element); + zui.addLimits(minZoom, maxZoom); + instance.current = zui; + flush(); + + return () => { + instance.current = null; + if (frame.current !== null) { + cancelAnimationFrame(frame.current); + frame.current = null; + } + }; + }, [target, element, minZoom, maxZoom, flush]); + + const zoomBy = useCallback( + (byF: number, clientX: number, clientY: number) => { + const zui = instance.current; + if (!zui) return; + zui.zoomBy(byF, clientX, clientY); + applyPanBounds(); + flush(); + }, + [applyPanBounds, flush] + ); + + const zoomTo = useCallback( + (scale: number, clientX: number, clientY: number) => { + const zui = instance.current; + if (!zui) return; + zui.zoomSet(scale, clientX, clientY); + applyPanBounds(); + flush(); + }, + [applyPanBounds, flush] + ); + + const panBy = useCallback( + (dx: number, dy: number) => { + const zui = instance.current; + if (!zui) return; + zui.translateSurface(dx, dy); + applyPanBounds(); + flush(); + }, + [applyPanBounds, flush] + ); + + const panTo = useCallback( + (x: number, y: number) => { + const zui = instance.current; + if (!zui) return; + const elements = zui.surfaceMatrix.elements; + zui.translateSurface(x - elements[2], y - elements[5]); + applyPanBounds(); + flush(); + }, + [applyPanBounds, flush] + ); + + const reset = useCallback(() => { + const zui = instance.current; + if (!zui) return; + zui.reset(); + flush(); + }, [flush]); + + const clientToSurface = useCallback((x: number, y: number): Point => { + const zui = instance.current; + if (!zui) return { x, y }; + const result = zui.clientToSurface(x, y); + return { x: result.x, y: result.y }; + }, []); + + const surfaceToClient = useCallback((x: number, y: number): Point => { + const zui = instance.current; + if (!zui) return { x, y }; + const result = zui.surfaceToClient(x, y); + return { x: result.x, y: result.y }; + }, []); + + const subscribe = useCallback((listener: () => void) => { + listeners.current.add(listener); + return () => { + listeners.current.delete(listener); + }; + }, []); + + const getSnapshot = useCallback(() => state.current, []); + + // Pointer panning and pinch zoom. One code path covers mouse, touch and pen. + useEffect(() => { + if (!element) return; + + const active = new Map(); + let lastCentroid: Point | null = null; + let lastDistance = 0; + + const points = () => Array.from(active.values()); + + const handlePointerDown = (event: PointerEvent) => { + const mode = optionsRef.current.pan ?? 'background'; + if (mode === false) return; + if (event.pointerType === 'mouse' && event.button !== 0) return; + if (mode === 'background' && hitTestPoint(event.clientX, event.clientY)) { + return; + } + + active.set(event.pointerId, { x: event.clientX, y: event.clientY }); + isPanning.current = true; + lastCentroid = centroid(points()); + lastDistance = active.size === 2 ? distance(points()[0], points()[1]) : 0; + }; + + const handlePointerMove = (event: PointerEvent) => { + if (!active.has(event.pointerId)) return; + + active.set(event.pointerId, { x: event.clientX, y: event.clientY }); + const current = points(); + const nextCentroid = centroid(current); + + if (lastCentroid) { + panBy(nextCentroid.x - lastCentroid.x, nextCentroid.y - lastCentroid.y); + } + lastCentroid = nextCentroid; + + if (current.length === 2) { + const nextDistance = distance(current[0], current[1]); + if (lastDistance > 0) { + zoomBy( + pinchZoomDelta(lastDistance, nextDistance), + nextCentroid.x, + nextCentroid.y + ); + } + lastDistance = nextDistance; + } + }; + + const endPointer = (event: PointerEvent) => { + if (!active.delete(event.pointerId)) return; + + const remaining = points(); + lastCentroid = remaining.length > 0 ? centroid(remaining) : null; + lastDistance = + remaining.length === 2 ? distance(remaining[0], remaining[1]) : 0; + isPanning.current = active.size > 0; + }; + + element.addEventListener('pointerdown', handlePointerDown); + window.addEventListener('pointermove', handlePointerMove); + window.addEventListener('pointerup', endPointer); + window.addEventListener('pointercancel', endPointer); + window.addEventListener('lostpointercapture', endPointer); + + return () => { + element.removeEventListener('pointerdown', handlePointerDown); + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', endPointer); + window.removeEventListener('pointercancel', endPointer); + window.removeEventListener('lostpointercapture', endPointer); + }; + }, [element, hitTestPoint, panBy, zoomBy]); + + // Wheel zoom, including ctrl+wheel trackpad pinch. + useEffect(() => { + if (!element) return; + + const handleWheel = (event: WheelEvent) => { + if ((optionsRef.current.zoom ?? 'wheel') === false) return; + + event.preventDefault(); + + const delta = event.ctrlKey + ? pinchWheelZoomDelta(event.deltaY) + : wheelZoomDelta( + event.deltaY, + event.deltaMode, + optionsRef.current.wheelZoomSpeed ?? 0.05, + element.clientHeight || window.innerHeight + ); + + zoomBy(delta, event.clientX, event.clientY); + }; + + element.addEventListener('wheel', handleWheel, { passive: false }); + return () => element.removeEventListener('wheel', handleWheel); + }, [element, zoomBy]); + + return useMemo( + () => ({ + state, + isPanning, + instance, + zoomBy, + zoomTo, + panBy, + panTo, + reset, + clientToSurface, + surfaceToClient, + subscribe, + getSnapshot, + }), + [ + zoomBy, + zoomTo, + panBy, + panTo, + reset, + clientToSurface, + surfaceToClient, + subscribe, + getSnapshot, + ] + ); +} + +/** + * Opt into re-rendering when zoom or pan changes. Only use this in components + * that actually display the value — `useZUI` alone never re-renders. + */ +export function useZUIState(controls: ZUIControls): ZUIState { + return useSyncExternalStore( + controls.subscribe, + controls.getSnapshot, + controls.getSnapshot + ); +} diff --git a/lib/main.ts b/lib/main.ts index 029aabc..3505b7b 100644 --- a/lib/main.ts +++ b/lib/main.ts @@ -1,6 +1,14 @@ export { Provider as Canvas } from './Provider'; export { Context, useTwo, useFrame } from './Context'; export { Group, type RefGroup } from './Group'; +export { + useZUI, + useZUIState, + type UseZUIOptions, + type ZUIControls, + type ZUIState, + type ReadonlyRef, +} from './ZUI'; export { SVG, type RefSVG } from './SVG'; export { Path, type RefPath } from './Path'; export { Points, type RefPoints } from './Points'; @@ -25,3 +33,7 @@ export { RadialGradient, type RefRadialGradient } from './RadialGradient'; // Texture exports export { Texture, type RefTexture } from './Texture'; + +// Event exports +export type { TwoEvent, EventHandler, EventHandlers } from './Events'; + diff --git a/lib/zuiMath.ts b/lib/zuiMath.ts new file mode 100644 index 0000000..e06e64d --- /dev/null +++ b/lib/zuiMath.ts @@ -0,0 +1,103 @@ +/** + * Pure gesture math for the ZUI hook. No React, no DOM — everything here is + * directly unit-testable. + * + * Two.js's ZUI treats zoom logarithmically (`scale = Math.exp(zoom)`), so all + * "delta" helpers below return log-space amounts suitable for `zui.zoomBy()`. + */ + +/** Approximate pixel height of one wheel "line" in DOM_DELTA_LINE mode. */ +export const LINE_HEIGHT_PX = 16; + +/** Log-space units applied per unit of ctrl+wheel (trackpad pinch) delta. */ +export const PINCH_WHEEL_FACTOR = 0.01; + +/** A single wheel notch in DOM_DELTA_PIXEL mode, used to normalise `speed`. */ +export const WHEEL_NOTCH_PX = 100; + +export interface Point { + x: number; + y: number; +} + +export interface PanBounds { + x?: [number, number]; + y?: [number, number]; +} + +export function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +/** + * Convert a `WheelEvent.deltaY` into pixels, accounting for `deltaMode`. + * Firefox reports lines (mode 1); some environments report pages (mode 2). + */ +export function normalizeWheelDelta( + deltaY: number, + deltaMode: number, + viewportHeight: number +): number { + switch (deltaMode) { + case 1: + return deltaY * LINE_HEIGHT_PX; + case 2: + return deltaY * viewportHeight; + default: + return deltaY; + } +} + +/** + * Log-space zoom delta for a standard scroll wheel. `speed` is expressed in + * log units per notch, so the default of 0.05 means one notch changes scale + * by a factor of e^0.05 (~5%). + */ +export function wheelZoomDelta( + deltaY: number, + deltaMode: number, + speed: number, + viewportHeight: number +): number { + const pixels = normalizeWheelDelta(deltaY, deltaMode, viewportHeight); + return (-pixels / WHEEL_NOTCH_PX) * speed; +} + +/** Log-space zoom delta for a trackpad pinch, which arrives as ctrl+wheel. */ +export function pinchWheelZoomDelta(deltaY: number): number { + return -deltaY * PINCH_WHEEL_FACTOR; +} + +/** + * Log-space zoom delta for a two-finger pinch. Using the log of the distance + * ratio makes the gesture feel identical at every zoom level — the WIP branch + * used a linear pixel difference, which did not. + */ +export function pinchZoomDelta( + prevDistance: number, + nextDistance: number +): number { + if (prevDistance <= 0 || nextDistance <= 0) { + return 0; + } + return Math.log(nextDistance / prevDistance); +} + +export function distance(a: Point, b: Point): number { + const dx = a.x - b.x; + const dy = a.y - b.y; + return Math.sqrt(dx * dx + dy * dy); +} + +export function centroid(points: Point[]): Point { + if (points.length === 0) { + return { x: 0, y: 0 }; + } + let x = 0; + let y = 0; + for (const point of points) { + x += point.x; + y += point.y; + } + return { x: x / points.length, y: y / points.length }; +} diff --git a/lib/zuiTypes.ts b/lib/zuiTypes.ts new file mode 100644 index 0000000..f54c49d --- /dev/null +++ b/lib/zuiTypes.ts @@ -0,0 +1,40 @@ +/** + * Local, non-ambient types for `two.js/extras/jsm/zui.js`, which ships no + * declarations. Declaring this as an ambient module would leak into every + * consumer of react-two.js, so we type it structurally and cast at the + * single import site in `lib/ZUI.ts`. + */ +import type { Group } from 'two.js/src/group'; +import type { Matrix } from 'two.js/src/matrix'; + +export interface ZUIVector { + x: number; + y: number; + z: number; +} + +export interface ZUIInstance { + zoom: number; + scale: number; + surfaceMatrix: Matrix; + viewport: HTMLElement; + limits: { + scale: { min: number; max: number }; + x: { min: number; max: number }; + y: { min: number; max: number }; + }; + addLimits(min?: number, max?: number): ZUIInstance; + zoomBy(byF: number, clientX: number, clientY: number): ZUIInstance; + zoomSet(zoom: number, clientX: number, clientY: number): ZUIInstance; + translateSurface(x: number, y: number): ZUIInstance; + clientToSurface(x: number, y: number, z?: number): ZUIVector; + surfaceToClient(x: number, y: number, z?: number): ZUIVector; + updateOffset(): ZUIInstance; + updateSurface(): ZUIInstance; + reset(): ZUIInstance; +} + +export type ZUIConstructor = new ( + group: Group, + domElement?: HTMLElement +) => ZUIInstance; diff --git a/package-lock.json b/package-lock.json index 5f6ecd3..901655c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "react-two.js", - "version": "0.8.23-r.1", + "version": "0.8.23-r.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "react-two.js", - "version": "0.8.23-r.1", + "version": "0.8.23-r.2", "license": "MIT", "devDependencies": { "@eslint/js": "^9.13.0", @@ -16,7 +16,7 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", - "@types/node": "^22.10.7", + "@types/node": "^22.20.1", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.3", @@ -2599,9 +2599,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.18.8", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.8.tgz", - "integrity": "sha512-pAZSHMiagDR7cARo/cch1f3rXy0AEXwsVsVH09FcyeJVAzCnGgmYis7P3JidtTUjyadhTeSo8TgRPswstghDaw==", + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index edbdd89..baef115 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", - "@types/node": "^22.10.7", + "@types/node": "^22.20.1", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.3", @@ -83,4 +83,4 @@ "react-dom": ">=19", "two.js": ">=v0.8.23" } -} \ No newline at end of file +} diff --git a/src/App.tsx b/src/App.tsx index 87bb916..b3e748e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,16 +16,21 @@ import { CommandLineIcon, CurrencyDollarIcon, SparklesIcon, + Squares2X2Icon, + ShareIcon, } from '@heroicons/react/20/solid'; import cn from 'clsx'; import Playground from './Playground'; import { useEffect, useState } from 'react'; import { version } from '../package.json'; +import { PLAYGROUNDS } from './playgrounds/registry'; export default function App() { const [domElement, setDomElement] = useState(null); const [width, setWidth] = useState(400); const [height, setHeight] = useState(300); + const [activePlaygroundId, setActivePlaygroundId] = useState('wiremarks'); + const sidebar = ( @@ -59,25 +64,43 @@ export default function App() { + + Playgrounds + {PLAYGROUNDS.map((playground) => { + const isWiremarks = playground.id === 'wiremarks'; + const Icon = isWiremarks ? ShareIcon : Squares2X2Icon; + const isCurrent = activePlaygroundId === playground.id; + + return ( + setActivePlaygroundId(playground.id)} + > + + {playground.name} + + ); + })} + + + + - Github + GitHub - Package + Package - Sponsor + Sponsor - + ChatGPT - - - - ); @@ -101,7 +124,11 @@ export default function App() { return ( - + ); } diff --git a/src/Playground.tsx b/src/Playground.tsx index 45f875f..e6393a5 100644 --- a/src/Playground.tsx +++ b/src/Playground.tsx @@ -1,406 +1,18 @@ -import Two from 'two.js'; -import { - Group, - Canvas, - Path, - RefPath, - useTwo, - Points, - RefPoints, - Circle, - RefCircle, - Rectangle, - RefRectangle, - RoundedRectangle, - RefRoundedRectangle, - Ellipse, - RefEllipse, - Line, - RefLine, - Polygon, - RefPolygon, - Star, - RefStar, - ArcSegment, - RefArcSegment, - Sprite, - RefSprite, - ImageSequence, - RefImageSequence, - LinearGradient, - RefLinearGradient, - RadialGradient, - RefRadialGradient, - Texture, - RefTexture, - Text, - SVG, - RefSVG, - RefGroup, -} from '../lib/main'; -import { useEffect, useRef, useState } from 'react'; -import { useFrame } from '../lib/Context'; +import { getPlaygroundById } from './playgrounds/registry'; -function Scene() { - const { width, height } = useTwo(); - - // Create refs for all components - const path = useRef(null); - const points = useRef(null); - const circle = useRef(null); - const rectangle = useRef(null); - const roundedRectangle = useRef(null); - const ellipse = useRef(null); - const line = useRef(null); - const polygon = useRef(null); - const star = useRef(null); - const arcSegment = useRef(null); - const sprite = useRef(null); - const imageSequence = useRef(null); - const svg = useRef(null); - - // Gradient refs - const [linearGradient, setLinearGradient] = useState< - string | RefLinearGradient - >('#FF6B6B'); - const radialGradient = useRef(null); - - // Texture ref - const [texture, setTexture] = useState('#E8E8E8'); - - // Interactive state - const [circleHovered, setCircleHovered] = useState(false); - const [rectangleClicked, setRectangleClicked] = useState(false); - const [starHovered, setStarHovered] = useState(false); - - useFrame((elapsed) => { - // Animate all the components - if (path.current) { - path.current.rotation = elapsed * 0.5; - } - if (points.current) { - points.current.rotation = -elapsed * 0.3; - } - if (circle.current) { - circle.current.scale = Math.sin(elapsed) * 0.25 + 1; - } - if (rectangle.current) { - rectangle.current.rotation = elapsed * 0.2; - } - if (roundedRectangle.current) { - roundedRectangle.current.rotation = -elapsed * 0.15; - } - if (ellipse.current) { - ellipse.current.scale = Math.cos(elapsed * 0.5) * 0.25 + 1; - ellipse.current.dashes.offset = -50 * elapsed; - } - if (line.current) { - line.current.rotation = elapsed * 0.4; - } - if (polygon.current) { - polygon.current.rotation = elapsed * 0.1; - } - if (star.current) { - star.current.rotation = -elapsed * 0.25; - star.current.innerRadius = 20 + Math.sin(elapsed * 2) * 10; - } - if (arcSegment.current) { - arcSegment.current.startAngle = elapsed % (Math.PI * 2); - } - if (sprite.current) { - sprite.current.rotation = elapsed * 0.3; - } - if (imageSequence.current) { - imageSequence.current.scale = Math.cos(elapsed * 1.2) * 0.15 + 1; - } - if (svg.current) { - svg.current.rotation = elapsed * 0.2; - svg.current.scale = Math.sin(elapsed * 0.8) * 0.2 + 1; - } - }, []); - - // Calculate grid positions - const gridSize = 4; - const cellWidth = width / gridSize; - const cellHeight = height / gridSize; - - return ( - - {/* Original components */} - - - - - - {/* Circle - Interactive with hover */} - - setCircleHovered(true)} - onPointerOut={() => setCircleHovered(false)} - /> - - - {/* Rectangle - Interactive with click */} - - setRectangleClicked(!rectangleClicked)} - /> - - - {/* SVG Example - Inline content */} - - - - - - - - `} - scale={0.6} - onLoad={(svg: RefGroup) => { - svg.center(); - }} - /> - - - {/* Rounded Rectangle */} - - - - - {/* Ellipse */} - - - - - {/* Line */} - - - - - {/* Text */} - - - - - {/* Polygon */} - - - - - {/* Star - Interactive with hover and pointer events */} - - setStarHovered(true)} - onPointerLeave={() => setStarHovered(false)} - /> - - - {/* ArcSegment */} - - - - - {/* Sprite */} - - - - - {/* Extra components in the 4th row */} - - - - - {/* Linear Gradient Example */} - - { - if (ref) setLinearGradient(ref); - }} - x1={0} - y1={0} - x2={1} - y2={1} - stops={[ - new Two.Stop(0, 'red'), - new Two.Stop(0.5, 'green'), - new Two.Stop(1, 'blue'), - ]} - /> - - - - {/* Radial Gradient Example */} - - - - - - {/* Texture Example */} - - { - if (ref) setTexture(ref); - }} - src="https://placehold.co/60x60/9B59B6/FFFFFF?text=TEX" - /> - - - - ); +interface PlaygroundProps { + width?: number; + height?: number; + activePlaygroundId?: string; } export default function Playground({ width, height, -}: { - width?: number; - height?: number; -}) { - // Demonstrate ref forwarding to access the actual canvas element - const canvasRef = useRef(null); - - // Example: You can now access the canvas element directly - useEffect(() => { - if (canvasRef.current) { - console.log('Canvas element:', canvasRef.current); - // You could get the 2D context: canvasRef.current.getContext('2d') - } - }, []); + activePlaygroundId = 'wiremarks', +}: PlaygroundProps) { + const playgroundDef = getPlaygroundById(activePlaygroundId); + const ActiveComponent = playgroundDef.component; - return ( - - - - ); + return ; } diff --git a/src/playgrounds/components-showcase/ComponentsShowcasePlayground.tsx b/src/playgrounds/components-showcase/ComponentsShowcasePlayground.tsx new file mode 100644 index 0000000..8c9c52b --- /dev/null +++ b/src/playgrounds/components-showcase/ComponentsShowcasePlayground.tsx @@ -0,0 +1,352 @@ +import Two from 'two.js'; +import { + Group, + Canvas, + Path, + RefPath, + useTwo, + Points, + RefPoints, + Circle, + RefCircle, + Rectangle, + RefRectangle, + RoundedRectangle, + RefRoundedRectangle, + Ellipse, + RefEllipse, + Line, + RefLine, + Polygon, + RefPolygon, + Star, + RefStar, + ArcSegment, + RefArcSegment, + Sprite, + RefSprite, + ImageSequence, + RefImageSequence, + LinearGradient, + RefLinearGradient, + RadialGradient, + RefRadialGradient, + Texture, + RefTexture, + Text, + SVG, + RefSVG, + RefGroup, + useFrame, +} from 'react-two.js'; +import { useRef, useState } from 'react'; +import { PlaygroundProps } from '../types'; + +function Scene() { + const { width, height } = useTwo(); + + const path = useRef(null); + const points = useRef(null); + const circle = useRef(null); + const rectangle = useRef(null); + const roundedRectangle = useRef(null); + const ellipse = useRef(null); + const line = useRef(null); + const polygon = useRef(null); + const star = useRef(null); + const arcSegment = useRef(null); + const sprite = useRef(null); + const imageSequence = useRef(null); + const svg = useRef(null); + + const [linearGradient, setLinearGradient] = useState< + string | RefLinearGradient + >('#FF6B6B'); + const radialGradient = useRef(null); + const [texture, setTexture] = useState('#E8E8E8'); + + const [circleHovered, setCircleHovered] = useState(false); + const [rectangleClicked, setRectangleClicked] = useState(false); + const [starHovered, setStarHovered] = useState(false); + + useFrame((elapsed) => { + if (path.current) path.current.rotation = elapsed * 0.5; + if (points.current) points.current.rotation = -elapsed * 0.3; + if (circle.current) circle.current.scale = Math.sin(elapsed) * 0.25 + 1; + if (rectangle.current) rectangle.current.rotation = elapsed * 0.2; + if (roundedRectangle.current) + roundedRectangle.current.rotation = -elapsed * 0.15; + if (ellipse.current) { + ellipse.current.scale = Math.cos(elapsed * 0.5) * 0.25 + 1; + if (ellipse.current.dashes) { + ellipse.current.dashes.offset = -50 * elapsed; + } + } + if (line.current) line.current.rotation = elapsed * 0.4; + if (polygon.current) polygon.current.rotation = elapsed * 0.1; + if (star.current) { + star.current.rotation = -elapsed * 0.25; + star.current.innerRadius = 20 + Math.sin(elapsed * 2) * 10; + } + if (arcSegment.current) + arcSegment.current.startAngle = elapsed % (Math.PI * 2); + if (sprite.current) sprite.current.rotation = elapsed * 0.3; + if (imageSequence.current) + imageSequence.current.scale = Math.cos(elapsed * 1.2) * 0.15 + 1; + if (svg.current) { + svg.current.rotation = elapsed * 0.2; + svg.current.scale = Math.sin(elapsed * 0.8) * 0.2 + 1; + } + }, []); + + const gridSize = 4; + const cellWidth = width / gridSize; + const cellHeight = height / gridSize; + + return ( + + + + + + + + setCircleHovered(true)} + onPointerOut={() => setCircleHovered(false)} + /> + + + + setRectangleClicked(!rectangleClicked)} + /> + + + + + + + + + + `} + scale={0.6} + onLoad={(svgGroup: RefGroup) => { + svgGroup.center(); + }} + /> + + + + + + + + + + + + + + + + + + + + + + + + setStarHovered(true)} + onPointerLeave={() => setStarHovered(false)} + /> + + + + + + + + + + + + + + + + { + if (ref) setLinearGradient(ref); + }} + x1={0} + y1={0} + x2={1} + y2={1} + stops={[ + new Two.Stop(0, 'red'), + new Two.Stop(0.5, 'green'), + new Two.Stop(1, 'blue'), + ]} + /> + + + + + + + + + + { + if (ref) setTexture(ref); + }} + src="https://placehold.co/60x60/9B59B6/FFFFFF?text=TEX" + /> + + + + ); +} + +export function ComponentsShowcasePlayground({ width, height }: PlaygroundProps) { + const canvasRef = useRef(null); + + return ( +
+ + + +
+ ); +} diff --git a/src/playgrounds/registry.ts b/src/playgrounds/registry.ts new file mode 100644 index 0000000..57317f7 --- /dev/null +++ b/src/playgrounds/registry.ts @@ -0,0 +1,23 @@ +import { PlaygroundDefinition } from './types'; +import { WiremarksPlayground } from './wiremarks/WiremarksPlayground'; +import { ComponentsShowcasePlayground } from './components-showcase/ComponentsShowcasePlayground'; + +export const PLAYGROUNDS: PlaygroundDefinition[] = [ + { + id: 'wiremarks', + name: 'Wiremarks', + description: 'Declarative text-to-wireframe interactive diagramming tool', + component: WiremarksPlayground, + }, + { + id: 'components-showcase', + name: 'Components Showcase', + description: 'Comprehensive grid demonstration of Two.js React primitives', + component: ComponentsShowcasePlayground, + }, +]; + +export function getPlaygroundById(id: string): PlaygroundDefinition { + const playground = PLAYGROUNDS.find((p) => p.id === id); + return playground || PLAYGROUNDS[0]; +} diff --git a/src/playgrounds/types.ts b/src/playgrounds/types.ts new file mode 100644 index 0000000..ce5a825 --- /dev/null +++ b/src/playgrounds/types.ts @@ -0,0 +1,13 @@ +import { ComponentType } from 'react'; + +export interface PlaygroundProps { + width?: number; + height?: number; +} + +export interface PlaygroundDefinition { + id: string; + name: string; + description: string; + component: ComponentType; +} diff --git a/src/playgrounds/wiremarks/WiremarkCanvas.tsx b/src/playgrounds/wiremarks/WiremarkCanvas.tsx new file mode 100644 index 0000000..09d4a34 --- /dev/null +++ b/src/playgrounds/wiremarks/WiremarkCanvas.tsx @@ -0,0 +1,112 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type MutableRefObject, +} from 'react'; +import { useZUI, Group, RefGroup, type ZUIControls } from 'react-two.js'; +import { useWiremarksGraph } from './hooks/useWiremarksGraph'; +import { WiremarksScene } from './components/WiremarksScene'; + +interface WiremarkCanvasProps { + instructions: string; + /** Receives the ZUI controls so DOM chrome outside can drive zoom. */ + controlsRef?: MutableRefObject; + /** Called at most once per frame while the zoom level changes. */ + onZoomChange?: (scale: number) => void; +} + +export function WiremarkCanvas({ + instructions, + controlsRef, + onZoomChange, +}: WiremarkCanvasProps) { + const sceneGroupRef = useRef(null); + + const [draggingNodeId, setDraggingNodeId] = useState(null); + const dragOriginRef = useRef<{ + pointer: { x: number; y: number }; + node: { x: number; y: number }; + } | null>(null); + + const { nodes, edges, nodesMap, updateNodePosition } = + useWiremarksGraph(instructions); + + const handleZoomChange = useCallback( + (state: { scale: number }) => onZoomChange?.(state.scale), + [onZoomChange], + ); + + // `pan: 'background'` leaves pointerdowns that landed on an entity alone, so + // dragging a node never also pans the canvas. + const zui = useZUI(sceneGroupRef, { + minZoom: 0.25, + maxZoom: 8, + pan: 'background', + onChange: handleZoomChange, + }); + + useEffect(() => { + if (controlsRef) { + controlsRef.current = zui; + } + }, [controlsRef, zui]); + + // The dash animation lives inside WiremarkConnection, which mutates its own + // Two.js path each frame. Driving it from here through React state + // re-rendered the whole graph 60 times a second. + + const handleDragStart = useCallback( + (nodeId: string, clientX: number, clientY: number) => { + setDraggingNodeId(nodeId); + const node = nodesMap.get(nodeId); + if (!node) return; + + const pointer = zui.clientToSurface(clientX, clientY); + dragOriginRef.current = { + pointer, + node: { x: node.x, y: node.y }, + }; + }, + [nodesMap, zui], + ); + + const handleDrag = useCallback( + (nodeId: string, clientX: number, clientY: number) => { + const origin = dragOriginRef.current; + if (!origin) return; + + // Diffing two surface-space points stays exact even if the view zooms + // or pans partway through the drag. + const pointer = zui.clientToSurface(clientX, clientY); + updateNodePosition( + nodeId, + origin.node.x + (pointer.x - origin.pointer.x), + origin.node.y + (pointer.y - origin.pointer.y), + ); + }, + [updateNodePosition, zui], + ); + + const handleDragEnd = useCallback(() => { + setDraggingNodeId(null); + dragOriginRef.current = null; + }, []); + + return ( + // NOTE: this Group's translation and scale are owned by useZUI. + // Do not add x, y, or scale props to it. + + + + ); +} diff --git a/src/playgrounds/wiremarks/WiremarksPlayground.tsx b/src/playgrounds/wiremarks/WiremarksPlayground.tsx new file mode 100644 index 0000000..1f6bd0d --- /dev/null +++ b/src/playgrounds/wiremarks/WiremarksPlayground.tsx @@ -0,0 +1,194 @@ +import { useEffect, useRef, useState } from 'react'; +import { Canvas } from '../../../lib/main'; +import type { ZUIControls } from '../../../lib/main'; +import Two from 'two.js'; +import { WiremarkCanvas } from './WiremarkCanvas'; +import { PlaygroundProps } from '../types'; +import { Button } from '@/components/catalyst/Button'; +import { + ArrowDownTrayIcon, + CodeBracketSquareIcon, + XMarkIcon, + ArrowPathIcon, +} from '@heroicons/react/20/solid'; + +const defaultPrompt = ` +# Welcome to Wiremarks! + +# Wiremarks is a simple interface to compose +# wireframes and organizational structures +# through text. Connect things with an arrow +# like so: +# Grandmother -> Mother + +# Each line of text is a connection. +# Mother -> Daughter + +# And you can label connections by using +# brackets like so: +# Grid -[Electricity]-> Home + +# Lastly, starting a line with a hashtag +# makes your text a comment and will not +# be compiled into any connections. +# Remove a hashtag above to see the +# Mother / Daughter connection. + +# When you close the instructions, you +# can drag each entity and move around +# to fine tune your composition. You +# can even save it out as an SVG! + +# Happy wire marking! +`.trim(); + +export function WiremarksPlayground({ width, height }: PlaygroundProps) { + const containerRef = useRef(null); + const textareaRef = useRef(null); + const zuiRef = useRef(null); + const [scale, setScale] = useState(1); + + const [text, setText] = useState(() => { + return window.localStorage.getItem('wiremarks-state') || defaultPrompt; + }); + const [isOpen, setIsOpen] = useState(true); + + useEffect(() => { + window.localStorage.setItem('wiremarks-state', text); + }, [text]); + + const handleOpen = () => { + setIsOpen(true); + requestAnimationFrame(() => { + textareaRef.current?.focus(); + }); + }; + + const handleClose = () => { + setIsOpen(false); + }; + + const handleReset = () => { + setText(defaultPrompt); + }; + + const handleDownload = () => { + const svgElement = containerRef.current?.querySelector('svg'); + if (!svgElement) return; + + const serializer = new XMLSerializer(); + const source = serializer.serializeToString(svgElement); + const a = document.createElement('a'); + a.href = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(source)}`; + a.download = 'wiremarks.svg'; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + a.remove(); + }; + + return ( +
+ {/* Two.js Canvas Stage */} +
+ + + +
+ + {/* Floating Action Controls */} +
+ {!isOpen && ( + + )} + +
+ + {/* Floating Zoom Controls Overlay */} +
+ +
+ + {/* DSL Editor Overlay Panel */} + {isOpen && ( +
+
+
+

+ + Wiremarks Script Editor +

+

+ Define nodes and connections using simple text syntax. +

+
+
+ + +
+
+ +
+