Files
goldenChart/frontend/src/components/ScreenRegionCaptureOverlay.tsx
T
2026-05-31 03:47:07 +09:00

188 lines
5.4 KiB
TypeScript

/**
* 화면(탭/창) 공유 스트림 위에서 드래그로 영역 선택 → PNG File 반환
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import {
captureErrorMessage,
clientRectToVideoPixels,
cropVideoFrameToFile,
requestDisplayCaptureStream,
stopMediaStream,
type ScreenCaptureRect,
} from '../utils/screenRegionCapture';
interface Props {
onCapture: (file: File) => void;
onCancel: () => void;
}
type Phase = 'starting' | 'ready' | 'selecting' | 'processing';
const MIN_DRAG_PX = 8;
const ScreenRegionCaptureOverlay: React.FC<Props> = ({ onCapture, onCancel }) => {
const videoRef = useRef<HTMLVideoElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
const [phase, setPhase] = useState<Phase>('starting');
const [selection, setSelection] = useState<ScreenCaptureRect | null>(null);
const [error, setError] = useState<string | null>(null);
const cleanup = useCallback(() => {
stopMediaStream(streamRef.current);
streamRef.current = null;
}, []);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const stream = await requestDisplayCaptureStream();
if (cancelled) {
stopMediaStream(stream);
return;
}
streamRef.current = stream;
const video = videoRef.current;
if (!video) {
stopMediaStream(stream);
onCancel();
return;
}
video.srcObject = stream;
video.muted = true;
await video.play();
if (cancelled) return;
setPhase('ready');
} catch (e) {
if (!cancelled) {
setError(captureErrorMessage(e));
setTimeout(onCancel, 2200);
}
}
})();
return () => {
cancelled = true;
cleanup();
};
}, [cleanup, onCancel]);
const finishCancel = useCallback(() => {
cleanup();
onCancel();
}, [cleanup, onCancel]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') finishCancel();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [finishCancel]);
const normalizeRect = (x0: number, y0: number, x1: number, y1: number): ScreenCaptureRect => {
const left = Math.min(x0, x1);
const top = Math.min(y0, y1);
return {
left,
top,
width: Math.abs(x1 - x0),
height: Math.abs(y1 - y0),
};
};
const handlePointerDown = (e: React.PointerEvent) => {
if (phase !== 'ready' && phase !== 'selecting') return;
if (e.button !== 0) return;
dragStartRef.current = { x: e.clientX, y: e.clientY };
setSelection({ left: e.clientX, top: e.clientY, width: 0, height: 0 });
setPhase('selecting');
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
};
const handlePointerMove = (e: React.PointerEvent) => {
if (!dragStartRef.current) return;
const start = dragStartRef.current;
setSelection(normalizeRect(start.x, start.y, e.clientX, e.clientY));
};
const handlePointerUp = async (e: React.PointerEvent) => {
if (!dragStartRef.current) return;
const start = dragStartRef.current;
dragStartRef.current = null;
const clientRect = normalizeRect(start.x, start.y, e.clientX, e.clientY);
if (clientRect.width < MIN_DRAG_PX || clientRect.height < MIN_DRAG_PX) {
setSelection(null);
setPhase('ready');
return;
}
const video = videoRef.current;
if (!video) {
finishCancel();
return;
}
setPhase('processing');
try {
const vidRect = clientRectToVideoPixels(clientRect, video);
if (!vidRect) throw new Error('REGION_TOO_SMALL');
const file = await cropVideoFrameToFile(video, vidRect);
cleanup();
onCapture(file);
} catch (err) {
setError(captureErrorMessage(err));
setPhase('ready');
setSelection(null);
}
};
const hint =
phase === 'starting'
? '화면 공유 준비 중…'
: phase === 'processing'
? '캡처 처리 중…'
: '캡처할 영역을 드래그하세요 · Esc 취소';
return createPortal(
<div className="screen-capture-overlay" role="dialog" aria-modal aria-label="화면 영역 캡처">
<video ref={videoRef} className="screen-capture-video" playsInline />
<div
className="screen-capture-interaction"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={e => void handlePointerUp(e)}
onPointerCancel={() => {
dragStartRef.current = null;
setSelection(null);
setPhase('ready');
}}
>
{selection && selection.width > 0 && selection.height > 0 && (
<div
className="screen-capture-selection"
style={{
left: selection.left,
top: selection.top,
width: selection.width,
height: selection.height,
}}
/>
)}
</div>
<div className="screen-capture-toolbar">
<span className="screen-capture-hint">{error ?? hint}</span>
<button type="button" className="screen-capture-cancel" onClick={finishCancel}>
취소
</button>
</div>
</div>,
document.body,
);
};
export default ScreenRegionCaptureOverlay;