Files
GhiVideo/client/src/hooks/useKeyboard.ts
T
b23042andClaude Opus 4.8 78fc090360 초기 커밋: 스테이션(측점) 기반 주행영상 플레이어
- 클라이언트(React/Vite): Video.js 플레이어, 하단 스테이션바, POI/구조물 영상 오버레이, 카메라 파라미터 보정, 미니맵/RoutePanel
- 서버(Express): Range 스트리밍, HLS 변환, 프레임 추출, tus 업로드, 주석 API
- 최근 작업: 스테이션바 종점역 표출/양끝 정렬/미도착(재생불가) 표시, POI 라벨 '구분' 우선·컴팩트 팝업·겹침제외 토글·동일좌표 다중행, 진행방향(상/하) 우선 표출, 커서 배지 크기·픽셀 떨림 개선

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:37:57 +09:00

114 lines
3.3 KiB
TypeScript

import { useEffect, useCallback } from 'react';
import type Player from 'video.js/dist/types/player';
interface KeyboardOptions {
playerRef: React.RefObject<Player | null>;
onStepForward: () => void;
onStepBackward: () => void;
onCaptureFrame: () => void;
onAddMemo: () => void;
onToggleHelp?: () => void;
containerRef: React.RefObject<HTMLDivElement | null>;
}
export function useKeyboard({
playerRef,
onStepForward,
onStepBackward,
onCaptureFrame,
onAddMemo,
onToggleHelp,
containerRef,
}: KeyboardOptions) {
const handleKey = useCallback(
(e: KeyboardEvent) => {
const target = e.target as HTMLElement;
// Don't fire when typing in an input
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return;
const player = playerRef.current;
if (!player) return;
const ct = player.currentTime() ?? 0;
const dur = player.duration() ?? 0;
switch (e.code) {
case 'Space':
e.preventDefault();
player.paused() ? player.play() : player.pause();
break;
case 'ArrowLeft':
e.preventDefault();
player.currentTime(Math.max(0, ct - 5));
break;
case 'ArrowRight':
e.preventDefault();
player.currentTime(Math.min(dur, ct + 5));
break;
case 'KeyJ':
player.currentTime(Math.max(0, ct - 10));
break;
case 'KeyL':
player.currentTime(Math.min(dur, ct + 10));
break;
case 'Comma':
e.preventDefault();
onStepBackward();
break;
case 'Period':
e.preventDefault();
onStepForward();
break;
case 'BracketLeft':
player.currentTime(Math.max(0, ct - 30));
break;
case 'BracketRight':
player.currentTime(Math.min(dur, ct + 30));
break;
case 'KeyF':
if (containerRef.current) {
if (!document.fullscreenElement) {
containerRef.current.requestFullscreen();
} else {
document.exitFullscreen();
}
}
break;
case 'KeyM':
if (e.shiftKey) {
e.preventDefault();
onAddMemo();
} else {
player.muted(!player.muted());
}
break;
case 'Equal':
case 'NumpadAdd':
player.playbackRate(Math.min(4, (player.playbackRate() ?? 1) + 0.25));
break;
case 'Minus':
case 'NumpadSubtract':
player.playbackRate(Math.max(0.25, (player.playbackRate() ?? 1) - 0.25));
break;
case 'KeyS':
if (e.shiftKey) { e.preventDefault(); onCaptureFrame(); }
break;
case 'Slash':
if (e.shiftKey) { e.preventDefault(); onToggleHelp?.(); }
break;
default:
if (e.code >= 'Digit0' && e.code <= 'Digit9') {
const pct = parseInt(e.code.replace('Digit', ''), 10) / 10;
player.currentTime(dur * pct);
}
}
},
[playerRef, onStepForward, onStepBackward, onCaptureFrame, onAddMemo, onToggleHelp, containerRef]
);
useEffect(() => {
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [handleKey]);
}