From d27224b3161e3b5019ed6bf62936a2c39eaf80fc Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Sat, 9 May 2026 00:03:14 +0900 Subject: [PATCH] =?UTF-8?q?feat(pointer-native-drawing):=20MAT-358=20undo/?= =?UTF-8?q?redo=20command=20=ED=8C=A8=ED=84=B4=20+=20bounds=20incremental?= =?UTF-8?q?=20+=20lazy=20init?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - engine/HistoryManager.ts: command 패턴 (AppendStroke / EraseStrokes) - append-stroke undo: O(1) — slice로 마지막 stroke 제거 - erase-strokes: snapshot 복원 (begin/commit/discardTransaction) - model/drawingTypes.ts: DocumentSnapshot, DrawingCanvasProps 추가 - DrawingCanvas.tsx: - HistoryManager 통합 (legacy historyRef/saveToHistory/restoreFromHistory 제거) - strokeBoundsRef incremental 관리 — createSnapshot 매 호출 N×P 재계산 회피 (Critical fix) - HistoryManager lazy init — useRef(null) + nullish-coalescing assign Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/DrawingCanvas.tsx | 199 ++++++++++-------- .../src/engine/HistoryManager.ts | 128 +++++++++++ .../src/model/drawingTypes.ts | 16 ++ 3 files changed, 253 insertions(+), 90 deletions(-) create mode 100644 packages/pointer-native-drawing/src/engine/HistoryManager.ts diff --git a/packages/pointer-native-drawing/src/DrawingCanvas.tsx b/packages/pointer-native-drawing/src/DrawingCanvas.tsx index 19a1c3c8..2f1e414d 100644 --- a/packages/pointer-native-drawing/src/DrawingCanvas.tsx +++ b/packages/pointer-native-drawing/src/DrawingCanvas.tsx @@ -4,6 +4,7 @@ import React, { useRef, useState, useCallback, + useEffect, useMemo, } from 'react'; import { View, StyleSheet, ScrollView } from 'react-native'; @@ -12,21 +13,20 @@ import { Gesture, GestureDetector, PointerType } from 'react-native-gesture-hand import { runOnJS, useSharedValue } from 'react-native-reanimated'; import { buildSmoothPath } from './smoothing'; -import { type Point, type Stroke, type DrawingCanvasRef } from './model/drawingTypes'; -import { deepCopyStrokes, safeMax } from './model/strokeUtils'; +import { + type Point, + type Stroke, + type StrokeBounds, + type DocumentSnapshot, + type DrawingCanvasRef, + type DrawingCanvasProps, +} from './model/drawingTypes'; +import { computeStrokeBounds, safeMax } from './model/strokeUtils'; +import { HistoryManager } from './engine/HistoryManager'; import { SkiaDrawingCanvasSurface } from './render/skia/SkiaDrawingCanvasSurface'; import { useSkiaDrawingRenderer } from './render/skia/useSkiaDrawingRenderer'; -type Props = { - strokeColor?: string; - strokeWidth?: number; - onChange?: (strokes: Stroke[]) => void; - onHistoryChange?: (canUndo: boolean, canRedo: boolean) => void; - eraserMode?: boolean; - eraserSize?: number; -}; - -const DrawingCanvas = forwardRef( +const DrawingCanvas = forwardRef( ( { strokeColor = 'black', @@ -52,56 +52,50 @@ const DrawingCanvas = forwardRef( const livePath = useRef(Skia.Path.Make()); const currentPoints = useRef([]); const strokesRef = useRef([]); + /** stroke와 동일 인덱스로 incremental 관리. createSnapshot N×P 재계산 회피. */ + const strokeBoundsRef = useRef([]); const eraserPoints = useRef([]); const lastEraserTime = useRef(0); const eraserDidModify = useRef(false); const ERASER_THROTTLE_MS = 16; // ~60fps - type HistoryState = { strokes: Stroke[] }; - const historyRef = useRef([]); - const historyIndexRef = useRef(-1); - - const notifyHistoryChange = useCallback(() => { - if (!onHistoryChange) return; - const canUndo = - historyIndexRef.current > 0 || - (historyIndexRef.current === 0 && historyRef.current.length > 1); - const canRedo = historyIndexRef.current + 1 < historyRef.current.length; - onHistoryChange(canUndo, canRedo); - }, [onHistoryChange]); - - const saveToHistory = useCallback(() => { - const currentState: HistoryState = { - strokes: deepCopyStrokes(strokesRef.current), - }; - - historyRef.current = historyRef.current.slice(0, historyIndexRef.current + 1); - historyRef.current.push(currentState); - historyIndexRef.current = historyRef.current.length - 1; + // 히스토리 매니저 — lazy init (StrictMode 더블 렌더 시 한 번만 생성) + const historyManagerRef = useRef(null); + historyManagerRef.current ??= new HistoryManager(50); + const historyManager = historyManagerRef.current; - if (historyRef.current.length > 50) { - historyRef.current.shift(); - historyIndexRef.current--; + useEffect(() => { + if (!onHistoryChange) { + historyManager.setListener(null); + return; } + historyManager.setListener(({ canUndo, canRedo }) => { + onHistoryChange(canUndo, canRedo); + }); + }, [historyManager, onHistoryChange]); + + /** 현재 stroke 상태의 경량 스냅샷. bounds는 ref incremental 결과 사용. */ + const createSnapshot = useCallback( + (): DocumentSnapshot => ({ + strokes: strokesRef.current, + bounds: [...strokeBoundsRef.current], + }), + [] + ); - notifyHistoryChange(); - }, [notifyHistoryChange]); - - const restoreFromHistory = useCallback( - (index: number) => { - if (index < 0 || index >= historyRef.current.length) return; - - const state = historyRef.current[index]; - const restoredStrokes = deepCopyStrokes(state.strokes); + const applySnapshot = useCallback( + (snapshot: DocumentSnapshot) => { + const restoredStrokes = [...snapshot.strokes]; const newPaths = restoredStrokes.map((stroke) => buildSmoothPath(stroke.points)); setStrokes(restoredStrokes); setPaths(newPaths); strokesRef.current = restoredStrokes; + strokeBoundsRef.current = [...snapshot.bounds]; - if (state.strokes.length > 0) { + if (snapshot.strokes.length > 0) { const strokesMaxY = safeMax( - state.strokes.flatMap((stroke) => stroke.points.map((p) => p.y)) + snapshot.strokes.flatMap((stroke) => stroke.points.map((p) => p.y)) ); maxY.current = strokesMaxY; canvasHeight.current = Math.max(800, strokesMaxY + 200); @@ -111,17 +105,18 @@ const DrawingCanvas = forwardRef( } onChange?.(restoredStrokes); - notifyHistoryChange(); }, - [onChange, notifyHistoryChange] + [onChange] ); const loadStrokes = useCallback( (newStrokes: Stroke[]) => { const newPaths = newStrokes.map((stroke) => buildSmoothPath(stroke.points)); + const newBounds = newStrokes.map((stroke) => computeStrokeBounds(stroke.points)); setStrokes(newStrokes); setPaths(newPaths); strokesRef.current = newStrokes; + strokeBoundsRef.current = newBounds; if (newStrokes.length > 0) { const maxYValue = safeMax(newStrokes.flatMap((stroke) => stroke.points.map((p) => p.y))); @@ -133,12 +128,9 @@ const DrawingCanvas = forwardRef( } onChange?.(newStrokes); - - historyRef.current = [{ strokes: deepCopyStrokes(newStrokes) }]; - historyIndexRef.current = 0; - notifyHistoryChange(); + historyManager.clear(); }, - [onChange, notifyHistoryChange] + [onChange, historyManager] ); const addPoint = useCallback((x: number, y: number) => { @@ -178,9 +170,11 @@ const DrawingCanvas = forwardRef( color: strokeColor, width: strokeWidth, }; + const bounds = computeStrokeBounds(strokeData.points); const nextStrokes = [...strokesRef.current, strokeData]; strokesRef.current = nextStrokes; + strokeBoundsRef.current.push(bounds); setStrokes(nextStrokes); setPaths((prev) => [...prev, newPath]); @@ -188,8 +182,8 @@ const DrawingCanvas = forwardRef( livePath.current.reset(); onChange?.(nextStrokes); - saveToHistory(); - }, [strokeColor, strokeWidth, onChange, saveToHistory]); + historyManager.push({ type: 'append-stroke', stroke: strokeData, bounds }); + }, [strokeColor, strokeWidth, onChange, historyManager]); const eraseAtPoint = useCallback( (x: number, y: number) => { @@ -199,7 +193,8 @@ const DrawingCanvas = forwardRef( const thresholdSquared = eraserSize * eraserSize; const prevStrokes = strokesRef.current; - const nextStrokes = prevStrokes.filter((stroke) => { + const prevBounds = strokeBoundsRef.current; + const keepMask = prevStrokes.map((stroke) => { const isTouched = stroke.points.some((point) => { const dx = point.x - x; const dy = point.y - y; @@ -208,14 +203,17 @@ const DrawingCanvas = forwardRef( return !isTouched; }); - if (nextStrokes.length !== prevStrokes.length) { - const newPaths = nextStrokes.map((s) => buildSmoothPath(s.points)); - setStrokes(nextStrokes); - setPaths(newPaths); - strokesRef.current = nextStrokes; - onChange?.(nextStrokes); - eraserDidModify.current = true; - } + if (keepMask.every((keep) => keep)) return; + + const nextStrokes = prevStrokes.filter((_, i) => keepMask[i]); + const nextBounds = prevBounds.filter((_, i) => keepMask[i]); + const newPaths = nextStrokes.map((s) => buildSmoothPath(s.points)); + setStrokes(nextStrokes); + setPaths(newPaths); + strokesRef.current = nextStrokes; + strokeBoundsRef.current = nextBounds; + onChange?.(nextStrokes); + eraserDidModify.current = true; }, [eraserSize, onChange] ); @@ -231,59 +229,80 @@ const DrawingCanvas = forwardRef( const startEraser = useCallback( (x: number, y: number) => { eraserPoints.current = [{ x, y }]; + historyManager.beginTransaction(createSnapshot()); eraseAtPoint(x, y); }, - [eraseAtPoint] + [eraseAtPoint, createSnapshot, historyManager] ); const finalizeEraser = useCallback(() => { eraserPoints.current = []; if (eraserDidModify.current) { - saveToHistory(); + historyManager.commitTransaction(createSnapshot()); eraserDidModify.current = false; + } else { + historyManager.discardTransaction(); } - }, [saveToHistory]); + }, [createSnapshot, historyManager]); const undo = useCallback(() => { - if (historyIndexRef.current > 0) { - historyIndexRef.current--; - restoreFromHistory(historyIndexRef.current); - } else if (historyIndexRef.current === 0) { - historyIndexRef.current = -1; - restoreFromHistory(0); + const entry = historyManager.undo(); + if (!entry) return; + + switch (entry.type) { + case 'append-stroke': { + const nextStrokes = strokesRef.current.slice(0, -1); + strokesRef.current = nextStrokes; + strokeBoundsRef.current = strokeBoundsRef.current.slice(0, -1); + setStrokes(nextStrokes); + setPaths((prev) => prev.slice(0, -1)); + onChange?.(nextStrokes); + break; + } + case 'erase-strokes': { + applySnapshot(entry.snapshotBefore); + break; + } } - }, [restoreFromHistory]); + }, [historyManager, applySnapshot, onChange]); const redo = useCallback(() => { - const nextIndex = historyIndexRef.current + 1; - if (nextIndex < historyRef.current.length) { - historyIndexRef.current = nextIndex; - restoreFromHistory(nextIndex); + const entry = historyManager.redo(); + if (!entry) return; + + switch (entry.type) { + case 'append-stroke': { + const nextStrokes = [...strokesRef.current, entry.stroke]; + strokesRef.current = nextStrokes; + strokeBoundsRef.current.push(entry.bounds); + setStrokes(nextStrokes); + setPaths((prev) => [...prev, buildSmoothPath(entry.stroke.points)]); + onChange?.(nextStrokes); + break; + } + case 'erase-strokes': { + applySnapshot(entry.snapshotAfter); + break; + } } - }, [restoreFromHistory]); + }, [historyManager, applySnapshot, onChange]); useImperativeHandle(ref, () => ({ clear() { setPaths([]); setStrokes([]); strokesRef.current = []; + strokeBoundsRef.current = []; livePath.current.reset(); maxY.current = 0; canvasHeight.current = 800; onChange?.([]); - - historyRef.current = []; - historyIndexRef.current = -1; - notifyHistoryChange(); + historyManager.clear(); }, undo, redo, - canUndo: () => { - if (historyIndexRef.current > 0) return true; - if (historyRef.current.length === 1) return false; - return historyIndexRef.current === 0 && historyRef.current.length > 1; - }, - canRedo: () => historyIndexRef.current + 1 < historyRef.current.length, + canUndo: () => historyManager.canUndo(), + canRedo: () => historyManager.canRedo(), getStrokes: () => strokesRef.current, setStrokes: loadStrokes, })); diff --git a/packages/pointer-native-drawing/src/engine/HistoryManager.ts b/packages/pointer-native-drawing/src/engine/HistoryManager.ts new file mode 100644 index 00000000..102d9320 --- /dev/null +++ b/packages/pointer-native-drawing/src/engine/HistoryManager.ts @@ -0,0 +1,128 @@ +import { type Stroke, type StrokeBounds, type DocumentSnapshot } from '../model/drawingTypes'; + +// --------------------------------------------------------------------------- +// History entry types +// --------------------------------------------------------------------------- + +export type AppendStrokeEntry = { + readonly type: 'append-stroke'; + readonly stroke: Stroke; + readonly bounds: StrokeBounds; +}; + +export type EraseStrokesEntry = { + readonly type: 'erase-strokes'; + readonly snapshotBefore: DocumentSnapshot; + readonly snapshotAfter: DocumentSnapshot; +}; + +export type HistoryEntry = AppendStrokeEntry | EraseStrokesEntry; + +export type HistoryStateListener = (state: { canUndo: boolean; canRedo: boolean }) => void; + +// --------------------------------------------------------------------------- +// HistoryManager — command pattern +// +// append-stroke undo: O(1) — slice로 마지막 stroke 제거 +// erase-strokes undo: snapshot 복원 +// --------------------------------------------------------------------------- + +const DEFAULT_MAX_SIZE = 50; + +export class HistoryManager { + private stack: HistoryEntry[] = []; + /** Points to the last pushed/applied entry (-1 = empty). */ + private pointer = -1; + private readonly maxSize: number; + private listener: HistoryStateListener | null = null; + private activeTransactionSnapshot: DocumentSnapshot | null = null; + + constructor(maxSize: number = DEFAULT_MAX_SIZE) { + this.maxSize = maxSize; + } + + setListener(listener: HistoryStateListener | null): void { + this.listener = listener; + } + + private notifyListener(): void { + this.listener?.({ canUndo: this.canUndo(), canRedo: this.canRedo() }); + } + + push(entry: HistoryEntry): void { + if (this.pointer < this.stack.length - 1) { + this.stack.length = this.pointer + 1; + } + + this.stack.push(entry); + this.pointer = this.stack.length - 1; + + if (this.stack.length > this.maxSize) { + this.stack.shift(); + this.pointer = this.stack.length - 1; + } + + this.notifyListener(); + } + + undo(): HistoryEntry | null { + if (!this.canUndo()) return null; + const entry = this.stack[this.pointer]; + this.pointer--; + this.notifyListener(); + return entry; + } + + redo(): HistoryEntry | null { + if (!this.canRedo()) return null; + this.pointer++; + const entry = this.stack[this.pointer]; + this.notifyListener(); + return entry; + } + + canUndo(): boolean { + return this.pointer >= 0; + } + + canRedo(): boolean { + return this.pointer < this.stack.length - 1; + } + + // ----------------------------------------------------------------------- + // Erase transaction + // ----------------------------------------------------------------------- + + beginTransaction(snapshotBefore: DocumentSnapshot): void { + this.activeTransactionSnapshot = snapshotBefore; + } + + commitTransaction(snapshotAfter: DocumentSnapshot): void { + if (!this.activeTransactionSnapshot) return; + const snapshotBefore = this.activeTransactionSnapshot; + this.activeTransactionSnapshot = null; + + if (snapshotBefore.strokes.length === snapshotAfter.strokes.length) return; + + this.push({ + type: 'erase-strokes', + snapshotBefore, + snapshotAfter, + }); + } + + discardTransaction(): void { + this.activeTransactionSnapshot = null; + } + + hasActiveTransaction(): boolean { + return this.activeTransactionSnapshot !== null; + } + + clear(): void { + this.discardTransaction(); + this.stack = []; + this.pointer = -1; + this.notifyListener(); + } +} diff --git a/packages/pointer-native-drawing/src/model/drawingTypes.ts b/packages/pointer-native-drawing/src/model/drawingTypes.ts index c641ee77..735d4ea1 100644 --- a/packages/pointer-native-drawing/src/model/drawingTypes.ts +++ b/packages/pointer-native-drawing/src/model/drawingTypes.ts @@ -29,3 +29,19 @@ export type DrawingCanvasRef = { getStrokes: () => Stroke[]; setStrokes: (strokes: Stroke[]) => void; }; + +export type DrawingCanvasProps = { + strokeColor?: string; + strokeWidth?: number; + onChange?: (strokes: Stroke[]) => void; + onHistoryChange?: (canUndo: boolean, canRedo: boolean) => void; + eraserMode?: boolean; + eraserSize?: number; +}; + +// ── Snapshot (lightweight — stores references) ── + +export type DocumentSnapshot = { + readonly strokes: readonly Stroke[]; + readonly bounds: readonly StrokeBounds[]; +};