Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 48 additions & 49 deletions packages/pointer-native-drawing/src/DrawingCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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';

Expand All @@ -47,7 +49,6 @@ const DrawingCanvas = forwardRef<DrawingCanvasRef, DrawingCanvasProps>(
const hoverX = useSharedValue(0);
const hoverY = useSharedValue(0);
const showHover = useSharedValue(false);
const isActiveGesture = useSharedValue(false);

const livePath = useRef<SkPath>(Skia.Path.Make());
const currentPoints = useRef<Point[]>([]);
Expand Down Expand Up @@ -185,6 +186,12 @@ const DrawingCanvas = forwardRef<DrawingCanvasRef, DrawingCanvasProps>(
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();
Expand All @@ -194,7 +201,17 @@ const DrawingCanvas = forwardRef<DrawingCanvasRef, DrawingCanvasProps>(
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;
Expand Down Expand Up @@ -307,61 +324,43 @@ const DrawingCanvas = forwardRef<DrawingCanvasRef, DrawingCanvasProps>(
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<DrawingInputCallbacks>(
() => ({
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()
Expand Down Expand Up @@ -397,8 +396,8 @@ const DrawingCanvas = forwardRef<DrawingCanvasRef, DrawingCanvasProps>(
);

const composedGesture = useMemo(
() => Gesture.Simultaneous(pan, hoverGesture),
[pan, hoverGesture]
() => Gesture.Simultaneous(inputAdapter.gesture, hoverGesture),
[inputAdapter.gesture, hoverGesture]
);

const { renderedPaths, hoverOpacity } = useSkiaDrawingRenderer({
Expand Down
18 changes: 18 additions & 0 deletions packages/pointer-native-drawing/src/input/inputAdapterTypes.ts
Original file line number Diff line number Diff line change
@@ -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<TGesture = unknown> = {
gesture: TGesture;
};

export type InputOverlayAdapter = {
overlay: ReactNode;
};
14 changes: 14 additions & 0 deletions packages/pointer-native-drawing/src/input/inputTypes.ts
Original file line number Diff line number Diff line change
@@ -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;
};
150 changes: 150 additions & 0 deletions packages/pointer-native-drawing/src/input/rnghPanAdapter.ts
Original file line number Diff line number Diff line change
@@ -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<number, PointerType> = {
[RnghPointerType.TOUCH]: 'touch',
[RnghPointerType.STYLUS]: 'pen',
[RnghPointerType.MOUSE]: 'mouse',
};
Comment on lines +10 to +14

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<ReturnType<typeof Gesture.Pan>> => {
// callbacksRef로 stable closure 유지 — gesture 재생성 트리거 회피
const callbacksRef = useRef<DrawingInputCallbacks>(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 };
};
14 changes: 14 additions & 0 deletions packages/pointer-native-drawing/src/model/drawingTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). 지우개 히트 테스트 최적화 기반. */
Expand Down
Loading