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] =?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,