From 9bcd7ecc2db257e75fd1915df2be1316e82c5101 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Wed, 29 Apr 2026 08:20:07 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(pointer-native-drawing):=20=ED=83=80?= =?UTF-8?q?=EC=9E=85/=EB=AA=A8=EB=8D=B8=20=EC=B6=94=EC=B6=9C=20+=20readonl?= =?UTF-8?q?y/StrokeBounds=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - model/drawingTypes.ts: Point, Stroke, TextItem, DrawingCanvasRef 추출 + ReadonlyPoint, ReadonlyStroke (불변 참조용, deepCopy 줄이는 기반) + StrokeBounds (지우개 히트 테스트 최적화 기반) - model/strokeUtils.ts: deepCopyStrokes, deepCopyTexts, safeMax 추출 + computeStrokeBounds (single-pass O(n) AABB 계산) - smoothing.ts: 로컬 Point 제거, drawingTypes에서 import - DrawingCanvas.tsx: 인라인 타입/유틸 제거, model에서 import - index.ts: 새 타입/유틸 re-export 추가 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/DrawingCanvas.tsx | 41 +++------------ packages/pointer-native-drawing/src/index.ts | 6 ++- .../src/model/drawingTypes.ts | 52 +++++++++++++++++++ .../src/model/strokeUtils.ts | 37 +++++++++++++ .../pointer-native-drawing/src/smoothing.ts | 2 +- 5 files changed, 102 insertions(+), 36 deletions(-) create mode 100644 packages/pointer-native-drawing/src/model/drawingTypes.ts create mode 100644 packages/pointer-native-drawing/src/model/strokeUtils.ts diff --git a/packages/pointer-native-drawing/src/DrawingCanvas.tsx b/packages/pointer-native-drawing/src/DrawingCanvas.tsx index 6c47ae742..0b0ae01ff 100644 --- a/packages/pointer-native-drawing/src/DrawingCanvas.tsx +++ b/packages/pointer-native-drawing/src/DrawingCanvas.tsx @@ -32,28 +32,13 @@ import { Gesture, GestureDetector, PointerType } from 'react-native-gesture-hand import { runOnJS, useSharedValue, useDerivedValue } from 'react-native-reanimated'; import { buildSmoothPath } from './smoothing'; - -export type Point = { x: number; y: number }; -export type Stroke = { points: Point[]; color: string; width: number }; -export type TextItem = { - id: string; - text: string; - x: number; - y: number; - fontSize: number; - color: string; -}; -export type DrawingCanvasRef = { - clear: () => void; - undo: () => void; - redo: () => void; - canUndo: () => boolean; - canRedo: () => boolean; - getStrokes: () => Stroke[]; - setStrokes: (strokes: Stroke[]) => void; - getTexts: () => TextItem[]; - setTexts: (texts: TextItem[]) => void; -}; +import { + type Point, + type Stroke, + type TextItem, + type DrawingCanvasRef, +} from './model/drawingTypes'; +import { deepCopyStrokes, deepCopyTexts, safeMax } from './model/strokeUtils'; type Props = { strokeColor?: string; @@ -66,18 +51,6 @@ type Props = { textFontPath?: number; // Skia에서 사용할 폰트 파일 경로 (require로 전달) }; -const deepCopyStrokes = (strokes: Stroke[]): Stroke[] => - strokes.map((stroke) => ({ - points: stroke.points.map((p) => ({ ...p })), - color: stroke.color, - width: stroke.width, - })); - -const deepCopyTexts = (texts: TextItem[]): TextItem[] => texts.map((text) => ({ ...text })); - -const safeMax = (arr: number[], fallback = 0): number => - arr.length > 0 ? arr.reduce((max, v) => (v > max ? v : max), arr[0]) : fallback; - const DrawingCanvas = forwardRef( ( { diff --git a/packages/pointer-native-drawing/src/index.ts b/packages/pointer-native-drawing/src/index.ts index 642b73a26..12e21e83d 100644 --- a/packages/pointer-native-drawing/src/index.ts +++ b/packages/pointer-native-drawing/src/index.ts @@ -1,3 +1,7 @@ export { default as DrawingCanvas } from './DrawingCanvas'; -export type { DrawingCanvasRef, Point, Stroke, TextItem } from './DrawingCanvas'; export { buildSmoothPath } from './smoothing'; + +// model +export type { Point, Stroke, TextItem, DrawingCanvasRef } from './model/drawingTypes'; +export type { ReadonlyPoint, ReadonlyStroke, StrokeBounds } from './model/drawingTypes'; +export { deepCopyStrokes, deepCopyTexts, safeMax, computeStrokeBounds } from './model/strokeUtils'; diff --git a/packages/pointer-native-drawing/src/model/drawingTypes.ts b/packages/pointer-native-drawing/src/model/drawingTypes.ts new file mode 100644 index 000000000..bf01df25b --- /dev/null +++ b/packages/pointer-native-drawing/src/model/drawingTypes.ts @@ -0,0 +1,52 @@ +// ── 기본 타입 ── + +export type Point = { x: number; y: number }; + +export type Stroke = { + points: Point[]; + color: string; + width: number; +}; + +export type TextItem = { + id: string; + text: string; + x: number; + y: number; + fontSize: number; + color: string; +}; + +// ── Readonly 타입 (불변 참조용) ── + +export type ReadonlyPoint = Readonly; + +export type ReadonlyStroke = { + readonly points: readonly ReadonlyPoint[]; + readonly color: string; + readonly width: number; +}; + +// ── Bounds ── + +/** stroke의 AABB (axis-aligned bounding box). 지우개 히트 테스트 최적화 기반. */ +export type StrokeBounds = { + readonly minX: number; + readonly minY: number; + readonly maxX: number; + readonly maxY: number; +}; + +// ── 컴포넌트 공개 API ── + +export type DrawingCanvasRef = { + clear: () => void; + undo: () => void; + redo: () => void; + canUndo: () => boolean; + canRedo: () => boolean; + getStrokes: () => Stroke[]; + setStrokes: (strokes: Stroke[]) => void; + getTexts: () => TextItem[]; + setTexts: (texts: TextItem[]) => void; +}; diff --git a/packages/pointer-native-drawing/src/model/strokeUtils.ts b/packages/pointer-native-drawing/src/model/strokeUtils.ts new file mode 100644 index 000000000..dfce5eec4 --- /dev/null +++ b/packages/pointer-native-drawing/src/model/strokeUtils.ts @@ -0,0 +1,37 @@ +import { type Point, type Stroke, type TextItem, type StrokeBounds } from './drawingTypes'; + +// ── Deep copy ── + +export const deepCopyStrokes = (strokes: Stroke[]): Stroke[] => + strokes.map((stroke) => ({ + points: stroke.points.map((p) => ({ ...p })), + color: stroke.color, + width: stroke.width, + })); + +export const deepCopyTexts = (texts: TextItem[]): TextItem[] => texts.map((text) => ({ ...text })); + +// ── 배열 유틸 ── + +export const safeMax = (arr: number[], fallback = 0): number => + arr.length > 0 ? arr.reduce((max, v) => (v > max ? v : max), arr[0]) : fallback; + +// ── Bounds 계산 ── + +/** single-pass O(n)으로 stroke의 AABB를 계산. MAT-360 지우개 히트 테스트 최적화 기반. */ +export function computeStrokeBounds(points: readonly Point[]): StrokeBounds { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + for (let i = 0; i < points.length; i++) { + const p = points[i]; + if (p.x < minX) minX = p.x; + if (p.y < minY) minY = p.y; + if (p.x > maxX) maxX = p.x; + if (p.y > maxY) maxY = p.y; + } + + return { minX, minY, maxX, maxY }; +} diff --git a/packages/pointer-native-drawing/src/smoothing.ts b/packages/pointer-native-drawing/src/smoothing.ts index 450e5449e..a6db910ac 100644 --- a/packages/pointer-native-drawing/src/smoothing.ts +++ b/packages/pointer-native-drawing/src/smoothing.ts @@ -1,6 +1,6 @@ import { Skia, type SkPath } from '@shopify/react-native-skia'; -type Point = { x: number; y: number }; +import { type Point } from './model/drawingTypes'; export function buildSmoothPath(points: Point[]): SkPath { const path = Skia.Path.Make(); From 9289a056ef629d64d3049e4772d7551ec84979f3 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 28 Apr 2026 00:20:05 +0900 Subject: [PATCH 2/4] =?UTF-8?q?refactor(pointer-native-drawing):=20?= =?UTF-8?q?=EB=A0=8C=EB=8D=94=EB=A7=81=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - render/skia/skiaRenderUtils.ts: wrapTextToLines (중복 텍스트 줄바꿈 로직 통합) - render/skia/useSkiaDrawingRenderer.tsx: renderedPaths, renderedTexts, hoverOpacity 훅 - render/skia/SkiaDrawingCanvasSurface.tsx: Canvas 래퍼 컴포넌트 - DrawingCanvas.tsx: calculateTextLineCount를 wrapTextToLines 기반으로 단순화 - DrawingCanvas.tsx에서 렌더링 로직 136줄 제거 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/DrawingCanvas.tsx | 158 +++--------------- .../render/skia/SkiaDrawingCanvasSurface.tsx | 16 ++ .../src/render/skia/skiaRenderUtils.ts | 42 +++++ .../render/skia/useSkiaDrawingRenderer.tsx | 80 +++++++++ 4 files changed, 160 insertions(+), 136 deletions(-) create mode 100644 packages/pointer-native-drawing/src/render/skia/SkiaDrawingCanvasSurface.tsx create mode 100644 packages/pointer-native-drawing/src/render/skia/skiaRenderUtils.ts create mode 100644 packages/pointer-native-drawing/src/render/skia/useSkiaDrawingRenderer.tsx diff --git a/packages/pointer-native-drawing/src/DrawingCanvas.tsx b/packages/pointer-native-drawing/src/DrawingCanvas.tsx index 0b0ae01ff..01d65c386 100644 --- a/packages/pointer-native-drawing/src/DrawingCanvas.tsx +++ b/packages/pointer-native-drawing/src/DrawingCanvas.tsx @@ -18,18 +18,9 @@ import { Keyboard, Platform, } from 'react-native'; -import { - Canvas, - Path, - type SkPath, - Skia, - Text, - useFont, - Circle, - Group, -} from '@shopify/react-native-skia'; +import { Path, type SkPath, Skia, useFont, Circle, Group } from '@shopify/react-native-skia'; import { Gesture, GestureDetector, PointerType } from 'react-native-gesture-handler'; -import { runOnJS, useSharedValue, useDerivedValue } from 'react-native-reanimated'; +import { runOnJS, useSharedValue } from 'react-native-reanimated'; import { buildSmoothPath } from './smoothing'; import { @@ -39,6 +30,9 @@ import { type DrawingCanvasRef, } from './model/drawingTypes'; import { deepCopyStrokes, deepCopyTexts, safeMax } from './model/strokeUtils'; +import { wrapTextToLines } from './render/skia/skiaRenderUtils'; +import { useSkiaDrawingRenderer } from './render/skia/useSkiaDrawingRenderer'; +import { SkiaDrawingCanvasSurface } from './render/skia/SkiaDrawingCanvasSurface'; type Props = { strokeColor?: string; @@ -199,50 +193,19 @@ const DrawingCanvas = forwardRef( return containerWidth - 40; // 좌우 패딩 20px씩 }, [containerWidth]); - // 텍스트의 실제 줄 수 계산 (자동 줄바꿈 포함) - 메모이제이션 + // 텍스트의 실제 줄 수 계산 (자동 줄바꿈 포함) - wrapTextToLines로 중복 제거 const textLineCountCache = useRef>(new Map()); const calculateTextLineCount = useCallback( (text: string): number => { if (!font) return 1; - // 캐시 확인 const cacheKey = `${text}-${maxTextWidth}`; if (textLineCountCache.current.has(cacheKey)) { return textLineCountCache.current.get(cacheKey) ?? 1; } - let totalLines = 0; - const paragraphs = text.split('\n'); - - paragraphs.forEach((paragraph) => { - if (!paragraph) { - totalLines += 1; - return; - } - - const words = paragraph.split(' '); - let currentLine = ''; - let paragraphLines = 0; - - words.forEach((word, idx) => { - const testLine = currentLine ? `${currentLine} ${word}` : word; - const textWidth = font.measureText(testLine).width; - - if (textWidth > maxTextWidth && currentLine) { - paragraphLines += 1; - currentLine = word; - } else { - currentLine = testLine; - } - - if (idx === words.length - 1) { - paragraphLines += 1; - } - }); - - totalLines += paragraphLines; - }); + const lineCount = wrapTextToLines(text, maxTextWidth, (t) => font.measureText(t)).length; // 캐시 저장 (최대 100개 항목만 유지) if (textLineCountCache.current.size > 100) { @@ -251,9 +214,9 @@ const DrawingCanvas = forwardRef( textLineCountCache.current.delete(firstKey); } } - textLineCountCache.current.set(cacheKey, totalLines); + textLineCountCache.current.set(cacheKey, lineCount); - return totalLines; + return lineCount; }, [font, maxTextWidth] ); @@ -1020,98 +983,22 @@ const DrawingCanvas = forwardRef( [] ); - // 호버 opacity를 위한 derived value - const hoverOpacity = useDerivedValue(() => { - return showHover.value ? 0.6 : 0; - }, [showHover]); - const composedGesture = useMemo( () => Gesture.Simultaneous(Gesture.Race(tap, pan), hoverGesture), [tap, pan, hoverGesture] ); - // 경로 렌더링 최적화: paths 배열이 변경될 때만 재렌더링 - // 각 stroke는 저장된 width와 color를 사용 - const renderedPaths = useMemo( - () => - paths.map((p, i) => { - const stroke = strokes[i]; - return ( - - ); - }), - [paths, strokes, strokeWidth, strokeColor] - ); - - // 텍스트 렌더링 최적화 (멀티라인 지원 + 자동 줄바꿈) - const renderedTexts = useMemo( - () => - font - ? texts - .filter((textItem) => { - // activeTextInput이 있고 id가 일치하면 편집 중이므로 렌더링하지 않음 - if (activeTextInput && activeTextInput.id === textItem.id) { - return false; - } - return true; - }) - .flatMap((textItem) => { - const allLines: string[] = []; - - // 먼저 명시적 줄바꿈으로 분할 - const paragraphs = textItem.text.split('\n'); - - // 각 문단을 너비 기준으로 추가 분할 - paragraphs.forEach((paragraph) => { - if (!paragraph) { - allLines.push(''); // 빈 줄 유지 - return; - } - - const words = paragraph.split(' '); - let currentLine = ''; - - words.forEach((word, idx) => { - const testLine = currentLine ? `${currentLine} ${word}` : word; - const textWidth = font.measureText(testLine).width; - - if (textWidth > maxTextWidth && currentLine) { - // 현재 줄이 최대 너비를 초과하면 줄바꿈 - allLines.push(currentLine); - currentLine = word; - } else { - currentLine = testLine; - } - - // 마지막 단어인 경우 현재 줄 추가 - if (idx === words.length - 1) { - allLines.push(currentLine); - } - }); - }); - - return allLines.map((line, lineIndex) => ( - - )); - }) - : null, - [texts, font, maxTextWidth, activeTextInput] - ); + const { renderedPaths, renderedTexts, hoverOpacity } = useSkiaDrawingRenderer({ + paths, + strokes, + strokeWidth, + strokeColor, + texts, + font, + maxTextWidth, + activeTextInputId: activeTextInput?.id ?? null, + showHover, + }); // 텍스트 삭제 버튼 렌더링 (텍스트 모드일 때만, 텍스트 시작 위치에 배치) const renderedTextDeleteButtons = useMemo(() => { @@ -1169,7 +1056,7 @@ const DrawingCanvas = forwardRef( // 실제 컨테이너 너비 업데이트 setContainerWidth(width); }}> - + {renderedPaths} {currentPoints.current.length > 0 && ( ( strokeWidth={1.5} /> - + {/* 인라인 텍스트 입력 박스 */} {activeTextInput && ( @@ -1263,7 +1150,6 @@ const styles = StyleSheet.create({ flexGrow: 1, }, container: { minHeight: 400, position: 'relative' }, - canvas: { width: '100%', backgroundColor: 'transparent' }, textInputWrapper: { position: 'absolute', backgroundColor: 'transparent', diff --git a/packages/pointer-native-drawing/src/render/skia/SkiaDrawingCanvasSurface.tsx b/packages/pointer-native-drawing/src/render/skia/SkiaDrawingCanvasSurface.tsx new file mode 100644 index 000000000..efb97674f --- /dev/null +++ b/packages/pointer-native-drawing/src/render/skia/SkiaDrawingCanvasSurface.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { StyleSheet } from 'react-native'; +import { Canvas } from '@shopify/react-native-skia'; + +type SkiaDrawingCanvasSurfaceProps = { + height: number; + children: React.ReactNode; +}; + +export function SkiaDrawingCanvasSurface({ height, children }: SkiaDrawingCanvasSurfaceProps) { + return {children}; +} + +const styles = StyleSheet.create({ + canvas: { width: '100%', backgroundColor: 'transparent' }, +}); diff --git a/packages/pointer-native-drawing/src/render/skia/skiaRenderUtils.ts b/packages/pointer-native-drawing/src/render/skia/skiaRenderUtils.ts new file mode 100644 index 000000000..fe9f6a91c --- /dev/null +++ b/packages/pointer-native-drawing/src/render/skia/skiaRenderUtils.ts @@ -0,0 +1,42 @@ +type MeasureTextFn = (text: string) => { width: number }; + +/** + * 텍스트를 최대 너비 기준으로 줄바꿈하여 라인 배열로 반환. + * calculateTextLineCount와 renderedTexts에서 공유하여 중복 제거. + */ +export function wrapTextToLines( + text: string, + maxWidth: number, + measureText: MeasureTextFn +): string[] { + const allLines: string[] = []; + const paragraphs = text.split('\n'); + + for (const paragraph of paragraphs) { + if (!paragraph) { + allLines.push(''); + continue; + } + + const words = paragraph.split(' '); + let currentLine = ''; + + for (let i = 0; i < words.length; i++) { + const testLine = currentLine ? `${currentLine} ${words[i]}` : words[i]; + const textWidth = measureText(testLine).width; + + if (textWidth > maxWidth && currentLine) { + allLines.push(currentLine); + currentLine = words[i]; + } else { + currentLine = testLine; + } + + if (i === words.length - 1) { + allLines.push(currentLine); + } + } + } + + return allLines; +} diff --git a/packages/pointer-native-drawing/src/render/skia/useSkiaDrawingRenderer.tsx b/packages/pointer-native-drawing/src/render/skia/useSkiaDrawingRenderer.tsx new file mode 100644 index 000000000..41efb6ed4 --- /dev/null +++ b/packages/pointer-native-drawing/src/render/skia/useSkiaDrawingRenderer.tsx @@ -0,0 +1,80 @@ +import React, { useMemo } from 'react'; +import { useDerivedValue, type SharedValue } from 'react-native-reanimated'; +import { Path, Text, type SkFont, type SkPath } from '@shopify/react-native-skia'; + +import { type Stroke, type TextItem } from '../../model/drawingTypes'; +import { wrapTextToLines } from './skiaRenderUtils'; + +type UseSkiaDrawingRendererParams = { + paths: SkPath[]; + strokes: Stroke[]; + strokeWidth: number; + strokeColor: string; + texts: TextItem[]; + font: SkFont | null; + maxTextWidth: number; + activeTextInputId: string | null; + showHover: SharedValue; +}; + +export function useSkiaDrawingRenderer({ + paths, + strokes, + strokeWidth, + strokeColor, + texts, + font, + maxTextWidth, + activeTextInputId, + showHover, +}: UseSkiaDrawingRendererParams) { + const hoverOpacity = useDerivedValue(() => { + return showHover.value ? 0.6 : 0; + }, [showHover]); + + const renderedPaths = useMemo( + () => + paths.map((p, i) => { + const stroke = strokes[i]; + return ( + + ); + }), + [paths, strokes, strokeWidth, strokeColor] + ); + + const renderedTexts = useMemo( + () => + font + ? texts + .filter((textItem) => textItem.id !== activeTextInputId) + .flatMap((textItem) => { + const lines = wrapTextToLines(textItem.text, maxTextWidth, (t) => + font.measureText(t) + ); + + return lines.map((line, lineIndex) => ( + + )); + }) + : null, + [texts, font, maxTextWidth, activeTextInputId] + ); + + return { renderedPaths, renderedTexts, hoverOpacity }; +} From 521676e205e88259df8c1d6fb2219b144f559fb8 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 28 Apr 2026 00:24:36 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat(pointer-native-drawing):=20undo/redo?= =?UTF-8?q?=20command=20=ED=8C=A8=ED=84=B4=20HistoryManager?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - engine/HistoryManager.ts: command 패턴 (AppendStroke, EraseStrokes, ReplaceDocument) - append-stroke undo O(1): slice로 마지막 stroke 제거 (deepCopy + path rebuild 제거) - erase transaction: beginTransaction/commitTransaction - lock/unlock: 텍스트 편집 중 canvas undo 차단 - cachedPathsBefore 구조 준비 (renderer 연동 후 적용) - model/drawingTypes.ts: DocumentSnapshot 타입 추가 - DrawingCanvas.tsx: 기존 historyRef/historyIndexRef/saveToHistory/restoreFromHistory 제거 → HistoryManager + createSnapshot/applySnapshot으로 교체 (-225줄, +129줄) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/DrawingCanvas.tsx | 348 +++++++----------- .../src/engine/HistoryManager.ts | 213 +++++++++++ .../src/model/drawingTypes.ts | 22 ++ 3 files changed, 358 insertions(+), 225 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 01d65c386..0e68b8653 100644 --- a/packages/pointer-native-drawing/src/DrawingCanvas.tsx +++ b/packages/pointer-native-drawing/src/DrawingCanvas.tsx @@ -24,12 +24,15 @@ import { runOnJS, useSharedValue } from 'react-native-reanimated'; import { buildSmoothPath } from './smoothing'; import { + type DrawingCanvasProps, + type DrawingCanvasRef, + type DocumentSnapshot, type Point, type Stroke, type TextItem, - type DrawingCanvasRef, } from './model/drawingTypes'; -import { deepCopyStrokes, deepCopyTexts, safeMax } from './model/strokeUtils'; +import { deepCopyTexts, safeMax, computeStrokeBounds } from './model/strokeUtils'; +import { HistoryManager } from './engine/HistoryManager'; import { wrapTextToLines } from './render/skia/skiaRenderUtils'; import { useSkiaDrawingRenderer } from './render/skia/useSkiaDrawingRenderer'; import { SkiaDrawingCanvasSurface } from './render/skia/SkiaDrawingCanvasSurface'; @@ -95,75 +98,53 @@ const DrawingCanvas = forwardRef( const eraserDidModify = useRef(false); const ERASER_THROTTLE_MS = 16; // ~60fps - // 히스토리 관리 (모든 동작에 대한 undo 지원) - type HistoryState = { strokes: Stroke[]; texts: TextItem[] }; - const historyRef = useRef([]); - const historyIndexRef = useRef(-1); + // 히스토리 관리 — command pattern + const historyManager = useRef(new HistoryManager(50)).current; - // 히스토리 상태 변경 알림 const notifyHistoryChange = useCallback(() => { if (!onHistoryChange) return; - - const canUndo = - activeTextInput !== null || - historyIndexRef.current > 0 || - (historyIndexRef.current === 0 && historyRef.current.length > 1); - - const canRedo = - activeTextInput === null && historyIndexRef.current + 1 < historyRef.current.length; - + const canUndo = activeTextInput !== null || historyManager.canUndo(); + const canRedo = activeTextInput === null && historyManager.canRedo(); onHistoryChange(canUndo, canRedo); - }, [onHistoryChange, activeTextInput]); - - // 현재 상태를 히스토리에 저장 - const saveToHistory = useCallback(() => { - const currentState: HistoryState = { - strokes: deepCopyStrokes(strokesRef.current), - texts: deepCopyTexts(textsRef.current), - }; - - // 현재 인덱스 이후의 히스토리 제거 (새 동작이 발생하면 redo 불가) - historyRef.current = historyRef.current.slice(0, historyIndexRef.current + 1); + }, [onHistoryChange, activeTextInput, historyManager]); - // 새 상태 추가 - historyRef.current.push(currentState); - historyIndexRef.current = historyRef.current.length - 1; - - // 히스토리 크기 제한 (메모리 관리) - if (historyRef.current.length > 50) { - historyRef.current.shift(); - historyIndexRef.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 restoredTexts = deepCopyTexts(state.texts); + useEffect(() => { + historyManager.setListener(onHistoryChange ? () => notifyHistoryChange() : null); + }, [historyManager, onHistoryChange, notifyHistoryChange]); + + /** 현재 stroke 상태의 경량 스냅샷 (reference 저장, deep copy 아님) */ + const createSnapshot = useCallback((): DocumentSnapshot => ({ + strokes: strokesRef.current, + bounds: strokesRef.current.map((s) => computeStrokeBounds(s.points)), + }), []); + + /** 스냅샷을 React 상태에 반영 + 선택적 텍스트 복원 */ + const applySnapshot = useCallback( + (snapshot: DocumentSnapshot, newTexts?: readonly TextItem[]) => { + const restoredStrokes = [...snapshot.strokes]; const newPaths = restoredStrokes.map((stroke) => buildSmoothPath(stroke.points)); setStrokes(restoredStrokes); setPaths(newPaths); - setTexts(restoredTexts); strokesRef.current = restoredStrokes; - textsRef.current = restoredTexts; + + if (newTexts) { + const restoredTexts = [...newTexts]; + setTexts(restoredTexts); + textsRef.current = restoredTexts; + } // 최대 Y 좌표 재계산 let maxYValue = 0; - 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)) ); maxYValue = Math.max(maxYValue, strokesMaxY); } - if (state.texts.length > 0) { - const textsMaxY = safeMax(state.texts.map((text) => text.y)); + const currentTexts = newTexts ?? textsRef.current; + if (currentTexts.length > 0) { + const textsMaxY = safeMax(currentTexts.map((text) => text.y)); maxYValue = Math.max(maxYValue, textsMaxY); } @@ -176,10 +157,8 @@ const DrawingCanvas = forwardRef( } onChange?.(restoredStrokes); - // 상태 변경으로 자동 리렌더링 - notifyHistoryChange(); }, - [onChange, notifyHistoryChange] + [onChange] ); // 폰트 로드 (Skia Text용) - 고정 15px @@ -242,17 +221,10 @@ const DrawingCanvas = forwardRef( // 상태 변경으로 자동 리렌더링 onChange?.(newStrokes); - // 히스토리 초기화 및 초기 상태 저장 (외부에서 로드한 경우) - historyRef.current = [ - { - strokes: deepCopyStrokes(newStrokes), - texts: deepCopyTexts(textsRef.current), - }, - ]; - historyIndexRef.current = 0; - notifyHistoryChange(); + // 히스토리 초기화 (외부에서 로드한 경우) + historyManager.clear(); }, - [onChange, notifyHistoryChange] + [onChange, historyManager] ); const loadTexts = useCallback((newTexts: TextItem[]) => { @@ -356,6 +328,8 @@ const DrawingCanvas = forwardRef( return; } + const snapshotBefore = createSnapshot(); + const pointsToFinalize = [...currentPoints.current]; // 최대 Y 좌표 업데이트 const strokeMaxY = safeMax(pointsToFinalize.map((p) => p.y)); @@ -370,9 +344,9 @@ const DrawingCanvas = forwardRef( color: strokeColor, width: strokeWidth, }; + const bounds = computeStrokeBounds(strokeData.points); const nextStrokes = [...strokesRef.current, strokeData]; - // ref를 먼저 동기화해 history/onChange가 항상 최신값을 사용하도록 보장 strokesRef.current = nextStrokes; setStrokes(nextStrokes); setPaths((prev) => [...prev, newPath]); @@ -381,8 +355,13 @@ const DrawingCanvas = forwardRef( livePath.current.reset(); onChange?.(nextStrokes); - saveToHistory(); - }, [strokeColor, strokeWidth, onChange, saveToHistory]); + historyManager.push({ + type: 'append-stroke', + stroke: strokeData, + bounds, + snapshotBefore, + }); + }, [strokeColor, strokeWidth, onChange, createSnapshot, historyManager]); // 지우개: 터치한 위치에서 가까운 점들을 제거 const eraseAtPoint = useCallback( @@ -425,18 +404,21 @@ 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 deleteText = useCallback((textId: string) => { setTexts((prev) => { @@ -588,6 +570,8 @@ const DrawingCanvas = forwardRef( if (activeTextInput && activeTextInput.value.trim()) { isConfirmingTextRef.current = true; const currentTexts = textsRef.current; + const snapshotBefore = createSnapshot(); + const textsBefore = [...currentTexts]; // 기존 텍스트 수정인지 새 텍스트 추가인지 확인 const existingTextIndex = currentTexts.findIndex((t) => t.id === activeTextInput.id); @@ -632,7 +616,13 @@ const DrawingCanvas = forwardRef( setTick((t) => t + 1); } - saveToHistory(); + historyManager.push({ + type: 'replace-document', + snapshotBefore, + snapshotAfter: createSnapshot(), + textsBefore, + textsAfter: [...nextTexts], + }); } // activeTextInput을 먼저 null로 설정하여 중복 실행 방지 @@ -640,7 +630,7 @@ const DrawingCanvas = forwardRef( // 플래그 리셋 isConfirmingTextRef.current = false; - }, [activeTextInput, saveToHistory, calculateTextLineCount]); + }, [activeTextInput, createSnapshot, historyManager, calculateTextLineCount]); const handleTextInputBlur = useCallback(() => { // 이미 처리 중이거나 activeTextInput이 없으면 실행하지 않음 @@ -673,150 +663,77 @@ const DrawingCanvas = forwardRef( ); const undo = useCallback(() => { - // 활성 텍스트 입력이 있으면 먼저 취소 if (activeTextInput) { setActiveTextInput(null); return; } - // 히스토리에서 이전 상태로 복원 - if (historyIndexRef.current > 0) { - const currentState = historyRef.current[historyIndexRef.current]; - const previousState = historyRef.current[historyIndexRef.current - 1]; - - // 현재에만 있고 이전에 없던 텍스트 찾기 (마지막에 추가된 텍스트) - const previousTextIds = new Set(previousState.texts.map((t) => t.id)); + const entry = historyManager.undo(); + if (!entry) return; - // 새로 추가된 텍스트 찾기 - const newlyAddedText = currentState.texts.find((text) => !previousTextIds.has(text.id)); - - if (newlyAddedText) { - // 새로 추가된 텍스트가 있으면 편집 모드로 전환 - // 이전 상태로 복원하되, 새로 추가된 텍스트는 activeTextInput으로 설정 - historyIndexRef.current--; - - // strokes는 이전 상태로 복원 - const newPaths = previousState.strokes.map((stroke) => buildSmoothPath(stroke.points)); - setStrokes(previousState.strokes); - setPaths(newPaths); - strokesRef.current = previousState.strokes; - - // texts는 이전 상태로 복원 (새로 추가된 텍스트 제외) - setTexts(previousState.texts); - textsRef.current = previousState.texts; - - // 새로 추가된 텍스트를 편집 모드로 설정 - setActiveTextInput({ - id: newlyAddedText.id, - x: newlyAddedText.x, - y: newlyAddedText.y, - value: newlyAddedText.text, - }); - - // TextInput 포커스 - setTimeout(() => { - textInputRef.current?.focus(); - }, 100); - - // 최대 Y 좌표 재계산 - let maxYValue = 0; - if (previousState.strokes.length > 0) { - const strokesMaxY = safeMax( - previousState.strokes.flatMap((stroke) => stroke.points.map((p) => p.y)) - ); - maxYValue = Math.max(maxYValue, strokesMaxY); - } - if (previousState.texts.length > 0) { - const textsMaxY = safeMax(previousState.texts.map((text) => text.y)); - maxYValue = Math.max(maxYValue, textsMaxY); - } - - if (maxYValue > 0) { - maxY.current = maxYValue; - canvasHeight.current = Math.max(800, maxY.current + 200); - } else { - maxY.current = 0; - canvasHeight.current = 800; - } - - onChange?.(previousState.strokes); - notifyHistoryChange(); - } else { - // 새로 추가된 텍스트가 없으면 일반적인 undo - historyIndexRef.current--; - restoreFromHistory(historyIndexRef.current); + switch (entry.type) { + case 'append-stroke': { + // O(1): 마지막 stroke/path만 제거 + const nextStrokes = strokesRef.current.slice(0, -1); + strokesRef.current = nextStrokes; + setStrokes(nextStrokes); + setPaths((prev) => prev.slice(0, -1)); + onChange?.(nextStrokes); + break; } - } else if (historyIndexRef.current === 0) { - // 첫 번째 상태로 복원 (빈 상태) - const currentState = historyRef.current[0]; - const previousState: HistoryState = { strokes: [], texts: [] }; - - // 현재 상태에 텍스트가 있고 이전 상태가 비어있으면 - if (currentState.texts.length > 0 && previousState.texts.length === 0) { - const newlyAddedText = currentState.texts[0]; // 첫 번째 텍스트 - - // strokes는 빈 상태로 - setStrokes([]); - setPaths([]); - strokesRef.current = []; - - // texts는 빈 상태로 - setTexts([]); - textsRef.current = []; - - // 첫 번째 텍스트를 편집 모드로 설정 - setActiveTextInput({ - id: newlyAddedText.id, - x: newlyAddedText.x, - y: newlyAddedText.y, - value: newlyAddedText.text, - }); - - setTimeout(() => { - textInputRef.current?.focus(); - }, 100); + case 'erase-strokes': { + applySnapshot(entry.snapshotBefore); + break; + } + case 'replace-document': { + // 텍스트 변경 undo — 새로 추가된 텍스트는 편집 모드로 되돌림 + const prevTextIds = new Set(entry.textsBefore.map((t) => t.id)); + const newlyAdded = entry.textsAfter.find((t) => !prevTextIds.has(t.id)); - maxY.current = 0; - canvasHeight.current = 800; + applySnapshot(entry.snapshotBefore, entry.textsBefore); - onChange?.([]); - historyIndexRef.current = -1; - notifyHistoryChange(); - } else { - historyIndexRef.current = -1; - restoreFromHistory(0); + if (newlyAdded) { + setActiveTextInput({ + id: newlyAdded.id, + x: newlyAdded.x, + y: newlyAdded.y, + value: newlyAdded.text, + }); + setTimeout(() => { + textInputRef.current?.focus(); + }, 100); + } + break; } } - // historyIndexRef.current === -1이면 undo할 히스토리가 없음 - }, [activeTextInput, restoreFromHistory, onChange, notifyHistoryChange]); + }, [activeTextInput, historyManager, applySnapshot, onChange]); const redo = useCallback(() => { - // 활성 텍스트 입력이 있으면 redo 불가 - if (activeTextInput) { - return; - } + if (activeTextInput) return; - // 히스토리에서 다음 상태로 복원 - const nextIndex = historyIndexRef.current + 1; - if (nextIndex < historyRef.current.length) { - historyIndexRef.current = nextIndex; - restoreFromHistory(nextIndex); - } - // nextIndex >= historyRef.current.length이면 redo할 히스토리가 없음 - }, [activeTextInput, restoreFromHistory]); + const entry = historyManager.redo(); + if (!entry) return; - // 초기 상태를 히스토리에 저장 - useEffect(() => { - if (historyRef.current.length === 0) { - const initialState: HistoryState = { - strokes: [], - texts: [], - }; - historyRef.current = [initialState]; - historyIndexRef.current = 0; - notifyHistoryChange(); + switch (entry.type) { + case 'append-stroke': { + // stroke + path 다시 추가 + const nextStrokes = [...strokesRef.current, entry.stroke]; + strokesRef.current = nextStrokes; + setStrokes(nextStrokes); + setPaths((prev) => [...prev, buildSmoothPath(entry.stroke.points)]); + onChange?.(nextStrokes); + break; + } + case 'erase-strokes': { + applySnapshot(entry.snapshotAfter); + break; + } + case 'replace-document': { + applySnapshot(entry.snapshotAfter, entry.textsAfter); + break; + } } - }, [notifyHistoryChange]); + }, [activeTextInput, historyManager, applySnapshot, onChange]); // activeTextInput 상태 변경 시 히스토리 상태 알림 useEffect(() => { @@ -841,33 +758,14 @@ const DrawingCanvas = forwardRef( livePath.current.reset(); maxY.current = 0; canvasHeight.current = 800; - // 상태 변경으로 자동 리렌더링 onChange?.([]); - // 히스토리 초기화 - historyRef.current = []; - historyIndexRef.current = -1; - notifyHistoryChange(); + historyManager.clear(); }, undo, redo, - canUndo: () => { - // 활성 텍스트 입력이 있으면 undo 가능 - if (activeTextInput) return true; - // 히스토리 인덱스가 0보다 크면 undo 가능 (이전 상태가 있음) - if (historyIndexRef.current > 0) return true; - // 초기 상태만 있고 실제 변경이 없으면 undo 불가능 - // 히스토리가 1개만 있으면 (초기 상태만) undo 불가능 - if (historyRef.current.length === 1) return false; - // 히스토리가 2개 이상이면 undo 가능 - return historyIndexRef.current === 0 && historyRef.current.length > 1; - }, - canRedo: () => { - // 활성 텍스트 입력이 있으면 redo 불가 - if (activeTextInput) return false; - // 다음 히스토리가 있으면 redo 가능 - return historyIndexRef.current + 1 < historyRef.current.length; - }, + canUndo: () => activeTextInput !== null || historyManager.canUndo(), + canRedo: () => activeTextInput === null && historyManager.canRedo(), getStrokes: () => strokesRef.current, setStrokes: loadStrokes, getTexts: () => textsRef.current, 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 000000000..5064c3496 --- /dev/null +++ b/packages/pointer-native-drawing/src/engine/HistoryManager.ts @@ -0,0 +1,213 @@ +import { type Stroke, type StrokeBounds, type TextItem, type DocumentSnapshot } from '../model/drawingTypes'; + +// --------------------------------------------------------------------------- +// History entry types (stroke only — textbox entries added in MAT-359) +// --------------------------------------------------------------------------- + +export type AppendStrokeEntry = { + readonly type: 'append-stroke'; + readonly stroke: Stroke; + readonly bounds: StrokeBounds; + readonly snapshotBefore: DocumentSnapshot; +}; + +export type EraseStrokesEntry = { + readonly type: 'erase-strokes'; + readonly snapshotBefore: DocumentSnapshot; + readonly snapshotAfter: DocumentSnapshot; + /** Cached SkPath[] from before the erase — used for O(1) undo. */ + readonly cachedPathsBefore?: readonly unknown[]; +}; + +export type ReplaceDocumentEntry = { + readonly type: 'replace-document'; + readonly snapshotBefore: DocumentSnapshot; + readonly snapshotAfter: DocumentSnapshot; + readonly textsBefore: readonly TextItem[]; + readonly textsAfter: readonly TextItem[]; +}; + +export type HistoryEntry = + | AppendStrokeEntry + | EraseStrokesEntry + | ReplaceDocumentEntry; + +// --------------------------------------------------------------------------- +// State listener +// --------------------------------------------------------------------------- + +export type HistoryStateListener = (state: { canUndo: boolean; canRedo: boolean }) => void; + +// --------------------------------------------------------------------------- +// HistoryManager — command pattern +// +// append-stroke undo: O(1) — slice로 마지막 stroke 제거 +// erase-strokes undo: snapshot 복원 +// replace-document undo: snapshot + texts 복원 +// --------------------------------------------------------------------------- + +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 locked = false; + private listener: HistoryStateListener | null = null; + private onEntryEvicted: ((entry: HistoryEntry) => void) | null = null; + private activeTransaction: { + snapshotBefore: DocumentSnapshot; + cachedPaths?: readonly unknown[]; + } | null = null; + + constructor(maxSize: number = DEFAULT_MAX_SIZE) { + this.maxSize = maxSize; + } + + // ----------------------------------------------------------------------- + // Listener + // ----------------------------------------------------------------------- + + setListener(listener: HistoryStateListener | null): void { + this.listener = listener; + } + + setOnEntryEvicted(cb: ((entry: HistoryEntry) => void) | null): void { + this.onEntryEvicted = cb; + } + + private notifyListener(): void { + this.listener?.({ canUndo: this.canUndo(), canRedo: this.canRedo() }); + } + + private evictEntries(entries: HistoryEntry[]): void { + if (!this.onEntryEvicted) return; + for (const e of entries) this.onEntryEvicted(e); + } + + // ----------------------------------------------------------------------- + // Push + // ----------------------------------------------------------------------- + + push(entry: HistoryEntry): void { + if (this.pointer < this.stack.length - 1) { + this.evictEntries(this.stack.slice(this.pointer + 1)); + this.stack.length = this.pointer + 1; + } + + this.stack.push(entry); + this.pointer = this.stack.length - 1; + + if (this.stack.length > this.maxSize) { + const evicted = this.stack.shift()!; + this.evictEntries([evicted]); + this.pointer = this.stack.length - 1; + } + + this.notifyListener(); + } + + // ----------------------------------------------------------------------- + // Undo / Redo — returns the entry for the caller to interpret + // ----------------------------------------------------------------------- + + 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; + } + + // ----------------------------------------------------------------------- + // Query + // ----------------------------------------------------------------------- + + canUndo(): boolean { + return !this.locked && this.pointer >= 0; + } + + canRedo(): boolean { + return !this.locked && this.pointer < this.stack.length - 1; + } + + // ----------------------------------------------------------------------- + // Erase transaction + // ----------------------------------------------------------------------- + + beginTransaction(snapshotBefore: DocumentSnapshot, cachedPaths?: readonly unknown[]): void { + this.discardTransaction(); + this.activeTransaction = { snapshotBefore, cachedPaths }; + } + + commitTransaction(snapshotAfter: DocumentSnapshot): void { + if (!this.activeTransaction) return; + const { snapshotBefore, cachedPaths } = this.activeTransaction; + this.activeTransaction = null; + + if (snapshotBefore.strokes.length === snapshotAfter.strokes.length) return; + + this.push({ + type: 'erase-strokes', + snapshotBefore, + snapshotAfter, + ...(cachedPaths ? { cachedPathsBefore: cachedPaths } : {}), + }); + } + + discardTransaction(): void { + if (this.activeTransaction?.cachedPaths) { + this.evictEntries([{ + type: 'erase-strokes', + snapshotBefore: this.activeTransaction.snapshotBefore, + snapshotAfter: this.activeTransaction.snapshotBefore, + cachedPathsBefore: this.activeTransaction.cachedPaths, + }]); + } + this.activeTransaction = null; + } + + hasActiveTransaction(): boolean { + return this.activeTransaction !== null; + } + + // ----------------------------------------------------------------------- + // Lock (text editing blocks canvas undo/redo) + // ----------------------------------------------------------------------- + + lock(): void { + this.locked = true; + this.notifyListener(); + } + + unlock(): void { + this.locked = false; + this.notifyListener(); + } + + isLocked(): boolean { + return this.locked; + } + + // ----------------------------------------------------------------------- + // Clear + // ----------------------------------------------------------------------- + + clear(): void { + this.evictEntries(this.stack); + this.discardTransaction(); + this.stack = []; + this.pointer = -1; + this.locked = false; + this.notifyListener(); + } +} diff --git a/packages/pointer-native-drawing/src/model/drawingTypes.ts b/packages/pointer-native-drawing/src/model/drawingTypes.ts index bf01df25b..d1d0235db 100644 --- a/packages/pointer-native-drawing/src/model/drawingTypes.ts +++ b/packages/pointer-native-drawing/src/model/drawingTypes.ts @@ -50,3 +50,25 @@ export type DrawingCanvasRef = { getTexts: () => TextItem[]; setTexts: (texts: TextItem[]) => void; }; + +export type DrawingCanvasProps = { + strokeColor?: string; + strokeWidth?: number; + onChange?: (strokes: Stroke[]) => void; + onHistoryChange?: (canUndo: boolean, canRedo: boolean) => void; + eraserMode?: boolean; + eraserSize?: number; + textMode?: boolean; + textFontPath?: number; +}; + +// ── Snapshot (lightweight — stores references, not deep copies) ─ + +export type DocumentSnapshot = { + readonly strokes: readonly Stroke[]; + readonly bounds: readonly StrokeBounds[]; +}; + +// ── History (legacy — DrawingCanvas에서 HistoryManager로 대체됨) ── + +export type HistoryState = { strokes: Stroke[]; texts: TextItem[] }; From c2bcb4a5bad8bf1acaeb9b712010e2f330b7fdf7 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 28 Apr 2026 00:27:37 +0900 Subject: [PATCH 4/4] =?UTF-8?q?refactor(pointer-native-drawing):=20?= =?UTF-8?q?=ED=85=8D=EC=8A=A4=ED=8A=B8=EB=B0=95=EC=8A=A4=20=EC=B6=94?= =?UTF-8?q?=EC=B6=9C=20+=20=EA=B8=B0=ED=9A=8D=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - textbox/textBoxTypes.ts: ActiveTextInput 타입, 텍스트 상수 (TEXT_FONT_SIZE, TEXT_LINE_HEIGHT, TEXT_COLOR) - textbox/TextBoxEditingOverlay.tsx: TextBoxEditingOverlay + TextDeleteButtons 컴포넌트 - DrawingCanvas.tsx: - isNearExistingText 제거 (240에서 삭제된 옛날 기획) - 기본 strokeColor 'black' → '#1E1E21' (240 기준) - inline TextInput/삭제 버튼 JSX → 컴포넌트로 교체 - 매직넘버 22.5 → TEXT_LINE_HEIGHT 상수 - 미사용 import/스타일 제거 (-185줄) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/DrawingCanvas.tsx | 225 ++++-------------- .../src/textbox/TextBoxEditingOverlay.tsx | 133 +++++++++++ .../src/textbox/textBoxTypes.ts | 12 + 3 files changed, 185 insertions(+), 185 deletions(-) create mode 100644 packages/pointer-native-drawing/src/textbox/TextBoxEditingOverlay.tsx create mode 100644 packages/pointer-native-drawing/src/textbox/textBoxTypes.ts diff --git a/packages/pointer-native-drawing/src/DrawingCanvas.tsx b/packages/pointer-native-drawing/src/DrawingCanvas.tsx index 0e68b8653..a0ea49e4b 100644 --- a/packages/pointer-native-drawing/src/DrawingCanvas.tsx +++ b/packages/pointer-native-drawing/src/DrawingCanvas.tsx @@ -10,10 +10,8 @@ import React, { import { View, StyleSheet, - TextInput, + type TextInput, Dimensions, - Pressable, - Text as RNText, ScrollView, Keyboard, Platform, @@ -31,11 +29,13 @@ import { type Stroke, type TextItem, } from './model/drawingTypes'; -import { deepCopyTexts, safeMax, computeStrokeBounds } from './model/strokeUtils'; +import { safeMax, computeStrokeBounds } from './model/strokeUtils'; import { HistoryManager } from './engine/HistoryManager'; import { wrapTextToLines } from './render/skia/skiaRenderUtils'; import { useSkiaDrawingRenderer } from './render/skia/useSkiaDrawingRenderer'; import { SkiaDrawingCanvasSurface } from './render/skia/SkiaDrawingCanvasSurface'; +import { type ActiveTextInput, TEXT_LINE_HEIGHT } from './textbox/textBoxTypes'; +import { TextBoxEditingOverlay, TextDeleteButtons } from './textbox/TextBoxEditingOverlay'; type Props = { strokeColor?: string; @@ -51,7 +51,7 @@ type Props = { const DrawingCanvas = forwardRef( ( { - strokeColor = 'black', + strokeColor = '#1E1E21', strokeWidth = 3, onChange, onHistoryChange, @@ -66,12 +66,7 @@ const DrawingCanvas = forwardRef( const [strokes, setStrokes] = useState([]); const [texts, setTexts] = useState([]); const [, setTick] = useState(0); - const [activeTextInput, setActiveTextInput] = useState<{ - id: string; - x: number; - y: number; - value: string; - } | null>(null); + const [activeTextInput, setActiveTextInput] = useState(null); const textInputRef = useRef(null); const scrollViewRef = useRef(null); const containerLayout = useRef<{ x: number; y: number; width: number; height: number } | null>( @@ -259,67 +254,22 @@ const DrawingCanvas = forwardRef( // 상태 변경으로 자동 리렌더링 }, []); - // 텍스트 영역과 충돌하는지 확인 (32px 여백 포함, 캔버스 전체 너비, 멀티라인 고려) - const isNearExistingText = useCallback( - (y: number): boolean => { - const safeDistance = 32; // 텍스트 주변 32px 보호 영역 + const addPoint = useCallback((x: number, y: number) => { + currentPoints.current.push({ x, y }); + livePath.current = buildSmoothPath(currentPoints.current); - for (const textItem of texts) { - // 실제 줄 수 계산 - const lineCount = calculateTextLineCount(textItem.text); - const totalTextHeight = lineCount * 22.5; // 고정 줄 높이 - - // 텍스트 영역의 Y 범위 (32px 여백 포함, X는 캔버스 전체 너비) - const textTop = textItem.y - safeDistance; - const textBottom = textItem.y + totalTextHeight + safeDistance; - - // Y 좌표가 텍스트 영역 내에 있으면 캔버스 전체 너비에서 필기 차단 - if (y >= textTop && y <= textBottom) { - return true; - } - } - return false; - }, - [texts, calculateTextLineCount] - ); - - const addPoint = useCallback( - (x: number, y: number) => { - // 텍스트 영역에서는 필기 차단 - if (isNearExistingText(y)) { - return; - } - - currentPoints.current.push({ x, y }); - // 경로는 매번 재생성 - livePath.current = buildSmoothPath(currentPoints.current); - - // 최대 Y 좌표 업데이트 시에만 리렌더링 - if (y > maxY.current) { - maxY.current = y; - canvasHeight.current = Math.max(800, maxY.current + 200); - setTick((t) => t + 1); - } else { - // 일반적인 경우에도 livePath가 변경되었으므로 리렌더링 필요 - setTick((t) => t + 1); - } - }, - [isNearExistingText] - ); - - const startStroke = useCallback( - (x: number, y: number) => { - // 텍스트 영역에서는 필기 시작 차단 - if (isNearExistingText(y)) { - return; - } + if (y > maxY.current) { + maxY.current = y; + canvasHeight.current = Math.max(800, maxY.current + 200); + } + setTick((t) => t + 1); + }, []); - currentPoints.current = [{ x, y }]; - livePath.current = buildSmoothPath(currentPoints.current); - setTick((t) => t + 1); - }, - [isNearExistingText] - ); + const startStroke = useCallback((x: number, y: number) => { + currentPoints.current = [{ x, y }]; + livePath.current = buildSmoothPath(currentPoints.current); + setTick((t) => t + 1); + }, []); const finalizeStroke = useCallback(() => { if (currentPoints.current.length === 0) { @@ -505,7 +455,7 @@ const DrawingCanvas = forwardRef( // 기존 텍스트 클릭 확인 for (const textItem of texts) { const lineCount = calculateTextLineCount(textItem.text); - const totalTextHeight = lineCount * 22.5; // 고정 줄 높이 + const totalTextHeight = lineCount * TEXT_LINE_HEIGHT; // 고정 줄 높이 // 텍스트 영역 확인 (X 좌표는 캔버스 전체 너비, Y 좌표만 확인) const textTop = textItem.y; @@ -541,7 +491,7 @@ const DrawingCanvas = forwardRef( if (texts.length > 0) { const textBottoms = texts.map((text) => { const lineCount = calculateTextLineCount(text.text); - const totalHeight = lineCount * 22.5; // 고정 줄 높이 + const totalHeight = lineCount * TEXT_LINE_HEIGHT; // 고정 줄 높이 return text.y + totalHeight; }); const maxTextBottom = safeMax(textBottoms); @@ -578,7 +528,7 @@ const DrawingCanvas = forwardRef( // 텍스트의 실제 줄 수 계산하여 최대 Y 좌표 업데이트 const lineCount = calculateTextLineCount(activeTextInput.value); - const totalTextHeight = lineCount * 22.5; + const totalTextHeight = lineCount * TEXT_LINE_HEIGHT; const textBottomY = activeTextInput.y + totalTextHeight; let nextTexts: TextItem[]; @@ -648,7 +598,7 @@ const DrawingCanvas = forwardRef( // 입력 중에도 캔버스 높이 동적 확장 if (text.trim()) { const lineCount = calculateTextLineCount(text); - const totalTextHeight = lineCount * 22.5; // 고정 줄 높이 22.5px + const totalTextHeight = lineCount * TEXT_LINE_HEIGHT; // 고정 줄 높이 22.5px const textBottomY = activeTextInput.y + totalTextHeight; if (textBottomY > maxY.current) { @@ -899,43 +849,6 @@ const DrawingCanvas = forwardRef( }); // 텍스트 삭제 버튼 렌더링 (텍스트 모드일 때만, 텍스트 시작 위치에 배치) - const renderedTextDeleteButtons = useMemo(() => { - if (!textMode || eraserMode) return null; - - return texts - .filter((textItem) => { - // activeTextInput이 있고 id가 일치하면 편집 중이므로 삭제 버튼도 표시하지 않음 - if (activeTextInput && activeTextInput.id === textItem.id) { - return false; - } - return true; - }) - .map((textItem) => { - const buttonSize = 20; - const buttonX = textItem.x - buttonSize + 10; // 텍스트 시작 왼쪽에 배치 - const buttonY = textItem.y + (15 - buttonSize) / 2 + 10; - - return ( - deleteText(textItem.id)}> - × - - ); - }); - }, [texts, textMode, eraserMode, deleteText, activeTextInput]); - return ( ( - {/* 인라인 텍스트 입력 박스 */} {activeTextInput && ( - - - + )} - {/* 텍스트 삭제 버튼 */} - {renderedTextDeleteButtons} + @@ -1048,33 +930,6 @@ const styles = StyleSheet.create({ flexGrow: 1, }, container: { minHeight: 400, position: 'relative' }, - textInputWrapper: { - position: 'absolute', - backgroundColor: 'transparent', - overflow: 'hidden', // 컨테이너 넘어가는 내용 숨김 - // width는 인라인 스타일로 동적 적용 - }, - inlineTextInput: { - backgroundColor: 'transparent', - borderWidth: 0, - padding: 0, - margin: 0, - textAlignVertical: 'top', - flexWrap: 'wrap', // 텍스트 줄바꿈 - // width는 인라인 스타일로 동적 적용 - }, - deleteButton: { - backgroundColor: 'rgba(0, 0, 0, 0.6)', - borderRadius: 10, - justifyContent: 'center', - alignItems: 'center', - }, - deleteButtonText: { - color: 'white', - fontSize: 16, - fontWeight: 'bold', - lineHeight: 16, - }, }); export default React.memo(DrawingCanvas); diff --git a/packages/pointer-native-drawing/src/textbox/TextBoxEditingOverlay.tsx b/packages/pointer-native-drawing/src/textbox/TextBoxEditingOverlay.tsx new file mode 100644 index 000000000..3448ab3b9 --- /dev/null +++ b/packages/pointer-native-drawing/src/textbox/TextBoxEditingOverlay.tsx @@ -0,0 +1,133 @@ +import React, { forwardRef } from 'react'; +import { View, TextInput, Pressable, Text as RNText, StyleSheet } from 'react-native'; + +import { type TextItem } from '../model/drawingTypes'; +import { type ActiveTextInput, TEXT_FONT_SIZE, TEXT_LINE_HEIGHT, TEXT_COLOR } from './textBoxTypes'; + +type TextBoxEditingOverlayProps = { + activeTextInput: ActiveTextInput; + maxTextWidth: number; + containerWidth: number; + containerHeight: number; + onChangeText: (text: string) => void; + onBlur: () => void; +}; + +export const TextBoxEditingOverlay = forwardRef( + ({ activeTextInput, maxTextWidth, containerWidth, containerHeight, onChangeText, onBlur }, ref) => ( + + + + ) +); + +TextBoxEditingOverlay.displayName = 'TextBoxEditingOverlay'; + +type TextDeleteButtonsProps = { + texts: TextItem[]; + textMode: boolean; + eraserMode: boolean; + activeTextInputId: string | null; + onDelete: (id: string) => void; +}; + +export function TextDeleteButtons({ + texts, + textMode, + eraserMode, + activeTextInputId, + onDelete, +}: TextDeleteButtonsProps) { + if (!textMode || eraserMode) return null; + + return ( + <> + {texts + .filter((t) => t.id !== activeTextInputId) + .map((textItem) => { + const buttonSize = 20; + const buttonX = textItem.x - buttonSize + 10; + const buttonY = textItem.y + (TEXT_FONT_SIZE - buttonSize) / 2 + 10; + + return ( + onDelete(textItem.id)}> + × + + ); + })} + + ); +} + +const styles = StyleSheet.create({ + textInputWrapper: { + position: 'absolute', + backgroundColor: 'transparent', + overflow: 'hidden', + }, + inlineTextInput: { + backgroundColor: 'transparent', + borderWidth: 0, + padding: 0, + margin: 0, + textAlignVertical: 'top', + flexWrap: 'wrap', + }, + deleteButton: { + backgroundColor: 'rgba(0, 0, 0, 0.6)', + borderRadius: 10, + justifyContent: 'center', + alignItems: 'center', + }, + deleteButtonText: { + color: 'white', + fontSize: 16, + fontWeight: 'bold', + lineHeight: 16, + }, +}); diff --git a/packages/pointer-native-drawing/src/textbox/textBoxTypes.ts b/packages/pointer-native-drawing/src/textbox/textBoxTypes.ts new file mode 100644 index 000000000..6a680fc55 --- /dev/null +++ b/packages/pointer-native-drawing/src/textbox/textBoxTypes.ts @@ -0,0 +1,12 @@ +export type ActiveTextInput = { + id: string; + x: number; + y: number; + value: string; +}; + +/** 고정 텍스트 스타일 상수 */ +export const TEXT_FONT_SIZE = 15; +export const TEXT_LINE_HEIGHT = 22.5; +export const TEXT_COLOR = '#1E1E21'; +export const TEXT_PADDING = 16;