455 lines
No EOL
21 KiB
JavaScript
455 lines
No EOL
21 KiB
JavaScript
// screens-editor.jsx — NLE timeline editor
|
|
// Depends on: window.TC (timecode.js), window.Timeline (timeline.js), window.ZAMPP_API
|
|
|
|
function _uid() { return 'ce_' + (++_uid._c || (_uid._c = 0)); }
|
|
|
|
function Editor() {
|
|
const [projectId, setProjectId] = React.useState(null);
|
|
const [sequences, setSequences] = React.useState([]);
|
|
const [currentSeq, setCurrentSeq] = React.useState(null);
|
|
const [assets, setAssets] = React.useState([]);
|
|
const [bins, setBins] = React.useState([]);
|
|
const [sourceAsset, setSourceAsset] = React.useState(null);
|
|
const [srcIn, setSrcIn] = React.useState(null);
|
|
const [srcOut, setSrcOut] = React.useState(null);
|
|
const [playing, setPlaying] = React.useState(false);
|
|
const [playheadFrames, setPlayheadFrames] = React.useState(0);
|
|
const [saveStatus, setSaveStatus] = React.useState('');
|
|
const [selectedClipId, setSelectedClipId] = React.useState(null);
|
|
const [history, setHistory] = React.useState([[]]);
|
|
const [historyIdx, setHistoryIdx] = React.useState(0);
|
|
const [scale, setScale] = React.useState(100);
|
|
const [tool, setTool] = React.useState('select');
|
|
const [seqFilter, setSeqFilter] = React.useState('');
|
|
const [showNewSeq, setShowNewSeq] = React.useState(false);
|
|
const [newSeqName, setNewSeqName] = React.useState('');
|
|
const [isDirty, setIsDirty] = React.useState(false);
|
|
const srcVideoRef = React.useRef(null);
|
|
const pgmVideoRef = React.useRef(null);
|
|
const tlRef = React.useRef(null);
|
|
const saveTimerRef = React.useRef(null);
|
|
const streamCacheRef = React.useRef({});
|
|
|
|
const tlInitRef = React.useRef(false);
|
|
|
|
React.useEffect(() => {
|
|
const data = window.ZAMPP_DATA;
|
|
setAssets(data.ASSETS || []);
|
|
setBins(data.BINS || []);
|
|
const pid = data.PROJECTS && data.PROJECTS.length ? data.PROJECTS[0].id : null;
|
|
setProjectId(pid);
|
|
if (pid) loadSequences(pid);
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
if (!tlRef.current || tlInitRef.current || !window.Timeline) return;
|
|
tlInitRef.current = true;
|
|
window.Timeline.init(tlRef.current, {
|
|
fps: 59.94,
|
|
scale: 100,
|
|
onClipsChanged: handleClipsChanged,
|
|
onPlayheadMoved: handlePlayheadMoved,
|
|
});
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
if (window.Timeline && tlInitRef.current) {
|
|
window.Timeline.setScale(scale);
|
|
}
|
|
}, [scale]);
|
|
|
|
async function loadSequences(pid) {
|
|
try {
|
|
const r = await window.ZAMPP_API.getSequences(pid);
|
|
const list = Array.isArray(r) ? r : [];
|
|
setSequences(list);
|
|
if (list.length) openSequence(list[0].id);
|
|
} catch (e) { console.error('Failed to load sequences', e); }
|
|
}
|
|
|
|
async function openSequence(id) {
|
|
try {
|
|
const r = await window.ZAMPP_API.getSequence(id);
|
|
const clips = (r.clips || []).map(c => ({ ...c, _id: _uid() }));
|
|
const seq = { ...r, clips };
|
|
setCurrentSeq(seq);
|
|
setPlayheadFrames(0);
|
|
setSelectedClipId(null);
|
|
setHistory([clips]);
|
|
setHistoryIdx(0);
|
|
setIsDirty(false);
|
|
setSaveStatus('');
|
|
renderTimelineClips(clips);
|
|
} catch (e) { console.error('Failed to open sequence', e); }
|
|
}
|
|
|
|
async function createNewSequence() {
|
|
if (!projectId) return;
|
|
const name = newSeqName.trim() || ('Sequence ' + (sequences.length + 1));
|
|
try {
|
|
const r = await window.ZAMPP_API.createSequence({ project_id: projectId, name });
|
|
setSequences(prev => [r, ...prev]);
|
|
setNewSeqName('');
|
|
setShowNewSeq(false);
|
|
openSequence(r.id);
|
|
} catch (e) { console.error('Failed to create sequence', e); }
|
|
}
|
|
|
|
function renderTimelineClips(clips) {
|
|
if (window.Timeline && tlRef.current) {
|
|
window.Timeline.render(clips || [], { fps: 59.94 });
|
|
}
|
|
}
|
|
|
|
function handleClipsChanged(clips) {
|
|
setCurrentSeq(prev => prev ? { ...prev, clips } : prev);
|
|
const newHistory = history.slice(0, historyIdx + 1);
|
|
newHistory.push(clips.map(c => ({ ...c })));
|
|
if (newHistory.length > 50) newHistory.shift();
|
|
setHistory(newHistory);
|
|
setHistoryIdx(newHistory.length - 1);
|
|
markDirty();
|
|
}
|
|
|
|
function handlePlayheadMoved(frames) {
|
|
setPlayheadFrames(Math.max(0, frames));
|
|
}
|
|
|
|
function markDirty() {
|
|
setIsDirty(true);
|
|
setSaveStatus('Unsaved');
|
|
clearTimeout(saveTimerRef.current);
|
|
saveTimerRef.current = setTimeout(saveSequence, 2000);
|
|
}
|
|
|
|
async function saveSequence() {
|
|
if (!currentSeq || !isDirty) return;
|
|
clearTimeout(saveTimerRef.current);
|
|
setSaveStatus('Saving\u2026');
|
|
try {
|
|
const clips = (currentSeq.clips || []).map(c => ({
|
|
asset_id: c.asset_id,
|
|
track: c.track,
|
|
timeline_in_frames: c.timeline_in_frames,
|
|
timeline_out_frames: c.timeline_out_frames,
|
|
source_in_frames: c.source_in_frames,
|
|
source_out_frames: c.source_out_frames,
|
|
}));
|
|
await window.ZAMPP_API.syncSequenceClips(currentSeq.id, clips);
|
|
setIsDirty(false);
|
|
setSaveStatus('Saved');
|
|
setTimeout(() => setSaveStatus(''), 2000);
|
|
} catch (e) {
|
|
setSaveStatus('Save failed');
|
|
console.error('Auto-save failed', e);
|
|
}
|
|
}
|
|
|
|
function undo() {
|
|
if (historyIdx <= 0) return;
|
|
const idx = historyIdx - 1;
|
|
setHistoryIdx(idx);
|
|
const clips = (history[idx] || []).map(c => ({ ...c, _id: _uid() }));
|
|
setCurrentSeq(prev => prev ? { ...prev, clips } : prev);
|
|
renderTimelineClips(clips);
|
|
markDirty();
|
|
}
|
|
|
|
function redo() {
|
|
if (historyIdx >= history.length - 1) return;
|
|
const idx = historyIdx + 1;
|
|
setHistoryIdx(idx);
|
|
const clips = (history[idx] || []).map(c => ({ ...c, _id: _uid() }));
|
|
setCurrentSeq(prev => prev ? { ...prev, clips } : prev);
|
|
renderTimelineClips(clips);
|
|
markDirty();
|
|
}
|
|
|
|
async function loadSourceAsset(asset) {
|
|
setSourceAsset(asset);
|
|
setSrcIn(null);
|
|
setSrcOut(null);
|
|
const vid = srcVideoRef.current;
|
|
if (!vid) return;
|
|
vid.src = '';
|
|
const cache = streamCacheRef.current;
|
|
let url = cache[asset.id];
|
|
if (!url) {
|
|
try {
|
|
const r = await window.ZAMPP_API.fetch('/assets/' + asset.id + '/stream');
|
|
if (r && r.url) { url = r.url; cache[asset.id] = url; }
|
|
} catch (e) { console.error('Failed to get stream URL', e); }
|
|
}
|
|
if (url) { vid.src = url; vid.load(); }
|
|
}
|
|
|
|
function markSrcIn() {
|
|
const vid = srcVideoRef.current;
|
|
if (vid) setSrcIn(vid.currentTime);
|
|
}
|
|
|
|
function markSrcOut() {
|
|
const vid = srcVideoRef.current;
|
|
if (vid) setSrcOut(vid.currentTime);
|
|
}
|
|
|
|
function addToTimeline() {
|
|
if (!sourceAsset || !currentSeq) return;
|
|
if (!window.Timeline) return;
|
|
const vid = srcVideoRef.current;
|
|
const sIn = srcIn != null ? srcIn : 0;
|
|
const sOut = srcOut != null ? srcOut : (vid && vid.duration ? vid.duration : ((sourceAsset.duration_ms || 10000) / 1000));
|
|
window.Timeline.addClip(
|
|
{ ...sourceAsset, streamUrl: streamCacheRef.current[sourceAsset.id] },
|
|
sIn, sOut, 0
|
|
);
|
|
}
|
|
|
|
function exportEDL() {
|
|
if (!currentSeq) return;
|
|
const name = (currentSeq.name || 'sequence').replace(/[^a-z0-9]/gi, '_') + '.edl';
|
|
window.ZAMPP_API.exportSequenceEDL(currentSeq.id, name).catch(e => console.error('EDL export failed', e));
|
|
}
|
|
|
|
const srcInLabel = srcIn != null ? (window.TC ? window.TC.framesToTC(window.TC.secondsToFrames(srcIn)) : 'In set') : 'Mark In';
|
|
const srcOutLabel = srcOut != null ? (window.TC ? window.TC.framesToTC(window.TC.secondsToFrames(srcOut)) : 'Out set') : 'Mark Out';
|
|
|
|
return (
|
|
<div className="editor-shell" style={{ position: 'relative', display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
|
{/* ── Top toolbar ── */}
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 12px', borderBottom: '1px solid var(--border)', background: 'var(--bg-panel)', flexShrink: 0, height: 40 }}>
|
|
<Icon name="editor" size={14} />
|
|
<select value={currentSeq ? currentSeq.id : ''} onChange={e => openSequence(e.target.value)}
|
|
style={{ fontSize: 12, height: 24, background: 'var(--bg-surface)', border: '1px solid var(--border)', borderRadius: 3, color: 'var(--text-primary)', maxWidth: 200 }}>
|
|
{sequences.length === 0 && <option value="">No sequences</option>}
|
|
{sequences.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
|
</select>
|
|
<button className="btn ghost sm" style={{ width: 22, height: 22, padding: 0 }} onClick={() => setShowNewSeq(true)} title="New sequence">
|
|
<Icon name="plus" size={12} />
|
|
</button>
|
|
<span style={{ width: 1, height: 16, background: 'var(--border)', margin: '0 4px' }} />
|
|
<button className="btn ghost sm" onClick={undo} disabled={historyIdx <= 0} title="Undo (Ctrl+Z)">\u21A9</button>
|
|
<button className="btn ghost sm" onClick={redo} disabled={historyIdx >= history.length - 1} title="Redo (Ctrl+Shift+Z)">\u21AA</button>
|
|
<span style={{ flex: 1 }} />
|
|
<button className="btn ghost sm" onClick={exportEDL} title="Export CMX3600 EDL">Export EDL</button>
|
|
<button className="btn primary sm" onClick={saveSequence}>{saveStatus || 'Save'}</button>
|
|
{saveStatus && <span style={{ fontSize: 10, color: saveStatus === 'Saved' ? 'var(--signal-good)' : 'var(--signal-warn)' }}>{saveStatus}</span>}
|
|
</div>
|
|
|
|
{/* ── 2x2 grid ── */}
|
|
<div style={{ flex: 1, display: 'grid', gridTemplateColumns: '1fr 1fr', gridTemplateRows: '35vh 1fr', overflow: 'hidden' }}>
|
|
{/* Source monitor */}
|
|
<div style={{ display: 'flex', flexDirection: 'column', borderRight: '1px solid var(--border)', borderBottom: '1px solid var(--border)', overflow: 'hidden', background: '#000' }}>
|
|
<video ref={srcVideoRef} preload="metadata" style={{ flex: 1, width: '100%', objectFit: 'contain', display: 'block' }} />
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', background: 'var(--bg-panel)', borderTop: '1px solid var(--border)', flexShrink: 0 }}>
|
|
<span style={{ fontSize: 10, fontFamily: 'monospace', color: 'var(--accent)', minWidth: 80 }}>
|
|
{sourceAsset ? (window.TC ? window.TC.framesToTC(window.TC.secondsToFrames(srcVideoRef.current ? srcVideoRef.current.currentTime : 0)) : '00:00:00;00') : '--:--:--;--'}
|
|
</span>
|
|
<button className="btn ghost sm" style={{ fontSize: 10, padding: '2px 6px' }} onClick={markSrcIn} title="Mark In (I)">{srcInLabel}</button>
|
|
<button className="btn ghost sm" style={{ fontSize: 10, padding: '2px 6px' }} onClick={markSrcOut} title="Mark Out (O)">{srcOutLabel}</button>
|
|
<div style={{ flex: 1 }} />
|
|
<button className="btn primary sm" style={{ fontSize: 10, padding: '2px 8px' }} onClick={addToTimeline} title="Insert at playhead">Insert</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Program monitor */}
|
|
<ProgramMonitor
|
|
videoRef={pgmVideoRef}
|
|
currentSeq={currentSeq}
|
|
playheadFrames={playheadFrames}
|
|
setPlayheadFrames={setPlayheadFrames}
|
|
streamCacheRef={streamCacheRef}
|
|
/>
|
|
|
|
{/* Media panel (bottom left) */}
|
|
<div style={{ display: 'flex', flexDirection: 'column', borderRight: '1px solid var(--border)', overflow: 'hidden', background: 'var(--bg-panel)' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
|
|
<span style={{ fontSize: 10, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--text-tertiary)' }}>Media</span>
|
|
<select value={seqFilter} onChange={e => setSeqFilter(e.target.value)}
|
|
style={{ fontSize: 10, height: 20, marginLeft: 'auto', background: 'var(--bg-surface)', border: '1px solid var(--border)', borderRadius: 2, color: 'var(--text-secondary)' }}>
|
|
<option value="">All bins</option>
|
|
{bins.map(b => <option key={b.id} value={b.id}>{b.name}</option>)}
|
|
</select>
|
|
</div>
|
|
<div style={{ flex: 1, overflowY: 'auto', padding: '4px 0' }}>
|
|
{assets.filter(a => !seqFilter || a.bin_id === seqFilter).length === 0 ? (
|
|
<div style={{ padding: '20px 12px', fontSize: 11, color: 'var(--text-tertiary)', textAlign: 'center' }}>No assets</div>
|
|
) : assets.filter(a => !seqFilter || a.bin_id === seqFilter).map(a => (
|
|
<div key={a.id} className="media-asset-item"
|
|
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', cursor: 'pointer', fontSize: 11, color: 'var(--text-secondary)', borderBottom: '1px solid var(--border-faint)' }}
|
|
onDoubleClick={() => loadSourceAsset(a)}>
|
|
<div style={{ width: 40, height: 24, borderRadius: 2, overflow: 'hidden', flexShrink: 0, background: 'var(--bg-surface)' }}>
|
|
<AssetThumb asset={a} size="sm" />
|
|
</div>
|
|
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{a.name}</span>
|
|
<span style={{ fontSize: 9, color: 'var(--text-tertiary)', fontFamily: 'monospace' }}>{a.duration}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Timeline (bottom right) */}
|
|
<div style={{ display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '4px 8px', borderBottom: '1px solid var(--border)', background: 'var(--bg-panel)', flexShrink: 0 }}>
|
|
<ToolBtn active={tool === 'select'} label="V" title="Select (V)" onClick={() => { setTool('select'); if (window.Timeline) window.Timeline.setTool('select'); }} />
|
|
<ToolBtn active={tool === 'razor'} label="C" title="Razor (C)" onClick={() => { setTool('razor'); if (window.Timeline) window.Timeline.setTool('razor'); }} />
|
|
<ToolBtn active={tool === 'hand'} label="H" title="Hand (H)" onClick={() => { setTool('hand'); if (window.Timeline) window.Timeline.setTool('hand'); }} />
|
|
<span style={{ width: 1, height: 14, background: 'var(--border)', margin: '0 4px' }} />
|
|
<input type="range" min="20" max="500" value={scale} style={{ width: 60, height: 3 }}
|
|
onChange={e => {
|
|
const s = Number(e.target.value);
|
|
setScale(s);
|
|
}} />
|
|
<span style={{ fontSize: 9, color: 'var(--text-tertiary)', fontFamily: 'monospace', width: 28 }}>{scale}px</span>
|
|
</div>
|
|
<div ref={tlRef} className="timeline-container" style={{ flex: 1, overflow: 'hidden' }} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── New sequence modal ── */}
|
|
{showNewSeq && (
|
|
<div style={{ position: 'fixed', inset: 0, zIndex: 80, background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
|
onClick={() => setShowNewSeq(false)}>
|
|
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 8, padding: 24, minWidth: 320 }}
|
|
onClick={e => e.stopPropagation()}>
|
|
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12 }}>New sequence</div>
|
|
<input value={newSeqName} onChange={e => setNewSeqName(e.target.value)}
|
|
placeholder="Sequence name"
|
|
style={{ width: '100%', height: 32, padding: '0 10px', background: 'var(--bg-deep)', border: '1px solid var(--border)', borderRadius: 4, color: 'var(--text-primary)', fontSize: 13, marginBottom: 12 }}
|
|
onKeyDown={e => { if (e.key === 'Enter') createNewSequence(); }} />
|
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
|
<button className="btn ghost sm" onClick={() => setShowNewSeq(false)}>Cancel</button>
|
|
<button className="btn primary sm" onClick={createNewSequence}>Create</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Overlay keyboard handler ── */}
|
|
<EditorKeyboard
|
|
onUndo={undo} onRedo={redo} onSave={saveSequence}
|
|
onMarkIn={markSrcIn} onMarkOut={markSrcOut}
|
|
currentSeq={currentSeq}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ToolBtn({ active, label, title, onClick }) {
|
|
return (
|
|
<button onClick={onClick} title={title}
|
|
style={{
|
|
width: 24, height: 22, fontSize: 11, fontWeight: 600,
|
|
background: active ? 'var(--accent-subtle)' : 'var(--bg-surface)',
|
|
border: '1px solid ' + (active ? 'var(--accent-border)' : 'var(--border)'),
|
|
borderRadius: 3, color: active ? 'var(--accent)' : 'var(--text-secondary)',
|
|
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
}}>{label}</button>
|
|
);
|
|
}
|
|
|
|
function ProgramMonitor({ videoRef, currentSeq, playheadFrames, setPlayheadFrames, streamCacheRef }) {
|
|
const [pgmPlaying, setPgmPlaying] = React.useState(false);
|
|
const [pgmClipIdx, setPgmClipIdx] = React.useState(-1);
|
|
const [pgmClips, setPgmClips] = React.useState([]);
|
|
|
|
function getV1Clips() {
|
|
if (!currentSeq || !currentSeq.clips) return [];
|
|
return currentSeq.clips.filter(c => c.track === 0).sort((a, b) => a.timeline_in_frames - b.timeline_out_frames);
|
|
}
|
|
|
|
function togglePlay() {
|
|
if (pgmPlaying) {
|
|
stopPgm();
|
|
return;
|
|
}
|
|
const v1 = getV1Clips();
|
|
if (!v1.length) return;
|
|
setPgmClips(v1);
|
|
const idx = v1.findIndex(c => playheadFrames >= c.timeline_in_frames && playheadFrames < c.timeline_out_frames);
|
|
setPgmClipIdx(idx >= 0 ? idx : 0);
|
|
setPgmPlaying(true);
|
|
}
|
|
|
|
function stopPgm() {
|
|
setPgmPlaying(false);
|
|
setPgmClipIdx(-1);
|
|
const vid = videoRef.current;
|
|
if (vid) vid.pause();
|
|
}
|
|
|
|
React.useEffect(() => {
|
|
if (!pgmPlaying || pgmClipIdx < 0 || pgmClipIdx >= pgmClips.length) return;
|
|
const clip = pgmClips[pgmClipIdx];
|
|
if (!clip) { stopPgm(); return; }
|
|
const vid = videoRef.current;
|
|
if (!vid) return;
|
|
const cache = streamCacheRef.current;
|
|
(async () => {
|
|
let url = cache[clip.asset_id];
|
|
if (!url) {
|
|
try {
|
|
const r = await window.ZAMPP_API.fetch('/assets/' + clip.asset_id + '/stream');
|
|
if (r && r.url) { url = r.url; cache[clip.asset_id] = url; }
|
|
} catch (e) { console.error('Stream fetch failed', e); return; }
|
|
}
|
|
if (vid.src !== url) { vid.src = url; await vid.load(); }
|
|
const srcInSecs = clip.source_in_frames / (window.TC ? window.TC.FPS : 59.94);
|
|
vid.currentTime = srcInSecs;
|
|
vid.play().catch(() => {});
|
|
})();
|
|
}, [pgmPlaying, pgmClipIdx]);
|
|
|
|
function advanceClip() {
|
|
const next = pgmClipIdx + 1;
|
|
if (next >= pgmClips.length) { stopPgm(); return; }
|
|
setPgmClipIdx(next);
|
|
}
|
|
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', borderBottom: '1px solid var(--border)', overflow: 'hidden', background: '#000' }}>
|
|
<video ref={videoRef} preload="auto" style={{ flex: 1, width: '100%', objectFit: 'contain', display: 'block' }}
|
|
onTimeUpdate={() => {
|
|
if (!pgmPlaying) return;
|
|
const clip = pgmClips[pgmClipIdx];
|
|
if (!clip) return;
|
|
const fps = window.TC ? window.TC.FPS : 59.94;
|
|
const srcOutSecs = clip.source_out_frames / fps;
|
|
if (videoRef.current && videoRef.current.currentTime >= srcOutSecs) {
|
|
advanceClip();
|
|
}
|
|
}}
|
|
onEnded={advanceClip}
|
|
/>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', background: 'var(--bg-panel)', borderTop: '1px solid var(--border)', flexShrink: 0 }}>
|
|
<button className="btn ghost sm" style={{ fontSize: 11, padding: '2px 6px' }} onClick={togglePlay}>{pgmPlaying ? '\u23F8' : '\u25B6'}</button>
|
|
<span style={{ fontSize: 10, fontFamily: 'monospace', color: 'var(--accent)', minWidth: 80 }}>
|
|
{window.TC ? window.TC.framesToTC(playheadFrames) : '00:00:00;00'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EditorKeyboard({ onUndo, onRedo, onSave, onMarkIn, onMarkOut, currentSeq }) {
|
|
React.useEffect(() => {
|
|
function handler(e) {
|
|
const tag = document.activeElement.tagName;
|
|
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(tag)) return;
|
|
if ((e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === 'z' || e.key === 'Z')) { e.preventDefault(); onRedo(); return; }
|
|
if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z')) { e.preventDefault(); onUndo(); return; }
|
|
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) { e.preventDefault(); onSave(); return; }
|
|
if (e.key === 'i' || e.key === 'I') { onMarkIn(); return; }
|
|
if (e.key === 'o' || e.key === 'O') { onMarkOut(); return; }
|
|
if (e.key === 'v' || e.key === 'V') { if (window.Timeline) window.Timeline.setTool('select'); return; }
|
|
if (e.key === 'c' || e.key === 'C') { if (window.Timeline) window.Timeline.setTool('razor'); return; }
|
|
if (e.key === 'h' || e.key === 'H') { if (window.Timeline) window.Timeline.setTool('hand'); return; }
|
|
if (e.code === 'Space' && !currentSeq) { e.preventDefault(); /* handled by PGM */ }
|
|
}
|
|
document.addEventListener('keydown', handler);
|
|
return () => document.removeEventListener('keydown', handler);
|
|
}, [onUndo, onRedo, onSave, onMarkIn, onMarkOut, currentSeq]);
|
|
return null;
|
|
}
|
|
|
|
window.Editor = Editor; |