diff --git a/src/components/board/Editor/index.tsx b/src/components/board/Editor/index.tsx index 954503e8..bc1ef049 100644 --- a/src/components/board/Editor/index.tsx +++ b/src/components/board/Editor/index.tsx @@ -50,7 +50,7 @@ export default function Editor({ initialContent }: EditorProps = {}) { if (!editor) return null; return ( -
+
{/* 숨겨진 파일 input — 슬래시 메뉴에서 각 ref를 통해 트리거 */} {item.uploaded === false && } diff --git a/src/components/board/PostEditorShell.tsx b/src/components/board/PostEditorShell.tsx index 22a0efdb..57de6ced 100644 --- a/src/components/board/PostEditorShell.tsx +++ b/src/components/board/PostEditorShell.tsx @@ -27,11 +27,14 @@ function PostEditorShell({ header, initialContent, align = 'start' }: PostEditor const files = usePostStore((s) => s.files); const snapshot = usePostStore((s) => s._snapshot); + // Tiptap은 빈 에디터에서도 '

' 등의 HTML을 반환하므로 태그를 제거 + const hasText = !!content.replace(/<[^>]*>/g, '').trim(); + const hasChanges = snapshot ? title !== snapshot.title || content !== snapshot.content || files.map((f) => f.id).join(',') !== snapshot.fileIds.join(',') - : title.length > 0 || content.length > 0 || files.length > 0; + : title.length > 0 || hasText || files.length > 0; const { open, onConfirm, onCancel, allowNavigation } = useNavigationGuard({ enabled: hasChanges, }); diff --git a/src/components/layout/header/PostingActions.tsx b/src/components/layout/header/PostingActions.tsx index 4f8e90a8..03da69d9 100644 --- a/src/components/layout/header/PostingActions.tsx +++ b/src/components/layout/header/PostingActions.tsx @@ -1,13 +1,12 @@ 'use client'; -import { usePathname, useRouter } from 'next/navigation'; +import { usePathname } from 'next/navigation'; import { Button, Icon } from '@/components/ui'; import { SendIcon } from '@/assets/icons'; import { useCreatePost, useUpdatePost } from '@/hooks'; function PostingActions() { - const router = useRouter(); const pathname = usePathname(); const editMatch = pathname.match(/^\/[^/]+\/board\/edit\/(\d+)$/); @@ -32,7 +31,7 @@ function PostingActions() { variant="secondary" size="md" className="typo-button1 text-text-strong px-4" - onClick={() => router.back()} + onClick={() => history.back()} > {isEditPage ? '수정 취소' : '작성 취소'} diff --git a/src/hooks/useCodeHighlight.ts b/src/hooks/useCodeHighlight.ts index a755c931..41c2452a 100644 --- a/src/hooks/useCodeHighlight.ts +++ b/src/hooks/useCodeHighlight.ts @@ -12,8 +12,18 @@ function useCodeHighlight(ref: RefObject, content: string) { const container = ref.current; if (!container) return; + container + .querySelectorAll('pre code[data-highlighted]') + .forEach((el) => el.removeAttribute('data-highlighted')); + const codeBlocks = container.querySelectorAll('pre code:not([data-highlighted])'); codeBlocks.forEach((codeEl) => { + // 이미 하이라이팅된 경우 skip + if (codeEl.querySelector('span')) return; + + // 이미 처리된 경우 skip + if (codeEl.hasAttribute('data-highlighted')) return; + const text = codeEl.textContent ?? ''; if (!text.trim()) return; @@ -21,12 +31,23 @@ function useCodeHighlight(ref: RefObject, content: string) { .find((cls) => cls.startsWith('language-')) ?.slice('language-'.length); - const result = - lang && lowlight.registered(lang) - ? lowlight.highlight(lang, text) - : lowlight.highlightAuto(text); + let result; + + try { + result = + lang && lowlight.registered(lang) + ? lowlight.highlight(lang, text) + : lowlight.highlightAuto(text); + } catch (e) { + console.error('highlight 실패:', e); + return; + } + + const html = toHtml(result); + + if (!html || html.trim() === '') return; - codeEl.innerHTML = toHtml(result); + codeEl.innerHTML = html; codeEl.setAttribute('data-highlighted', ''); }); }, [ref, content]); diff --git a/src/hooks/useNavigationGuard.ts b/src/hooks/useNavigationGuard.ts index 6ddf17ef..0176accf 100644 --- a/src/hooks/useNavigationGuard.ts +++ b/src/hooks/useNavigationGuard.ts @@ -23,10 +23,16 @@ function isGuardEntry() { function useNavigationGuard({ enabled }: UseNavigationGuardOptions) { const [open, setOpen] = useState(false); const router = useRouter(); + const enabledRef = useRef(enabled); const isLeaving = useRef(false); const guardUrl = useRef(''); const pendingUrl = useRef(null); const resetTimerId = useRef | null>(null); + const hasGuardEntry = useRef(false); + + useEffect(() => { + enabledRef.current = enabled; + }, [enabled]); // 네비게이션이 실제로 일어나지 않았을 경우 가드를 복원하는 안전장치 const scheduleGuardReset = (snapshot: string) => { @@ -38,40 +44,56 @@ function useNavigationGuard({ enabled }: UseNavigationGuardOptions) { guardUrl.current = location.href; if (!isGuardEntry()) { history.pushState(GUARD_STATE, '', location.href); + hasGuardEntry.current = true; } } }, NAVIGATION_TIMEOUT); }; + // enabled가 true가 될 때 guard entry를 push useEffect(() => { if (!enabled) { - if (isGuardEntry() && !isLeaving.current) { - history.back(); - } + hasGuardEntry.current = isGuardEntry(); return; } - - // allowNavigation()으로 이탈이 허용된 상태라면 guard를 재설정하지 않음 if (isLeaving.current) return; guardUrl.current = location.href; - if (!isGuardEntry()) { history.pushState(GUARD_STATE, '', location.href); } + hasGuardEntry.current = true; + }, [enabled]); + // 이벤트 리스너는 마운트 시 한 번만 등록하고, ref를 통해 최신 상태를 참조 + useEffect(() => { const handlePopState = () => { if (isLeaving.current) return; + // Next.js App Router는 pushState/replaceState 호출 시 popstate를 디스패치한다. + // guard entry 위에 있다면 우리 코드가 pushState한 것이므로 무시한다. + if (isGuardEntry()) return; + + // guard가 비활성 상태면 guard entry를 투명하게 건너뛴다 + if (!enabledRef.current) { + if (hasGuardEntry.current) { + hasGuardEntry.current = false; + history.back(); + } + return; + } + guardUrl.current = location.href; setOpen(true); }; const handleBeforeUnload = (e: BeforeUnloadEvent) => { + if (!enabledRef.current) return; e.preventDefault(); }; // 클릭을 캡처 단계에서 가로채서 Next.js Link 내비게이션을 차단 const handleClick = (e: MouseEvent) => { + if (!enabledRef.current) return; if (isLeaving.current) return; // ctrl/cmd/shift 등 새 탭/새 창 클릭은 무시 if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; @@ -106,10 +128,11 @@ function useNavigationGuard({ enabled }: UseNavigationGuardOptions) { resetTimerId.current = null; } }; - }, [enabled]); + }, []); const onConfirm = () => { isLeaving.current = true; + hasGuardEntry.current = false; setOpen(false); const snapshot = location.href; @@ -133,11 +156,16 @@ function useNavigationGuard({ enabled }: UseNavigationGuardOptions) { return; } setOpen(false); - history.pushState(GUARD_STATE, '', guardUrl.current); + // 뒤로가기로 guard entry를 벗어난 경우에만 재설정 + if (!isGuardEntry()) { + history.pushState(GUARD_STATE, '', guardUrl.current); + hasGuardEntry.current = true; + } }; const allowNavigation = () => { isLeaving.current = true; + hasGuardEntry.current = false; pendingUrl.current = null; scheduleGuardReset(location.href); };