From 4a7c98983239102364454477dd6c414dca5eed87 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Sun, 10 May 2026 19:22:15 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(problem):=20MAT-585=20=E2=80=94=20?= =?UTF-8?q?=EB=8F=84=EA=B5=AC=20toolbar=20New=20version=20=EA=B5=90?= =?UTF-8?q?=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProblemDrawingToolbar: floating 캡슐 toolbar 신설. 펼침/축소 (229×62 / 62×62), Undo/Redo/Pen/Eraser, primary-200 selected. - 4 모서리 snap: 1초 long-press 후 drag. 3×3 zone 분할에서 모서리 zone 안 release 시에만 corner 변경, 그 외엔 원래 모서리로 복귀. - 자동 collapse: DrawingCanvas onStrokeStart 시 collapsed=true, collapsed 단일 버튼 탭으로 expand. - ProblemScreen: Header / BottomActionBar 사이 본문 wrapper 분리, toolbar overlay 영역 한정 (X 버튼·액션바 충돌 회피). - drawing.tsx: pan.onBegin stylus 진입 시점 onStrokeStart 호출 (textMode 분기 다음, 그리기 모드에서만 fire). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/ProblemDrawingToolbar.tsx | 345 +++++++++++++++--- .../student/problem/screens/ProblemScreen.tsx | 113 +++--- .../student/scrap/utils/skia/drawing.tsx | 6 + 3 files changed, 365 insertions(+), 99 deletions(-) diff --git a/apps/native/src/features/student/problem/components/ProblemDrawingToolbar.tsx b/apps/native/src/features/student/problem/components/ProblemDrawingToolbar.tsx index 01b52cf62..59c616221 100644 --- a/apps/native/src/features/student/problem/components/ProblemDrawingToolbar.tsx +++ b/apps/native/src/features/student/problem/components/ProblemDrawingToolbar.tsx @@ -1,5 +1,12 @@ -import React from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { View } from 'react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import Animated, { + runOnJS, + useAnimatedStyle, + useSharedValue, + withSpring, +} from 'react-native-reanimated'; import { Undo2, Redo2 } from 'lucide-react-native'; import { PencilFilledIcon, EraserFilledIcon } from '@components/system/icons'; @@ -14,8 +21,55 @@ interface ProblemDrawingToolbarProps { isEraserMode: boolean; onPenModePress: () => void; onEraserModePress: () => void; + collapsed: boolean; + onCollapsedChange: (collapsed: boolean) => void; + containerWidth: number; + containerHeight: number; } +type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; + +const BUTTON_SIZE = 42; +const BUTTON_RADIUS = 12; +const ICON_SIZE = 20; +const PADDING = 10; +const GAP = 10; +const DIVIDER_WIDTH = 1; +const SCREEN_MARGIN = 24; +const EXPANDED_RADIUS = 24; +const COLLAPSED_RADIUS = 20; + +const EXPANDED_W = + PADDING + + BUTTON_SIZE + + GAP + + BUTTON_SIZE + + GAP + + DIVIDER_WIDTH + + GAP + + BUTTON_SIZE + + GAP + + BUTTON_SIZE + + PADDING; +const TOOLBAR_H = PADDING + BUTTON_SIZE + PADDING; +const COLLAPSED_W = TOOLBAR_H; +const SPRING = { damping: 20, stiffness: 220, mass: 0.8 }; +const LONG_PRESS_MS = 1000; + +const cornerXY = (corner: Corner, width: number, cw: number, ch: number) => { + const x = corner.endsWith('left') ? SCREEN_MARGIN : cw - width - SCREEN_MARGIN; + const y = corner.startsWith('top') ? SCREEN_MARGIN : ch - TOOLBAR_H - SCREEN_MARGIN; + return { x, y }; +}; + +const SHADOW = { + shadowColor: '#1E1E21', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.1, + shadowRadius: 4.5, + elevation: 3, +}; + export const ProblemDrawingToolbar = ({ canUndo, canRedo, @@ -24,57 +78,248 @@ export const ProblemDrawingToolbar = ({ isEraserMode, onPenModePress, onEraserModePress, + collapsed, + onCollapsedChange, + containerWidth, + containerHeight, }: ProblemDrawingToolbarProps) => { + const [corner, setCorner] = useState('bottom-left'); + const width = collapsed ? COLLAPSED_W : EXPANDED_W; + + const translateX = useSharedValue(SCREEN_MARGIN); + const translateY = useSharedValue(SCREEN_MARGIN); + const dragStartX = useSharedValue(0); + const dragStartY = useSharedValue(0); + const dragged = useSharedValue(false); + + useEffect(() => { + if (containerWidth <= 0 || containerHeight <= 0) return; + const { x, y } = cornerXY(corner, width, containerWidth, containerHeight); + translateX.value = withSpring(x, SPRING); + translateY.value = withSpring(y, SPRING); + }, [corner, width, containerWidth, containerHeight, translateX, translateY]); + + const snapToCorner = useCallback( + (x: number, y: number) => { + if (containerWidth <= 0 || containerHeight <= 0) return; + const cx = x + width / 2; + const cy = y + TOOLBAR_H / 2; + + const inLeft = cx < containerWidth / 3; + const inRight = cx > (containerWidth * 2) / 3; + const inTop = cy < containerHeight / 3; + const inBottom = cy > (containerHeight * 2) / 3; + + let next: Corner = corner; + if ((inLeft || inRight) && (inTop || inBottom)) { + next = inTop + ? inLeft + ? 'top-left' + : 'top-right' + : inLeft + ? 'bottom-left' + : 'bottom-right'; + } + + const { x: tx, y: ty } = cornerXY(next, width, containerWidth, containerHeight); + translateX.value = withSpring(tx, SPRING); + translateY.value = withSpring(ty, SPRING); + setCorner(next); + }, + [containerWidth, containerHeight, width, corner, translateX, translateY] + ); + + const panGesture = Gesture.Pan() + .activateAfterLongPress(LONG_PRESS_MS) + .onBegin(() => { + dragStartX.value = translateX.value; + dragStartY.value = translateY.value; + dragged.value = false; + }) + .onUpdate((e) => { + dragged.value = true; + translateX.value = dragStartX.value + e.translationX; + translateY.value = dragStartY.value + e.translationY; + }) + .onEnd(() => { + if (!dragged.value) return; + runOnJS(snapToCorner)(translateX.value, translateY.value); + }); + + const composedGesture = Gesture.Simultaneous(panGesture, Gesture.Native()); + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: translateX.value }, { translateY: translateY.value }], + })); + return ( - - {/* Undo/Redo 그룹 */} - - {/* Undo 버튼 */} - - - - - {/* Redo 버튼 */} - - - - - - {/* 구분선 */} - - - {/* Pencil/Eraser 그룹 */} - - {/* Eraser 버튼 */} - - + + {collapsed ? ( + onCollapsedChange(false)} /> + ) : ( + - - - {/* Pencil 버튼 */} - - - - - + )} + + ); }; + +const ExpandedToolbar = ({ + canUndo, + canRedo, + onUndo, + onRedo, + isEraserMode, + onPenModePress, + onEraserModePress, +}: Pick< + ProblemDrawingToolbarProps, + | 'canUndo' + | 'canRedo' + | 'onUndo' + | 'onRedo' + | 'isEraserMode' + | 'onPenModePress' + | 'onEraserModePress' +>) => ( + + + } + isActive={canUndo} + /> + + } + isActive={canRedo} + /> + + + } + isActive={!isEraserMode} + /> + + } + isActive={isEraserMode} + /> + +); + +const CollapsedToolbar = ({ + isEraserMode, + onPress, +}: { + isEraserMode: boolean; + onPress: () => void; +}) => ( + + + {isEraserMode ? ( + + ) : ( + + )} + + +); + +const ToolbarButton = ({ + onPress, + disabled, + isActive, + activeBg, + icon, +}: { + onPress: () => void; + disabled?: boolean; + isActive?: boolean; + activeBg: string; + icon: React.ReactNode; +}) => ( + + {icon} + +); diff --git a/apps/native/src/features/student/problem/screens/ProblemScreen.tsx b/apps/native/src/features/student/problem/screens/ProblemScreen.tsx index ea3ff47ca..005c68aa0 100644 --- a/apps/native/src/features/student/problem/screens/ProblemScreen.tsx +++ b/apps/native/src/features/student/problem/screens/ProblemScreen.tsx @@ -473,6 +473,30 @@ const ProblemScreen = ({ navigation }: ProblemScreenProps) => { const canvasRef = useRef(null); const drawingState = useDrawingState(); + const [toolbarCollapsed, setToolbarCollapsed] = useState(false); + const [toolbarArea, setToolbarArea] = useState({ width: 0, height: 0 }); + + const handleToolbarAreaLayout = useCallback( + ({ nativeEvent }: LayoutChangeEvent) => { + const { width, height } = nativeEvent.layout; + setToolbarArea((prev) => + prev.width === width && prev.height === height ? prev : { width, height } + ); + }, + [] + ); + + const handlePenModePress = useCallback(() => { + drawingState.setPenMode(); + }, [drawingState]); + + const handleEraserModePress = useCallback(() => { + if (drawingState.isEraserMode) { + drawingState.setPenMode(); + } else { + drawingState.setEraserMode(); + } + }, [drawingState]); const screenHeight = Dimensions.get('window').height; @@ -486,65 +510,56 @@ const ProblemScreen = ({ navigation }: ProblemScreenProps) => { right={ setIsCloseVisible(true)} />} /> - - + + + + {/* Problem */} + + + + {/* 위층: DrawingCanvas - ProblemViewer 위에 겹쳐짐 */} + + + setToolbarCollapsed(true)} + /> + + + + + + + canvasRef.current?.undo()} onRedo={() => canvasRef.current?.redo()} isEraserMode={drawingState.isEraserMode} - onPenModePress={drawingState.setPenMode} - onEraserModePress={() => { - if (drawingState.isEraserMode) { - drawingState.setPenMode(); - } else { - drawingState.setEraserMode(); - } - }} + onPenModePress={handlePenModePress} + onEraserModePress={handleEraserModePress} + collapsed={toolbarCollapsed} + onCollapsedChange={setToolbarCollapsed} + containerWidth={toolbarArea.width} + containerHeight={toolbarArea.height} /> - - - {/* Problem */} - - - - {/* 위층: DrawingCanvas - ProblemViewer 위에 겹쳐짐 */} - - - - - - - - void; onHistoryChange?: (canUndo: boolean, canRedo: boolean) => void; + onStrokeStart?: () => void; eraserMode?: boolean; eraserSize?: number; textMode?: boolean; @@ -85,6 +86,7 @@ const DrawingCanvas = forwardRef( strokeWidth = 3, onChange, onHistoryChange, + onStrokeStart, eraserMode = false, eraserSize = 20, textMode = false, @@ -966,6 +968,9 @@ const DrawingCanvas = forwardRef( isActiveGesture.value = true; showHover.value = false; // 그리기 시작 시 호버 숨김 if (textMode) return; // 텍스트 모드에서는 그리기 비활성화 + if (onStrokeStart) { + runOnJS(onStrokeStart)(); + } if (eraserMode) { runOnJS(startEraser)(e.x, e.y); } else { @@ -1001,6 +1006,7 @@ const DrawingCanvas = forwardRef( [ textMode, eraserMode, + onStrokeStart, startStroke, addPoint, finalizeStroke, From 4417ce1029ee490e5dd078875bae5b98f5dde79f Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Sun, 10 May 2026 22:48:24 +0900 Subject: [PATCH 2/4] =?UTF-8?q?chore(problem):=20MAT-585=20=E2=80=94=20too?= =?UTF-8?q?lbar=20lint/prettier=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProblemDrawingToolbar: snapToCorner nested ternary 를 vertical/horizontal 분리 + template literal cast 로 리팩토링 (unicorn/no-nested-ternary). - ProblemScreen: handleToolbarAreaLayout prettier 포매팅 정리. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../problem/components/ProblemDrawingToolbar.tsx | 13 ++++--------- .../student/problem/screens/ProblemScreen.tsx | 15 ++++++--------- 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/apps/native/src/features/student/problem/components/ProblemDrawingToolbar.tsx b/apps/native/src/features/student/problem/components/ProblemDrawingToolbar.tsx index 59c616221..320c0e70d 100644 --- a/apps/native/src/features/student/problem/components/ProblemDrawingToolbar.tsx +++ b/apps/native/src/features/student/problem/components/ProblemDrawingToolbar.tsx @@ -112,13 +112,9 @@ export const ProblemDrawingToolbar = ({ let next: Corner = corner; if ((inLeft || inRight) && (inTop || inBottom)) { - next = inTop - ? inLeft - ? 'top-left' - : 'top-right' - : inLeft - ? 'bottom-left' - : 'bottom-right'; + const vertical = inTop ? 'top' : 'bottom'; + const horizontal = inLeft ? 'left' : 'right'; + next = `${vertical}-${horizontal}` as Corner; } const { x: tx, y: ty } = cornerXY(next, width, containerWidth, containerHeight); @@ -154,8 +150,7 @@ export const ProblemDrawingToolbar = ({ return ( - + {collapsed ? ( onCollapsedChange(false)} /> ) : ( diff --git a/apps/native/src/features/student/problem/screens/ProblemScreen.tsx b/apps/native/src/features/student/problem/screens/ProblemScreen.tsx index 005c68aa0..0cdcd40dd 100644 --- a/apps/native/src/features/student/problem/screens/ProblemScreen.tsx +++ b/apps/native/src/features/student/problem/screens/ProblemScreen.tsx @@ -476,15 +476,12 @@ const ProblemScreen = ({ navigation }: ProblemScreenProps) => { const [toolbarCollapsed, setToolbarCollapsed] = useState(false); const [toolbarArea, setToolbarArea] = useState({ width: 0, height: 0 }); - const handleToolbarAreaLayout = useCallback( - ({ nativeEvent }: LayoutChangeEvent) => { - const { width, height } = nativeEvent.layout; - setToolbarArea((prev) => - prev.width === width && prev.height === height ? prev : { width, height } - ); - }, - [] - ); + const handleToolbarAreaLayout = useCallback(({ nativeEvent }: LayoutChangeEvent) => { + const { width, height } = nativeEvent.layout; + setToolbarArea((prev) => + prev.width === width && prev.height === height ? prev : { width, height } + ); + }, []); const handlePenModePress = useCallback(() => { drawingState.setPenMode(); From b68b7f9655ba9c4ea037d462bceaf422c4f38e5c Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Sun, 10 May 2026 23:05:29 +0900 Subject: [PATCH 3/4] =?UTF-8?q?refactor(problem):=20MAT-585=20=E2=80=94=20?= =?UTF-8?q?floating=20toolbar=20=EB=94=94=EB=A0=89=ED=86=A0=EB=A6=AC=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20+=20snap=20hook=20=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - apps/native/src/features/student/problem/components/floating-toolbar/ - shared.tsx: 상수, Corner, ToolbarButton, ToolbarDivider, ColorSwatch, cornerXY - useFloatingToolbarSnap.ts: 1초 long-press + drag + 4-corner snap + opacity gate hook - ProblemDrawingToolbar.tsx: hook 사용 + initialCorner prop. 색상 prop 제거. - index.ts: 배럴 - ProblemScreen import 경로 갱신. - 기존 ProblemDrawingToolbar.tsx 삭제. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/ProblemDrawingToolbar.tsx | 320 ------------------ .../ProblemDrawingToolbar.tsx | 217 ++++++++++++ .../components/floating-toolbar/index.ts | 1 + .../components/floating-toolbar/shared.tsx | 107 ++++++ .../useFloatingToolbarSnap.ts | 86 +++++ .../student/problem/screens/ProblemScreen.tsx | 2 +- 6 files changed, 412 insertions(+), 321 deletions(-) delete mode 100644 apps/native/src/features/student/problem/components/ProblemDrawingToolbar.tsx create mode 100644 apps/native/src/features/student/problem/components/floating-toolbar/ProblemDrawingToolbar.tsx create mode 100644 apps/native/src/features/student/problem/components/floating-toolbar/index.ts create mode 100644 apps/native/src/features/student/problem/components/floating-toolbar/shared.tsx create mode 100644 apps/native/src/features/student/problem/components/floating-toolbar/useFloatingToolbarSnap.ts diff --git a/apps/native/src/features/student/problem/components/ProblemDrawingToolbar.tsx b/apps/native/src/features/student/problem/components/ProblemDrawingToolbar.tsx deleted file mode 100644 index 320c0e70d..000000000 --- a/apps/native/src/features/student/problem/components/ProblemDrawingToolbar.tsx +++ /dev/null @@ -1,320 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import { View } from 'react-native'; -import { Gesture, GestureDetector } from 'react-native-gesture-handler'; -import Animated, { - runOnJS, - useAnimatedStyle, - useSharedValue, - withSpring, -} from 'react-native-reanimated'; -import { Undo2, Redo2 } from 'lucide-react-native'; - -import { PencilFilledIcon, EraserFilledIcon } from '@components/system/icons'; -import { colors } from '@theme/tokens'; -import { AnimatedPressable } from '@components/common'; - -interface ProblemDrawingToolbarProps { - canUndo: boolean; - canRedo: boolean; - onUndo: () => void; - onRedo: () => void; - isEraserMode: boolean; - onPenModePress: () => void; - onEraserModePress: () => void; - collapsed: boolean; - onCollapsedChange: (collapsed: boolean) => void; - containerWidth: number; - containerHeight: number; -} - -type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; - -const BUTTON_SIZE = 42; -const BUTTON_RADIUS = 12; -const ICON_SIZE = 20; -const PADDING = 10; -const GAP = 10; -const DIVIDER_WIDTH = 1; -const SCREEN_MARGIN = 24; -const EXPANDED_RADIUS = 24; -const COLLAPSED_RADIUS = 20; - -const EXPANDED_W = - PADDING + - BUTTON_SIZE + - GAP + - BUTTON_SIZE + - GAP + - DIVIDER_WIDTH + - GAP + - BUTTON_SIZE + - GAP + - BUTTON_SIZE + - PADDING; -const TOOLBAR_H = PADDING + BUTTON_SIZE + PADDING; -const COLLAPSED_W = TOOLBAR_H; -const SPRING = { damping: 20, stiffness: 220, mass: 0.8 }; -const LONG_PRESS_MS = 1000; - -const cornerXY = (corner: Corner, width: number, cw: number, ch: number) => { - const x = corner.endsWith('left') ? SCREEN_MARGIN : cw - width - SCREEN_MARGIN; - const y = corner.startsWith('top') ? SCREEN_MARGIN : ch - TOOLBAR_H - SCREEN_MARGIN; - return { x, y }; -}; - -const SHADOW = { - shadowColor: '#1E1E21', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.1, - shadowRadius: 4.5, - elevation: 3, -}; - -export const ProblemDrawingToolbar = ({ - canUndo, - canRedo, - onUndo, - onRedo, - isEraserMode, - onPenModePress, - onEraserModePress, - collapsed, - onCollapsedChange, - containerWidth, - containerHeight, -}: ProblemDrawingToolbarProps) => { - const [corner, setCorner] = useState('bottom-left'); - const width = collapsed ? COLLAPSED_W : EXPANDED_W; - - const translateX = useSharedValue(SCREEN_MARGIN); - const translateY = useSharedValue(SCREEN_MARGIN); - const dragStartX = useSharedValue(0); - const dragStartY = useSharedValue(0); - const dragged = useSharedValue(false); - - useEffect(() => { - if (containerWidth <= 0 || containerHeight <= 0) return; - const { x, y } = cornerXY(corner, width, containerWidth, containerHeight); - translateX.value = withSpring(x, SPRING); - translateY.value = withSpring(y, SPRING); - }, [corner, width, containerWidth, containerHeight, translateX, translateY]); - - const snapToCorner = useCallback( - (x: number, y: number) => { - if (containerWidth <= 0 || containerHeight <= 0) return; - const cx = x + width / 2; - const cy = y + TOOLBAR_H / 2; - - const inLeft = cx < containerWidth / 3; - const inRight = cx > (containerWidth * 2) / 3; - const inTop = cy < containerHeight / 3; - const inBottom = cy > (containerHeight * 2) / 3; - - let next: Corner = corner; - if ((inLeft || inRight) && (inTop || inBottom)) { - const vertical = inTop ? 'top' : 'bottom'; - const horizontal = inLeft ? 'left' : 'right'; - next = `${vertical}-${horizontal}` as Corner; - } - - const { x: tx, y: ty } = cornerXY(next, width, containerWidth, containerHeight); - translateX.value = withSpring(tx, SPRING); - translateY.value = withSpring(ty, SPRING); - setCorner(next); - }, - [containerWidth, containerHeight, width, corner, translateX, translateY] - ); - - const panGesture = Gesture.Pan() - .activateAfterLongPress(LONG_PRESS_MS) - .onBegin(() => { - dragStartX.value = translateX.value; - dragStartY.value = translateY.value; - dragged.value = false; - }) - .onUpdate((e) => { - dragged.value = true; - translateX.value = dragStartX.value + e.translationX; - translateY.value = dragStartY.value + e.translationY; - }) - .onEnd(() => { - if (!dragged.value) return; - runOnJS(snapToCorner)(translateX.value, translateY.value); - }); - - const composedGesture = Gesture.Simultaneous(panGesture, Gesture.Native()); - - const animatedStyle = useAnimatedStyle(() => ({ - transform: [{ translateX: translateX.value }, { translateY: translateY.value }], - })); - - return ( - - - {collapsed ? ( - onCollapsedChange(false)} /> - ) : ( - - )} - - - ); -}; - -const ExpandedToolbar = ({ - canUndo, - canRedo, - onUndo, - onRedo, - isEraserMode, - onPenModePress, - onEraserModePress, -}: Pick< - ProblemDrawingToolbarProps, - | 'canUndo' - | 'canRedo' - | 'onUndo' - | 'onRedo' - | 'isEraserMode' - | 'onPenModePress' - | 'onEraserModePress' ->) => ( - - - } - isActive={canUndo} - /> - - } - isActive={canRedo} - /> - - - } - isActive={!isEraserMode} - /> - - } - isActive={isEraserMode} - /> - -); - -const CollapsedToolbar = ({ - isEraserMode, - onPress, -}: { - isEraserMode: boolean; - onPress: () => void; -}) => ( - - - {isEraserMode ? ( - - ) : ( - - )} - - -); - -const ToolbarButton = ({ - onPress, - disabled, - isActive, - activeBg, - icon, -}: { - onPress: () => void; - disabled?: boolean; - isActive?: boolean; - activeBg: string; - icon: React.ReactNode; -}) => ( - - {icon} - -); diff --git a/apps/native/src/features/student/problem/components/floating-toolbar/ProblemDrawingToolbar.tsx b/apps/native/src/features/student/problem/components/floating-toolbar/ProblemDrawingToolbar.tsx new file mode 100644 index 000000000..29a6ce935 --- /dev/null +++ b/apps/native/src/features/student/problem/components/floating-toolbar/ProblemDrawingToolbar.tsx @@ -0,0 +1,217 @@ +import { View } from 'react-native'; +import { GestureDetector } from 'react-native-gesture-handler'; +import Animated from 'react-native-reanimated'; +import { Undo2, Redo2 } from 'lucide-react-native'; + +import { AnimatedPressable } from '@components/common'; +import { EraserFilledIcon, PencilFilledIcon } from '@components/system/icons'; +import { colors } from '@theme/tokens'; + +import { + BUTTON_RADIUS, + BUTTON_SIZE, + COLLAPSED_RADIUS, + COLLAPSED_W, + type Corner, + DIVIDER_WIDTH, + EXPANDED_RADIUS, + GAP, + ICON_SIZE, + PADDING, + SHADOW, + TOOLBAR_H, + ToolbarButton, + ToolbarDivider, +} from './shared'; +import { useFloatingToolbarSnap } from './useFloatingToolbarSnap'; + +const EXPANDED_W = + PADDING + + BUTTON_SIZE + + GAP + + BUTTON_SIZE + + GAP + + DIVIDER_WIDTH + + GAP + + BUTTON_SIZE + + GAP + + BUTTON_SIZE + + PADDING; + +interface ProblemDrawingToolbarProps { + canUndo: boolean; + canRedo: boolean; + onUndo: () => void; + onRedo: () => void; + isEraserMode: boolean; + onPenModePress: () => void; + onEraserModePress: () => void; + collapsed: boolean; + onCollapsedChange: (collapsed: boolean) => void; + containerWidth: number; + containerHeight: number; + initialCorner?: Corner; +} + +export const ProblemDrawingToolbar = ({ + canUndo, + canRedo, + onUndo, + onRedo, + isEraserMode, + onPenModePress, + onEraserModePress, + collapsed, + onCollapsedChange, + containerWidth, + containerHeight, + initialCorner = 'bottom-left', +}: ProblemDrawingToolbarProps) => { + const toolbarWidth = collapsed ? COLLAPSED_W : EXPANDED_W; + const { composedGesture, animatedStyle, ready } = useFloatingToolbarSnap({ + containerWidth, + containerHeight, + toolbarWidth, + initialCorner, + }); + + return ( + + + {collapsed ? ( + onCollapsedChange(false)} /> + ) : ( + + )} + + + ); +}; + +const ExpandedToolbar = ({ + canUndo, + canRedo, + onUndo, + onRedo, + isEraserMode, + onPenModePress, + onEraserModePress, +}: Pick< + ProblemDrawingToolbarProps, + | 'canUndo' + | 'canRedo' + | 'onUndo' + | 'onRedo' + | 'isEraserMode' + | 'onPenModePress' + | 'onEraserModePress' +>) => ( + + + } + isActive={canUndo} + /> + + } + isActive={canRedo} + /> + + + } + isActive={!isEraserMode} + /> + + } + isActive={isEraserMode} + /> + +); + +const CollapsedToolbar = ({ + isEraserMode, + onPress, +}: { + isEraserMode: boolean; + onPress: () => void; +}) => ( + + + {isEraserMode ? ( + + ) : ( + + )} + + +); diff --git a/apps/native/src/features/student/problem/components/floating-toolbar/index.ts b/apps/native/src/features/student/problem/components/floating-toolbar/index.ts new file mode 100644 index 000000000..fc4e8576b --- /dev/null +++ b/apps/native/src/features/student/problem/components/floating-toolbar/index.ts @@ -0,0 +1 @@ +export { ProblemDrawingToolbar } from './ProblemDrawingToolbar'; diff --git a/apps/native/src/features/student/problem/components/floating-toolbar/shared.tsx b/apps/native/src/features/student/problem/components/floating-toolbar/shared.tsx new file mode 100644 index 000000000..aee68124a --- /dev/null +++ b/apps/native/src/features/student/problem/components/floating-toolbar/shared.tsx @@ -0,0 +1,107 @@ +import { View } from 'react-native'; + +import { AnimatedPressable } from '@components/common'; +import { colors } from '@theme/tokens'; + +export type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; + +export const BUTTON_SIZE = 42; +export const BUTTON_RADIUS = 12; +export const ICON_SIZE = 20; +export const PADDING = 10; +export const GAP = 10; +export const DIVIDER_WIDTH = 1; +export const SCREEN_MARGIN = 24; +export const EXPANDED_RADIUS = 24; +export const COLLAPSED_RADIUS = 20; +export const COLOR_BTN_SIZE = 28; +export const COLOR_CIRCLE_SIZE = 24; +export const COLOR_GRID_GAP = 4; +export const COLOR_GRID_W = COLOR_BTN_SIZE * 2 + COLOR_GRID_GAP; +export const TOOLBAR_H = PADDING + BUTTON_SIZE + PADDING; +export const COLLAPSED_W = TOOLBAR_H; +export const SPRING = { damping: 20, stiffness: 220, mass: 0.8 }; +export const LONG_PRESS_MS = 1000; + +export const SHADOW = { + shadowColor: '#1E1E21', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.1, + shadowRadius: 4.5, + elevation: 3, +} as const; + +export const cornerXY = (corner: Corner, width: number, cw: number, ch: number) => { + const x = corner.endsWith('left') ? SCREEN_MARGIN : cw - width - SCREEN_MARGIN; + const y = corner.startsWith('top') ? SCREEN_MARGIN : ch - TOOLBAR_H - SCREEN_MARGIN; + return { x, y }; +}; + +interface ToolbarButtonProps { + onPress: () => void; + disabled?: boolean; + isActive?: boolean; + activeBg: string; + icon: React.ReactNode; +} + +export const ToolbarButton = ({ + onPress, + disabled, + isActive, + activeBg, + icon, +}: ToolbarButtonProps) => ( + + {icon} + +); + +export const ToolbarDivider = () => ( + +); + +interface ColorSwatchProps { + color: string; + selected: boolean; + onPress: () => void; +} + +export const ColorSwatch = ({ color, selected, onPress }: ColorSwatchProps) => ( + + + +); diff --git a/apps/native/src/features/student/problem/components/floating-toolbar/useFloatingToolbarSnap.ts b/apps/native/src/features/student/problem/components/floating-toolbar/useFloatingToolbarSnap.ts new file mode 100644 index 000000000..b9bb9ebe5 --- /dev/null +++ b/apps/native/src/features/student/problem/components/floating-toolbar/useFloatingToolbarSnap.ts @@ -0,0 +1,86 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Gesture } from 'react-native-gesture-handler'; +import { runOnJS, useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated'; + +import { type Corner, LONG_PRESS_MS, SCREEN_MARGIN, SPRING, TOOLBAR_H, cornerXY } from './shared'; + +interface UseFloatingToolbarSnapOptions { + containerWidth: number; + containerHeight: number; + toolbarWidth: number; + initialCorner?: Corner; +} + +export function useFloatingToolbarSnap({ + containerWidth, + containerHeight, + toolbarWidth, + initialCorner = 'bottom-left', +}: UseFloatingToolbarSnapOptions) { + const [corner, setCorner] = useState(initialCorner); + + const translateX = useSharedValue(SCREEN_MARGIN); + const translateY = useSharedValue(SCREEN_MARGIN); + const dragStartX = useSharedValue(0); + const dragStartY = useSharedValue(0); + const dragged = useSharedValue(false); + + useEffect(() => { + if (containerWidth <= 0 || containerHeight <= 0) return; + const { x, y } = cornerXY(corner, toolbarWidth, containerWidth, containerHeight); + translateX.value = withSpring(x, SPRING); + translateY.value = withSpring(y, SPRING); + }, [corner, toolbarWidth, containerWidth, containerHeight, translateX, translateY]); + + const snapToCorner = useCallback( + (x: number, y: number) => { + if (containerWidth <= 0 || containerHeight <= 0) return; + const cx = x + toolbarWidth / 2; + const cy = y + TOOLBAR_H / 2; + + const inLeft = cx < containerWidth / 3; + const inRight = cx > (containerWidth * 2) / 3; + const inTop = cy < containerHeight / 3; + const inBottom = cy > (containerHeight * 2) / 3; + + let next: Corner = corner; + if ((inLeft || inRight) && (inTop || inBottom)) { + const vertical = inTop ? 'top' : 'bottom'; + const horizontal = inLeft ? 'left' : 'right'; + next = `${vertical}-${horizontal}` as Corner; + } + + const { x: tx, y: ty } = cornerXY(next, toolbarWidth, containerWidth, containerHeight); + translateX.value = withSpring(tx, SPRING); + translateY.value = withSpring(ty, SPRING); + setCorner(next); + }, + [containerWidth, containerHeight, toolbarWidth, corner, translateX, translateY] + ); + + const panGesture = Gesture.Pan() + .activateAfterLongPress(LONG_PRESS_MS) + .onBegin(() => { + dragStartX.value = translateX.value; + dragStartY.value = translateY.value; + dragged.value = false; + }) + .onUpdate((e) => { + dragged.value = true; + translateX.value = dragStartX.value + e.translationX; + translateY.value = dragStartY.value + e.translationY; + }) + .onEnd(() => { + if (!dragged.value) return; + runOnJS(snapToCorner)(translateX.value, translateY.value); + }); + + const composedGesture = Gesture.Simultaneous(panGesture, Gesture.Native()); + + const ready = containerWidth > 0 && containerHeight > 0; + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: translateX.value }, { translateY: translateY.value }], + })); + + return { composedGesture, animatedStyle, ready }; +} diff --git a/apps/native/src/features/student/problem/screens/ProblemScreen.tsx b/apps/native/src/features/student/problem/screens/ProblemScreen.tsx index 0cdcd40dd..863bdab83 100644 --- a/apps/native/src/features/student/problem/screens/ProblemScreen.tsx +++ b/apps/native/src/features/student/problem/screens/ProblemScreen.tsx @@ -40,7 +40,7 @@ import BottomActionBar from '../components/BottomActionBar'; import { buildDocumentInit } from '../transforms/contentRendererTransforms'; import { DrawingCanvas, type DrawingCanvasRef } from '../../scrap/utils/skia'; import { useDrawingState } from '../../scrap/hooks/useDrawingState'; -import { ProblemDrawingToolbar } from '../components/ProblemDrawingToolbar'; +import { ProblemDrawingToolbar } from '../components/floating-toolbar'; import { ConfirmationModal } from '../../scrap/components/Dialog'; type ProblemScreenProps = Partial>; From b41adb3f6c054bd90c90cb3b29121c587af80554 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Sun, 10 May 2026 23:26:02 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix(problem):=20MAT-585=20=E2=80=94=20copil?= =?UTF-8?q?ot=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useFloatingToolbarSnap: 첫 measure 시 isFirstMount ref 로 즉시 위치 set (spring 없이) → 좌상단→corner spring 비행 + opacity flash 둘 다 해결. - snapToCorner 분기: next === corner 면 직접 spring (effect 안 트리거되므로), next !== corner 면 setCorner 만 호출 (effect 가 spring 담당) → spring 중복 호출 제거. - ProblemDrawingToolbar: Animated.View 에 pointerEvents 게이트 추가 (measured 전 invisible 영역의 GestureDetector 가 터치 가로채지 않도록). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ProblemDrawingToolbar.tsx | 1 + .../useFloatingToolbarSnap.ts | 27 ++++++++++++++----- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/apps/native/src/features/student/problem/components/floating-toolbar/ProblemDrawingToolbar.tsx b/apps/native/src/features/student/problem/components/floating-toolbar/ProblemDrawingToolbar.tsx index 29a6ce935..fd890f4e9 100644 --- a/apps/native/src/features/student/problem/components/floating-toolbar/ProblemDrawingToolbar.tsx +++ b/apps/native/src/features/student/problem/components/floating-toolbar/ProblemDrawingToolbar.tsx @@ -78,6 +78,7 @@ export const ProblemDrawingToolbar = ({ return ( (initialCorner); + const isFirstMount = useRef(true); const translateX = useSharedValue(SCREEN_MARGIN); const translateY = useSharedValue(SCREEN_MARGIN); @@ -25,11 +26,18 @@ export function useFloatingToolbarSnap({ const dragStartY = useSharedValue(0); const dragged = useSharedValue(false); + // 첫 measure 시 즉시 위치 (좌상단 → corner spring 비행 방지). 이후 corner / size 변경 시에만 spring. useEffect(() => { if (containerWidth <= 0 || containerHeight <= 0) return; const { x, y } = cornerXY(corner, toolbarWidth, containerWidth, containerHeight); - translateX.value = withSpring(x, SPRING); - translateY.value = withSpring(y, SPRING); + if (isFirstMount.current) { + translateX.value = x; + translateY.value = y; + isFirstMount.current = false; + } else { + translateX.value = withSpring(x, SPRING); + translateY.value = withSpring(y, SPRING); + } }, [corner, toolbarWidth, containerWidth, containerHeight, translateX, translateY]); const snapToCorner = useCallback( @@ -50,10 +58,15 @@ export function useFloatingToolbarSnap({ next = `${vertical}-${horizontal}` as Corner; } - const { x: tx, y: ty } = cornerXY(next, toolbarWidth, containerWidth, containerHeight); - translateX.value = withSpring(tx, SPRING); - translateY.value = withSpring(ty, SPRING); - setCorner(next); + if (next === corner) { + // 같은 corner zone 으로 release: setCorner 가 effect 트리거 못 하므로 직접 spring 으로 복귀. + const { x: tx, y: ty } = cornerXY(next, toolbarWidth, containerWidth, containerHeight); + translateX.value = withSpring(tx, SPRING); + translateY.value = withSpring(ty, SPRING); + } else { + // 다른 corner: setCorner 만 호출 → effect 가 spring 담당 (중복 spring 방지). + setCorner(next); + } }, [containerWidth, containerHeight, toolbarWidth, corner, translateX, translateY] );