From faa60fbdd991ca0ade242ed4bdda22b927a4a54f Mon Sep 17 00:00:00 2001 From: H-sooyeon Date: Sun, 21 Jun 2026 21:18:50 +0900 Subject: [PATCH 1/8] =?UTF-8?q?refactor:=20=EC=9E=84=EC=8B=9C=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=95=88=EB=82=B4=20=ED=86=A0=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B6=84=EB=8B=A8=EC=9C=84=20=EC=95=88=EB=82=B4=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=83=81=EB=8C=80=EC=A0=81=EC=9D=B8=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=EC=9C=BC=EB=A1=9C=20=ED=91=9C=EC=8B=9C=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95(#287)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존 5분 전, 1469분 전으로 표시되던 것을 하루전, 한 달 전 등으로 표현 --- .../src/app/(post)/_components/editor/RecordEditor.tsx | 4 ++-- frontend/src/lib/date.ts | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/(post)/_components/editor/RecordEditor.tsx b/frontend/src/app/(post)/_components/editor/RecordEditor.tsx index 96a3d3571..a5dc13647 100644 --- a/frontend/src/app/(post)/_components/editor/RecordEditor.tsx +++ b/frontend/src/app/(post)/_components/editor/RecordEditor.tsx @@ -71,6 +71,7 @@ import LocationDrawer from '@/components/map/LocationDrawer'; import { toast } from 'sonner'; import { groupDetailOptions } from '@/lib/api/group'; import { cn } from '@/lib/utils'; +import { formatRelativeTime } from '@/lib/date'; interface PostEditorProps { mode: 'add' | 'edit'; @@ -439,8 +440,7 @@ export default function PostEditor({ if (!draft) return; const savedDate = new Date(draft.savedAt); - const minutes = Math.floor((Date.now() - savedDate.getTime()) / 60_000); - const timeLabel = minutes < 1 ? '방금' : `${minutes}분 전`; + const timeLabel = formatRelativeTime(savedDate); toast(`${timeLabel} 작성하던 기록이 있어요`, { description: draft.title ? `"${draft.title}"` : '제목 없음', diff --git a/frontend/src/lib/date.ts b/frontend/src/lib/date.ts index dc8c1897b..a96ee45ea 100644 --- a/frontend/src/lib/date.ts +++ b/frontend/src/lib/date.ts @@ -63,7 +63,7 @@ export function formatTimeStr(timeStr: string): string { /** * 날짜를 상대적인 시간으로 표시 * @param date - 비교할 날짜 - * @returns "방금 전", "5분 전", "어제", "3일 전" 등의 문자열 + * @returns "방금 전", "5분 전", "하루 전", "3일 전" 등의 문자열 * @example * formatRelativeTime(new Date()) // "방금 전" * formatRelativeTime(new Date(Date.now() - 1000 * 60 * 5)) // "5분 전" @@ -82,9 +82,11 @@ export function formatRelativeTime(date: Date): string { if (seconds < 60) return '방금 전'; if (minutes < 60) return `${minutes}분 전`; if (hours < 24) return `${hours}시간 전`; - if (days === 1) return '어제'; + if (days === 1) return '하루 전'; if (days < 30) return `${days}일 전`; - if (months < 12) return `${months}개월 전`; + if (months === 1) return '한 달 전'; + if (months === 2) return '두 달 전'; + if (months < 12) return `${months}달 전`; return `${years}년 전`; } From 25e33b37cbfe4ad32f8bce150c2c282b98f1f21c Mon Sep 17 00:00:00 2001 From: H-sooyeon Date: Thu, 25 Jun 2026 17:06:10 +0900 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20=EA=B3=B5=EB=8F=99=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=EC=97=90=EC=84=9C=20=EB=9D=BD=20=EB=AF=B8=EB=B3=B4?= =?UTF-8?q?=EC=9C=A0=20=EC=83=81=ED=83=9C=EB=A1=9C=20=EB=B8=94=EB=A1=9D=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=8B=9C=20=EB=B0=9C=EC=83=9D=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EC=9C=84=EC=B9=98=20=EC=B6=A9=EB=8F=8C=20=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20=EC=88=98=EC=A0=95(#287)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 블록 삭제를 낙관적(즉시 로컬 삭제)에서 비관적 처리로 전환 - 텍스트 블록 등에서 포커스가 풀려 락이 해제된 뒤 삭제를 시도하면 백엔드는 락 미보유로 삭제를 거부하지만 프론트는 이를 모르고 로컬에서 먼저 지워버려 블록 목록이 어긋나고 저장 시 다른 블록과 위치가 겹치면 문제 해결 - BLOCK_DELETE 전송 후 PATCH_COMMITTED로 실제 커밋을 확인한 다음에만 로컬에서 블록 제거, 8초 이내 확인이 없으면 자동 복구 - 삭제 대기 중인 블록은 반투명 처리 + 클릭 차단 --- .../_components/editor/RecordEditor.tsx | 13 +- .../app/(post)/_hooks/usePostEditorBlocks.ts | 145 +++++++++++++++--- 2 files changed, 131 insertions(+), 27 deletions(-) diff --git a/frontend/src/app/(post)/_components/editor/RecordEditor.tsx b/frontend/src/app/(post)/_components/editor/RecordEditor.tsx index a5dc13647..b88aba492 100644 --- a/frontend/src/app/(post)/_components/editor/RecordEditor.tsx +++ b/frontend/src/app/(post)/_components/editor/RecordEditor.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useState, useRef, useCallback, memo, useMemo } from 'react'; -import { GripVertical, User } from 'lucide-react'; +import { GripVertical, Loader2, User } from 'lucide-react'; // 컴포넌트 및 필드 임포트 import RecordEditorHeader from './RecordEditorHeader'; @@ -86,6 +86,7 @@ interface PostEditorProps { const BlockItem = memo(function BlockItem({ block, isDraggingId, + isDeleting, locks, mySessionId, members, @@ -100,6 +101,7 @@ const BlockItem = memo(function BlockItem({ }: { block: RecordBlock; isDraggingId: string | null; + isDeleting: boolean; locks: Record; mySessionId: string | null; members: PresenceMember[]; @@ -159,8 +161,13 @@ const BlockItem = memo(function BlockItem({ data-block-id={block.id} onPointerUp={handleDragEnd} onPointerCancel={handleDragEnd} - className={`cursor-grab relative group/field select-none ${isDraggingId ? 'touch-none' : 'touch-auto'} ${block.layout.span === 1 ? 'col-span-1' : 'col-span-2'} ${isDraggingId === block.id ? 'opacity-20 scale-95' : 'opacity-100'} ${!isDraggingId ? 'transition-all duration-300' : ''}`} + className={`cursor-grab relative group/field select-none ${isDraggingId ? 'touch-none' : 'touch-auto'} ${block.layout.span === 1 ? 'col-span-1' : 'col-span-2'} ${isDraggingId === block.id ? 'opacity-20 scale-95' : isDeleting ? 'opacity-40 pointer-events-none' : 'opacity-100'} ${!isDraggingId ? 'transition-all duration-300' : ''}`} > + {isDeleting && ( +
+ +
+ )}
(null); + const { socket } = useSocketStore(); + + // 삭제 요청을 보냈지만 백엔드 커밋 확인을 기다리는 중인 블록 id 집합. + // (비관적 삭제: 커밋 확인 전까지 블록을 화면에 "삭제 중" 상태로 남겨둔다) + const [deletingBlockIds, setDeletingBlockIds] = useState>( + new Set(), + ); + const deleteTimeoutsRef = useRef>(new Map()); + + const finalizeBlockDeletion = useCallback((id: string) => { + const timeout = deleteTimeoutsRef.current.get(id); + if (timeout) { + clearTimeout(timeout); + deleteTimeoutsRef.current.delete(id); + } + setDeletingBlockIds((prev) => { + if (!prev.has(id)) return prev; + const next = new Set(prev); + next.delete(id); + return next; + }); + }, []); + + // 내가 보낸 BLOCK_DELETE가 실제로 커밋됐는지 PATCH_COMMITTED로 확인한다. + // (락을 보유하지 않은 채 삭제를 시도하면 백엔드가 거부할 수 있는데, + // 이 경우 PATCH_COMMITTED가 오지 않으므로 아래 타임아웃에서 원상복구된다) + useEffect(() => { + if (!socket || !draftId) return; + + const handlePatchCommitted = ({ + patch, + }: { + patch: PatchApplyPayload | PatchApplyPayload[]; + }) => { + const commands = Array.isArray(patch) ? patch : [patch]; + commands.forEach((cmd) => { + if (cmd.type !== 'BLOCK_DELETE') return; + if (!deleteTimeoutsRef.current.has(cmd.blockId)) return; + + finalizeBlockDeletion(cmd.blockId); + setBlocks((prev) => + normalizeLayout(prev.filter((b) => b.id !== cmd.blockId)), + ); + }); + }; + + socket.on('PATCH_COMMITTED', handlePatchCommitted); + return () => { + socket.off('PATCH_COMMITTED', handlePatchCommitted); + }; + }, [socket, draftId, finalizeBlockDeletion, setBlocks]); + + // 언마운트 시 남아있는 삭제 대기 타임아웃 정리 + useEffect(() => { + return () => { + deleteTimeoutsRef.current.forEach((timeout) => clearTimeout(timeout)); + deleteTimeoutsRef.current.clear(); + }; + }, []); + // EXIF 메타데이터 상태 const [pendingMetadata, setPendingMetadata] = useState<{ images: ImageWithMetadata[]; @@ -79,9 +144,6 @@ export function usePostEditorBlocks({ }; } | null>(null); - // TODO: 추후 고려해볼 사항 - // 타입이 필드면 락 걸고 삭제하고 락 풀고 - // 타입이 드로어라면, 바로 삭제만하고(삭제전에 락 검증해야할까?) const removeBlock = useCallback( (id: string) => { // 블록 타입 확인하여 메타데이터 적용 상태 업데이트 @@ -117,31 +179,60 @@ export function usePostEditorBlocks({ } } - if (draftId) { - const lockKey = `block:${id}`; - const ownerSessionId = locks?.[lockKey]; - const isLockedByOther = !!ownerSessionId && ownerSessionId !== mySessionId; + // 공동작성 중이 아니면(draftId 없음) 락 개념이 없으므로 즉시 로컬 삭제 + if (!draftId) { + setBlocks((prev) => normalizeLayout(prev.filter((b) => b.id !== id))); + return; + } - // 다른 사용자가 편집 중인 블록은 삭제 차단 - if (isLockedByOther) { - toast.error('다른 사용자가 편집 중인 필드는 삭제할 수 없습니다.', { - id: `delete-locked-${lockKey}`, - }); - return; - } + if (deletingBlockIds.has(id)) return; // 이미 삭제 요청 중 + + const lockKey = `block:${id}`; + const ownerSessionId = locks?.[lockKey]; + const isLockedByOther = + !!ownerSessionId && ownerSessionId !== mySessionId; - applyPatch({ - type: 'BLOCK_DELETE', - blockId: id, + // 다른 사용자가 편집 중인 블록은 삭제 차단 + if (isLockedByOther) { + toast.error('다른 사용자가 편집 중인 필드는 삭제할 수 없습니다.', { + id: `delete-locked-${lockKey}`, }); - // 내가 락을 쥐고 있었다면 해제 - if (ownerSessionId === mySessionId) { - releaseLock(lockKey); - } + return; } - // 로컬 상태 반영 및 레이아웃 정규화 - setBlocks((prev) => normalizeLayout(prev.filter((b) => b.id !== id))); + // 비관적 삭제: 락을 보유하지 않은 채로 곧바로 로컬에서 지워버리면, + // 백엔드가 락 없음을 이유로 삭제를 거부했을 때 프론트와 백엔드의 블록 목록이 + // 어긋나 저장 시 같은 위치에 두 블록이 겹치는 문제가 있었음. + // PATCH_COMMITTED로 삭제가 커밋된 걸 확인하기 전까지는 "삭제 중" 상태로만 표시한다. + setDeletingBlockIds((prev) => { + const next = new Set(prev); + next.add(id); + return next; + }); + + applyPatch({ + type: 'BLOCK_DELETE', + blockId: id, + }); + // 내가 락을 쥐고 있었다면 해제 + if (ownerSessionId === mySessionId) { + releaseLock(lockKey); + } + + // 타임아웃 내에 PATCH_COMMITTED가 오지 않으면(백엔드 거부, 응답 지연 등) 원상복구 + const timeout = setTimeout(() => { + deleteTimeoutsRef.current.delete(id); + setDeletingBlockIds((prev) => { + if (!prev.has(id)) return prev; + const next = new Set(prev); + next.delete(id); + return next; + }); + toast.error('삭제에 실패했습니다. 다시 시도해 주세요.', { + id: `delete-failed-${id}`, + }); + }, DELETE_CONFIRM_TIMEOUT_MS); + deleteTimeoutsRef.current.set(id, timeout); }, [ blocks, @@ -152,6 +243,7 @@ export function usePostEditorBlocks({ setBlocks, locks, mySessionId, + deletingBlockIds, ], ); @@ -298,7 +390,9 @@ export function usePostEditorBlocks({ // 타인이 락을 쥐고 있다면 동작 차단 if (isLockedByOther) { - toast.error('현재 다른 사용자가 편집 중입니다.', { id: `locked-${lockKey}` }); + toast.error('현재 다른 사용자가 편집 중입니다.', { + id: `locked-${lockKey}`, + }); return; } } @@ -509,6 +603,7 @@ export function usePostEditorBlocks({ handleDone, addOrShowBlock, removeBlock, + deletingBlockIds, handleApplyTemplate, applyPendingMetadata, handleEditMetadata, From 9e9c55561f9117375ce61adc3a4dc1df52936bdd Mon Sep 17 00:00:00 2001 From: H-sooyeon Date: Mon, 29 Jun 2026 17:10:42 +0900 Subject: [PATCH 3/8] =?UTF-8?q?refactor:=20iOS=20=EC=82=AC=ED=8C=8C?= =?UTF-8?q?=EB=A6=AC=20=ED=96=85=ED=8B=B1=20=EB=B0=98=EC=9D=91=EC=9D=84=20?= =?UTF-8?q?switch=20=ED=83=80=EC=9E=85=20=EC=B2=B4=ED=81=AC=EB=B0=95?= =?UTF-8?q?=EC=8A=A4=EB=A1=9C=20=EC=88=98=EC=A0=95(#287)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/hooks/useHaptic.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/hooks/useHaptic.ts b/frontend/src/hooks/useHaptic.ts index 17e26feca..bbfb9833e 100644 --- a/frontend/src/hooks/useHaptic.ts +++ b/frontend/src/hooks/useHaptic.ts @@ -14,6 +14,8 @@ export function useHaptic() { const input = document.createElement('input'); input.type = 'checkbox'; input.id = '__haptic_trigger'; + // iOS 18+ WebKit이 switch 타입 체크박스를 토글할 때만 햅틱을 발생시킴 + input.setAttribute('switch', ''); input.style.cssText = 'position:fixed;opacity:0;pointer-events:none;width:0;height:0'; @@ -32,7 +34,7 @@ export function useHaptic() { (duration = 30) => { try { if (isIOS()) { - // iOS: hidden checkbox toggle → Taptic Engine 발동 + // iOS: hidden switch checkbox toggle → Taptic Engine 발동 getLabelEl()?.click(); } else if (navigator.vibrate) { navigator.vibrate(duration); From d62f0f1a18eb9c5d4651ac301c40f328bd4156da Mon Sep 17 00:00:00 2001 From: H-sooyeon Date: Mon, 6 Jul 2026 14:52:52 +0900 Subject: [PATCH 4/8] =?UTF-8?q?refactor:=20iPad=20=ED=99=98=EA=B2=BD?= =?UTF-8?q?=EC=9D=98=20useAgent=EC=97=90=20=EB=A7=9E=EC=B6=B0=20=EC=88=98?= =?UTF-8?q?=EC=A0=95(#287)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/hooks/useHaptic.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/frontend/src/hooks/useHaptic.ts b/frontend/src/hooks/useHaptic.ts index bbfb9833e..594c83cd2 100644 --- a/frontend/src/hooks/useHaptic.ts +++ b/frontend/src/hooks/useHaptic.ts @@ -1,8 +1,13 @@ import { useCallback, useRef } from 'react'; -const isIOS = () => - typeof navigator !== 'undefined' && - /iPad|iPhone|iPod/.test(navigator.userAgent); +const isIOS = () => { + if (typeof navigator === 'undefined') return false; + const iOSDevice = /iPad|iPhone|iPod/.test(navigator.userAgent); + // iPadOS 13+는 userAgent에 "iPad"가 없고 MacIntel로 표시됨 + const iPadOS = + navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1; + return iOSDevice || iPadOS; +}; export function useHaptic() { const labelElRef = useRef(null); From 27f8d3fcdaa751a19710a598c36d66b5befa537d Mon Sep 17 00:00:00 2001 From: H-sooyeon Date: Mon, 6 Jul 2026 18:22:26 +0900 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20=EA=B8=B0=EB=A1=9D=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=A7=80=EC=97=B0=EC=8B=9C=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=ED=86=A0=EC=8A=A4=ED=8A=B8=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95(#287)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 10초 안전망 타이머 발동시의 isSaving false 갱신 제거 - toast.error를 toast.info로 변경해, 실제 실패가 아닌 지연 상황에서 에러 메시지를 표시해 사용자에게 혼란을 주던 문제 수정 --- .../app/(post)/_components/editor/RecordEditor.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/app/(post)/_components/editor/RecordEditor.tsx b/frontend/src/app/(post)/_components/editor/RecordEditor.tsx index b88aba492..c7302b817 100644 --- a/frontend/src/app/(post)/_components/editor/RecordEditor.tsx +++ b/frontend/src/app/(post)/_components/editor/RecordEditor.tsx @@ -294,7 +294,10 @@ export default function PostEditor({ setIsSaving(false); resetNavigatingToRecord(); }, - onSuccess: isPersonalNew ? clearDraftAndPhotos : undefined, + onSuccess: () => { + setIsSaving(false); + if (isPersonalNew) clearDraftAndPhotos(); + }, }); // 네비게이션이 실패하거나 느린 경우 로딩 화면이 무한히 뜨는 것을 방지하는 안전망 @@ -304,14 +307,11 @@ export default function PostEditor({ return () => clearTimeout(timer); }, [isPublishing, setIsPublishing]); - // 개인 기록 저장 응답이 너무 오래 걸릴 경우 로딩 화면 해제 및 알림 + // 저장 응답이 오래 걸릴 경우 지연 안내 (로딩 화면은 유지) useEffect(() => { if (!isSaving) return; const timer = setTimeout(() => { - setIsSaving(false); - toast.error( - '네트워크 지연 또는 기록 저장에 실패했습니다. 다시 시도해 주세요.', - ); + toast.info('저장이 지연되고 있습니다. 잠시 후 자동으로 완료됩니다.'); }, 10_000); return () => clearTimeout(timer); }, [isSaving]); From e978b09bf6519d8ba7ec0fec73694ae5a8ba1ceb Mon Sep 17 00:00:00 2001 From: H-sooyeon Date: Mon, 6 Jul 2026 19:10:26 +0900 Subject: [PATCH 6/8] =?UTF-8?q?refactor:=20=EC=97=90=EB=94=94=ED=84=B0?= =?UTF-8?q?=EC=9D=98=20=EB=B8=94=EB=A1=9D=20=EC=9D=B4=EB=8F=99=EC=8B=9C=20?= =?UTF-8?q?=EB=A7=88=EC=9A=B0=EC=8A=A4=20=EB=B0=8F=20=ED=8A=B8=EB=9E=99?= =?UTF-8?q?=ED=8C=A8=EB=93=9C=20=EC=9E=85=EB=A0=A5=EB=A7=8C=20=EB=A1=B1?= =?UTF-8?q?=ED=94=84=EB=A0=88=EC=8A=A4=EA=B0=80=20=EC=95=84=EB=8B=8C=20?= =?UTF-8?q?=EB=A7=88=EC=9A=B0=EC=8A=A4=20=EB=B0=A9=EC=8B=9D=20=EB=93=9C?= =?UTF-8?q?=EB=9E=98=EA=B7=B8=EB=A1=9C=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EB=B3=80=EA=B2=BD=20=ED=8A=B8=EB=A6=AC=EA=B1=B0?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95(#287)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/(post)/_hooks/useRecordEditorDnD.ts | 54 +++++++++++++++---- 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/frontend/src/app/(post)/_hooks/useRecordEditorDnD.ts b/frontend/src/app/(post)/_hooks/useRecordEditorDnD.ts index a6530ccde..093408771 100644 --- a/frontend/src/app/(post)/_hooks/useRecordEditorDnD.ts +++ b/frontend/src/app/(post)/_hooks/useRecordEditorDnD.ts @@ -7,6 +7,7 @@ import { useHaptic } from '@/hooks/useHaptic'; const LONG_PRESS_DURATION = 300; // ms — 이 시간 이상 누르고 있어야 드래그 시작 const SCROLL_CANCEL_THRESHOLD = 6; // px — 롱프레스 대기 중 이 이상 움직이면 스크롤로 판단해 취소 +const MOUSE_DRAG_THRESHOLD = 6; // px — 마우스(데스크탑) 드래그 시작 이동 거리 임계값 export const useRecordEditorDnD = ( blocks: RecordBlock[], @@ -30,7 +31,7 @@ export const useRecordEditorDnD = ( blockId: string; startX: number; startY: number; - timer: ReturnType; + timer: ReturnType | null; } | null>(null); // touch 이벤트 기반 롱프레스 대기 (textarea/input — iOS pointercancel 우회) @@ -62,6 +63,19 @@ export const useRecordEditorDnD = ( const blockId = blockEl.getAttribute('data-block-id'); if (!blockId) return; + // 마우스(데스크탑): 타이머 없이 펜딩만 설정 — pointermove에서 움직임 기준으로 드래그 시작 + if (e.pointerType === 'mouse') { + pendingDragRef.current = { + pointerId: e.pointerId, + blockId, + startX: e.clientX, + startY: e.clientY, + timer: null, + }; + return; + } + + // 터치/펜: 기존 롱프레스 방식 const timer = setTimeout(() => { if (pendingDragRef.current?.blockId === blockId) { // 롱프레스 완료 → 드래그 활성화 @@ -264,12 +278,34 @@ export const useRecordEditorDnD = ( ) { const dx = e.clientX - pendingDragRef.current.startX; const dy = e.clientY - pendingDragRef.current.startY; - // 롱프레스 대기 중 스크롤 의도 감지 → 타이머 취소 - if ( - dx * dx + dy * dy > - SCROLL_CANCEL_THRESHOLD * SCROLL_CANCEL_THRESHOLD - ) { - clearTimeout(pendingDragRef.current.timer); + const distSq = dx * dx + dy * dy; + + if (e.pointerType === 'mouse') { + // 마우스(데스크탑): 움직임 임계값 초과 시 즉시 드래그 활성화 + if (distSq > MOUSE_DRAG_THRESHOLD * MOUSE_DRAG_THRESHOLD) { + const { blockId } = pendingDragRef.current; + const el = document.querySelector( + `[data-block-id="${blockId}"]`, + ) as HTMLElement | null; + if (el) { + try { + el.setPointerCapture(e.pointerId); + } catch {} + pointerIdRef.current = e.pointerId; + capturedElementRef.current = el; + } + isPointerDraggingRef.current = true; + isDraggingIdRef.current = blockId; + setIsDraggingId(blockId); + pendingDragRef.current = null; + } + return; + } + + // 터치/펜: 롱프레스 대기 중 스크롤 의도 감지 → 타이머 취소 + if (distSq > SCROLL_CANCEL_THRESHOLD * SCROLL_CANCEL_THRESHOLD) { + if (pendingDragRef.current.timer) + clearTimeout(pendingDragRef.current.timer); pendingDragRef.current = null; } return; @@ -317,7 +353,7 @@ export const useRecordEditorDnD = ( pendingDragRef.current && e.pointerId === pendingDragRef.current.pointerId ) { - clearTimeout(pendingDragRef.current.timer); + clearTimeout(pendingDragRef.current.timer ?? undefined); pendingDragRef.current = null; } }; @@ -357,7 +393,7 @@ export const useRecordEditorDnD = ( const handleDragEnd = useCallback(() => { if (pendingDragRef.current) { - clearTimeout(pendingDragRef.current.timer); + clearTimeout(pendingDragRef.current.timer ?? undefined); pendingDragRef.current = null; } if (pendingTouchDragRef.current) { From 3ba1d30f50b88efb5becf59ad72f2def111aa129 Mon Sep 17 00:00:00 2001 From: H-sooyeon Date: Mon, 13 Jul 2026 13:46:51 +0900 Subject: [PATCH 7/8] =?UTF-8?q?fix:=20INVALIDE=5FAUTH=5FCODE=EB=A5=BC=20TE?= =?UTF-8?q?RMINAL=5FAUTH=5FERRORS=EC=97=90=20=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /auth/exchange가 반환하는 INVALID_AUTH_CODE가 프론트 errorHandler에 분류돼 있지 않은 문제 - 해당 코드가 어디에도 분류돼 있지 않아 공용 API 클라이언트 401 처리 로직에서 복구 불가능한 인증 에러로 인식되지 못하고 일반 에러로 새어나갈 수 있는 가능성 존재 - 공용 클라이언트를 타는 다른 경로에서 발생할 경우를 대비해 안정성 있게 등록 --- frontend/src/lib/utils/errorHandler.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/lib/utils/errorHandler.ts b/frontend/src/lib/utils/errorHandler.ts index 30389789f..68d6ae814 100644 --- a/frontend/src/lib/utils/errorHandler.ts +++ b/frontend/src/lib/utils/errorHandler.ts @@ -19,6 +19,7 @@ export const ERROR_CODES = { SESSION_INVALID: 'SESSION_INVALID', UNAUTHORIZED: 'UNAUTHORIZED', NETWORK_ERROR: 'NETWORK_ERROR', + INVALID_AUTH_CODE: 'INVALID_AUTH_CODE', } as const; const REFRESHABLE_AUTH_ERRORS: string[] = [ @@ -34,6 +35,7 @@ const TERMINAL_AUTH_ERRORS: string[] = [ ERROR_CODES.REFRESH_TOKEN_REUSE_DETECTED, ERROR_CODES.REFRESH_TOKEN_EXPIRED, ERROR_CODES.SESSION_INVALID, + ERROR_CODES.INVALID_AUTH_CODE, ]; /** From 3b8d8cdcb112362df182c1c99516ae1091355e7e Mon Sep 17 00:00:00 2001 From: H-sooyeon Date: Mon, 13 Jul 2026 13:52:00 +0900 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20=EA=B7=B8=EB=A3=B9=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EB=B0=9C=ED=96=89=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C?= =?UTF-8?q?=20=EC=95=8C=EC=88=98=20=EC=97=86=EB=8A=94=20=EC=97=90=EB=9F=AC?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=EB=8A=94=20=EC=A1=B0=EC=9A=A9=ED=9E=88=20?= =?UTF-8?q?=EB=AC=B4=EC=8B=9C=EB=90=98=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/lib/utils/error/publishHandler.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/src/lib/utils/error/publishHandler.ts b/frontend/src/lib/utils/error/publishHandler.ts index dbcd078ad..b7f9a734d 100644 --- a/frontend/src/lib/utils/error/publishHandler.ts +++ b/frontend/src/lib/utils/error/publishHandler.ts @@ -36,5 +36,9 @@ export const handlePublishError = ( if (handler) { handler(); + } else { + // 알려지지 않은 에러 코드(네트워크 오류, 예기치 못한 서버 오류 등)도 + // 사용자에게 실패를 알려야 로딩 화면만 조용히 사라지는 상황을 막을 수 있다. + toast.error('기록 저장에 실패했습니다. 다시 시도해 주세요.'); } };