diff --git a/README.md b/README.md
index 4254075..8a38a9c 100644
--- a/README.md
+++ b/README.md
@@ -115,6 +115,28 @@ function App() {
>
> ```
+Canvas accepts `svg` / `SVGRenderer`, `canvas` / `CanvasRenderer`, and
+`webgl` / `WebGLRenderer`. The default is SVG. `width` and `height` update the
+existing renderer; changing `autostart` plays or pauses the existing instance.
+`type`, `fullscreen`, `ratio`, `overdraw`, and `smoothing` are construction
+options. Change the Canvas `key` to apply new values and recreate its scene.
+Changing these options without a remount warns in development.
+
+```tsx
+
+```
+
+When sizing SVG with CSS, use a `viewBox` to scale the artwork with its viewport:
+
+```tsx
+
+```
+
### Adding Shapes
All Two.js primitives are available as React components:
@@ -278,6 +300,61 @@ All Two.js properties work as React props:
/>
```
+### Pointer events and dragging
+
+Shapes and Groups accept click, double-click, context-menu, wheel, and pointer
+handlers. Every `TwoEvent.point` is in **renderer world coordinates**, measured
+in logical pixels from the top-left origin, before shape or Group transforms.
+Canvas/WebGL CSS sizing and pixel ratio are accounted for; SVG uses its screen
+transform, including `viewBox` and `preserveAspectRatio`. Bubbling and capture
+use the same coordinates. To obtain target-local coordinates, apply the inverse
+of the target's `worldMatrix` to `event.point`.
+
+The topmost interactive geometry follows the current complete Two.js scene
+order. `event.target` is the hit leaf shape, including when only its Group has
+handlers. `event.currentTarget` is the shape or Group running the handler.
+Handlers bubble through all ancestor Groups; `stopPropagation()` stops that
+dispatch. Noninteractive shapes do not block interactive shapes underneath.
+
+A Group is interactive through its descendants' geometry, so empty space in a
+sparse Group does not hit. Invisible or zero-opacity shapes and ancestors,
+`clip` shapes, and paths with neither visible fill nor stroke are excluded.
+Masks restrict hits to their geometry in the owner's local space. Geometry
+precision and fill/stroke tests otherwise follow Two.js `contains()` behavior.
+Pointer over/out bubble once per target transition; enter/leave run once for
+each entered or exited ancestor, without bubbling again.
+
+Use the event's capture methods for dragging. Capture belongs to
+`currentTarget`, is independent for each pointer, and routes subsequent move,
+up, and cancel events through that target's ancestors even outside the
+renderer. Release occurs automatically on up, cancel, lost native capture,
+removal of the last handler, or unmount; it can also be explicit.
+
+```tsx
+ {
+ event.setPointerCapture((event.nativeEvent as PointerEvent).pointerId);
+ }}
+ onPointerMove={(event) => {
+ const id = (event.nativeEvent as PointerEvent).pointerId;
+ if (event.hasPointerCapture(id)) {
+ // This example has no transformed parent Group.
+ event.currentTarget.translation.set(event.point.x, event.point.y);
+ }
+ }}
+ onPointerUp={(event) => {
+ event.releasePointerCapture((event.nativeEvent as PointerEvent).pointerId);
+ }}
+/>
+```
+
+`Canvas.onPointerMissed` runs once on pointer-up for a complete primary-button
+press that both began and ended outside interactive geometry. A hit at either
+end, any capture during the press, cancellation, a nonprimary/right-button
+press, or an up without a preceding down suppresses it. The subsequent click
+does not fire a second missed notification; click handlers use the geometry
+under the click independently of pointer capture.
+
## TypeScript
Full TypeScript support with ref types for all components:
@@ -412,6 +489,24 @@ function AnimatedIcon() {
- `onError(error)` — Callback when loading fails
- All Two.js Group properties (fill, stroke, opacity, etc.)
+`shallow` (default `false`) removes the imported top-level SVG Group and places
+its children directly in the component's stable wrapper. Nested `` groups
+remain intact. This mirrors Two.js shallow interpretation: root SVG transforms
+are discarded with that top-level Group, so leave `shallow` off when preserving
+root transforms matters. Changing `src`, `content`, or `shallow` replaces only
+the imported artwork; JSX children remain attached after the imports in scene
+order and stay visible during loading.
+
+URL sources use `fetch` and an `AbortSignal`, including URLs with query strings.
+Source replacement and unmount abort pending requests, ignore stale responses,
+and release imported resources. Inline content must be a well-formed SVG
+document. Successful loads apply declarative styles before invoking the latest
+`onLoad` callback once per load; network, HTTP, and parsing failures invoke
+`onError`. Cancellation does not invoke either callback. Updating callbacks
+does not reload the artwork. User callback exceptions are logged separately
+from loading errors. If both sources are supplied at runtime, `src` wins with
+a warning; missing sources report an error.
+
> [!NOTE]
> The SVG component uses Two.js's `load()` method which supports a subset of SVG 1.1 features. Complex SVG features like filters, animations (SMIL), and some advanced elements may not be fully supported. Refer to [Two.js SVG documentation](https://two.js.org/docs/two/#two-interpret) for details on supported features.
@@ -453,6 +548,12 @@ npm test
npm run lint
```
+For real renderer and native pointer-capture checks, run `npm run dev` and open
+`http://localhost:3000/tests/browser/`. The harness checks every renderer
+spelling with real SVG, Canvas, and WebGL, then offers drag checks for native
+capture, bubbling, and release outside the renderer. The unit tests mock the
+WebGL constructor because jsdom does not provide a GPU context.
+
The development server runs the documentation site which imports the library components directly from the `lib/` directory, allowing you to see changes in real-time.
## Acknowledgments
diff --git a/lib/Events.ts b/lib/Events.ts
index 79e9f3d..ffcf431 100644
--- a/lib/Events.ts
+++ b/lib/Events.ts
@@ -7,6 +7,10 @@ export interface TwoEvent {
target: T;
currentTarget: T;
point: { x: number; y: number };
+ /** Capture this pointer for currentTarget until release, up, or cancel. */
+ setPointerCapture: (pointerId: number) => void;
+ releasePointerCapture: (pointerId: number) => void;
+ hasPointerCapture: (pointerId: number) => boolean;
stopPropagation: () => void;
readonly stopped: boolean;
}
@@ -51,49 +55,51 @@ export interface EventShape {
parent?: Group;
}
-/**
- * Convert DOM event coordinates to canvas-relative coordinates (center origin)
- */
-export function getCanvasCoordinates(
- nativeEvent: PointerEvent | MouseEvent | WheelEvent,
- canvas: HTMLElement,
- two: Two
-): { x: number; y: number } {
- const rect = canvas.getBoundingClientRect();
-
- // Convert from DOM space (top-left origin) to Two.js space (center origin)
- const x = nativeEvent.clientX - rect.left - two.width / 2;
- const y = nativeEvent.clientY - rect.top - two.height / 2;
-
- 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.
- */
+/** Renderer world coordinates, in logical pixels from the top-left origin. */
export function clientToWorldPoint(
clientX: number,
clientY: number,
- canvas: HTMLElement
+ canvas: HTMLElement,
+ two?: Two | null,
): { x: number; y: number } {
+ // SVG viewport sizing does not scale its contents unless a viewBox (or CSS
+ // transform) does so. Its screen CTM also accounts for preserveAspectRatio.
+ const svg = canvas as HTMLElement & { getScreenCTM?: () => DOMMatrix | null };
+ const screen = svg.getScreenCTM?.();
+ if (screen) {
+ const inverse = screen.inverse();
+ return {
+ x: inverse.a * clientX + inverse.c * clientY + inverse.e,
+ y: inverse.b * clientX + inverse.d * clientY + inverse.f,
+ };
+ }
const rect = canvas.getBoundingClientRect();
-
return {
- x: clientX - rect.left,
- y: clientY - rect.top,
+ x: (clientX - rect.left) * (two && rect.width > 0 ? two.width / rect.width : 1),
+ y: (clientY - rect.top) * (two && rect.height > 0 ? two.height / rect.height : 1),
};
}
-/**
- * Convert DOM event coordinates to world-space coordinates for hit testing
- * World-space uses top-left origin (same as DOM but relative to canvas)
- */
+export function getCanvasCoordinates(
+ event: PointerEvent | MouseEvent | WheelEvent,
+ canvas: HTMLElement,
+ two: Two,
+): { x: number; y: number } {
+ return clientToWorldPoint(event.clientX, event.clientY, canvas, two);
+}
+
export function getWorldCoordinates(
- nativeEvent: PointerEvent | MouseEvent,
- canvas: HTMLElement
+ event: PointerEvent | MouseEvent | WheelEvent,
+ canvas: HTMLElement,
+ two?: Two | null,
): { x: number; y: number } {
- return clientToWorldPoint(nativeEvent.clientX, nativeEvent.clientY, canvas);
+ return clientToWorldPoint(event.clientX, event.clientY, canvas, two);
+}
+
+export interface PointerCaptureControls {
+ setPointerCapture: (pointerId: number) => void;
+ releasePointerCapture: (pointerId: number) => void;
+ hasPointerCapture: (pointerId: number) => boolean;
}
/**
@@ -103,7 +109,8 @@ export function createTwoEvent(
nativeEvent: PointerEvent | MouseEvent | WheelEvent,
target: T,
currentTarget: T,
- point: { x: number; y: number }
+ point: { x: number; y: number },
+ capture?: PointerCaptureControls,
): TwoEvent {
let stopped = false;
@@ -112,6 +119,9 @@ export function createTwoEvent(
target,
currentTarget,
point,
+ setPointerCapture: (id) => capture?.setPointerCapture(id),
+ releasePointerCapture: (id) => capture?.releasePointerCapture(id),
+ hasPointerCapture: (id) => capture?.hasPointerCapture(id) ?? false,
stopPropagation: () => {
stopped = true;
},
@@ -121,146 +131,106 @@ 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, two?: Two | null): boolean {
- // Check if shape is visible
- if ('visible' in shape && !shape.visible) {
- return false;
- }
-
- // 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
- }
+/** Test leaf geometry; groups are the union of their descendants. */
+export function hitTest(shape: Shape | Group, x: number, y: number): boolean {
+ if ('visible' in shape && !shape.visible) return false;
+ if ('opacity' in shape && (shape as Group).opacity <= 0) return false;
+ if ('children' in shape) {
+ return Array.from((shape as Group).children).some((child) => hitTest(child, x, y));
}
+ const candidate = shape as Shape & { contains?: (x: number, y: number) => boolean };
+ if (typeof candidate.contains === 'function') return candidate.contains(x, y);
+ const rect = (shape as Shape & { getBoundingClientRect?: () => DOMRect }).getBoundingClientRect?.();
+ return !!rect && x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
+}
- // 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;
- }
- }
+/** Walk the actual scene ancestry, including groups without handlers. */
+export function getParentHierarchy(
+ shape: Shape | Group,
+ shapes: Map,
+): Array {
+ const hierarchy: Array = [];
+ const visited = new Set();
+ let current: Shape | Group | undefined = shape;
+ while (current && !visited.has(current)) {
+ visited.add(current);
+ hierarchy.push(current);
+ current = (current as Shape & { parent?: Group }).parent ?? shapes.get(current)?.parent;
}
-
- return false;
+ return hierarchy;
}
-/**
- * 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.
- */
+/** Compare complete scene paths, including nested and reordered groups. */
export function sortFrontToBack(
hits: Array,
- shapes: Map
+ 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);
+ const pathA = getParentHierarchy(a, shapes).reverse();
+ const pathB = getParentHierarchy(b, shapes).reverse();
+ let i = 0;
+ while (i < pathA.length && i < pathB.length && pathA[i] === pathB[i]) i++;
+ if (i > 0 && i < pathA.length && i < pathB.length) {
+ const siblings = (pathA[i - 1] as Group).children;
+ return siblings.indexOf(pathB[i]) - siblings.indexOf(pathA[i]);
+ }
+ if (i > 0) return pathB.length - pathA.length;
+ return 0;
+ });
+}
- // 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
- }
- }
+function passesMasks(shape: Shape | Group, x: number, y: number): boolean {
+ let current: Shape | Group | undefined = shape;
+ while (current) {
+ const owner = current as Shape & { mask?: Shape | Group; clip?: boolean };
+ if (owner.clip) return false;
+ if (owner.mask) {
+ // Masks are rendered in their owner's local space, even if unattached.
+ const inverse = owner.worldMatrix.inverse();
+ if (!inverse) return false;
+ const local = inverse.multiply(x, y, 1);
+ const maskParent = (owner.mask as Shape & { parent?: Group }).parent;
+ const point = maskParent?.worldMatrix.multiply(local[0], local[1], 1) ?? local;
+ if (!maskContains(owner.mask, point[0], point[1])) return false;
}
+ current = (current as Shape & { parent?: Group }).parent;
+ }
+ return true;
+}
- // 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
- });
+function maskContains(mask: Shape | Group, x: number, y: number): boolean {
+ if ('children' in mask) {
+ return Array.from((mask as Group).children).some((child) => maskContains(child, x, y));
+ }
+ const geometry = mask as Shape & {
+ contains: (x: number, y: number, options: Record) => boolean;
+ };
+ return geometry.contains(x, y, { ignoreVisibility: true, fill: true, stroke: false });
}
-/**
- * Get all shapes at a point, sorted by depth (front to back)
- * Uses scene graph traversal and registration order to maintain z-order
- */
+function hasPaint(shape: Shape | Group): boolean {
+ if (!('fill' in shape) && !('stroke' in shape)) return true;
+ const painted = shape as Shape & { fill?: unknown; stroke?: unknown; linewidth?: number };
+ const visiblePaint = (paint: unknown) => !!paint &&
+ (typeof paint !== 'string' || !/^(none|transparent)$/i.test(paint));
+ return visiblePaint(painted.fill) || ((painted.linewidth ?? 1) > 0 && visiblePaint(painted.stroke));
+}
+
+/** Two.js traversal supplies authoritative draw order; groups never use bounds. */
export function getShapesAtPoint(
shapes: Map,
x: number,
y: number,
- two?: Two | null
+ two?: Two | null,
): Array {
if (two?.scene) {
(two.scene as unknown as { _update: (deep?: boolean) => void })._update(true);
+ const scene = two.scene as Group & {
+ getShapesAtPoint: (x: number, y: number) => Array;
+ };
+ return Array.from(scene.getShapesAtPoint(x, y)).filter((shape) =>
+ hasPaint(shape) && passesMasks(shape, x, y) &&
+ getParentHierarchy(shape, shapes).some((parent) => shapes.has(parent)));
}
-
- const hits: Array = [];
-
- for (const [shape] of shapes) {
- if (hitTest(shape, x, y, two)) {
- hits.push(shape);
- }
- }
-
- return sortFrontToBack(hits, shapes);
-}
-
-/**
- * Get the parent hierarchy for event bubbling
- */
-export function getParentHierarchy(
- shape: Shape | Group,
- shapes: Map
-): Array {
- const hierarchy: Array = [shape];
-
- // Walk up the parent chain
- const entry = shapes.get(shape);
- if (entry?.parent) {
- let currentParent: Group | undefined = entry.parent;
-
- while (currentParent) {
- hierarchy.push(currentParent);
- const parentEntry = shapes.get(currentParent);
- currentParent = parentEntry?.parent;
- }
- }
-
- return hierarchy;
+ return sortFrontToBack(Array.from(shapes.keys()).filter((shape) => hitTest(shape, x, y)), shapes);
}
diff --git a/lib/Provider.tsx b/lib/Provider.tsx
index 2d326c9..0e6066a 100644
--- a/lib/Provider.tsx
+++ b/lib/Provider.tsx
@@ -24,7 +24,6 @@ import {
clientToWorldPoint,
createTwoEvent,
getCanvasCoordinates,
- getWorldCoordinates,
getParentHierarchy,
getShapesAtPoint,
type EventShape,
@@ -32,6 +31,24 @@ import {
type TwoConstructorProps = ConstructorParameters[0];
+function normalizeRenderer(type: unknown): string {
+ switch (type) {
+ case 'canvas': return Two.Types.canvas;
+ case 'webgl': return Two.Types.webgl;
+ case 'svg':
+ case undefined: return Two.Types.svg;
+ default: return type as string;
+ }
+}
+
+function isProduction(): boolean {
+ return (import.meta as ImportMeta & { env?: { PROD?: boolean } }).env?.PROD === true ||
+ (globalThis as { process?: { env?: { NODE_ENV?: string } } }).process?.env?.NODE_ENV === 'production';
+}
+
+const IMMUTABLE_OPTIONS = ['type', 'fullscreen', 'ratio', 'overdraw', 'smoothing'] as const;
+const FULLSCREEN_BODY_STYLES = ['overflow', 'margin', 'padding', 'top', 'left', 'right', 'bottom', 'position'] as const;
+
// List of Two.js constructor prop keys
const TWO_CONSTRUCTOR_KEYS = [
'fullscreen',
@@ -50,7 +67,7 @@ type TwoConstructorOnlyProps = {
autostart?: boolean;
width?: number;
height?: number;
- type?: (typeof Two.Types)[keyof typeof Two.Types];
+ type?: 'canvas' | 'CanvasRenderer' | 'svg' | 'SVGRenderer' | 'webgl' | 'WebGLRenderer';
overdraw?: boolean;
smoothing?: boolean;
ratio?: number;
@@ -83,10 +100,7 @@ type ComponentProps = React.PropsWithChildren<
* Warns in development mode if DOM elements or incompatible components are found.
*/
function validateChildren(children: React.ReactNode): void {
- if (
- (globalThis as { process?: { env?: { NODE_ENV?: string } } }).process?.env
- ?.NODE_ENV === 'production'
- ) {
+ if (isProduction()) {
return;
}
@@ -135,10 +149,16 @@ export const Provider = React.forwardRef<
const { two, parent } = useTwo();
const container = useRef(null);
const eventShapes = useRef