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 01b52cf62..000000000 --- a/apps/native/src/features/student/problem/components/ProblemDrawingToolbar.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React from 'react'; -import { View } from 'react-native'; -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; -} - -export const ProblemDrawingToolbar = ({ - canUndo, - canRedo, - onUndo, - onRedo, - isEraserMode, - onPenModePress, - onEraserModePress, -}: ProblemDrawingToolbarProps) => { - return ( - - {/* Undo/Redo 그룹 */} - - {/* Undo 버튼 */} - - - - - {/* Redo 버튼 */} - - - - - - {/* 구분선 */} - - - {/* Pencil/Eraser 그룹 */} - - {/* Eraser 버튼 */} - - - - - {/* Pencil 버튼 */} - - - - - - ); -}; 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..fd890f4e9 --- /dev/null +++ b/apps/native/src/features/student/problem/components/floating-toolbar/ProblemDrawingToolbar.tsx @@ -0,0 +1,218 @@ +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..406cbdc75 --- /dev/null +++ b/apps/native/src/features/student/problem/components/floating-toolbar/useFloatingToolbarSnap.ts @@ -0,0 +1,99 @@ +import { useCallback, useEffect, useRef, 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 isFirstMount = useRef(true); + + const translateX = useSharedValue(SCREEN_MARGIN); + const translateY = useSharedValue(SCREEN_MARGIN); + const dragStartX = useSharedValue(0); + 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); + 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( + (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; + } + + 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] + ); + + 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 ea3ff47ca..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>; @@ -473,6 +473,27 @@ 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 +507,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,