Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Group ref={groupRef}>` 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.
Expand Down
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,43 @@ useFrame((elapsed: number) => {
})
```

#### `useZUI(targetRef, options?)`

Adds zoom and pan interactions to a `<Group>` 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<RefGroup | null>(null);
const zui = useZUI(groupRef, { minZoom: 0.25, maxZoom: 8 });

return (
<Group ref={groupRef}>
<Circle x={0} y={0} radius={50} fill="#00AEFF" />
</Group>
);
}
```

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:
Expand Down
24 changes: 16 additions & 8 deletions lib/ArcSegment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,13 @@ export const ArcSegment = React.forwardRef<Instance, ComponentProps>(

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];
Expand Down Expand Up @@ -78,14 +81,19 @@ export const ArcSegment = React.forwardRef<Instance, ComponentProps>(
}
}, [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,
Expand Down
25 changes: 17 additions & 8 deletions lib/Circle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,13 @@ export const Circle = React.forwardRef<Instance, ComponentProps>(

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];
Expand All @@ -67,20 +70,26 @@ export const Circle = React.forwardRef<Instance, ComponentProps>(
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,
Expand Down
2 changes: 2 additions & 0 deletions lib/Context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface TwoCoreContextValue {
parent?: Group
) => void;
unregisterEventShape: (shape: Shape | Group) => void;
hitTestPoint: (clientX: number, clientY: number) => boolean;
}

export interface TwoParentContextValue {
Expand All @@ -27,6 +28,7 @@ export const TwoCoreContext = createContext<TwoCoreContextValue>({
two: null,
registerEventShape: () => {},
unregisterEventShape: () => {},
hitTestPoint: () => false,
});

export const TwoParentContext = createContext<TwoParentContextValue>({
Expand Down
24 changes: 16 additions & 8 deletions lib/Ellipse.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,13 @@ export const Ellipse = React.forwardRef<Instance | null, ComponentProps>(

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];
Expand Down Expand Up @@ -74,14 +77,19 @@ export const Ellipse = React.forwardRef<Instance | null, ComponentProps>(
}
}, [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,
Expand Down
Loading
Loading