diff --git a/example/app/_layout.tsx b/example/app/_layout.tsx
index c9a7665..3331963 100644
--- a/example/app/_layout.tsx
+++ b/example/app/_layout.tsx
@@ -57,6 +57,10 @@ export default function RootLayout() {
name="brushes"
options={{ drawerLabel: 'Brushes', title: 'Brushes' }}
/>
+
{
+ const out: Note[] = [];
+ let index = 0;
+ for (let gx = 0; gx < NOTE_GRID; gx++) {
+ for (let gy = 0; gy < NOTE_GRID; gy++) {
+ out.push({
+ id: `n${gx}-${gy}`,
+ x: 300 + gx * NOTE_GAP,
+ y: 300 + gy * NOTE_GAP,
+ color: NOTE_COLORS[index % NOTE_COLORS.length]!,
+ text: NOTE_TEXTS[index % NOTE_TEXTS.length]!,
+ });
+ index++;
+ }
+ }
+ return out;
+})();
+
+const NOTE_BY_ID: Record = Object.fromEntries(
+ NOTES.map((note) => [note.id, note])
+);
+
+const FIELD_CENTER = {
+ x: 300 + ((NOTE_GRID - 1) * NOTE_GAP) / 2,
+ y: 300 + ((NOTE_GRID - 1) * NOTE_GAP) / 2,
+};
+
+interface Transform {
+ x: number;
+ y: number;
+ scaleX: number;
+ scaleY: number;
+ rotation: number;
+}
+
+type Overrides = Record;
+
+function noteTransform(id: string, overrides: Overrides): Transform {
+ const override = overrides[id];
+ if (override) return override;
+ const note = NOTE_BY_ID[id]!;
+ return { x: note.x, y: note.y, scaleX: 1, scaleY: 1, rotation: 0 };
+}
+
+function DotGrid() {
+ const scene = useSceneTransform();
+ const sceneOffsetSV = scene?.sceneOffsetSV;
+ const sceneScaleSV = scene?.sceneScaleSV;
+ const viewportWidth = scene?.width ?? 0;
+ const viewportHeight = scene?.height ?? 0;
+
+ const gridPath = useDerivedValue(() => {
+ const path = Skia.Path.Make();
+ if (!sceneOffsetSV || !sceneScaleSV) return path;
+ const scale = sceneScaleSV.value.x || 1;
+ const offset = sceneOffsetSV.value;
+
+ const left = -offset.x / scale;
+ const top = -offset.y / scale;
+ const right = (viewportWidth - offset.x) / scale;
+ const bottom = (viewportHeight - offset.y) / scale;
+
+ let spacing = BASE_SPACING;
+ while (spacing * scale < TARGET_SCREEN_SPACING) spacing *= 2;
+ while (spacing * scale >= TARGET_SCREEN_SPACING * 2) spacing /= 2;
+ const radius = DOT_SCREEN_RADIUS / scale;
+
+ const startX = Math.floor(left / spacing) * spacing;
+ const startY = Math.floor(top / spacing) * spacing;
+ for (let x = startX; x <= right; x += spacing) {
+ for (let y = startY; y <= bottom; y += spacing) {
+ path.addCircle(x, y, radius);
+ }
+ }
+ return path;
+ }, [sceneOffsetSV, sceneScaleSV, viewportWidth, viewportHeight]);
+
+ return ;
+}
+
+interface NotesLayerProps {
+ overrides: Overrides;
+ selected: string | null;
+ font: SkFont | null;
+ onSelect: (id: string) => void;
+ onCommit: (id: string, t: TransformResult) => void;
+}
+
+const NotesLayer = memo(function NotesLayer({
+ overrides,
+ selected,
+ font,
+ onSelect,
+ onCommit,
+}: NotesLayerProps) {
+ return (
+ <>
+ {NOTES.map((note) => {
+ const t = noteTransform(note.id, overrides);
+ const isSelected = note.id === selected;
+ return (
+ {
+ onSelect(note.id);
+ e.cancelBubble = true;
+ }}
+ onTransformEnd={(e: EventObject) =>
+ onCommit(note.id, e.evt as TransformResult)
+ }
+ >
+
+
+
+ );
+ })}
+ >
+ );
+});
+
+export default function InfiniteScreen() {
+ const { width, height } = useWindowDimensions();
+ const stageRef = useRef(null);
+ const [zoom, setZoom] = useState(1);
+ const [selected, setSelected] = useState(null);
+ const [overrides, setOverrides] = useState({});
+
+ const font = useFont(FONT_URL, 20);
+
+ useEffect(() => {
+ stageRef.current?.centerOn(FIELD_CENTER.x, FIELD_CENTER.y, 0.7);
+ }, []);
+
+ const commit = useCallback((id: string, t: TransformResult) => {
+ setOverrides((prev) => ({
+ ...prev,
+ [id]: {
+ x: t.x,
+ y: t.y,
+ scaleX: t.scaleX,
+ scaleY: t.scaleY,
+ rotation: t.rotation,
+ },
+ }));
+ }, []);
+
+ return (
+
+ setZoom(scene.scale)}
+ >
+ setSelected(null)}
+ >
+
+
+
+ {
+ if (selected) commit(selected, e);
+ }}
+ />
+
+
+
+
+ stageRef.current?.zoomOut()}
+ >
+ −
+
+
+ stageRef.current?.centerOn(FIELD_CENTER.x, FIELD_CENTER.y, 0.7)
+ }
+ >
+
+ {Math.round(zoom * 100)}%
+
+
+ stageRef.current?.zoomIn()}
+ >
+ +
+
+
+
+
+
+ );
+}
+
+const CARD_SHADOW = {
+ shadowColor: '#000',
+ shadowOpacity: 0.12,
+ shadowRadius: 8,
+ shadowOffset: { width: 0, height: 3 },
+ elevation: 4,
+};
+
+const styles = StyleSheet.create({
+ root: { flex: 1 },
+ stage: { backgroundColor: '#F5F5F4' },
+ countPill: {
+ position: 'absolute',
+ top: 52,
+ alignSelf: 'center',
+ backgroundColor: '#1b0030cc',
+ borderRadius: 16,
+ paddingHorizontal: 14,
+ paddingVertical: 7,
+ },
+ countText: { color: '#ffffff', fontSize: 13, fontWeight: '600' },
+ zoomBar: {
+ position: 'absolute',
+ right: 20,
+ bottom: 40,
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: '#ffffff',
+ borderRadius: 24,
+ paddingHorizontal: 6,
+ paddingVertical: 4,
+ gap: 2,
+ ...CARD_SHADOW,
+ },
+ zoomButton: {
+ width: 40,
+ height: 40,
+ borderRadius: 20,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ zoomButtonText: { fontSize: 24, color: '#1f1f1f', fontWeight: '500' },
+ zoomLabel: {
+ minWidth: 58,
+ height: 40,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ zoomLabelText: { fontSize: 15, color: '#1f1f1f', fontWeight: '600' },
+});
diff --git a/src/components/Portal.tsx b/src/components/Portal.tsx
index 548bdb0..e0d4af4 100644
--- a/src/components/Portal.tsx
+++ b/src/components/Portal.tsx
@@ -93,6 +93,14 @@ export const Portal = memo(
};
}, [registry, id, transformable, dragOffsetSV, scaleSV, rotationSV]);
+ useLayoutEffect(() => {
+ if (!registry || id == null) return;
+ registry.registerResolvedTransform(id, resolvedTransformSV);
+ return () => {
+ registry.unregisterResolvedTransform(id);
+ };
+ }, [registry, id, resolvedTransformSV]);
+
useLayoutEffect(() => {
resolvedTransformSV.value = {
x: resolved.x,
diff --git a/src/components/Stage.tsx b/src/components/Stage.tsx
index b223506..c342472 100644
--- a/src/components/Stage.tsx
+++ b/src/components/Stage.tsx
@@ -12,11 +12,22 @@ import {
import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
import {
Canvas,
+ Group as SkiaGroup,
ImageFormat,
Skia,
useCanvasRef,
type SkImage,
+ type Transforms3d,
} from '@shopify/react-native-skia';
+import { useDerivedValue, useAnimatedReaction } from 'react-native-reanimated';
+import { scheduleOnRN } from 'react-native-worklets';
+import {
+ zoomAroundPoint,
+ DEFAULT_MIN_ZOOM,
+ DEFAULT_MAX_ZOOM,
+ type SceneState,
+} from '../core/scene';
+import { clampToBounds } from '../core/nodeBounds';
import {
Gesture,
GestureDetector,
@@ -55,6 +66,13 @@ export interface StageHandle {
makeImageSnapshot: (options?: StageToImageOptions) => Promise;
toBase64: (options?: StageToImageOptions) => Promise;
toDataURL: (options?: StageToImageOptions) => Promise;
+ getScene: () => SceneState;
+ zoomTo: (scale: number, focal?: { x: number; y: number }) => void;
+ zoomIn: (step?: number) => void;
+ zoomOut: (step?: number) => void;
+ panTo: (x: number, y: number) => void;
+ centerOn: (x: number, y: number, scale?: number) => void;
+ resetView: () => void;
}
const MIME_TYPE_TO_IMAGE_FORMAT: Record<
@@ -72,6 +90,10 @@ export interface StageProps {
style?: StyleProp;
listening?: boolean;
gestureEnabled?: boolean;
+ infinite?: boolean;
+ minZoom?: number;
+ maxZoom?: number;
+ onSceneChange?: (scene: SceneState) => void;
pinchSensitivity?: number;
rotationSensitivity?: number;
simultaneousGesture?:
@@ -89,6 +111,10 @@ export const Stage = memo(
style,
listening = true,
gestureEnabled: _gestureEnabled = true,
+ infinite = false,
+ minZoom = DEFAULT_MIN_ZOOM,
+ maxZoom = DEFAULT_MAX_ZOOM,
+ onSceneChange,
pinchSensitivity,
rotationSensitivity,
simultaneousGesture,
@@ -121,11 +147,41 @@ export const Stage = memo(
}, [registry, rootId]);
const gestureEnabled = listening !== false && _gestureEnabled !== false;
- const { gesture, activeGestureSV } = useStageGestures(
- registry,
- rootId,
- gestureEnabled,
- { pinchSensitivity, rotationSensitivity }
+ const { gesture, activeGestureSV, sceneOffsetSV, sceneScaleSV } =
+ useStageGestures(registry, rootId, gestureEnabled, {
+ pinchSensitivity,
+ rotationSensitivity,
+ infinite,
+ });
+
+ const sceneTransform = useDerivedValue(() => {
+ const offset = sceneOffsetSV.value;
+ const scale = sceneScaleSV.value;
+ const out: Transforms3d = [];
+ if (offset.x !== 0) out.push({ translateX: offset.x });
+ if (offset.y !== 0) out.push({ translateY: offset.y });
+ if (scale.x !== 1) out.push({ scaleX: scale.x });
+ if (scale.y !== 1) out.push({ scaleY: scale.y });
+ return out;
+ }, []);
+
+ const emitSceneChange = useCallback(
+ (scene: SceneState) => {
+ onSceneChange?.(scene);
+ },
+ [onSceneChange]
+ );
+
+ useAnimatedReaction(
+ () => Math.round(sceneScaleSV.value.x * 100),
+ (percent, previous) => {
+ if (percent === previous) return;
+ scheduleOnRN(emitSceneChange, {
+ x: sceneOffsetSV.value.x,
+ y: sceneOffsetSV.value.y,
+ scale: sceneScaleSV.value.x,
+ });
+ }
);
const composedGesture = useMemo(() => {
@@ -144,10 +200,20 @@ export const Stage = memo(
snapshotSV,
getTransform,
activeGestureSV,
+ sceneOffsetSV,
+ sceneScaleSV,
width,
height,
}),
- [snapshotSV, getTransform, activeGestureSV, width, height]
+ [
+ snapshotSV,
+ getTransform,
+ activeGestureSV,
+ sceneOffsetSV,
+ sceneScaleSV,
+ width,
+ height,
+ ]
);
const registerPortal = useCallback((entry: PortalEntry) => {
@@ -190,6 +256,29 @@ export const Stage = memo(
return image.encodeToBase64(format, quality);
};
+ const applyScene = (
+ nextOffset: { x: number; y: number },
+ next: number
+ ) => {
+ sceneOffsetSV.value = nextOffset;
+ sceneScaleSV.value = { x: next, y: next };
+ emitSceneChange({ x: nextOffset.x, y: nextOffset.y, scale: next });
+ };
+
+ const zoomTo = (scale: number, focal?: { x: number; y: number }) => {
+ const current = sceneScaleSV.value.x;
+ const next = clampToBounds(scale, minZoom, maxZoom);
+ const point = focal ?? { x: width / 2, y: height / 2 };
+ const offset = zoomAroundPoint(
+ sceneOffsetSV.value,
+ current,
+ next,
+ point.x,
+ point.y
+ );
+ applyScene(offset, next);
+ };
+
return {
makeImageSnapshot: snapshot,
toBase64: encode,
@@ -198,8 +287,39 @@ export const Stage = memo(
if (base64 == null) return null;
return `data:${options?.mimeType ?? 'image/png'};base64,${base64}`;
},
+ getScene: () => ({
+ x: sceneOffsetSV.value.x,
+ y: sceneOffsetSV.value.y,
+ scale: sceneScaleSV.value.x,
+ }),
+ zoomTo,
+ zoomIn: (step = 1.2) => zoomTo(sceneScaleSV.value.x * step),
+ zoomOut: (step = 1.2) => zoomTo(sceneScaleSV.value.x / step),
+ panTo: (x: number, y: number) =>
+ applyScene({ x, y }, sceneScaleSV.value.x),
+ centerOn: (x: number, y: number, scale?: number) => {
+ const next = clampToBounds(
+ scale ?? sceneScaleSV.value.x,
+ minZoom,
+ maxZoom
+ );
+ applyScene(
+ { x: width / 2 - next * x, y: height / 2 - next * y },
+ next
+ );
+ },
+ resetView: () => applyScene({ x: 0, y: 0 }, 1),
};
- }, [canvasRef]);
+ }, [
+ canvasRef,
+ sceneOffsetSV,
+ sceneScaleSV,
+ emitSceneChange,
+ minZoom,
+ maxZoom,
+ width,
+ height,
+ ]);
const canvas = useMemo(
() => (
@@ -214,7 +334,13 @@ export const Stage = memo(
- {children}
+ {infinite ? (
+
+ {children}
+
+ ) : (
+ {children}
+ )}
@@ -226,6 +352,8 @@ export const Stage = memo(
children,
gestureEnabled,
height,
+ infinite,
+ sceneTransform,
portalContext,
gestureState,
registry,
@@ -253,6 +381,8 @@ export const Stage = memo(
entries={portalEntries}
snapshotSV={snapshotSV}
getTransform={getTransform}
+ sceneOffsetSV={sceneOffsetSV}
+ sceneScaleSV={sceneScaleSV}
/>
);
diff --git a/src/components/internal/gestureState.tsx b/src/components/internal/gestureState.tsx
index 17f4671..30b6e4b 100644
--- a/src/components/internal/gestureState.tsx
+++ b/src/components/internal/gestureState.tsx
@@ -1,12 +1,14 @@
import { createContext, useContext } from 'react';
import type { SharedValue } from 'react-native-reanimated';
-import type { ActiveGesture } from '../../core/types';
+import type { ActiveGesture, Vector2d } from '../../core/types';
import type { Snapshot, TransformLookup } from '../../core/snapshot';
export interface GestureStateValue {
snapshotSV: SharedValue;
getTransform: TransformLookup;
activeGestureSV: SharedValue;
+ sceneOffsetSV: SharedValue;
+ sceneScaleSV: SharedValue;
width: number;
height: number;
}
@@ -18,3 +20,21 @@ export const GestureStateContext = createContext(
export function useGestureState(): GestureStateValue | null {
return useContext(GestureStateContext);
}
+
+export interface SceneTransform {
+ sceneOffsetSV: SharedValue;
+ sceneScaleSV: SharedValue;
+ width: number;
+ height: number;
+}
+
+export function useSceneTransform(): SceneTransform | null {
+ const state = useContext(GestureStateContext);
+ if (!state) return null;
+ return {
+ sceneOffsetSV: state.sceneOffsetSV,
+ sceneScaleSV: state.sceneScaleSV,
+ width: state.width,
+ height: state.height,
+ };
+}
diff --git a/src/components/internal/portal.tsx b/src/components/internal/portal.tsx
index 18e3bef..ff9c555 100644
--- a/src/components/internal/portal.tsx
+++ b/src/components/internal/portal.tsx
@@ -97,10 +97,14 @@ export function PortalHost({
entries,
snapshotSV,
getTransform,
+ sceneOffsetSV,
+ sceneScaleSV,
}: {
entries: PortalEntry[];
snapshotSV: SharedValue;
getTransform: TransformLookup;
+ sceneOffsetSV: SharedValue;
+ sceneScaleSV: SharedValue;
}) {
if (entries.length === 0) return null;
const ordered = [...entries].sort((a, b) => a.id - b.id);
@@ -112,6 +116,8 @@ export function PortalHost({
entry={entry}
snapshotSV={snapshotSV}
getTransform={getTransform}
+ sceneOffsetSV={sceneOffsetSV}
+ sceneScaleSV={sceneScaleSV}
/>
))}
@@ -122,10 +128,14 @@ function PortalView({
entry,
snapshotSV,
getTransform,
+ sceneOffsetSV,
+ sceneScaleSV,
}: {
entry: PortalEntry;
snapshotSV: SharedValue;
getTransform: TransformLookup;
+ sceneOffsetSV: SharedValue;
+ sceneScaleSV: SharedValue;
}) {
const {
id,
@@ -172,12 +182,35 @@ function PortalView({
offsetX: t.offsetX,
offsetY: t.offsetY,
});
+ const sceneOffset = sceneOffsetSV.value;
+ const sceneScale = sceneScaleSV.value;
+ const rootId = snapshot.rootId;
+ const getTransformWithoutScene: TransformLookup = (lookupId) => {
+ if (lookupId === rootId) {
+ return { offset: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 };
+ }
+ return getTransform(lookupId);
+ };
const parentAbsoluteMatrix = getAbsoluteMatrixFromSnapshot(
snapshot,
- getTransform,
+ getTransformWithoutScene,
node.parentId
);
- const m = multiply(parentAbsoluteMatrix, localMatrix);
+ const sceneMatrix = composeMatrix({
+ x: sceneOffset.x,
+ y: sceneOffset.y,
+ rotation: 0,
+ scaleX: sceneScale.x,
+ scaleY: sceneScale.y,
+ skewX: 0,
+ skewY: 0,
+ offsetX: 0,
+ offsetY: 0,
+ });
+ const m = multiply(
+ sceneMatrix,
+ multiply(parentAbsoluteMatrix, localMatrix)
+ );
return {
opacity: 1,
diff --git a/src/components/internal/useStageGestures.ts b/src/components/internal/useStageGestures.ts
index 047550a..acd7cc4 100644
--- a/src/components/internal/useStageGestures.ts
+++ b/src/components/internal/useStageGestures.ts
@@ -34,6 +34,16 @@ import {
type PinchState,
} from '../../core/gestures';
import type { Vector2d } from '../../core/types';
+import {
+ scenePanBegin,
+ scenePanUpdate,
+ scenePanEnd,
+ sceneZoomBegin,
+ sceneZoomUpdate,
+ sceneZoomEnd,
+ type ScenePanState,
+ type SceneZoomState,
+} from '../../core/scene';
type idToSVMap = Record>;
@@ -41,11 +51,14 @@ export interface StageGestureOptions {
// User multipliers on top of the base sensitivity (default 1 = base only).
pinchSensitivity?: number;
rotationSensitivity?: number;
+ infinite?: boolean;
}
export interface StageGestures {
gesture: ComposedGesture;
activeGestureSV: SharedValue;
+ sceneOffsetSV: SharedValue;
+ sceneScaleSV: SharedValue;
}
export function useStageGestures(
@@ -58,6 +71,11 @@ export function useStageGestures(
PINCH_SCALE_SENSITIVITY * (options.pinchSensitivity ?? 1);
const rotationSensitivity =
ROTATION_SENSITIVITY * (options.rotationSensitivity ?? 1);
+ const infinite = options.infinite ?? false;
+ const sceneOffsetSV = useSharedValue({ x: 0, y: 0 });
+ const sceneScaleSV = useSharedValue({ x: 1, y: 1 });
+ const scenePanStateSV = useSharedValue(null);
+ const sceneZoomStateSV = useSharedValue(null);
const snapshotSV = useSharedValue(EMPTY_SNAPSHOT);
const idToDragOffsetMapSV = useSharedValue>({});
const idToScaleMapSV = useSharedValue>({});
@@ -99,6 +117,16 @@ export function useStageGestures(
brushPointsSV,
]);
+ useEffect(() => {
+ if (!infinite) return;
+ registry.registerDragOffset(rootId, sceneOffsetSV);
+ registry.registerScale(rootId, sceneScaleSV);
+ return () => {
+ registry.unregisterDragOffset(rootId);
+ registry.unregisterScale(rootId);
+ };
+ }, [registry, rootId, infinite, sceneOffsetSV, sceneScaleSV]);
+
const dispatchRef = useRef((type: string, id: number, payload?: unknown) => {
dispatch(registry, type, id, payload ?? {});
});
@@ -214,6 +242,12 @@ export function useStageGestures(
e.y,
Date.now()
);
+ if (infinite) {
+ const press = pressStateSV.value;
+ if (!press || press.dragTargetId === -1) {
+ scenePanBegin(sceneOffsetSV, scenePanStateSV, e.x, e.y);
+ }
+ }
})
.onUpdate((e) => {
'worklet';
@@ -224,6 +258,10 @@ export function useStageGestures(
brushPoints.value = [...brushPoints.value, e.x, e.y];
return;
}
+ if (scenePanStateSV.value) {
+ scenePanUpdate(sceneOffsetSV, scenePanStateSV, e.x, e.y, null);
+ return;
+ }
pointerMove(
snapshotSV.value,
getTransform,
@@ -255,6 +293,9 @@ export function useStageGestures(
e.y,
Date.now()
);
+ if (infinite) {
+ scenePanEnd(scenePanStateSV);
+ }
});
const pinch = Gesture.Pinch()
@@ -271,9 +312,34 @@ export function useStageGestures(
touchesSV.value,
pressStateSV
);
+ if (infinite) {
+ const p = pinchStateSV.value;
+ const hasScalableTarget =
+ p != null && p.targetId !== -1 && p.scalable;
+ if (!hasScalableTarget) {
+ sceneZoomBegin(
+ sceneOffsetSV,
+ sceneScaleSV,
+ sceneZoomStateSV,
+ touchesSV.value
+ );
+ }
+ }
})
.onUpdate((e) => {
'worklet';
+ if (sceneZoomStateSV.value) {
+ sceneZoomUpdate(
+ sceneOffsetSV,
+ sceneScaleSV,
+ sceneZoomStateSV,
+ touchesSV.value,
+ e.scale,
+ pinchSensitivity,
+ null
+ );
+ return;
+ }
pinchUpdate(
snapshotSV.value,
getTransform,
@@ -285,6 +351,9 @@ export function useStageGestures(
})
.onFinalize(() => {
'worklet';
+ if (sceneZoomStateSV.value) {
+ sceneZoomEnd(sceneZoomStateSV);
+ }
pinchEnd(snapshotSV.value, getTransform, pinchStateSV, callbacks);
});
@@ -335,7 +404,12 @@ export function useStageGestures(
enabled,
pinchSensitivity,
rotationSensitivity,
+ infinite,
+ sceneOffsetSV,
+ sceneScaleSV,
+ scenePanStateSV,
+ sceneZoomStateSV,
]);
- return { gesture, activeGestureSV };
+ return { gesture, activeGestureSV, sceneOffsetSV, sceneScaleSV };
}
diff --git a/src/components/transformer/TransformerBorder.tsx b/src/components/transformer/TransformerBorder.tsx
index dd5d6a2..a992653 100644
--- a/src/components/transformer/TransformerBorder.tsx
+++ b/src/components/transformer/TransformerBorder.tsx
@@ -13,6 +13,7 @@ import {
interface TransformerBorderProps extends TransformChannels {
rect: BoundsRect;
resolvedTransformSV: SharedValue;
+ sceneScaleFactorSV: SharedValue;
showRotater: boolean;
rotateAnchorOffset: number;
stroke: string;
@@ -23,6 +24,7 @@ export const TransformerBorder = memo((props: TransformerBorderProps) => {
const {
rect,
resolvedTransformSV,
+ sceneScaleFactorSV,
dragOffsetSV,
scaleSV,
rotationSV,
@@ -76,6 +78,7 @@ export const TransformerBorder = memo((props: TransformerBorderProps) => {
}, [
rect,
resolvedTransformSV,
+ sceneScaleFactorSV,
dragOffsetSV,
scaleSV,
rotationSV,
@@ -83,12 +86,17 @@ export const TransformerBorder = memo((props: TransformerBorderProps) => {
rotateAnchorOffset,
]);
+ const strokeWidthSV = useDerivedValue(
+ () => strokeWidth / sceneScaleFactorSV.value,
+ [strokeWidth, sceneScaleFactorSV]
+ );
+
return (
);
});
diff --git a/src/components/transformer/TransformerHandles.tsx b/src/components/transformer/TransformerHandles.tsx
index 70822b8..4715040 100644
--- a/src/components/transformer/TransformerHandles.tsx
+++ b/src/components/transformer/TransformerHandles.tsx
@@ -14,6 +14,7 @@ import {
interface TransformerHandlesProps extends TransformChannels {
rect: BoundsRect;
resolvedTransformSV: SharedValue;
+ sceneScaleFactorSV: SharedValue;
enabledAnchors: AnchorId[];
anchorSize: number;
rotateAnchorOffset: number;
@@ -26,6 +27,7 @@ export const TransformerHandles = memo((props: TransformerHandlesProps) => {
const {
rect,
resolvedTransformSV,
+ sceneScaleFactorSV,
dragOffsetSV,
scaleSV,
rotationSV,
@@ -39,6 +41,7 @@ export const TransformerHandles = memo((props: TransformerHandlesProps) => {
const handlesPath = useDerivedValue(() => {
const p = Skia.Path.Make();
+ const screenScale = sceneScaleFactorSV.value;
const t = computeTransform(resolvedTransformSV.value, {
dragOffsetSV,
scaleSV,
@@ -46,7 +49,8 @@ export const TransformerHandles = memo((props: TransformerHandlesProps) => {
});
const ox = resolvedTransformSV.value.offsetX;
const oy = resolvedTransformSV.value.offsetY;
- const half = anchorSize / 2;
+ const half = anchorSize / screenScale / 2;
+ const rotOffset = rotateAnchorOffset;
for (let i = 0; i < enabledAnchors.length; i++) {
const anchor = enabledAnchors[i]!;
if (anchor === 'rotater') {
@@ -60,7 +64,7 @@ export const TransformerHandles = memo((props: TransformerHandlesProps) => {
const center = rotaterAnchorPoint(
tc,
t.rotation * DEG_TO_RAD,
- rotateAnchorOffset
+ rotOffset
);
p.addCircle(center.x, center.y, half);
continue;
@@ -73,6 +77,7 @@ export const TransformerHandles = memo((props: TransformerHandlesProps) => {
}, [
rect,
resolvedTransformSV,
+ sceneScaleFactorSV,
dragOffsetSV,
scaleSV,
rotationSV,
@@ -81,6 +86,11 @@ export const TransformerHandles = memo((props: TransformerHandlesProps) => {
rotateAnchorOffset,
]);
+ const strokeWidthSV = useDerivedValue(
+ () => strokeWidth / sceneScaleFactorSV.value,
+ [strokeWidth, sceneScaleFactorSV]
+ );
+
return (
@@ -88,7 +98,7 @@ export const TransformerHandles = memo((props: TransformerHandlesProps) => {
path={handlesPath}
style="stroke"
color={stroke}
- strokeWidth={strokeWidth}
+ strokeWidth={strokeWidthSV}
/>
);
diff --git a/src/components/transformer/index.tsx b/src/components/transformer/index.tsx
index 2990b98..4c640d4 100644
--- a/src/components/transformer/index.tsx
+++ b/src/components/transformer/index.tsx
@@ -1,5 +1,9 @@
import { Fragment, memo, useCallback, useMemo } from 'react';
-import { useAnimatedReaction, useSharedValue } from 'react-native-reanimated';
+import {
+ useAnimatedReaction,
+ useDerivedValue,
+ useSharedValue,
+} from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';
import { Rect } from '../shapes/Rect';
import { Circle } from '../shapes/Circle';
@@ -84,7 +88,11 @@ export const Transformer = memo((props: TransformerProps) => {
const activeAnchorSV = useSharedValue(null);
- const activeGestureSV = useGestureState()?.activeGestureSV;
+ const gestureState = useGestureState();
+ const activeGestureSV = gestureState?.activeGestureSV;
+ const sceneScaleSV = gestureState?.sceneScaleSV;
+
+ const sceneScaleFactorSV = useDerivedValue(() => sceneScaleSV?.value.x ?? 1);
const onTransform = useOnTransform(_onTransform);
@@ -265,6 +273,7 @@ export const Transformer = memo((props: TransformerProps) => {
{
{
+ const scene = useSceneTransform();
+ const sceneOffsetSV = scene?.sceneOffsetSV;
+ const sceneScaleSV = scene?.sceneScaleSV;
+ const viewportWidth = scene?.width ?? 0;
+ const viewportHeight = scene?.height ?? 0;
+
+ const boundsSV = useSharedValue(null);
+ const lastSampleSV = useSharedValue(null);
+
+ useAnimatedReaction(
+ () => {
+ if (!sceneOffsetSV || !sceneScaleSV) return null;
+ const offset = sceneOffsetSV.value;
+ return {
+ offsetX: offset.x,
+ offsetY: offset.y,
+ scale: sceneScaleSV.value.x || 1,
+ };
+ },
+ (sample) => {
+ if (!sample) return;
+ const last = lastSampleSV.value;
+ const threshold = Math.max(margin * 0.5, 16);
+ const movedEnough =
+ last == null ||
+ Math.abs(sample.offsetX - last.offsetX) > threshold ||
+ Math.abs(sample.offsetY - last.offsetY) > threshold ||
+ Math.abs(sample.scale / last.scale - 1) > 0.02;
+ if (!movedEnough) return;
+ lastSampleSV.value = sample;
+
+ const scale = sample.scale;
+ const left = (-sample.offsetX - margin) / scale;
+ const top = (-sample.offsetY - margin) / scale;
+ const right = (viewportWidth - sample.offsetX + margin) / scale;
+ const bottom = (viewportHeight - sample.offsetY + margin) / scale;
+ boundsSV.value = {
+ x: left,
+ y: top,
+ width: right - left,
+ height: bottom - top,
+ };
+ },
+ [sceneOffsetSV, sceneScaleSV, viewportWidth, viewportHeight, margin]
+ );
+
+ return boundsSV;
+}
+
+export function rectIntersectsBounds(
+ x: number,
+ y: number,
+ width: number,
+ height: number,
+ bounds: VisibleBounds
+): boolean {
+ return (
+ x < bounds.x + bounds.width &&
+ x + width > bounds.x &&
+ y < bounds.y + bounds.height &&
+ y + height > bounds.y
+ );
+}
diff --git a/src/core/__tests__/scene.test.ts b/src/core/__tests__/scene.test.ts
new file mode 100644
index 0000000..4a9cf28
--- /dev/null
+++ b/src/core/__tests__/scene.test.ts
@@ -0,0 +1,127 @@
+import { describe, it, expect } from '@jest/globals';
+import {
+ scenePanBegin,
+ scenePanUpdate,
+ scenePanEnd,
+ sceneZoomBegin,
+ sceneZoomUpdate,
+ sceneZoomEnd,
+ DEFAULT_MIN_ZOOM,
+ DEFAULT_MAX_ZOOM,
+ type ScenePanState,
+ type SceneZoomState,
+ type SceneBounds,
+} from '../scene';
+import type { SharedValue } from 'react-native-reanimated';
+import type { Vector2d } from '../types';
+
+function sv(value: T): SharedValue {
+ return { value } as unknown as SharedValue;
+}
+
+describe('scene pan', () => {
+ it('translates the scene offset by the pointer delta', () => {
+ const offset = sv({ x: 10, y: 20 });
+ const panState = sv(null);
+
+ scenePanBegin(offset, panState, 100, 100);
+ scenePanUpdate(offset, panState, 130, 90, null);
+
+ expect(offset.value).toEqual({ x: 40, y: 10 });
+
+ scenePanEnd(panState);
+ expect(panState.value).toBeNull();
+ });
+
+ it('clamps the offset to the provided bounds', () => {
+ const offset = sv({ x: 0, y: 0 });
+ const panState = sv(null);
+ const bounds: SceneBounds = {
+ minOffsetX: -50,
+ maxOffsetX: 0,
+ minOffsetY: -50,
+ maxOffsetY: 0,
+ minZoom: DEFAULT_MIN_ZOOM,
+ maxZoom: DEFAULT_MAX_ZOOM,
+ };
+
+ scenePanBegin(offset, panState, 0, 0);
+ scenePanUpdate(offset, panState, 999, -999, bounds);
+
+ expect(offset.value).toEqual({ x: 0, y: -50 });
+ });
+
+ it('is a no-op when there is no active pan', () => {
+ const offset = sv({ x: 5, y: 5 });
+ const panState = sv(null);
+ scenePanUpdate(offset, panState, 100, 100, null);
+ expect(offset.value).toEqual({ x: 5, y: 5 });
+ });
+});
+
+describe('scene zoom', () => {
+ it('keeps the scene point under the initial focal fixed (focal zoom)', () => {
+ const offset = sv({ x: 0, y: 0 });
+ const scale = sv({ x: 1, y: 1 });
+ const zoomState = sv(null);
+ const touches: Vector2d[] = [
+ { x: 180, y: 100 },
+ { x: 220, y: 100 },
+ ];
+
+ sceneZoomBegin(offset, scale, zoomState, touches);
+ sceneZoomUpdate(offset, scale, zoomState, touches, 2, 1, null);
+
+ expect(scale.value).toEqual({ x: 2, y: 2 });
+ const focal = { x: 200, y: 100 };
+ const scenePointX = (focal.x - offset.value.x) / scale.value.x;
+ const scenePointY = (focal.y - offset.value.y) / scale.value.y;
+ expect(scenePointX).toBeCloseTo(200);
+ expect(scenePointY).toBeCloseTo(100);
+ });
+
+ it('pans while zooming when the centroid moves', () => {
+ const offset = sv({ x: 0, y: 0 });
+ const scale = sv({ x: 1, y: 1 });
+ const zoomState = sv(null);
+ const startTouches: Vector2d[] = [
+ { x: 90, y: 100 },
+ { x: 110, y: 100 },
+ ];
+ sceneZoomBegin(offset, scale, zoomState, startTouches);
+ const movedTouches: Vector2d[] = [
+ { x: 140, y: 100 },
+ { x: 160, y: 100 },
+ ];
+ sceneZoomUpdate(offset, scale, zoomState, movedTouches, 1, 1, null);
+
+ expect(scale.value).toEqual({ x: 1, y: 1 });
+ expect(offset.value.x).toBeCloseTo(50);
+ expect(offset.value.y).toBeCloseTo(0);
+ });
+
+ it('clamps zoom to the min/max range', () => {
+ const offset = sv({ x: 0, y: 0 });
+ const scale = sv({ x: 1, y: 1 });
+ const zoomState = sv(null);
+ const touches: Vector2d[] = [
+ { x: 0, y: 0 },
+ { x: 10, y: 0 },
+ ];
+ const bounds: SceneBounds = {
+ minOffsetX: -Infinity,
+ maxOffsetX: Infinity,
+ minOffsetY: -Infinity,
+ maxOffsetY: Infinity,
+ minZoom: 0.5,
+ maxZoom: 3,
+ };
+
+ sceneZoomBegin(offset, scale, zoomState, touches);
+ sceneZoomUpdate(offset, scale, zoomState, touches, 100, 1, bounds);
+ expect(scale.value.x).toBe(3);
+
+ sceneZoomEnd(zoomState);
+ expect(zoomState.value).toBeNull();
+ });
+});
diff --git a/src/core/gestures.ts b/src/core/gestures.ts
index 2e0fcc4..7890959 100644
--- a/src/core/gestures.ts
+++ b/src/core/gestures.ts
@@ -310,7 +310,8 @@ export function pointerDown(
rootId: number,
px: number,
py: number,
- now: number
+ now: number,
+ allowDrag: boolean = true
): void {
'worklet';
const hitNodeId = getHitNodeIdFromSnapshot(
@@ -320,15 +321,17 @@ export function pointerDown(
px,
py
);
- const dragHitNodeId = getHitNodeIdFromSnapshot(
- snapshot,
- getTransform,
- rootId,
- px,
- py,
- false,
- true
- );
+ const dragHitNodeId = allowDrag
+ ? getHitNodeIdFromSnapshot(
+ snapshot,
+ getTransform,
+ rootId,
+ px,
+ py,
+ false,
+ true
+ )
+ : -1;
const dragTargetId =
dragHitNodeId !== -1 ? findDragTarget(snapshot, dragHitNodeId) : -1;
const dragNode = dragTargetId !== -1 ? snapshot.nodes[dragTargetId] : null;
diff --git a/src/core/scene.ts b/src/core/scene.ts
new file mode 100644
index 0000000..b0a9a86
--- /dev/null
+++ b/src/core/scene.ts
@@ -0,0 +1,166 @@
+import type { SharedValue } from 'react-native-reanimated';
+import { clampToBounds } from './nodeBounds';
+import type { Vector2d } from './types';
+
+export const DEFAULT_MIN_ZOOM = 0.1;
+export const DEFAULT_MAX_ZOOM = 10;
+
+export interface ScenePanState {
+ startTouchX: number;
+ startTouchY: number;
+ startOffsetX: number;
+ startOffsetY: number;
+}
+
+export interface SceneZoomState {
+ focalX: number;
+ focalY: number;
+ startScale: number;
+ startOffsetX: number;
+ startOffsetY: number;
+}
+
+export interface SceneBounds {
+ minOffsetX: number;
+ maxOffsetX: number;
+ minOffsetY: number;
+ maxOffsetY: number;
+ minZoom: number;
+ maxZoom: number;
+}
+
+export interface SceneState {
+ x: number;
+ y: number;
+ scale: number;
+}
+
+export function zoomAroundPoint(
+ offset: Vector2d,
+ currentScale: number,
+ nextScale: number,
+ focalX: number,
+ focalY: number
+): Vector2d {
+ 'worklet';
+ if (currentScale === 0) return offset;
+ const scaleRatio = nextScale / currentScale;
+ return {
+ x: focalX - scaleRatio * (focalX - offset.x),
+ y: focalY - scaleRatio * (focalY - offset.y),
+ };
+}
+
+function centroidOf(touches: Vector2d[]): Vector2d {
+ 'worklet';
+ let sumX = 0;
+ let sumY = 0;
+ for (let i = 0; i < touches.length; i++) {
+ sumX += touches[i]!.x;
+ sumY += touches[i]!.y;
+ }
+ const count = touches.length || 1;
+ return { x: sumX / count, y: sumY / count };
+}
+
+function clampOffset(offset: Vector2d, bounds: SceneBounds | null): Vector2d {
+ 'worklet';
+ if (!bounds) return offset;
+ return {
+ x: clampToBounds(offset.x, bounds.minOffsetX, bounds.maxOffsetX),
+ y: clampToBounds(offset.y, bounds.minOffsetY, bounds.maxOffsetY),
+ };
+}
+
+export function scenePanBegin(
+ sceneOffset: SharedValue,
+ panState: SharedValue,
+ px: number,
+ py: number
+): void {
+ 'worklet';
+ const offset = sceneOffset.value;
+ panState.value = {
+ startTouchX: px,
+ startTouchY: py,
+ startOffsetX: offset.x,
+ startOffsetY: offset.y,
+ };
+}
+
+export function scenePanUpdate(
+ sceneOffset: SharedValue,
+ panState: SharedValue,
+ px: number,
+ py: number,
+ bounds: SceneBounds | null
+): void {
+ 'worklet';
+ const state = panState.value;
+ if (!state) return;
+ const next = {
+ x: state.startOffsetX + (px - state.startTouchX),
+ y: state.startOffsetY + (py - state.startTouchY),
+ };
+ sceneOffset.value = clampOffset(next, bounds);
+}
+
+export function scenePanEnd(panState: SharedValue): void {
+ 'worklet';
+ panState.value = null;
+}
+
+export function sceneZoomBegin(
+ sceneOffset: SharedValue,
+ sceneScale: SharedValue,
+ zoomState: SharedValue,
+ touches: Vector2d[]
+): void {
+ 'worklet';
+ const focal = centroidOf(touches);
+ const offset = sceneOffset.value;
+ zoomState.value = {
+ focalX: focal.x,
+ focalY: focal.y,
+ startScale: sceneScale.value.x,
+ startOffsetX: offset.x,
+ startOffsetY: offset.y,
+ };
+}
+
+export function sceneZoomUpdate(
+ sceneOffset: SharedValue,
+ sceneScale: SharedValue,
+ zoomState: SharedValue,
+ touches: Vector2d[],
+ gestureScale: number,
+ sensitivity: number,
+ bounds: SceneBounds | null
+): void {
+ 'worklet';
+ const state = zoomState.value;
+ if (!state || state.startScale === 0) return;
+ const dampedScale = 1 + (gestureScale - 1) * sensitivity;
+ const minZoom = bounds ? bounds.minZoom : DEFAULT_MIN_ZOOM;
+ const maxZoom = bounds ? bounds.maxZoom : DEFAULT_MAX_ZOOM;
+ const nextScale = clampToBounds(
+ state.startScale * dampedScale,
+ minZoom,
+ maxZoom
+ );
+ const current = centroidOf(touches);
+ const scaleRatio = nextScale / state.startScale;
+ const next = {
+ x: current.x - scaleRatio * (state.focalX - state.startOffsetX),
+ y: current.y - scaleRatio * (state.focalY - state.startOffsetY),
+ };
+ sceneScale.value = { x: nextScale, y: nextScale };
+ sceneOffset.value = clampOffset(next, bounds);
+}
+
+export function sceneZoomEnd(
+ zoomState: SharedValue
+): void {
+ 'worklet';
+ zoomState.value = null;
+}
diff --git a/src/index.tsx b/src/index.tsx
index 2cae4f4..aacc683 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -7,6 +7,16 @@ export type {
StageHandle,
StageToImageOptions,
} from './components/Stage';
+export type { SceneState } from './core/scene';
+export {
+ useSceneTransform,
+ type SceneTransform,
+} from './components/internal/gestureState';
+export {
+ useRenderBounds,
+ rectIntersectsBounds,
+ type VisibleBounds,
+} from './components/useVisibleBounds';
export { Layer } from './components/Layer';
export type { LayerProps } from './components/Layer';
export { Group } from './components/Group';