[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:
2026-03-31 15:09:27 +09:00
parent 6b3e6cce5e
commit 20b82dc57b
6 changed files with 511 additions and 116 deletions

View File

@@ -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)}

View File

@@ -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>
);
}
});

View File

@@ -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>
</>
);
}

View 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">
&nbsp;·&nbsp; 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>
</>
);
}

View File

@@ -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",

View File

@@ -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",