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';