모의투자 관리 기능 추가
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* 화면(탭/창) 공유 스트림 위에서 드래그로 영역 선택 → 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;
|
||||
Reference in New Issue
Block a user