188 lines
5.4 KiB
TypeScript
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;
|