Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
312 changes: 190 additions & 122 deletions apps/native/src/features/student/scrap/hooks/useHandwritingManager.ts
Original file line number Diff line number Diff line change
@@ -1,154 +1,222 @@
import { useEffect, useCallback, useRef } from 'react';
import { Alert, AppState, type AppStateStatus } from 'react-native';
import { useCallback, useEffect, useRef, useState } from 'react';
import { AppState, type AppStateStatus } from 'react-native';

import { useGetHandwriting, useUpdateHandwriting } from '@/apis';

import { type DrawingCanvasRef } from '../utils/skia/drawing';
import { encodeHandwritingData, decodeHandwritingData } from '../utils/handwritingEncoder';
import { showToast } from '../components/Notification/Toast';
import { type DrawingCanvasRef, type Stroke, type TextItem } from '../utils/skia/drawing';
import { decodeHandwritingData, encodeHandwritingData } from '../utils/handwritingEncoder';

const AUTOSAVE_INTERVAL_MS = 5000;
const FLUSH_TIMEOUT_MS = 5000;

type FlushDecision =
| 'allow'
| 'no-needs-save'
| 'pending-load'
| 'decode-error'
| 'scrap-id-mismatch'
| 'no-canvas';

type FlushContext = {
needsSave: boolean;
pendingLoad: boolean;
decodeError: string | null;
appliedScrapId: number | null;
currentScrapId: number;
hasCanvas: boolean;
};

type MarkContext = {
pendingLoad: boolean;
appliedScrapId: number | null;
currentScrapId: number;
decodeError: string | null;
};

function evaluateFlush(ctx: FlushContext): FlushDecision {
if (!ctx.needsSave) return 'no-needs-save';
if (ctx.pendingLoad) return 'pending-load';
if (ctx.decodeError) return 'decode-error';
if (ctx.appliedScrapId !== ctx.currentScrapId) return 'scrap-id-mismatch';
if (!ctx.hasCanvas) return 'no-canvas';
return 'allow';
}

function canMark(ctx: MarkContext): boolean {
if (ctx.pendingLoad) return false;
if (ctx.appliedScrapId !== ctx.currentScrapId) return false;
if (ctx.decodeError) return false;
return true;
}

export interface UseHandwritingManagerProps {
scrapId: number;
canvasRef: React.RefObject<DrawingCanvasRef | null>;
hasUnsavedChanges: boolean;
onSaveSuccess?: () => void;
onSaveError?: () => void;
}

