[RAPID] 메신저 화면 캡처 기능 구현
- modern-screenshot 패키지 추가 - ScreenCapture: 전체화면 오버레이 + 드래그 영역 선택 + DOM 캡처 - MessengerModal: 캡처 버튼(Scissors) 추가, 캡처 중 모달 숨김 - MessageInput: forwardRef로 addFiles 메서드 외부 노출 - ChatPanel: messageInputRef prop 추가 및 전달 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,28 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { MessageSquare, Pencil, Check, X } from "lucide-react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useMessages, useMarkAsRead, useUpdateRoom } from "@/hooks/useMessenger";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useMessengerContext } from "@/contexts/MessengerContext";
|
||||
import { useMessengerSocket } from "@/hooks/useMessengerSocket";
|
||||
import { MessageItem } from "./MessageItem";
|
||||
import { MessageInput } from "./MessageInput";
|
||||
import type { MessageInputHandle } from "./MessageInput";
|
||||
import type { Room } from "@/hooks/useMessenger";
|
||||
|
||||
interface ChatPanelProps {
|
||||
room: Room | null;
|
||||
messageInputRef?: React.RefObject<MessageInputHandle | null>;
|
||||
}
|
||||
|
||||
export function ChatPanel({ room }: ChatPanelProps) {
|
||||
export function ChatPanel({ room, messageInputRef }: ChatPanelProps) {
|
||||
const { user } = useAuth();
|
||||
const { selectedRoomId } = useMessengerContext();
|
||||
const { data: messages } = useMessages(selectedRoomId);
|
||||
const markAsRead = useMarkAsRead();
|
||||
const updateRoom = useUpdateRoom();
|
||||
const { emitTypingStart, emitTypingStop, typingUsers } = useMessengerSocket();
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
const [editName, setEditName] = useState("");
|
||||
const editInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -33,9 +34,12 @@ export function ChatPanel({ room }: ChatPanelProps) {
|
||||
}
|
||||
}, [selectedRoomId, messages?.length]);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages?.length]);
|
||||
const lastMessageId = messages?.[messages.length - 1]?.id;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [lastMessageId]);
|
||||
|
||||
if (!room) {
|
||||
return (
|
||||
@@ -48,12 +52,25 @@ export function ChatPanel({ room }: ChatPanelProps) {
|
||||
|
||||
const roomTyping = selectedRoomId ? typingUsers.get(selectedRoomId) : undefined;
|
||||
|
||||
// Group consecutive messages from same sender
|
||||
const isSameGroup = (idx: number) => {
|
||||
if (idx === 0) return false;
|
||||
// First message in a time group (shows avatar + name)
|
||||
const isFirstInGroup = (idx: number) => {
|
||||
if (idx === 0) return true;
|
||||
const prev = messages![idx - 1];
|
||||
const curr = messages![idx];
|
||||
return prev.senderId === curr.senderId && !curr.isDeleted && !prev.isDeleted;
|
||||
if (prev.senderId !== curr.senderId || curr.isDeleted || prev.isDeleted) return true;
|
||||
const gap = new Date(curr.createdAt).getTime() - new Date(prev.createdAt).getTime();
|
||||
return gap > 5 * 60 * 1000;
|
||||
};
|
||||
|
||||
const isLastInGroup = (idx: number) => {
|
||||
if (!messages) return true;
|
||||
if (idx === messages.length - 1) return true;
|
||||
const curr = messages[idx];
|
||||
const next = messages[idx + 1];
|
||||
if (curr.senderId !== next.senderId || next.isDeleted || curr.isDeleted) return true;
|
||||
// Gap > 5 minutes → new time group
|
||||
const gap = new Date(next.createdAt).getTime() - new Date(curr.createdAt).getTime();
|
||||
return gap > 5 * 60 * 1000;
|
||||
};
|
||||
|
||||
// Date separator helper
|
||||
@@ -79,7 +96,7 @@ export function ChatPanel({ room }: ChatPanelProps) {
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className="flex-1 flex flex-col min-w-0 min-h-0 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="border-b px-4 py-2 flex items-center gap-2">
|
||||
{isEditingName ? (
|
||||
@@ -125,8 +142,8 @@ export function ChatPanel({ room }: ChatPanelProps) {
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="py-2">
|
||||
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="pt-2">
|
||||
{messages?.map((msg, idx) => (
|
||||
<div key={msg.id}>
|
||||
{shouldShowDate(idx) && (
|
||||
@@ -146,23 +163,20 @@ export function ChatPanel({ room }: ChatPanelProps) {
|
||||
<MessageItem
|
||||
message={msg}
|
||||
isOwn={msg.senderId === user?.userId}
|
||||
showAvatar={!isSameGroup(idx)}
|
||||
showAvatar={isFirstInGroup(idx)}
|
||||
isLastInGroup={isLastInGroup(idx)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
<div className="px-4 h-5 flex items-center text-xs text-muted-foreground">
|
||||
{roomTyping && roomTyping.length > 0 ? `${roomTyping.join(", ")}님이 입력 중...` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Typing indicator */}
|
||||
{roomTyping && roomTyping.length > 0 && (
|
||||
<div className="px-4 py-1 text-xs text-muted-foreground">
|
||||
{roomTyping.join(", ")}님이 입력 중...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<MessageInput
|
||||
ref={messageInputRef}
|
||||
roomId={room.id}
|
||||
onTypingStart={() => emitTypingStart(room.id)}
|
||||
onTypingStop={() => emitTypingStop(room.id)}
|
||||
|
||||
@@ -1,20 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback, useEffect, KeyboardEvent, ChangeEvent } from "react";
|
||||
import { Paperclip, Send, SmilePlus } from "lucide-react";
|
||||
import { useState, useRef, useCallback, useEffect, forwardRef, useImperativeHandle, KeyboardEvent, ChangeEvent } from "react";
|
||||
import { Paperclip, Send, SmilePlus, X, FileIcon } from "lucide-react";
|
||||
import { useSendMessage, useUploadFile, useCompanyUsers } from "@/hooks/useMessenger";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const QUICK_EMOJIS = ["\u{1F44D}", "\u{2764}\u{FE0F}", "\u{1F602}", "\u{1F44F}", "\u{1F64F}", "\u{1F525}", "\u{1F389}", "\u{1F914}"];
|
||||
|
||||
interface PendingFile {
|
||||
file: File;
|
||||
previewUrl: string | null; // object URL for images, null for others
|
||||
}
|
||||
|
||||
export interface MessageInputHandle {
|
||||
addFiles: (files: File[]) => void;
|
||||
}
|
||||
|
||||
interface MessageInputProps {
|
||||
roomId: string;
|
||||
onTypingStart?: () => void;
|
||||
onTypingStop?: () => void;
|
||||
}
|
||||
|
||||
export function MessageInput({ roomId, onTypingStart, onTypingStop }: MessageInputProps) {
|
||||
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(
|
||||
function MessageInput({ roomId, onTypingStart, onTypingStop }: MessageInputProps, ref) {
|
||||
const [text, setText] = useState("");
|
||||
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
|
||||
const [showEmoji, setShowEmoji] = useState(false);
|
||||
const emojiRef = useRef<HTMLDivElement>(null);
|
||||
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
|
||||
const [mentionIndex, setMentionIndex] = useState(0);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -29,6 +42,13 @@ export function MessageInput({ roomId, onTypingStart, onTypingStop }: MessageInp
|
||||
? users.filter((u) => u.userName.toLowerCase().includes(mentionQuery.toLowerCase())).slice(0, 5)
|
||||
: [];
|
||||
|
||||
// Revoke object URLs when pending files change or component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
pendingFiles.forEach((pf) => { if (pf.previewUrl) URL.revokeObjectURL(pf.previewUrl); });
|
||||
};
|
||||
}, [pendingFiles]);
|
||||
|
||||
const adjustHeight = useCallback(() => {
|
||||
const el = textareaRef.current;
|
||||
if (el) {
|
||||
@@ -37,49 +57,85 @@ export function MessageInput({ roomId, onTypingStart, onTypingStop }: MessageInp
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
adjustHeight();
|
||||
}, [text, adjustHeight]);
|
||||
useEffect(() => { adjustHeight(); }, [text, adjustHeight]);
|
||||
|
||||
// Auto-focus when room changes
|
||||
useEffect(() => {
|
||||
if (roomId) {
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
if (roomId) textareaRef.current?.focus();
|
||||
}, [roomId]);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
// Close emoji picker on outside click
|
||||
useEffect(() => {
|
||||
if (!showEmoji) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (emojiRef.current && !emojiRef.current.contains(e.target as Node)) setShowEmoji(false);
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [showEmoji]);
|
||||
|
||||
const BLOCKED_TYPES = /^(audio|video)\//;
|
||||
|
||||
const addFiles = (files: FileList | File[]) => {
|
||||
const arr = Array.from(files).filter((f) => {
|
||||
if (BLOCKED_TYPES.test(f.type)) {
|
||||
toast(`${f.name} — 음원/동영상 파일은 전송할 수 없습니다.`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (arr.length === 0) return;
|
||||
setPendingFiles((prev) => [
|
||||
...prev,
|
||||
...arr.map((file) => ({
|
||||
file,
|
||||
previewUrl: file.type.startsWith("image/") ? URL.createObjectURL(file) : null,
|
||||
})),
|
||||
]);
|
||||
textareaRef.current?.focus();
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({ addFiles }));
|
||||
|
||||
const removePendingFile = (idx: number) => {
|
||||
setPendingFiles((prev) => {
|
||||
const pf = prev[idx];
|
||||
if (pf.previewUrl) URL.revokeObjectURL(pf.previewUrl);
|
||||
return prev.filter((_, i) => i !== idx);
|
||||
});
|
||||
};
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
sendMessage.mutate({ roomId, content: trimmed });
|
||||
setText("");
|
||||
if (!trimmed && pendingFiles.length === 0) return;
|
||||
|
||||
// Upload all pending files (backend creates a message per file)
|
||||
for (const pf of pendingFiles) {
|
||||
try {
|
||||
await uploadFile.mutateAsync({ file: pf.file, roomId });
|
||||
if (pf.previewUrl) URL.revokeObjectURL(pf.previewUrl);
|
||||
} catch {
|
||||
// upload failed silently
|
||||
}
|
||||
}
|
||||
setPendingFiles([]);
|
||||
|
||||
// Send text message if any
|
||||
if (trimmed) {
|
||||
sendMessage.mutate({ roomId, content: trimmed });
|
||||
setText("");
|
||||
}
|
||||
|
||||
onTypingStop?.();
|
||||
}, [text, roomId, sendMessage, onTypingStop]);
|
||||
}, [text, pendingFiles, roomId, uploadFile, sendMessage, onTypingStop]);
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (mentionQuery !== null && filteredMentionUsers.length > 0) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setMentionIndex((p) => Math.min(p + 1, filteredMentionUsers.length - 1));
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setMentionIndex((p) => Math.max(p - 1, 0));
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
insertMention(filteredMentionUsers[mentionIndex]);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setMentionQuery(null);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowDown") { e.preventDefault(); setMentionIndex((p) => Math.min(p + 1, filteredMentionUsers.length - 1)); return; }
|
||||
if (e.key === "ArrowUp") { e.preventDefault(); setMentionIndex((p) => Math.max(p - 1, 0)); return; }
|
||||
if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); insertMention(filteredMentionUsers[mentionIndex]); return; }
|
||||
if (e.key === "Escape") { setMentionQuery(null); return; }
|
||||
}
|
||||
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
@@ -91,53 +147,49 @@ export function MessageInput({ roomId, onTypingStart, onTypingStop }: MessageInp
|
||||
const val = el.value;
|
||||
const atIdx = val.lastIndexOf("@", el.selectionStart - 1);
|
||||
if (atIdx === -1) return;
|
||||
const before = val.slice(0, atIdx);
|
||||
const after = val.slice(el.selectionStart);
|
||||
setText(`${before}@${user.userName} ${after}`);
|
||||
setText(`${val.slice(0, atIdx)}@${user.userName} ${val.slice(el.selectionStart)}`);
|
||||
setMentionQuery(null);
|
||||
};
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const val = e.target.value;
|
||||
setText(val);
|
||||
|
||||
// Typing events
|
||||
onTypingStart?.();
|
||||
if (typingTimerRef.current) clearTimeout(typingTimerRef.current);
|
||||
typingTimerRef.current = setTimeout(() => onTypingStop?.(), 2000);
|
||||
|
||||
// Mention detection
|
||||
if (val.trim()) { onTypingStart?.(); } else { onTypingStop?.(); }
|
||||
const cursor = e.target.selectionStart;
|
||||
const textBeforeCursor = val.slice(0, cursor);
|
||||
const atMatch = textBeforeCursor.match(/@(\S*)$/);
|
||||
if (atMatch) {
|
||||
setMentionQuery(atMatch[1]);
|
||||
setMentionIndex(0);
|
||||
} else {
|
||||
setMentionQuery(null);
|
||||
}
|
||||
const atMatch = val.slice(0, cursor).match(/@(\S*)$/);
|
||||
if (atMatch) { setMentionQuery(atMatch[1]); setMentionIndex(0); } else { setMentionQuery(null); }
|
||||
};
|
||||
|
||||
const handleFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const result = await uploadFile.mutateAsync(file);
|
||||
sendMessage.mutate({
|
||||
roomId,
|
||||
content: file.name,
|
||||
type: "file",
|
||||
});
|
||||
} catch {
|
||||
// upload failed silently
|
||||
}
|
||||
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files?.length) addFiles(e.target.files);
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer.files?.length) addFiles(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.clipboardData.files?.length) {
|
||||
e.preventDefault();
|
||||
addFiles(e.clipboardData.files);
|
||||
}
|
||||
};
|
||||
|
||||
const canSend = text.trim().length > 0 || pendingFiles.length > 0;
|
||||
|
||||
return (
|
||||
<div className="border-t p-2 relative">
|
||||
<div
|
||||
className="border-t relative shrink-0"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
|
||||
{/* Mention dropdown */}
|
||||
{mentionQuery !== null && filteredMentionUsers.length > 0 && (
|
||||
<div className="absolute bottom-full left-2 right-2 bg-background border rounded-md shadow-md max-h-40 overflow-y-auto">
|
||||
<div className="absolute bottom-full left-2 right-2 bg-background border rounded-md shadow-md max-h-40 overflow-y-auto z-10">
|
||||
{filteredMentionUsers.map((u, i) => (
|
||||
<button
|
||||
key={u.userId}
|
||||
@@ -151,13 +203,42 @@ export function MessageInput({ roomId, onTypingStart, onTypingStop }: MessageInp
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-end gap-1">
|
||||
<button onClick={() => fileRef.current?.click()} className="p-1.5 hover:bg-muted rounded">
|
||||
{/* Pending file previews */}
|
||||
{pendingFiles.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 px-3 pt-2 pb-1">
|
||||
{pendingFiles.map((pf, idx) => (
|
||||
<div key={idx} className="relative group/preview">
|
||||
{pf.previewUrl ? (
|
||||
<img
|
||||
src={pf.previewUrl}
|
||||
alt={pf.file.name}
|
||||
className="h-16 w-16 rounded-md object-cover border"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-16 w-28 rounded-md border bg-muted flex flex-col items-center justify-center gap-1 px-2">
|
||||
<FileIcon className="h-5 w-5 text-muted-foreground shrink-0" />
|
||||
<span className="text-[10px] text-muted-foreground truncate w-full text-center">{pf.file.name}</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => removePendingFile(idx)}
|
||||
className="absolute -top-1.5 -right-1.5 h-4 w-4 rounded-full bg-foreground text-background flex items-center justify-center opacity-0 group-hover/preview:opacity-100 transition-opacity"
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input toolbar */}
|
||||
<div className="flex items-center gap-1 p-2">
|
||||
<button onClick={() => fileRef.current?.click()} className="p-1.5 hover:bg-muted rounded shrink-0">
|
||||
<Paperclip className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
<input ref={fileRef} type="file" className="hidden" onChange={handleFileChange} />
|
||||
<input ref={fileRef} type="file" multiple className="hidden" onChange={handleFileChange} />
|
||||
|
||||
<div className="relative">
|
||||
<div className="relative shrink-0" ref={emojiRef}>
|
||||
<button onClick={() => setShowEmoji((p) => !p)} className="p-1.5 hover:bg-muted rounded">
|
||||
<SmilePlus className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
@@ -166,11 +247,7 @@ export function MessageInput({ roomId, onTypingStart, onTypingStop }: MessageInp
|
||||
{QUICK_EMOJIS.map((emoji) => (
|
||||
<button
|
||||
key={emoji}
|
||||
onClick={() => {
|
||||
setText((p) => p + emoji);
|
||||
setShowEmoji(false);
|
||||
textareaRef.current?.focus();
|
||||
}}
|
||||
onClick={() => { setText((p) => p + emoji); setShowEmoji(false); textareaRef.current?.focus(); }}
|
||||
className="hover:bg-muted rounded p-1 text-lg"
|
||||
>
|
||||
{emoji}
|
||||
@@ -185,19 +262,20 @@ export function MessageInput({ roomId, onTypingStart, onTypingStop }: MessageInp
|
||||
value={text}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
placeholder="메시지를 입력하세요..."
|
||||
rows={1}
|
||||
className="flex-1 resize-none bg-transparent text-sm outline-none placeholder:text-muted-foreground py-1.5 px-2 max-h-[120px]"
|
||||
className="flex-1 resize-none bg-transparent text-sm outline-none placeholder:text-muted-foreground py-1.5 pl-1.5 pr-2 ml-1 max-h-[120px]"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!text.trim()}
|
||||
className="p-1.5 hover:bg-muted rounded disabled:opacity-40"
|
||||
disabled={!canSend || uploadFile.isPending}
|
||||
className="p-1.5 hover:bg-muted rounded disabled:opacity-40 shrink-0"
|
||||
>
|
||||
<Send className="h-4 w-4 text-primary" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,31 +1,206 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { X, Settings } from "lucide-react";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { X, Settings, Scissors } from "lucide-react";
|
||||
import { useMessengerContext } from "@/contexts/MessengerContext";
|
||||
import { useRooms } from "@/hooks/useMessenger";
|
||||
import { useRooms, useUnreadCount } from "@/hooks/useMessenger";
|
||||
import { RoomList } from "./RoomList";
|
||||
import { ChatPanel } from "./ChatPanel";
|
||||
import { MessengerSettings } from "./MessengerSettings";
|
||||
import { ScreenCapture } from "./ScreenCapture";
|
||||
import type { MessageInputHandle } from "./MessageInput";
|
||||
|
||||
const MIN_W = 400, MIN_H = 320;
|
||||
const MAX_W = 1000, MAX_H = 800;
|
||||
const INIT_W = 720, INIT_H = 500;
|
||||
|
||||
type ResizeDir = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw";
|
||||
|
||||
const CURSOR: Record<ResizeDir, string> = {
|
||||
n: "ns-resize", s: "ns-resize",
|
||||
e: "ew-resize", w: "ew-resize",
|
||||
ne: "nesw-resize", sw: "nesw-resize",
|
||||
nw: "nwse-resize", se: "nwse-resize",
|
||||
};
|
||||
|
||||
export function MessengerModal() {
|
||||
const { isOpen, closeMessenger, selectedRoomId } = useMessengerContext();
|
||||
const { isOpen, closeMessenger, selectedRoomId, setUnreadCount } = useMessengerContext();
|
||||
const { data: rooms = [] } = useRooms();
|
||||
const { data: serverUnread } = useUnreadCount();
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [capturing, setCapturing] = useState(false);
|
||||
const messageInputRef = useRef<MessageInputHandle>(null);
|
||||
|
||||
if (!isOpen) return null;
|
||||
useEffect(() => {
|
||||
const count = (serverUnread as any)?.unread_count ?? serverUnread ?? 0;
|
||||
setUnreadCount(Number(count));
|
||||
}, [serverUnread, setUnreadCount]);
|
||||
|
||||
const selectedRoom = rooms.find((r) => r.id === selectedRoomId) || null;
|
||||
|
||||
return (
|
||||
// Position & size state
|
||||
const [pos, setPos] = useState({ x: 0, y: 0 });
|
||||
const [size, setSize] = useState({ w: INIT_W, h: INIT_H });
|
||||
const initialized = useRef(false);
|
||||
|
||||
// Initialize position to bottom-right on first open
|
||||
useEffect(() => {
|
||||
if (isOpen && !initialized.current) {
|
||||
setPos({
|
||||
x: window.innerWidth - INIT_W - 24,
|
||||
y: window.innerHeight - INIT_H - 24,
|
||||
});
|
||||
initialized.current = true;
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Clamp position when window resizes
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
setPos((p) => ({
|
||||
x: Math.min(p.x, window.innerWidth - size.w),
|
||||
y: Math.min(p.y, window.innerHeight - size.h),
|
||||
}));
|
||||
};
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, [size]);
|
||||
|
||||
// --- Drag to move ---
|
||||
const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
|
||||
|
||||
const onHeaderMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest("button")) return;
|
||||
e.preventDefault();
|
||||
dragRef.current = { startX: e.clientX, startY: e.clientY, origX: pos.x, origY: pos.y };
|
||||
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
if (!dragRef.current) return;
|
||||
const dx = ev.clientX - dragRef.current.startX;
|
||||
const dy = ev.clientY - dragRef.current.startY;
|
||||
setPos({
|
||||
x: Math.max(0, Math.min(dragRef.current.origX + dx, window.innerWidth - size.w)),
|
||||
y: Math.max(0, Math.min(dragRef.current.origY + dy, window.innerHeight - size.h)),
|
||||
});
|
||||
};
|
||||
const onUp = () => {
|
||||
dragRef.current = null;
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
}, [pos, size]);
|
||||
|
||||
// --- Resize ---
|
||||
const resizeRef = useRef<{
|
||||
dir: ResizeDir;
|
||||
startX: number; startY: number;
|
||||
origX: number; origY: number;
|
||||
origW: number; origH: number;
|
||||
} | null>(null);
|
||||
|
||||
const onResizeMouseDown = useCallback((e: React.MouseEvent, dir: ResizeDir) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
resizeRef.current = {
|
||||
dir,
|
||||
startX: e.clientX, startY: e.clientY,
|
||||
origX: pos.x, origY: pos.y,
|
||||
origW: size.w, origH: size.h,
|
||||
};
|
||||
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
const r = resizeRef.current;
|
||||
if (!r) return;
|
||||
const dx = ev.clientX - r.startX;
|
||||
const dy = ev.clientY - r.startY;
|
||||
|
||||
let newX = r.origX, newY = r.origY, newW = r.origW, newH = r.origH;
|
||||
|
||||
if (r.dir.includes("e")) newW = Math.min(MAX_W, Math.max(MIN_W, r.origW + dx));
|
||||
if (r.dir.includes("s")) newH = Math.min(MAX_H, Math.max(MIN_H, r.origH + dy));
|
||||
if (r.dir.includes("w")) {
|
||||
const w = Math.min(MAX_W, Math.max(MIN_W, r.origW - dx));
|
||||
newX = r.origX + (r.origW - w);
|
||||
newW = w;
|
||||
}
|
||||
if (r.dir.includes("n")) {
|
||||
const h = Math.min(MAX_H, Math.max(MIN_H, r.origH - dy));
|
||||
newY = r.origY + (r.origH - h);
|
||||
newH = h;
|
||||
}
|
||||
|
||||
// Clamp to viewport
|
||||
newX = Math.max(0, Math.min(newX, window.innerWidth - newW));
|
||||
newY = Math.max(0, Math.min(newY, window.innerHeight - newH));
|
||||
|
||||
setPos({ x: newX, y: newY });
|
||||
setSize({ w: newW, h: newH });
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
resizeRef.current = null;
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
}, [pos, size]);
|
||||
|
||||
const handle = (dir: ResizeDir, className: string) => (
|
||||
<div
|
||||
className="fixed bottom-6 right-6 z-[9999] flex flex-col bg-background border rounded-lg shadow-2xl overflow-hidden"
|
||||
style={{ width: 720, height: 500 }}
|
||||
className={`absolute z-20 ${className}`}
|
||||
style={{ cursor: CURSOR[dir] }}
|
||||
onMouseDown={(e) => onResizeMouseDown(e, dir)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{capturing && (
|
||||
<ScreenCapture
|
||||
onCapture={(file) => {
|
||||
setCapturing(false);
|
||||
messageInputRef.current?.addFiles([file]);
|
||||
}}
|
||||
onCancel={() => setCapturing(false)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="fixed z-[9999] flex flex-col bg-background border rounded-lg shadow-2xl overflow-hidden"
|
||||
style={{
|
||||
left: pos.x,
|
||||
top: pos.y,
|
||||
width: size.w,
|
||||
height: size.h,
|
||||
display: isOpen && !capturing ? "flex" : "none",
|
||||
}}
|
||||
>
|
||||
{/* Resize handles */}
|
||||
{handle("n", "top-0 left-2 right-2 h-1.5")}
|
||||
{handle("s", "bottom-0 left-2 right-2 h-1.5")}
|
||||
{handle("e", "right-0 top-2 bottom-2 w-1.5")}
|
||||
{handle("w", "left-0 top-2 bottom-2 w-1.5")}
|
||||
{handle("ne", "top-0 right-0 w-3 h-3")}
|
||||
{handle("nw", "top-0 left-0 w-3 h-3")}
|
||||
{handle("se", "bottom-0 right-0 w-3 h-3")}
|
||||
{handle("sw", "bottom-0 left-0 w-3 h-3")}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b bg-muted/30">
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-2 border-b bg-muted/30 select-none cursor-grab active:cursor-grabbing"
|
||||
onMouseDown={onHeaderMouseDown}
|
||||
>
|
||||
<h2 className="text-sm font-semibold">메신저</h2>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setCapturing(true)}
|
||||
className="p-1 hover:bg-muted rounded"
|
||||
aria-label="화면 캡처"
|
||||
>
|
||||
<Scissors className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowSettings((p) => !p)}
|
||||
className="p-1 hover:bg-muted rounded"
|
||||
@@ -46,9 +221,8 @@ export function MessengerModal() {
|
||||
{/* Body */}
|
||||
<div className="flex flex-1 min-h-0 relative">
|
||||
<RoomList />
|
||||
<ChatPanel room={selectedRoom} />
|
||||
<ChatPanel room={selectedRoom} messageInputRef={messageInputRef} />
|
||||
|
||||
{/* Settings slide panel */}
|
||||
{showSettings && (
|
||||
<div className="absolute inset-0 bg-background z-10 flex flex-col">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b">
|
||||
@@ -62,5 +236,6 @@ export function MessengerModal() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
120
frontend/components/messenger/ScreenCapture.tsx
Normal file
120
frontend/components/messenger/ScreenCapture.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
interface ScreenCaptureProps {
|
||||
onCapture: (file: File) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ScreenCapture({ onCapture, onCancel }: ScreenCaptureProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [selecting, setSelecting] = useState(false);
|
||||
const startRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const [rect, setRect] = useState<Rect | null>(null);
|
||||
|
||||
// ESC to cancel
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onCancel();
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onCancel]);
|
||||
|
||||
const getRect = (ax: number, ay: number, bx: number, by: number): Rect => ({
|
||||
x: Math.min(ax, bx),
|
||||
y: Math.min(ay, by),
|
||||
w: Math.abs(bx - ax),
|
||||
h: Math.abs(by - ay),
|
||||
});
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
startRef.current = { x: e.clientX, y: e.clientY };
|
||||
setSelecting(true);
|
||||
setRect(null);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!selecting || !startRef.current) return;
|
||||
setRect(getRect(startRef.current.x, startRef.current.y, e.clientX, e.clientY));
|
||||
};
|
||||
|
||||
const handleMouseUp = async (e: React.MouseEvent) => {
|
||||
if (!selecting || !startRef.current) return;
|
||||
setSelecting(false);
|
||||
const r = getRect(startRef.current.x, startRef.current.y, e.clientX, e.clientY);
|
||||
startRef.current = null;
|
||||
|
||||
if (r.w < 4 || r.h < 4) {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture via modern-screenshot
|
||||
try {
|
||||
const { domToPng } = await import("modern-screenshot");
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const dataUrl = await domToPng(document.body, {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
style: {
|
||||
transform: "none",
|
||||
transformOrigin: "top left",
|
||||
},
|
||||
});
|
||||
|
||||
// Crop the captured region
|
||||
const img = new Image();
|
||||
img.src = dataUrl;
|
||||
await new Promise((res) => { img.onload = res; });
|
||||
|
||||
const canvas = canvasRef.current!;
|
||||
canvas.width = r.w * dpr;
|
||||
canvas.height = r.h * dpr;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(img, r.x * dpr, r.y * dpr, r.w * dpr, r.h * dpr, 0, 0, r.w * dpr, r.h * dpr);
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) { onCancel(); return; }
|
||||
const file = new File([blob], `capture-${Date.now()}.png`, { type: "image/png" });
|
||||
onCapture(file);
|
||||
}, "image/png");
|
||||
} catch {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40 cursor-crosshair select-none"
|
||||
style={{ zIndex: 99999 }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
>
|
||||
{/* instruction */}
|
||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 bg-black/70 text-white text-sm px-4 py-2 rounded-full pointer-events-none">
|
||||
드래그하여 캡처 영역을 선택하세요 · ESC로 취소
|
||||
</div>
|
||||
|
||||
{/* selection rect */}
|
||||
{rect && rect.w > 0 && rect.h > 0 && (
|
||||
<div
|
||||
className="absolute border-2 border-blue-400 bg-blue-400/10 pointer-events-none"
|
||||
style={{ left: rect.x, top: rect.y, width: rect.w, height: rect.h }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
7
frontend/package-lock.json
generated
7
frontend/package-lock.json
generated
@@ -73,6 +73,7 @@
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.525.0",
|
||||
"mammoth": "^1.11.0",
|
||||
"modern-screenshot": "^4.6.8",
|
||||
"next": "^15.4.8",
|
||||
"next-themes": "^0.4.6",
|
||||
"qrcode": "^1.5.4",
|
||||
@@ -12488,6 +12489,12 @@
|
||||
"mkdirp": "bin/cmd.js"
|
||||
}
|
||||
},
|
||||
"node_modules/modern-screenshot": {
|
||||
"version": "4.6.8",
|
||||
"resolved": "https://registry.npmjs.org/modern-screenshot/-/modern-screenshot-4.6.8.tgz",
|
||||
"integrity": "sha512-GJkv/yWPOJTlxj1LZDU2k474cDyOWL+LVaqTdDWQwQ5d8zIuTz1892+1cV9V0ZpK6HYZFo/+BNLBbierO9d2TA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.525.0",
|
||||
"mammoth": "^1.11.0",
|
||||
"modern-screenshot": "^4.6.8",
|
||||
"next": "^15.4.8",
|
||||
"next-themes": "^0.4.6",
|
||||
"qrcode": "^1.5.4",
|
||||
|
||||
Reference in New Issue
Block a user