From ee884d3600b2e5f9064bb9639cd93d76facb0b38 Mon Sep 17 00:00:00 2001 From: adithyavis Date: Sat, 1 Aug 2026 17:40:02 +0530 Subject: [PATCH 1/7] feat: scene utils --- src/core/__tests__/scene.test.ts | 127 +++++++++++++++++++++++ src/core/scene.ts | 166 +++++++++++++++++++++++++++++++ 2 files changed, 293 insertions(+) create mode 100644 src/core/__tests__/scene.test.ts create mode 100644 src/core/scene.ts 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/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; +} From 1272965bc2223a24258bd4f1b733b48fdcf44be7 Mon Sep 17 00:00:00 2001 From: adithyavis Date: Sat, 1 Aug 2026 17:48:08 +0530 Subject: [PATCH 2/7] feat: stage scene offset etc --- src/components/Stage.tsx | 146 ++++++++++++++++++-- src/components/internal/useStageGestures.ts | 76 +++++++++- src/core/gestures.ts | 23 +-- 3 files changed, 226 insertions(+), 19 deletions(-) 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/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/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; From 4b65badddf50b3b1ccd004f951a4d3ef80d2fa45 Mon Sep 17 00:00:00 2001 From: adithyavis Date: Sat, 1 Aug 2026 17:48:28 +0530 Subject: [PATCH 3/7] feat: portal support --- src/components/Portal.tsx | 8 +++++++ src/components/internal/portal.tsx | 37 ++++++++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) 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/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, From 9609101475a13ccc746705796eefb971533170f8 Mon Sep 17 00:00:00 2001 From: adithyavis Date: Sat, 1 Aug 2026 17:49:18 +0530 Subject: [PATCH 4/7] feat: infinite board --- example/app/_layout.tsx | 4 + example/app/infinite.tsx | 352 +++++++++++++++++++++++ src/components/internal/gestureState.tsx | 22 +- src/index.tsx | 5 + 4 files changed, 382 insertions(+), 1 deletion(-) create mode 100644 example/app/infinite.tsx 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' }} /> + `#${id}`; + +interface Transform { + x: number; + y: number; + scaleX: number; + scaleY: number; + rotation: number; +} + +function buildInitialTransforms(): Record { + const out: Record = {}; + for (const sticky of STICKIES) { + out[sel(sticky.id)] = { + x: sticky.x, + y: sticky.y, + scaleX: 1, + scaleY: 1, + rotation: 0, + }; + } + return out; +} + +const STICKY_CLUSTER = (() => { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const sticky of STICKIES) { + minX = Math.min(minX, sticky.x); + minY = Math.min(minY, sticky.y); + maxX = Math.max(maxX, sticky.x + STICKY_SIZE); + maxY = Math.max(maxY, sticky.y + STICKY_SIZE); + } + return { + centerX: (minX + maxX) / 2, + centerY: (minY + maxY) / 2, + width: maxX - minX, + height: maxY - minY, + }; +})(); + +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 ; +} + +export default function InfiniteScreen() { + const { width, height } = useWindowDimensions(); + const stageRef = useRef(null); + const [zoom, setZoom] = useState(1); + const [selected, setSelected] = useState(null); + const [transforms, setTransforms] = useState(buildInitialTransforms); + + const fitScale = useMemo(() => { + const padding = 80; + const scale = Math.min( + (width - padding) / STICKY_CLUSTER.width, + (height - padding) / STICKY_CLUSTER.height + ); + return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, scale)); + }, [width, height]); + + useEffect(() => { + stageRef.current?.centerOn( + STICKY_CLUSTER.centerX, + STICKY_CLUSTER.centerY, + fitScale + ); + }, [fitScale]); + + const commit = (selector: string, t: TransformResult) => + setTransforms((prev) => ({ + ...prev, + [selector]: { + x: t.x, + y: t.y, + scaleX: t.scaleX, + scaleY: t.scaleY, + rotation: t.rotation, + }, + })); + + return ( + + setZoom(scene.scale)} + > + + setSelected(null)} + /> + + + {STICKIES.map((sticky) => { + const t = transforms[sel(sticky.id)]!; + return ( + { + setSelected(sel(sticky.id)); + e.cancelBubble = true; + }} + onTransformEnd={(e: EventObject) => + commit(sel(sticky.id), e.evt as TransformResult) + } + > + + {sticky.text} + + + ); + })} + + { + if (selected) commit(selected, e); + }} + /> + + + + + stageRef.current?.zoomOut()} + > + − + + + stageRef.current?.centerOn( + STICKY_CLUSTER.centerX, + STICKY_CLUSTER.centerY, + fitScale + ) + } + > + {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' }, + sticky: { + flex: 1, + borderRadius: 4, + padding: 14, + justifyContent: 'flex-start', + borderWidth: StyleSheet.hairlineWidth, + borderColor: '#00000014', + }, + stickyText: { + color: '#1f1f1f', + fontSize: 20, + 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/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/index.tsx b/src/index.tsx index 2cae4f4..2a58ed0 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -7,6 +7,11 @@ export type { StageHandle, StageToImageOptions, } from './components/Stage'; +export type { SceneState } from './core/scene'; +export { + useSceneTransform, + type SceneTransform, +} from './components/internal/gestureState'; export { Layer } from './components/Layer'; export type { LayerProps } from './components/Layer'; export { Group } from './components/Group'; From a993308fc60fb9b7536bcc49be067bef951fa4d5 Mon Sep 17 00:00:00 2001 From: adithyavis Date: Sat, 1 Aug 2026 22:34:09 +0530 Subject: [PATCH 5/7] feat: visible bounds --- src/components/useVisibleBounds.ts | 84 ++++++++++++++++++++++++++++++ src/index.tsx | 5 ++ 2 files changed, 89 insertions(+) create mode 100644 src/components/useVisibleBounds.ts diff --git a/src/components/useVisibleBounds.ts b/src/components/useVisibleBounds.ts new file mode 100644 index 0000000..7f2d010 --- /dev/null +++ b/src/components/useVisibleBounds.ts @@ -0,0 +1,84 @@ +import { + useAnimatedReaction, + useSharedValue, + type SharedValue, +} from 'react-native-reanimated'; +import { useSceneTransform } from './internal/gestureState'; + +export interface VisibleBounds { + x: number; + y: number; + width: number; + height: number; +} + +interface SampledScene { + offsetX: number; + offsetY: number; + scale: number; +} + +export function useRenderBounds(margin = 0): SharedValue { + 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/index.tsx b/src/index.tsx index 2a58ed0..aacc683 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -12,6 +12,11 @@ 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'; From e3b66d53496ba3b5d8104ed717bc1fe84c07fcf9 Mon Sep 17 00:00:00 2001 From: adithyavis Date: Sat, 1 Aug 2026 22:35:05 +0530 Subject: [PATCH 6/7] feat: transform scaling --- src/components/transformer/TransformerBorder.tsx | 10 +++++++++- .../transformer/TransformerHandles.tsx | 16 +++++++++++++--- src/components/transformer/index.tsx | 14 ++++++++++++-- 3 files changed, 34 insertions(+), 6 deletions(-) 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) => { { Date: Sun, 2 Aug 2026 12:42:53 +0530 Subject: [PATCH 7/7] feat: infinite board --- example/app/infinite.tsx | 304 +++++++++++++++++++-------------------- 1 file changed, 149 insertions(+), 155 deletions(-) diff --git a/example/app/infinite.tsx b/example/app/infinite.tsx index f04b571..3ac57b5 100644 --- a/example/app/infinite.tsx +++ b/example/app/infinite.tsx @@ -1,18 +1,19 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useRef, useState } from 'react'; import { Pressable, StyleSheet, - Text, + Text as RNText, View, useWindowDimensions, } from 'react-native'; -import { Path, Skia } from '@shopify/react-native-skia'; +import { Path, Skia, useFont, type SkFont } from '@shopify/react-native-skia'; import { useDerivedValue } from 'react-native-reanimated'; import { Stage, Layer, + Group, Rect, - Portal, + Text, Transformer, useSceneTransform, type StageHandle, @@ -22,8 +23,9 @@ import { type TransformResult, } from 'react-native-canvas-kit'; import { DrawerButton } from '../src/DrawerButton'; +import { FONT_URL } from '../src/constants'; -const SCENE_SIZE = 6000; +const SCENE_SIZE = 12000; const MIN_ZOOM = 0.25; const MAX_ZOOM = 4; @@ -31,8 +33,10 @@ const BASE_SPACING = 32; const TARGET_SCREEN_SPACING = 24; const DOT_SCREEN_RADIUS = 1.6; -const STICKY_SIZE = 180; -const STICKY_COLORS = [ +const NOTE_SIZE = 180; +const NOTE_GAP = 300; +const NOTE_GRID = 24; +const NOTE_COLORS = [ '#FEF3B0', '#D5F692', '#A6CCF5', @@ -40,8 +44,20 @@ const STICKY_COLORS = [ '#FFCE9E', '#C7A2F5', ]; +const NOTE_TEXTS = [ + 'Idea', + 'To do', + 'Ship it', + 'Research', + 'Design', + 'Review', + 'Later', + 'Bug', + 'Follow up', + 'Draft', +]; -interface Sticky { +interface Note { id: string; x: number; y: number; @@ -49,42 +65,32 @@ interface Sticky { text: string; } -const STICKIES: Sticky[] = [ - { - id: 's1', - x: 1720, - y: 1680, - color: STICKY_COLORS[0]!, - text: 'Kickoff ideas', - }, - { id: 's2', x: 1940, y: 1720, color: STICKY_COLORS[1]!, text: 'Ship v1' }, - { - id: 's3', - x: 1700, - y: 1900, - color: STICKY_COLORS[2]!, - text: 'User research', - }, - { - id: 's4', - x: 1930, - y: 1940, - color: STICKY_COLORS[3]!, - text: 'Pinch to zoom', - }, - { id: 's5', x: 2200, y: 1780, color: STICKY_COLORS[4]!, text: 'Drag to pan' }, - { - id: 's6', - x: 2180, - y: 2010, - color: STICKY_COLORS[5]!, - text: 'Tap to select', - }, - { id: 's7', x: 2460, y: 1860, color: STICKY_COLORS[0]!, text: 'Resize me' }, - { id: 's8', x: 1480, y: 1820, color: STICKY_COLORS[2]!, text: 'Comments' }, -]; +const NOTES: Note[] = (() => { + 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 sel = (id: string) => `#${id}`; +const FIELD_CENTER = { + x: 300 + ((NOTE_GRID - 1) * NOTE_GAP) / 2, + y: 300 + ((NOTE_GRID - 1) * NOTE_GAP) / 2, +}; interface Transform { x: number; @@ -94,38 +100,14 @@ interface Transform { rotation: number; } -function buildInitialTransforms(): Record { - const out: Record = {}; - for (const sticky of STICKIES) { - out[sel(sticky.id)] = { - x: sticky.x, - y: sticky.y, - scaleX: 1, - scaleY: 1, - rotation: 0, - }; - } - return out; -} +type Overrides = Record; -const STICKY_CLUSTER = (() => { - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - for (const sticky of STICKIES) { - minX = Math.min(minX, sticky.x); - minY = Math.min(minY, sticky.y); - maxX = Math.max(maxX, sticky.x + STICKY_SIZE); - maxY = Math.max(maxY, sticky.y + STICKY_SIZE); - } - return { - centerX: (minX + maxX) / 2, - centerY: (minY + maxY) / 2, - width: maxX - minX, - height: maxY - minY, - }; -})(); +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(); @@ -163,34 +145,78 @@ function DotGrid() { 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 [transforms, setTransforms] = useState(buildInitialTransforms); + const [overrides, setOverrides] = useState({}); - const fitScale = useMemo(() => { - const padding = 80; - const scale = Math.min( - (width - padding) / STICKY_CLUSTER.width, - (height - padding) / STICKY_CLUSTER.height - ); - return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, scale)); - }, [width, height]); + const font = useFont(FONT_URL, 20); useEffect(() => { - stageRef.current?.centerOn( - STICKY_CLUSTER.centerX, - STICKY_CLUSTER.centerY, - fitScale - ); - }, [fitScale]); + stageRef.current?.centerOn(FIELD_CENTER.x, FIELD_CENTER.y, 0.7); + }, []); - const commit = (selector: string, t: TransformResult) => - setTransforms((prev) => ({ + const commit = useCallback((id: string, t: TransformResult) => { + setOverrides((prev) => ({ ...prev, - [selector]: { + [id]: { x: t.x, y: t.y, scaleX: t.scaleX, @@ -198,6 +224,7 @@ export default function InfiniteScreen() { rotation: t.rotation, }, })); + }, []); return ( @@ -211,53 +238,25 @@ export default function InfiniteScreen() { maxZoom={MAX_ZOOM} onSceneChange={(scene: SceneState) => setZoom(scene.scale)} > - - setSelected(null)} - /> + setSelected(null)} + > - - {STICKIES.map((sticky) => { - const t = transforms[sel(sticky.id)]!; - return ( - { - setSelected(sel(sticky.id)); - e.cancelBubble = true; - }} - onTransformEnd={(e: EventObject) => - commit(sel(sticky.id), e.evt as TransformResult) - } - > - - {sticky.text} - - - ); - })} + { if (selected) commit(selected, e); }} @@ -270,25 +269,23 @@ export default function InfiniteScreen() { style={styles.zoomButton} onPress={() => stageRef.current?.zoomOut()} > - − + − - stageRef.current?.centerOn( - STICKY_CLUSTER.centerX, - STICKY_CLUSTER.centerY, - fitScale - ) + stageRef.current?.centerOn(FIELD_CENTER.x, FIELD_CENTER.y, 0.7) } > - {Math.round(zoom * 100)}% + + {Math.round(zoom * 100)}% + stageRef.current?.zoomIn()} > - + + + @@ -308,19 +305,16 @@ const CARD_SHADOW = { const styles = StyleSheet.create({ root: { flex: 1 }, stage: { backgroundColor: '#F5F5F4' }, - sticky: { - flex: 1, - borderRadius: 4, - padding: 14, - justifyContent: 'flex-start', - borderWidth: StyleSheet.hairlineWidth, - borderColor: '#00000014', - }, - stickyText: { - color: '#1f1f1f', - fontSize: 20, - fontWeight: '600', + 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,