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
8 changes: 7 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,17 @@ import router from './routes';
import { QueryClientProvider } from '@tanstack/react-query';
import queryClient from './utils/queryClient.ts';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { ToastProvider } from './components/Toast/ToastProvider.tsx';
import ToastViewport from './components/Toast/ToastViewport.tsx';

function App() {
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<ToastProvider>
<RouterProvider router={router} />
<ToastViewport />
</ToastProvider>

{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
</QueryClientProvider>
);
Expand Down
74 changes: 65 additions & 9 deletions src/components/DetailView/DetailTitle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,23 @@
// 상세페이지 제목 컴포넌트

import { useEffect, useRef } from 'react';
import { useToast } from '../Toast/ToastProvider';

interface DetailTitleProps {
defaultTitle: string;
title: string;
setTitle: (value: string) => void;

isEditable: boolean; // true일 때만 상세 설명 내용 입력 가능함
}

const MAX_TITLE = 20; // 최대 제목 글자 수

const DetailTitle = ({ defaultTitle, title, setTitle, isEditable }: DetailTitleProps) => {
const textarea = useRef<HTMLTextAreaElement>(null);
const committedLenRef = useRef(title.length); // 마지막으로 커밋된(클램프 반영된) 길이
const warnedRef = useRef(false); // 이번 글자수 초과 구간에서 토스트를 이미 띄웠는지 확인
const composingRef = useRef(false); // 입력 방식 편집기(IME) 사용 중인지
const { showToast } = useToast();

const handleResizeHeight = () => {
if (textarea.current) {
Expand All @@ -21,23 +27,71 @@ const DetailTitle = ({ defaultTitle, title, setTitle, isEditable }: DetailTitleP
}
};

// 입력 시
const clamp = (s: string) => (s.length > MAX_TITLE ? s.slice(0, MAX_TITLE) : s);

// 길이 상태 평가 + (필요 시) 토스트 1회
const evaluateLimitAndMaybeToast = (nextRaw: string) => {
const isOver = nextRaw.length > MAX_TITLE;
const wasUnderOrEqual = committedLenRef.current <= MAX_TITLE;

// 20자 이하로 돌아오면 다음 초과 때 다시 토스트 허용
if (!isOver) warnedRef.current = false;

if (wasUnderOrEqual && isOver && !warnedRef.current) {
showToast({
contents: '최대 20자까지 작성할 수 있습니다.',
key: 'titleMax', // 중복 합치기
});
warnedRef.current = true;
}
};

// onChange: 모든 입력 변경(키보드, 붙여넣기, 마우스 등)에서 호출됨
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setTitle(e.target.value);
const nextRaw = e.target.value;

// IME 조합 중에는 토스트/클램프를 지연하고, 조합 종료에서 한 번에 처리
if (composingRef.current) {
setTitle(nextRaw);
handleResizeHeight();
return;
}

// 조합이 아니면 즉시 검사 및 토스트
evaluateLimitAndMaybeToast(nextRaw);

// 조합이 아니면 즉시 평가 -> 토스트 -> 클램프 -> 커밋
evaluateLimitAndMaybeToast(nextRaw);
const next = clamp(nextRaw);
setTitle(next);
committedLenRef.current = next.length;
handleResizeHeight();
};

// IME 조합 시작/종료
const handleCompositionStart = () => {
composingRef.current = true;
};
const handleCompositionEnd = (e: React.CompositionEvent<HTMLTextAreaElement>) => {
composingRef.current = false;
const nextRaw = e.currentTarget.value;

// 조합 종료 시 한 번만 평가 -> 토스트 -> 클램프 -> 커밋
evaluateLimitAndMaybeToast(nextRaw);
const next = clamp(nextRaw);
setTitle(next);
committedLenRef.current = next.length;
handleResizeHeight();
};

// 초기 mount 및 title 업데이트 시 제목 textarea 높이 조절되게
useEffect(() => {
handleResizeHeight();
}, [title]);

// 뷰포트 사이즈 변경 시에도 제목 textarea 높이 재조정
useEffect(() => {
window.addEventListener('resize', handleResizeHeight);
return () => {
window.removeEventListener('resize', handleResizeHeight);
};
const r = () => handleResizeHeight();
window.addEventListener('resize', r);
return () => window.removeEventListener('resize', r);
}, []);

return (
Expand All @@ -48,6 +102,8 @@ const DetailTitle = ({ defaultTitle, title, setTitle, isEditable }: DetailTitleP
value={title}
rows={1}
onChange={handleChange}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault(); // 엔터 키를 눌러 직접 줄바꿈하는 것 방지
Expand Down
230 changes: 230 additions & 0 deletions src/components/Toast/ToastProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';

export const MAX_VISIBLE = 3; // 한번에 보일 최대 토스트 수
export const DEFAULT_DURATION = 2000; // 자동 닫힘 시간(ms)
export const ANIMATION_DURATION = 400; // 입/퇴장 애니메이션 시간(ms)

export type ShowToastArguments = {
title?: string;
contents: ReactNode;
key?: string; // 같은 key면 기존 토스트 갱신 + 타이머 리셋
duration?: number; // 자동 닫힘 시간
};

export type ToastItem = Required<Pick<ShowToastArguments, 'title' | 'contents'>> & {
id: string; // 전역 고유 id (randomUUID 기반)
key?: string;
duration: number;
closing?: boolean; // 내려가는 애니메이션 중인지
};

type ToastCtx = {
visible: ToastItem[]; // 현재 화면에 있는 토스트들
showToast: (args: ShowToastArguments) => void;
dismissToast: (id: string) => void;
};

const ToastContext = createContext<ToastCtx | null>(null);

export const useToast = () => {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast는 <ToastProvider> 내에서 사용되어야 합니다.');
return ctx;
};

// 전역 고유 id 생성기
const nextToastId = (): string => {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID();
}
return `t_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
};

// 각 토스트별 타이머(닫힘 트리거/제거 타이머)를 관리
type Timers = { closeTimer?: number; removeTimer?: number };

export const ToastProvider = ({ children }: { children: ReactNode }) => {
const [visible, setVisible] = useState<ToastItem[]>([]);
const [, setQueue] = useState<ToastItem[]>([]); // 후순위 대기열(읽지 않아도 됨)
const timersRef = useRef<Map<string, Timers>>(new Map());

const clearTimers = useCallback((id: string) => {
const t = timersRef.current.get(id);
if (!t) return;
if (t.closeTimer) clearTimeout(t.closeTimer);
if (t.removeTimer) clearTimeout(t.removeTimer);
timersRef.current.delete(id);
}, []);

// duration 이후 closing=true, 이후 ANIMATION_DURATION 지나면 실제 제거 + 큐 승격
const startTimers = useCallback(
(toast: ToastItem) => {
clearTimers(toast.id);

const closeTimer = window.setTimeout(() => {
// 내려가는 애니메이션 시작
setVisible((prev) => prev.map((t) => (t.id === toast.id ? { ...t, closing: true } : t)));

const removeTimer = window.setTimeout(() => {
// 화면에서 제거
setVisible((prev) => prev.filter((t) => t.id !== toast.id));
timersRef.current.delete(toast.id);

// 큐 승격
setQueue((q) => {
if (q.length === 0) return q;
const [next, ...rest] = q;
setVisible((prev) => {
// 이미 들어있으면 중복 추가 방지
if (prev.some((p) => p.id === next.id)) return prev;
startTimers(next);
return [...prev, next];
});
return rest;
});
}, ANIMATION_DURATION);

const prev = timersRef.current.get(toast.id) ?? {};
timersRef.current.set(toast.id, { ...prev, removeTimer });
}, toast.duration);

const prev = timersRef.current.get(toast.id) ?? {};
timersRef.current.set(toast.id, { ...prev, closeTimer });
},
[clearTimers]
);

const dismissToast = useCallback(
(id: string) => {
// 즉시 closing -> ANIMATION_DURATION 뒤 제거
clearTimers(id);

// 이미 closing이면 중복 처리 방지
setVisible((prev) => {
const target = prev.find((t) => t.id === id);
if (!target) return prev;
if (target.closing) return prev;
return prev.map((t) => (t.id === id ? { ...t, closing: true } : t));
});

const removeTimer = window.setTimeout(() => {
setVisible((prev) => prev.filter((t) => t.id !== id));
timersRef.current.delete(id);

// 큐 승격
setQueue((q) => {
if (q.length === 0) return q;
const [next, ...rest] = q;
setVisible((prev) => {
if (prev.some((p) => p.id === next.id)) return prev;
if (prev.length >= MAX_VISIBLE) return prev;
startTimers(next);
return [...prev, next];
});
return rest;
});
}, ANIMATION_DURATION);

const prev = timersRef.current.get(id) ?? {};
timersRef.current.set(id, { ...prev, removeTimer });
},
[clearTimers, startTimers]
);

const showToast = useCallback(
(args: ShowToastArguments) => {
const item: ToastItem = {
id: nextToastId(),
title: args.title ?? '알림',
contents: args.contents,
key: args.key,
duration: args.duration ?? DEFAULT_DURATION,
closing: false,
};

// 1) key 중복합치기 (visible/queue)
if (item.key) {
let updated = false;

setVisible((prev) => {
const idx = prev.findIndex((t) => t.key === item.key);
if (idx >= 0) {
const old = prev[idx];
clearTimers(old.id);
const merged: ToastItem = {
...old,
title: item.title,
contents: item.contents,
duration: item.duration,
closing: false, // 재활성화
};
startTimers(merged);
const clone = prev.slice();
clone[idx] = merged;
updated = true;
return clone;
}
return prev;
});

if (!updated) {
setQueue((prev) => {
const idx = prev.findIndex((t) => t.key === item.key);
if (idx >= 0) {
const clone = prev.slice();
clone[idx] = {
...clone[idx],
title: item.title,
contents: item.contents,
duration: item.duration,
};
updated = true;
return clone;
}
return prev;
});
}

if (updated) return;
}

// 2) 빈 자리면 visible + 타이머 시작, 아니면 큐 쌓기
setVisible((prev) => {
if (prev.length < MAX_VISIBLE) {
startTimers(item);
return [...prev, item];
}
setQueue((q) => [...q, item]);
return prev;
});
},
[startTimers, clearTimers]
);

// 언마운트 시 모든 타이머 정리
useEffect(() => {
return () => {
timersRef.current.forEach(({ closeTimer, removeTimer }) => {
if (closeTimer) clearTimeout(closeTimer);
if (removeTimer) clearTimeout(removeTimer);
});
timersRef.current.clear();
};
}, []);

const ctx = useMemo(
() => ({ visible, showToast, dismissToast }),
[visible, showToast, dismissToast]
);

return <ToastContext.Provider value={ctx}>{children}</ToastContext.Provider>;
};
Loading