From ff4d8790c34e74e9a7d186de222d076bff067f4c Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Sat, 9 May 2026 00:39:45 +0900 Subject: [PATCH] =?UTF-8?q?feat(pointer-native-drawing):=20MAT-363=20Zoom/?= =?UTF-8?q?Pan=20=EC=9D=B8=ED=94=84=EB=9D=BC=20(SharedValue=20+=20worklet?= =?UTF-8?q?=20=EA=B8=B0=EB=B0=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - transform.ts: ViewTransform + screenToCanvas/canvasToScreen/clampTransform/transformToMatrix3 - 모두 worklet 호환 (UI thread 직접 호출) - render/rendererTypes.ts: RendererViewport - canvas/useCanvasViewportController.ts: - viewTransform SharedValue (Skia matrix prop 자동 감지, setState 매 호출 회피) - viewport size SharedValue dual (handleLayout JS, worklet은 SharedValue) - canvasHeight SharedValue (worklet clampTransform 호출용) - minCanvasHeight useEffect sync (첫 렌더 capture 회피) - setCanvasHeightValue setState 외부 callback 분리 (StrictMode 안전) - 매직넘버 const: MIN_CANVAS_HEIGHT_FLOOR=400, CANVAS_HEIGHT_BUFFER=200, ZOOM_CONTENT_HEIGHT_MULTIPLIER=2 - canvas/useCanvasGestureComposer.ts: - pinch/fingerPan onUpdate worklet 안에서 viewTransform.value 직접 갱신 - clampTransform worklet 호출 — runOnJS 매 프레임 / setState 매 transform 회피 (Critical) - pinchDead/pinchActive 협업 SharedValue DrawingCanvas 통합은 PR #306 (캔버스 통합)에서 진행. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/canvas/useCanvasGestureComposer.ts | 168 ++++++++++++++++ .../src/canvas/useCanvasViewportController.ts | 181 ++++++++++++++++++ .../src/render/rendererTypes.ts | 4 + .../pointer-native-drawing/src/transform.ts | 84 ++++++++ 4 files changed, 437 insertions(+) create mode 100644 packages/pointer-native-drawing/src/canvas/useCanvasGestureComposer.ts create mode 100644 packages/pointer-native-drawing/src/canvas/useCanvasViewportController.ts create mode 100644 packages/pointer-native-drawing/src/render/rendererTypes.ts create mode 100644 packages/pointer-native-drawing/src/transform.ts diff --git a/packages/pointer-native-drawing/src/canvas/useCanvasGestureComposer.ts b/packages/pointer-native-drawing/src/canvas/useCanvasGestureComposer.ts new file mode 100644 index 00000000..48734045 --- /dev/null +++ b/packages/pointer-native-drawing/src/canvas/useCanvasGestureComposer.ts @@ -0,0 +1,168 @@ +import { useMemo } from 'react'; +import { Gesture, type GestureType } from 'react-native-gesture-handler'; +import { useSharedValue, type SharedValue } from 'react-native-reanimated'; + +import { type ViewTransform, IDENTITY_TRANSFORM, clampTransform } from '../transform'; + +export type UseCanvasGestureComposerArgs = { + enableZoomPan: boolean; + maxZoomScale: number; + nativeFingerInput?: boolean; + viewTransform: SharedValue; + viewportWidthShared: SharedValue; + viewportHeightShared: SharedValue; + canvasHeightShared: SharedValue; + drawPanGesture: GestureType; +}; + +export function useCanvasGestureComposer({ + enableZoomPan, + maxZoomScale, + nativeFingerInput = false, + viewTransform, + viewportWidthShared, + viewportHeightShared, + canvasHeightShared, + drawPanGesture, +}: UseCanvasGestureComposerArgs) { + // gesture 간 협업용 SharedValues + const pinchDeadShared = useSharedValue(false); + const pinchActiveShared = useSharedValue(false); + + // base/anchor SharedValue — pinch start 시점의 transform / focal anchor 저장 + const baseTransformShared = useSharedValue(IDENTITY_TRANSFORM); + const anchorXShared = useSharedValue(0); + const anchorYShared = useSharedValue(0); + + const fingerPanBaseShared = useSharedValue(IDENTITY_TRANSFORM); + const fingerPanActiveShared = useSharedValue(false); + + const pinchGesture = useMemo( + () => + Gesture.Pinch() + .onStart((e) => { + 'worklet'; + pinchDeadShared.value = false; + pinchActiveShared.value = true; + const current = viewTransform.value; + baseTransformShared.value = current; + const s = current.scale || 1; + anchorXShared.value = (e.focalX - current.translateX) / s; + anchorYShared.value = (e.focalY - current.translateY) / s; + }) + .onUpdate((e) => { + 'worklet'; + if (e.numberOfPointers < 2) { + pinchDeadShared.value = true; + return; + } + if (pinchDeadShared.value) return; + + const base = baseTransformShared.value; + const newScale = Math.min(Math.max(base.scale * e.scale, 1), maxZoomScale); + const next: ViewTransform = { + scale: newScale, + translateX: e.focalX - newScale * anchorXShared.value, + translateY: e.focalY - newScale * anchorYShared.value, + }; + viewTransform.value = clampTransform( + next, + viewportWidthShared.value, + canvasHeightShared.value, + viewportWidthShared.value, + viewportHeightShared.value, + maxZoomScale + ); + }) + .onEnd(() => { + 'worklet'; + pinchActiveShared.value = false; + // 마지막 clamp 한 번 더 (boundary 보정) + viewTransform.value = clampTransform( + viewTransform.value, + viewportWidthShared.value, + canvasHeightShared.value, + viewportWidthShared.value, + viewportHeightShared.value, + maxZoomScale + ); + }) + .onFinalize(() => { + 'worklet'; + pinchActiveShared.value = false; + }), + [ + viewTransform, + viewportWidthShared, + viewportHeightShared, + canvasHeightShared, + pinchDeadShared, + pinchActiveShared, + baseTransformShared, + anchorXShared, + anchorYShared, + maxZoomScale, + ] + ); + + const fingerPanGesture = useMemo( + () => + Gesture.Pan() + .minPointers(2) + .maxPointers(2) + .minDistance(1) + .onStart(() => { + 'worklet'; + if (pinchActiveShared.value) return; + fingerPanActiveShared.value = true; + fingerPanBaseShared.value = viewTransform.value; + }) + .onUpdate((e) => { + 'worklet'; + if (pinchActiveShared.value) return; + if (!fingerPanActiveShared.value) return; + const base = fingerPanBaseShared.value; + const next: ViewTransform = { + scale: base.scale, + translateX: base.translateX + e.translationX, + translateY: base.translateY + e.translationY, + }; + viewTransform.value = clampTransform( + next, + viewportWidthShared.value, + canvasHeightShared.value, + viewportWidthShared.value, + viewportHeightShared.value, + maxZoomScale + ); + }) + .onFinalize(() => { + 'worklet'; + fingerPanActiveShared.value = false; + }), + [ + viewTransform, + viewportWidthShared, + viewportHeightShared, + canvasHeightShared, + pinchActiveShared, + fingerPanActiveShared, + fingerPanBaseShared, + maxZoomScale, + ] + ); + + const composedGesture = useMemo(() => { + if (!enableZoomPan) { + return drawPanGesture; + } + if (nativeFingerInput) { + // native overlay가 finger touch를 받는 경우 — pinch/finger pan은 native 측 처리. + // RNGH는 stylus draw만, zoom은 native에서 별도 게스처로. + return drawPanGesture; + } + return Gesture.Simultaneous(drawPanGesture, fingerPanGesture, pinchGesture); + }, [enableZoomPan, nativeFingerInput, drawPanGesture, fingerPanGesture, pinchGesture]); + + return { composedGesture }; +} diff --git a/packages/pointer-native-drawing/src/canvas/useCanvasViewportController.ts b/packages/pointer-native-drawing/src/canvas/useCanvasViewportController.ts new file mode 100644 index 00000000..98830d34 --- /dev/null +++ b/packages/pointer-native-drawing/src/canvas/useCanvasViewportController.ts @@ -0,0 +1,181 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + type LayoutChangeEvent, + type NativeScrollEvent, + type NativeSyntheticEvent, + type ScrollView, +} from 'react-native'; +import { useSharedValue, type SharedValue } from 'react-native-reanimated'; + +import { type ViewTransform, IDENTITY_TRANSFORM, clampTransform } from '../transform'; +import { type RendererViewport } from '../render/rendererTypes'; + +const MIN_CANVAS_HEIGHT_FLOOR = 400; +const CANVAS_HEIGHT_BUFFER = 200; +const ZOOM_CONTENT_HEIGHT_MULTIPLIER = 2; + +export type UseCanvasViewportControllerArgs = { + minCanvasHeight: number; + enableZoomPan: boolean; + maxZoomScale: number; + maxYRef: React.RefObject; + onCanvasHeightChange?: (height: number) => void; + onScrollOffsetChange?: (offsetY: number) => void; + updateViewport: (viewport: RendererViewport) => void; +}; + +export function useCanvasViewportController({ + minCanvasHeight, + enableZoomPan, + maxZoomScale, + maxYRef, + onCanvasHeightChange, + onScrollOffsetChange, + updateViewport, +}: UseCanvasViewportControllerArgs) { + // viewTransform: SharedValue — gesture worklet 직접 갱신, Skia matrix prop 자동 감지 + const viewTransform = useSharedValue(IDENTITY_TRANSFORM); + + // viewport size — JS state + SharedValue dual (layout은 JS, worklet 사용은 SharedValue) + const [viewportSize, setViewportSize] = useState({ width: 0, height: 0 }); + const viewportWidthShared = useSharedValue(0); + const viewportHeightShared = useSharedValue(0); + + useEffect(() => { + viewportWidthShared.value = viewportSize.width; + viewportHeightShared.value = viewportSize.height; + }, [viewportSize.width, viewportSize.height, viewportWidthShared, viewportHeightShared]); + + const scrollViewRef = useRef(null); + const minimumCanvasHeightRef = useRef(Math.max(MIN_CANVAS_HEIGHT_FLOOR, minCanvasHeight)); + const [canvasHeight, setCanvasHeight] = useState(minimumCanvasHeightRef.current); + const canvasHeightRef = useRef(canvasHeight); + canvasHeightRef.current = canvasHeight; + + // Low fix: minCanvasHeight prop sync (첫 렌더 capture 회피) + useEffect(() => { + minimumCanvasHeightRef.current = Math.max(MIN_CANVAS_HEIGHT_FLOOR, minCanvasHeight); + }, [minCanvasHeight]); + + const onCanvasHeightChangeRef = useRef(onCanvasHeightChange); + const onScrollOffsetChangeRef = useRef(onScrollOffsetChange); + onCanvasHeightChangeRef.current = onCanvasHeightChange; + onScrollOffsetChangeRef.current = onScrollOffsetChange; + + // Low fix: setState updater 외부에서 callback 분리 (StrictMode 더블 렌더 안전) + const setCanvasHeightValue = useCallback((nextHeight: number) => { + const normalized = Math.max(minimumCanvasHeightRef.current, nextHeight); + if (normalized === canvasHeightRef.current) return; + setCanvasHeight(normalized); + onCanvasHeightChangeRef.current?.(normalized); + }, []); + + const resetCanvasHeight = useCallback(() => { + setCanvasHeightValue(minimumCanvasHeightRef.current); + }, [setCanvasHeightValue]); + + const maybeGrowCanvasHeight = useCallback( + (nextMaxY: number) => { + if (nextMaxY > maxYRef.current) { + maxYRef.current = nextMaxY; + setCanvasHeightValue(nextMaxY + CANVAS_HEIGHT_BUFFER); + } + }, + [setCanvasHeightValue, maxYRef] + ); + + const syncCanvasHeightFromMaxY = useCallback( + (nextMaxY: number) => { + if (nextMaxY <= 0) { + maxYRef.current = 0; + resetCanvasHeight(); + return; + } + maxYRef.current = nextMaxY; + setCanvasHeightValue(nextMaxY + CANVAS_HEIGHT_BUFFER); + }, + [resetCanvasHeight, setCanvasHeightValue, maxYRef] + ); + + // zoom 활성화 시 content height = max(canvasHeight, viewportHeight × ZOOM_CONTENT_HEIGHT_MULTIPLIER) + const effectiveCanvasHeight = enableZoomPan + ? Math.max(canvasHeight, viewportSize.height * ZOOM_CONTENT_HEIGHT_MULTIPLIER) + : canvasHeight; + + // canvas height SharedValue (worklet에서 clampTransform 호출용) + const canvasHeightShared = useSharedValue(canvasHeight); + useEffect(() => { + canvasHeightShared.value = effectiveCanvasHeight; + }, [effectiveCanvasHeight, canvasHeightShared]); + + // applyTransform: JS-side callback. consumer 직접 호출 (e.g. external transform 변경). + // gesture worklet 안에서는 inline clampTransform + viewTransform.value 갱신 — runOnJS 매 프레임 회피. + const applyTransform = useCallback( + (next: ViewTransform) => { + const clamped = clampTransform( + next, + viewportSize.width, + effectiveCanvasHeight, + viewportSize.width, + viewportSize.height, + maxZoomScale + ); + viewTransform.value = clamped; + }, + [viewTransform, viewportSize.width, viewportSize.height, effectiveCanvasHeight, maxZoomScale] + ); + + const viewportRef = useRef({ scrollOffsetY: 0, viewportHeight: 0 }); + + const handleScroll = useCallback( + (event: NativeSyntheticEvent) => { + const offsetY = event.nativeEvent.contentOffset.y; + updateViewport({ + scrollOffsetY: offsetY, + viewportHeight: viewportRef.current.viewportHeight, + }); + viewportRef.current = { ...viewportRef.current, scrollOffsetY: offsetY }; + onScrollOffsetChangeRef.current?.(offsetY); + }, + [updateViewport] + ); + + const handleLayout = useCallback( + (event: LayoutChangeEvent) => { + const { width, height } = event.nativeEvent.layout; + setViewportSize((prev) => { + if (prev.width === width && prev.height === height) return prev; + return { width, height }; + }); + updateViewport({ scrollOffsetY: viewportRef.current.scrollOffsetY, viewportHeight: height }); + viewportRef.current = { ...viewportRef.current, viewportHeight: height }; + }, + [updateViewport] + ); + + return { + viewTransform, + viewportWidthShared, + viewportHeightShared, + canvasHeightShared, + viewportSize, + canvasHeight: effectiveCanvasHeight, + scrollViewRef, + setCanvasHeightValue, + resetCanvasHeight, + maybeGrowCanvasHeight, + syncCanvasHeightFromMaxY, + applyTransform, + handleScroll, + handleLayout, + }; +} + +export type UseCanvasViewportControllerResult = ReturnType; + +export type UseCanvasViewportControllerSharedValues = { + viewTransform: SharedValue; + viewportWidthShared: SharedValue; + viewportHeightShared: SharedValue; + canvasHeightShared: SharedValue; +}; diff --git a/packages/pointer-native-drawing/src/render/rendererTypes.ts b/packages/pointer-native-drawing/src/render/rendererTypes.ts new file mode 100644 index 00000000..1249c239 --- /dev/null +++ b/packages/pointer-native-drawing/src/render/rendererTypes.ts @@ -0,0 +1,4 @@ +export type RendererViewport = { + scrollOffsetY: number; + viewportHeight: number; +}; diff --git a/packages/pointer-native-drawing/src/transform.ts b/packages/pointer-native-drawing/src/transform.ts new file mode 100644 index 00000000..4609fe84 --- /dev/null +++ b/packages/pointer-native-drawing/src/transform.ts @@ -0,0 +1,84 @@ +/** + * View transform utilities for zoom/pan support. + * 모든 함수는 worklet 호환 (UI thread 호출 가능). + */ + +export type ViewTransform = { + scale: number; + translateX: number; + translateY: number; +}; + +export const IDENTITY_TRANSFORM: ViewTransform = { + scale: 1, + translateX: 0, + translateY: 0, +}; + +/** Convert screen (view) coordinates to canvas coordinates. */ +export function screenToCanvas( + sx: number, + sy: number, + transform: ViewTransform +): { x: number; y: number } { + 'worklet'; + const s = transform.scale || 1; // 240 명시 fix: 0/NaN guard + return { + x: (sx - transform.translateX) / s, + y: (sy - transform.translateY) / s, + }; +} + +/** Convert canvas coordinates to screen (view) coordinates. */ +export function canvasToScreen( + cx: number, + cy: number, + transform: ViewTransform +): { x: number; y: number } { + 'worklet'; + return { + x: cx * transform.scale + transform.translateX, + y: cy * transform.scale + transform.translateY, + }; +} + +/** Clamp a transform so the canvas stays within viewport bounds. */ +export function clampTransform( + transform: ViewTransform, + canvasW: number, + canvasH: number, + vpW: number, + vpH: number, + maxScale: number +): ViewTransform { + 'worklet'; + if (canvasW <= 0 || canvasH <= 0 || vpW <= 0 || vpH <= 0) { + return transform; + } + const minScale = Math.min(1, vpW / canvasW); + const scale = Math.min(Math.max(transform.scale, minScale), maxScale); + + const minTx = vpW - canvasW * scale; + const minTy = vpH - canvasH * scale; + + const translateX = Math.min(0, Math.max(minTx, transform.translateX)); + const translateY = Math.min(0, Math.max(minTy, transform.translateY)); + + return { scale, translateX, translateY }; +} + +/** Skia matrix array (3x3 row-major) for ``. */ +export function transformToMatrix3(transform: ViewTransform): number[] { + 'worklet'; + return [ + transform.scale, + 0, + transform.translateX, + 0, + transform.scale, + transform.translateY, + 0, + 0, + 1, + ]; +}