[RAPID-fix] 메신저 3가지 수정
- 최신 메시지 버튼: 스크롤 컨테이너 밖으로 이동, 입력창 위 중앙 고정 - 스크롤 하단: rAF + 600ms 지연 2회 (이미지 비동기 로드 대응) - 캡처: 버튼 클릭 즉시 오버레이 + domToPng 병렬 실행, mouseup에서 await (font:false 최적화)
This commit is contained in:
@@ -39,12 +39,15 @@ export function ChatPanel({ room, messageInputRef, onCaptureClick }: ChatPanelPr
|
||||
const lastMessageId = messages?.[messages.length - 1]?.id;
|
||||
|
||||
// Scroll to bottom when messages load or room changes
|
||||
// useEffect + rAF to let images/content finish laying out first
|
||||
// Two-pass: immediate rAF + 600ms delayed (for async image loads)
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const scrollToBottom = () => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
};
|
||||
requestAnimationFrame(scrollToBottom);
|
||||
const t = setTimeout(scrollToBottom, 600);
|
||||
return () => clearTimeout(t);
|
||||
}, [lastMessageId, selectedRoomId]);
|
||||
|
||||
// Re-attach scroll listener whenever room changes (scrollRef mounts after room is set)
|
||||
@@ -113,7 +116,7 @@ export function ChatPanel({ room, messageInputRef, onCaptureClick }: ChatPanelPr
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-w-0 min-h-0 overflow-hidden">
|
||||
<div className="flex-1 flex flex-col min-w-0 min-h-0 overflow-hidden relative">
|
||||
{/* Header */}
|
||||
<div className="border-b px-4 py-2 flex items-center gap-2">
|
||||
{isEditingName ? (
|
||||
@@ -189,17 +192,18 @@ export function ChatPanel({ room, messageInputRef, onCaptureClick }: ChatPanelPr
|
||||
{roomTyping && roomTyping.length > 0 ? `${roomTyping.join(", ")}님이 입력 중...` : ""}
|
||||
</div>
|
||||
</div>
|
||||
{!isAtBottom && (
|
||||
<button
|
||||
onClick={() => { scrollRef.current!.scrollTop = scrollRef.current!.scrollHeight; }}
|
||||
className="absolute bottom-16 right-4 z-10 flex items-center gap-1 bg-primary text-primary-foreground text-xs px-3 py-1.5 rounded-full shadow-md hover:bg-primary/90"
|
||||
>
|
||||
<ChevronsDown className="h-3.5 w-3.5" />
|
||||
최신 메시지
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isAtBottom && (
|
||||
<button
|
||||
onClick={() => { const el = scrollRef.current; if (el) el.scrollTop = el.scrollHeight; }}
|
||||
className="absolute bottom-14 left-1/2 -translate-x-1/2 z-10 flex items-center gap-1 bg-primary text-primary-foreground text-xs px-3 py-1.5 rounded-full shadow-md hover:bg-primary/90"
|
||||
>
|
||||
<ChevronsDown className="h-3.5 w-3.5" />
|
||||
최신 메시지
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Input */}
|
||||
<MessageInput
|
||||
ref={messageInputRef}
|
||||
|
||||
@@ -30,6 +30,7 @@ export function MessengerModal() {
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [capturing, setCapturing] = useState(false);
|
||||
const capturedImgRef = useRef<HTMLImageElement | null>(null);
|
||||
const capturePromiseRef = useRef<Promise<HTMLImageElement> | null>(null);
|
||||
const messageInputRef = useRef<MessageInputHandle>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -39,32 +40,23 @@ export function MessengerModal() {
|
||||
|
||||
const selectedRoom = rooms.find((r) => r.id === selectedRoomId) || null;
|
||||
|
||||
const handleCaptureClick = useCallback(async () => {
|
||||
try {
|
||||
const stream = await (navigator.mediaDevices as any).getDisplayMedia({
|
||||
video: true,
|
||||
preferCurrentTab: true,
|
||||
const handleCaptureClick = useCallback(() => {
|
||||
// Start capture immediately (messenger still visible — captures full page)
|
||||
capturePromiseRef.current = (async (): Promise<HTMLImageElement> => {
|
||||
const { domToPng } = await import("modern-screenshot");
|
||||
const dataUrl = await domToPng(document.body, {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
scale: window.devicePixelRatio || 1,
|
||||
font: false,
|
||||
});
|
||||
const video = document.createElement("video");
|
||||
video.srcObject = stream;
|
||||
await new Promise<void>((res) => { video.onloadedmetadata = () => res(); });
|
||||
await video.play();
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
canvas.getContext("2d")!.drawImage(video, 0, 0);
|
||||
stream.getTracks().forEach((t: MediaStreamTrack) => t.stop());
|
||||
|
||||
const img = new Image();
|
||||
img.src = canvas.toDataURL("image/png");
|
||||
img.src = dataUrl;
|
||||
await new Promise<void>((res) => { img.onload = () => res(); });
|
||||
|
||||
capturedImgRef.current = img;
|
||||
setCapturing(true);
|
||||
} catch {
|
||||
// user cancelled
|
||||
}
|
||||
return img;
|
||||
})();
|
||||
// Show overlay immediately — don't await capture
|
||||
setCapturing(true);
|
||||
}, []);
|
||||
|
||||
// Position & size state
|
||||
@@ -187,9 +179,9 @@ export function MessengerModal() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{capturing && capturedImgRef.current && (
|
||||
{capturing && capturePromiseRef.current && (
|
||||
<ScreenCapture
|
||||
capturedImg={capturedImgRef.current}
|
||||
capturePromise={capturePromiseRef.current}
|
||||
onCapture={(file) => {
|
||||
setCapturing(false);
|
||||
messageInputRef.current?.addFiles([file]);
|
||||
|
||||
@@ -10,22 +10,19 @@ interface Rect {
|
||||
}
|
||||
|
||||
interface ScreenCaptureProps {
|
||||
capturedImg: HTMLImageElement;
|
||||
capturePromise: Promise<HTMLImageElement>;
|
||||
onCapture: (file: File) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ScreenCapture({ capturedImg, onCapture, onCancel }: ScreenCaptureProps) {
|
||||
export function ScreenCapture({ capturePromise, 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();
|
||||
};
|
||||
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); };
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onCancel]);
|
||||
@@ -48,7 +45,7 @@ export function ScreenCapture({ capturedImg, onCapture, onCancel }: ScreenCaptur
|
||||
setRect(getRect(startRef.current.x, startRef.current.y, e.clientX, e.clientY));
|
||||
};
|
||||
|
||||
const handleMouseUp = (e: React.MouseEvent) => {
|
||||
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);
|
||||
@@ -56,33 +53,32 @@ export function ScreenCapture({ capturedImg, onCapture, onCancel }: ScreenCaptur
|
||||
|
||||
if (r.w < 4 || r.h < 4) { onCancel(); return; }
|
||||
|
||||
const img = capturedImg;
|
||||
const scaleX = img.naturalWidth / window.innerWidth;
|
||||
const scaleY = img.naturalHeight / window.innerHeight;
|
||||
try {
|
||||
const img = await capturePromise;
|
||||
const scaleX = img.naturalWidth / window.innerWidth;
|
||||
const scaleY = img.naturalHeight / window.innerHeight;
|
||||
|
||||
const canvas = canvasRef.current!;
|
||||
canvas.width = r.w * scaleX;
|
||||
canvas.height = r.h * scaleY;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(
|
||||
img,
|
||||
r.x * scaleX, r.y * scaleY, r.w * scaleX, r.h * scaleY,
|
||||
0, 0, r.w * scaleX, r.h * scaleY,
|
||||
);
|
||||
const canvas = canvasRef.current!;
|
||||
canvas.width = r.w * scaleX;
|
||||
canvas.height = r.h * scaleY;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(img, r.x * scaleX, r.y * scaleY, r.w * scaleX, r.h * scaleY, 0, 0, r.w * scaleX, r.h * scaleY);
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) { onCancel(); return; }
|
||||
const file = new File([blob], `capture-${Date.now()}.png`, { type: "image/png" });
|
||||
onCapture(file);
|
||||
}, "image/png");
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) { onCancel(); return; }
|
||||
onCapture(new File([blob], `capture-${Date.now()}.png`, { type: "image/png" }));
|
||||
}, "image/png");
|
||||
} catch {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40 select-none"
|
||||
style={{ zIndex: 99999, cursor: "crosshair" }}
|
||||
className="fixed inset-0 bg-black/40 select-none cursor-crosshair"
|
||||
style={{ zIndex: 99999 }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
@@ -90,7 +86,6 @@ export function ScreenCapture({ capturedImg, onCapture, onCancel }: ScreenCaptur
|
||||
<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>
|
||||
|
||||
{rect && rect.w > 0 && rect.h > 0 && (
|
||||
<div
|
||||
className="absolute border-2 border-blue-400 bg-blue-400/10 pointer-events-none"
|
||||
|
||||
Reference in New Issue
Block a user