diff --git a/packages/pointer-native-drawing/src/DrawingCanvas.tsx b/packages/pointer-native-drawing/src/DrawingCanvas.tsx index 2f1e414d..4025cc28 100644 --- a/packages/pointer-native-drawing/src/DrawingCanvas.tsx +++ b/packages/pointer-native-drawing/src/DrawingCanvas.tsx @@ -10,7 +10,7 @@ import React, { import { View, StyleSheet, ScrollView } from 'react-native'; import { Path, type SkPath, Skia, Circle, Group } from '@shopify/react-native-skia'; import { Gesture, GestureDetector, PointerType } from 'react-native-gesture-handler'; -import { runOnJS, useSharedValue } from 'react-native-reanimated'; +import { useSharedValue } from 'react-native-reanimated'; import { buildSmoothPath } from './smoothing'; import { @@ -23,6 +23,8 @@ import { } from './model/drawingTypes'; import { computeStrokeBounds, safeMax } from './model/strokeUtils'; import { HistoryManager } from './engine/HistoryManager'; +import { type DrawingInputCallbacks } from './input/inputTypes'; +import { useRnghPanAdapter } from './input/rnghPanAdapter'; import { SkiaDrawingCanvasSurface } from './render/skia/SkiaDrawingCanvasSurface'; import { useSkiaDrawingRenderer } from './render/skia/useSkiaDrawingRenderer'; @@ -47,7 +49,6 @@ const DrawingCanvas = forwardRef( const hoverX = useSharedValue(0); const hoverY = useSharedValue(0); const showHover = useSharedValue(false); - const isActiveGesture = useSharedValue(false); const livePath = useRef(Skia.Path.Make()); const currentPoints = useRef([]); @@ -185,6 +186,12 @@ const DrawingCanvas = forwardRef( historyManager.push({ type: 'append-stroke', stroke: strokeData, bounds }); }, [strokeColor, strokeWidth, onChange, historyManager]); + const cancelStroke = useCallback(() => { + currentPoints.current = []; + livePath.current.reset(); + setTick((t) => t + 1); + }, []); + const eraseAtPoint = useCallback( (x: number, y: number) => { const now = Date.now(); @@ -194,7 +201,17 @@ const DrawingCanvas = forwardRef( const thresholdSquared = eraserSize * eraserSize; const prevStrokes = strokesRef.current; const prevBounds = strokeBoundsRef.current; - const keepMask = prevStrokes.map((stroke) => { + const keepMask = prevStrokes.map((stroke, i) => { + // AABB 사전 검사: bounds + eraserSize 영역 밖이면 무조건 keep (점 검사 skip) + const b = prevBounds[i]; + if ( + x < b.minX - eraserSize || + x > b.maxX + eraserSize || + y < b.minY - eraserSize || + y > b.maxY + eraserSize + ) { + return true; + } const isTouched = stroke.points.some((point) => { const dx = point.x - x; const dy = point.y - y; @@ -307,61 +324,43 @@ const DrawingCanvas = forwardRef( setStrokes: loadStrokes, })); - const pan = useMemo( - () => - Gesture.Pan() - .minPointers(1) - .maxPointers(1) - .onBegin((e) => { - 'worklet'; - const pointerType = e.pointerType; - if (pointerType !== PointerType.STYLUS && pointerType !== PointerType.MOUSE) { - return; - } - isActiveGesture.value = true; - showHover.value = false; - if (eraserMode) { - runOnJS(startEraser)(e.x, e.y); - } else { - runOnJS(startStroke)(e.x, e.y); - } - }) - .onUpdate((e) => { - 'worklet'; - const pointerType = e.pointerType; - if (pointerType !== PointerType.STYLUS && pointerType !== PointerType.MOUSE) { - return; - } - if (eraserMode) { - runOnJS(addEraserPoint)(e.x, e.y); - } else { - runOnJS(addPoint)(e.x, e.y); - } - }) - .onEnd(() => { - 'worklet'; - if (!isActiveGesture.value) return; - isActiveGesture.value = false; - if (eraserMode) { - runOnJS(finalizeEraser)(); - } else { - runOnJS(finalizeStroke)(); - } - }) - .minDistance(1), + const drawingCallbacks = useMemo( + () => ({ + onInteractionBegin: () => { + showHover.value = false; + }, + onInteractionFinalize: () => { + if (eraserMode) { + finalizeEraser(); + } + }, + onDrawStart: (input) => startStroke(input.x, input.y), + onDrawMove: (input) => addPoint(input.x, input.y), + onDrawEnd: () => finalizeStroke(), + onDrawCancel: () => cancelStroke(), + onEraseStart: (input) => startEraser(input.x, input.y), + onEraseMove: (input) => addEraserPoint(input.x, input.y), + }), [ + showHover, eraserMode, startStroke, addPoint, finalizeStroke, + cancelStroke, startEraser, addEraserPoint, finalizeEraser, - isActiveGesture, - showHover, ] ); + const inputAdapter = useRnghPanAdapter({ + eraserMode, + pencilOnly: true, + minDistance: 1, + callbacks: drawingCallbacks, + }); + const hoverGesture = useMemo( () => Gesture.Hover() @@ -397,8 +396,8 @@ const DrawingCanvas = forwardRef( ); const composedGesture = useMemo( - () => Gesture.Simultaneous(pan, hoverGesture), - [pan, hoverGesture] + () => Gesture.Simultaneous(inputAdapter.gesture, hoverGesture), + [inputAdapter.gesture, hoverGesture] ); const { renderedPaths, hoverOpacity } = useSkiaDrawingRenderer({ diff --git a/packages/pointer-native-drawing/src/input/inputAdapterTypes.ts b/packages/pointer-native-drawing/src/input/inputAdapterTypes.ts new file mode 100644 index 00000000..d5160ff9 --- /dev/null +++ b/packages/pointer-native-drawing/src/input/inputAdapterTypes.ts @@ -0,0 +1,18 @@ +import { type ReactNode } from 'react'; + +import { type DrawingInputCallbacks } from './inputTypes'; + +export type InputAdapterConfig = { + eraserMode: boolean; + pencilOnly: boolean; + minDistance: number; + callbacks: DrawingInputCallbacks; +}; + +export type InputAdapter = { + gesture: TGesture; +}; + +export type InputOverlayAdapter = { + overlay: ReactNode; +}; diff --git a/packages/pointer-native-drawing/src/input/inputTypes.ts b/packages/pointer-native-drawing/src/input/inputTypes.ts new file mode 100644 index 00000000..7d9aa801 --- /dev/null +++ b/packages/pointer-native-drawing/src/input/inputTypes.ts @@ -0,0 +1,14 @@ +import { type InputEvent } from '../model/drawingTypes'; + +export type CancelReason = 'gesture_failed' | 'interrupted' | 'unknown'; + +export type DrawingInputCallbacks = { + onInteractionBegin: () => void; + onInteractionFinalize: () => void; + onDrawStart: (input: InputEvent) => void; + onDrawMove: (input: InputEvent) => void; + onDrawEnd: () => void; + onDrawCancel: (reason?: CancelReason) => void; + onEraseStart: (input: InputEvent) => void; + onEraseMove: (input: InputEvent) => void; +}; diff --git a/packages/pointer-native-drawing/src/input/rnghPanAdapter.ts b/packages/pointer-native-drawing/src/input/rnghPanAdapter.ts new file mode 100644 index 00000000..10de6ddd --- /dev/null +++ b/packages/pointer-native-drawing/src/input/rnghPanAdapter.ts @@ -0,0 +1,150 @@ +import { useCallback, useMemo, useRef } from 'react'; +import { Gesture, PointerType as RnghPointerType } from 'react-native-gesture-handler'; +import { runOnJS, useSharedValue } from 'react-native-reanimated'; + +import { type InputEvent, type PointerType } from '../model/drawingTypes'; + +import { type DrawingInputCallbacks } from './inputTypes'; +import { type InputAdapter, type InputAdapterConfig } from './inputAdapterTypes'; + +const RNGH_POINTER_TYPE_MAP: Record = { + [RnghPointerType.TOUCH]: 'touch', + [RnghPointerType.STYLUS]: 'pen', + [RnghPointerType.MOUSE]: 'mouse', +}; + +type RnghEventLike = { + x: number; + y: number; + pointerType?: number; +}; + +const createInputEvent = (event: RnghEventLike, timestamp: number): InputEvent => { + 'worklet'; + const pointerType = + event.pointerType !== undefined + ? (RNGH_POINTER_TYPE_MAP[event.pointerType] ?? 'unknown') + : 'unknown'; + return { x: event.x, y: event.y, timestamp, pointerType }; +}; + +export const useRnghPanAdapter = ({ + eraserMode, + pencilOnly, + minDistance, + callbacks, + enabled = true, +}: InputAdapterConfig & { enabled?: boolean }): InputAdapter> => { + // callbacksRef로 stable closure 유지 — gesture 재생성 트리거 회피 + const callbacksRef = useRef(callbacks); + callbacksRef.current = callbacks; + + const eraserModeShared = useSharedValue(eraserMode); + eraserModeShared.value = eraserMode; + const pencilOnlyShared = useSharedValue(pencilOnly); + pencilOnlyShared.value = pencilOnly; + const isActiveShared = useSharedValue(false); + + const handleInteractionBegin = useCallback(() => { + callbacksRef.current.onInteractionBegin(); + }, []); + + const handleDrawStart = useCallback((input: InputEvent) => { + callbacksRef.current.onDrawStart(input); + }, []); + + const handleDrawMove = useCallback((input: InputEvent) => { + callbacksRef.current.onDrawMove(input); + }, []); + + const handleDrawEnd = useCallback(() => { + callbacksRef.current.onDrawEnd(); + }, []); + + const handleDrawCancel = useCallback(() => { + callbacksRef.current.onDrawCancel('gesture_failed'); + }, []); + + const handleEraseStart = useCallback((input: InputEvent) => { + callbacksRef.current.onEraseStart(input); + }, []); + + const handleEraseMove = useCallback((input: InputEvent) => { + callbacksRef.current.onEraseMove(input); + }, []); + + const handleInteractionFinalize = useCallback(() => { + callbacksRef.current.onInteractionFinalize(); + }, []); + + const gesture = useMemo( + () => + Gesture.Pan() + .enabled(enabled) + .maxPointers(1) + .averageTouches(true) + .minDistance(minDistance) + .onBegin(() => { + 'worklet'; + runOnJS(handleInteractionBegin)(); + }) + .onStart((event) => { + 'worklet'; + if (pencilOnlyShared.value && event.pointerType === RnghPointerType.TOUCH) { + return; + } + const input = createInputEvent(event, Date.now()); + isActiveShared.value = true; + + if (eraserModeShared.value) { + runOnJS(handleEraseStart)(input); + return; + } + runOnJS(handleDrawStart)(input); + }) + .onUpdate((event) => { + 'worklet'; + if (pencilOnlyShared.value && event.pointerType === RnghPointerType.TOUCH) { + return; + } + const input = createInputEvent(event, Date.now()); + + if (eraserModeShared.value) { + runOnJS(handleEraseMove)(input); + return; + } + runOnJS(handleDrawMove)(input); + }) + .onEnd(() => { + 'worklet'; + isActiveShared.value = false; + if (eraserModeShared.value) return; + runOnJS(handleDrawEnd)(); + }) + .onFinalize(() => { + 'worklet'; + if (!eraserModeShared.value && isActiveShared.value) { + runOnJS(handleDrawCancel)(); + } + isActiveShared.value = false; + runOnJS(handleInteractionFinalize)(); + }), + [ + enabled, + minDistance, + eraserModeShared, + pencilOnlyShared, + isActiveShared, + handleInteractionBegin, + handleDrawStart, + handleDrawMove, + handleDrawEnd, + handleDrawCancel, + handleEraseStart, + handleEraseMove, + handleInteractionFinalize, + ] + ); + + return { gesture }; +}; diff --git a/packages/pointer-native-drawing/src/model/drawingTypes.ts b/packages/pointer-native-drawing/src/model/drawingTypes.ts index 735d4ea1..d4646142 100644 --- a/packages/pointer-native-drawing/src/model/drawingTypes.ts +++ b/packages/pointer-native-drawing/src/model/drawingTypes.ts @@ -8,6 +8,20 @@ export type Stroke = { width: number; }; +// ── Input ── + +export type PointerType = 'touch' | 'pen' | 'mouse' | 'unknown'; + +export type InputEvent = { + x: number; + y: number; + timestamp: number; + pressure?: number; + pointerType?: PointerType; + tiltX?: number; + tiltY?: number; +}; + // ── Bounds ── /** stroke의 AABB (axis-aligned bounding box). 지우개 히트 테스트 최적화 기반. */