Files
vexplor_dev/frontend/components/messenger/MessengerModal.tsx
syc0123 20b82dc57b [RAPID] 메신저 화면 캡처 기능 구현
- modern-screenshot 패키지 추가
- ScreenCapture: 전체화면 오버레이 + 드래그 영역 선택 + DOM 캡처
- MessengerModal: 캡처 버튼(Scissors) 추가, 캡처 중 모달 숨김
- MessageInput: forwardRef로 addFiles 메서드 외부 노출
- ChatPanel: messageInputRef prop 추가 및 전달

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 12:20:41 +09:00

242 lines
8.0 KiB
TypeScript

"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { X, Settings, Scissors } from "lucide-react";
import { useMessengerContext } from "@/contexts/MessengerContext";
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, 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);
useEffect(() => {
const count = (serverUnread as any)?.unread_count ?? serverUnread ?? 0;
setUnreadCount(Number(count));
}, [serverUnread, setUnreadCount]);
const selectedRoom = rooms.find((r) => r.id === selectedRoomId) || null;
// 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={`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 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"
aria-label="설정"
>
<Settings className="h-4 w-4 text-muted-foreground" />
</button>
<button
onClick={closeMessenger}
className="p-1 hover:bg-muted rounded"
aria-label="닫기"
>
<X className="h-4 w-4 text-muted-foreground" />
</button>
</div>
</div>
{/* Body */}
<div className="flex flex-1 min-h-0 relative">
<RoomList />
<ChatPanel room={selectedRoom} messageInputRef={messageInputRef} />
{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">
<span className="text-sm font-semibold"></span>
<button onClick={() => setShowSettings(false)} className="p-1 hover:bg-muted rounded">
<X className="h-4 w-4" />
</button>
</div>
<MessengerSettings />
</div>
)}
</div>
</div>
</>
);
}