Skip to content
Merged
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
2 changes: 1 addition & 1 deletion src/components/board/Editor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export default function Editor({ initialContent }: EditorProps = {}) {
if (!editor) return null;

return (
<div ref={containerRef} className="relative flex min-h-[400px] w-full flex-col">
<div ref={containerRef} className="relative flex min-h-[400px] w-full flex-col overflow-hidden">
{/* 숨겨진 파일 input — 슬래시 메뉴에서 각 ref를 통해 트리거 */}
<input
ref={imageInputRef}
Expand Down
6 changes: 1 addition & 5 deletions src/components/board/ImageList/ImageCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,7 @@ function ImageCard({ item, className, imgClassName, removable, onRemove }: Image
src={item.fileUrl}
alt={item.fileName}
draggable={false}
className={cn(
'h-full w-full object-cover',
item.uploaded === false && 'opacity-50',
imgClassName,
)}
className={cn('object-cover', item.uploaded === false && 'opacity-50', imgClassName)}
/>

{item.uploaded === false && <LoadingOverlay />}
Expand Down
5 changes: 4 additions & 1 deletion src/components/board/PostEditorShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,14 @@ function PostEditorShell({ header, initialContent, align = 'start' }: PostEditor
const files = usePostStore((s) => s.files);
const snapshot = usePostStore((s) => s._snapshot);

// Tiptap은 빈 에디터에서도 '<p></p>' 등의 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,
});
Expand Down
5 changes: 2 additions & 3 deletions src/components/layout/header/PostingActions.tsx
Original file line number Diff line number Diff line change
@@ -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+)$/);
Expand All @@ -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 ? '수정 취소' : '작성 취소'}
</Button>
Expand Down
31 changes: 26 additions & 5 deletions src/hooks/useCodeHighlight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,42 @@ function useCodeHighlight(ref: RefObject<HTMLElement | null>, 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;

const lang = [...codeEl.classList]
.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]);
Expand Down
44 changes: 36 additions & 8 deletions src/hooks/useNavigationGuard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null);
const resetTimerId = useRef<ReturnType<typeof setTimeout> | null>(null);
const hasGuardEntry = useRef(false);

useEffect(() => {
enabledRef.current = enabled;
}, [enabled]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 네비게이션이 실제로 일어나지 않았을 경우 가드를 복원하는 안전장치
const scheduleGuardReset = (snapshot: string) => {
Expand All @@ -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();
};

// <a> 클릭을 캡처 단계에서 가로채서 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;
Expand Down Expand Up @@ -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;
Expand All @@ -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);
};
Expand Down
Loading