[RAPID-fix] 메신저 3가지 수정

- 최신 메시지 버튼: 스크롤 컨테이너 밖으로 이동, 입력창 위 중앙 고정
- 스크롤 하단: rAF + 600ms 지연 2회 (이미지 비동기 로드 대응)
- 캡처: 버튼 클릭 즉시 오버레이 + domToPng 병렬 실행, mouseup에서 await (font:false 최적화)
This commit is contained in:
2026-03-31 15:49:03 +09:00
parent ce91f7f77e
commit eaa47ee5df
3 changed files with 56 additions and 65 deletions

View File

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

View File

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

View 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">
&nbsp;·&nbsp;ESC로
</div>
{rect && rect.w > 0 && rect.h > 0 && (
<div
className="absolute border-2 border-blue-400 bg-blue-400/10 pointer-events-none"