export function useHandwritingManager({
scrapId,
canvasRef,
hasUnsavedChanges,
onSaveSuccess,
onSaveError,
}: UseHandwritingManagerProps) {
export function useHandwritingManager({ scrapId }: UseHandwritingManagerProps) {
const { data: handwritingData, isLoading } = useGetHandwriting(scrapId, !!scrapId);
const { mutate: updateHandwriting, isPending: isSaving } = useUpdateHandwriting();
const lastSavedDataRef = useRef<string>('');
const currentScrapIdRef = useRef<number>(scrapId);

// scrapId가 변경되면 lastSavedDataRef 초기화
// canvas 인스턴스는 ref 로, mount 사실은 boolean state 로 분리.
// useImperativeHandle (drawing.tsx) 가 deps 없이 매 render 마다 새 object
// 로 ref 를 업데이트하므로 object 자체를 useState 에 담으면 매 render 마다
// setState → 무한 루프. boolean 은 같은 값일 때 React 가 dedupe → 안전.
const canvasRef = useRef<DrawingCanvasRef | null>(null);
const [canvasMounted, setCanvasMounted] = useState(false);
const setCanvasRef = useCallback((node: DrawingCanvasRef | null) => {
canvasRef.current = node;
setCanvasMounted(node !== null);
}, []);

const updateMutation = useUpdateHandwriting();

const needsSaveRef = useRef(false);
const pendingLoadRef = useRef(false);
const appliedScrapIdRef = useRef<number | null>(null);
const [decodeError, setDecodeError] = useState<string | null>(null);

// decodeError / updateMutation 을 ref 로 미러링 — 콜백 deps 에서 제외해
// 매 render 마다 새 reference 가 만들어지는 걸 막음 (DrawingCanvas 의
// notifyHistoryChange 가 prop 변경에 따라 새 reference 가 되어 캔버스 내부
// useEffect 가 재발화 → onHistoryChange 콜백 호출 → setState → 무한 루프).
const decodeErrorRef = useRef<string | null>(null);
decodeErrorRef.current = decodeError;
const updateMutationRef = useRef(updateMutation);
updateMutationRef.current = updateMutation;

const applyData = useCallback(
(strokes: Stroke[], texts: TextItem[]) => {
const c = canvasRef.current;
if (!c) return;
pendingLoadRef.current = true;
try {
c.setStrokes(strokes);
c.setTexts(texts);
} finally {
pendingLoadRef.current = false;
}
appliedScrapIdRef.current = scrapId;
needsSaveRef.current = false;
},
[scrapId]
);

useEffect(() => {
if (currentScrapIdRef.current !== scrapId) {
lastSavedDataRef.current = '';
currentScrapIdRef.current = scrapId;
appliedScrapIdRef.current = null;
needsSaveRef.current = false;
setDecodeError(null);
pendingLoadRef.current = true;
try {
canvasRef.current?.clear();
} finally {
pendingLoadRef.current = false;
}
}, [scrapId]);

// 필기 데이터 로드
useEffect(() => {
// 저장 중이 아니고, scrapId가 일치할 때만 로드 (데이터 유실 방지)
if (
handwritingData?.data &&
canvasRef.current &&
currentScrapIdRef.current === scrapId &&
!isSaving
) {
// clear() 완료를 보장하기 위해 약간의 지연 후 로드
const loadTimer = setTimeout(() => {
// 다시 한번 scrapId 확인 (clear() 실행 중일 수 있음)
if (currentScrapIdRef.current === scrapId && canvasRef.current && !isSaving) {
try {
const decodedData = decodeHandwritingData(handwritingData.data);
canvasRef.current.setStrokes(decodedData.strokes);
canvasRef.current.setTexts(decodedData.texts);
lastSavedDataRef.current = handwritingData.data;
} catch (error) {
console.error('필기 데이터 로드 실패:', error);
}
}
}, 50); // 50ms 지연으로 clear() 완료 보장

return () => clearTimeout(loadTimer);
if (handwritingData === undefined) return;
if (appliedScrapIdRef.current === scrapId) return;
if (!canvasMounted) return;
try {
const decoded = handwritingData?.data
? decodeHandwritingData(handwritingData.data)
: { strokes: [] as Stroke[], texts: [] as TextItem[] };
applyData(decoded.strokes, decoded.texts);
} catch (e) {
console.error('[handwriting] decode failed', e);
setDecodeError('필기를 불러오지 못했어요.');
}
}, [handwritingData, canvasRef, scrapId]);
}, [handwritingData, scrapId, applyData, canvasMounted]);

// 저장하기 함수
const handleSave = useCallback(
(isAutoSave = false, targetScrapId?: number) => {
if (!canvasRef.current) return Promise.resolve(false);
const markNeedsSave = useCallback(() => {
if (
!canMark({
pendingLoad: pendingLoadRef.current,
appliedScrapId: appliedScrapIdRef.current,
currentScrapId: scrapId,
decodeError: decodeErrorRef.current,
})
)
return;
needsSaveRef.current = true;
}, [scrapId]);

// 이미 저장 중이면 중복 저장 방지
if (isSaving) {
return Promise.resolve(false);
const flushFireAndForget = useCallback(() => {
const c = canvasRef.current;
const decision = evaluateFlush({
needsSave: needsSaveRef.current,
pendingLoad: pendingLoadRef.current,
decodeError: decodeErrorRef.current,
appliedScrapId: appliedScrapIdRef.current,
currentScrapId: scrapId,
hasCanvas: !!c,
});
if (decision !== 'allow' || !c) return;
const data = encodeHandwritingData(c.getStrokes() ?? [], c.getTexts() ?? []);
needsSaveRef.current = false;
updateMutationRef.current.mutate(
{ scrapId, request: { data } },
{
// autosave 는 silent — 토스트 없이 다음 interval 에 재시도
onError: () => {
needsSaveRef.current = true;
},
}
);
}, [scrapId]);

const strokes = canvasRef.current.getStrokes();
const texts = canvasRef.current.getTexts();
const flushPending = useCallback(async (): Promise<void> => {
const c = canvasRef.current;
const decision = evaluateFlush({
needsSave: needsSaveRef.current,
pendingLoad: pendingLoadRef.current,
decodeError: decodeErrorRef.current,
appliedScrapId: appliedScrapIdRef.current,
currentScrapId: scrapId,
hasCanvas: !!c,
});
if (decision !== 'allow' || !c) return;

const data = encodeHandwritingData(c.getStrokes() ?? [], c.getTexts() ?? []);
needsSaveRef.current = false;

try {
await Promise.race([
updateMutationRef.current.mutateAsync({ scrapId, request: { data } }),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('flush-timeout')), FLUSH_TIMEOUT_MS)
),
]);
} catch {
needsSaveRef.current = true;
showToast('error', '저장에 실패했어요');
}
}, [scrapId]);

try {
const base64Data = encodeHandwritingData(strokes || [], texts || []);

// 변경사항 없으면 저장 안 함
if (base64Data === lastSavedDataRef.current) {
if (!isAutoSave) {
Alert.alert('알림', '변경사항이 없습니다.');
}
return Promise.resolve(true);
}

// targetScrapId가 제공되면 그것을 사용, 아니면 scrapId 사용
const saveScrapId = targetScrapId ?? scrapId;

return new Promise<boolean>((resolve) => {
updateHandwriting(
{
scrapId: saveScrapId,
request: { data: base64Data },
},
{
onSuccess: () => {
lastSavedDataRef.current = base64Data;
onSaveSuccess?.();
if (!isAutoSave) {
Alert.alert('성공', '필기가 저장되었습니다.');
}
resolve(true);
},
onError: (error) => {
console.error('필기 저장 실패:', error);
onSaveError?.();
if (!isAutoSave) {
Alert.alert('오류', '필기 저장에 실패했습니다.');
}
resolve(false);
},
}
);
});
} catch (error) {
console.error('필기 데이터 변환 실패:', error);
if (!isAutoSave) {
Alert.alert('오류', '필기 데이터 변환에 실패했습니다.');
}
return Promise.resolve(false);
}
},
[scrapId, canvasRef, updateHandwriting, onSaveSuccess, onSaveError, isSaving]
);
const flushFireAndForgetRef = useRef(flushFireAndForget);
flushFireAndForgetRef.current = flushFireAndForget;

// 5초마다 자동 저장
useEffect(() => {
const autoSaveInterval = setInterval(() => {
if (hasUnsavedChanges && !isSaving) {
handleSave(true);
}
}, 5000); // 5초마다 실행
const id = setInterval(() => flushFireAndForgetRef.current(), AUTOSAVE_INTERVAL_MS);
return () => clearInterval(id);
}, []);

return () => clearInterval(autoSaveInterval);
}, [hasUnsavedChanges, isSaving, handleSave]);
const flushPendingRef = useRef(flushPending);
flushPendingRef.current = flushPending;

useEffect(() => {
const subscription = AppState.addEventListener('change', (nextState: AppStateStatus) => {
if (nextState === 'background' && hasUnsavedChanges && !isSaving) {
handleSave(true);
}
const sub = AppState.addEventListener('change', (next: AppStateStatus) => {
if (next === 'background') void flushPendingRef.current();
});
return () => subscription.remove();
}, [hasUnsavedChanges, isSaving, handleSave]);
return () => sub.remove();
}, []);

return {
isLoading,
isSaving,
handleSave,
decodeError,
markNeedsSave,
flushPending,
setCanvasRef,
canvasRef,
};
}
Loading