implement zoom #113

pull/276/head
Mikael Finstad 6 years ago
parent 4c14cde1d1
commit 765caff34f

@ -11,7 +11,7 @@ const InverseCutSegment = ({ seg, duration, invertCutSegments }) => (
top: 0, top: 0,
bottom: 0, bottom: 0,
left: `${(seg.start / duration) * 100}%`, left: `${(seg.start / duration) * 100}%`,
width: `${Math.max(((seg.end - seg.start) / duration) * 100, 1)}%`, width: `${((seg.end - seg.start) / duration) * 100}%`,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
pointerEvents: 'none', pointerEvents: 'none',

@ -9,15 +9,14 @@ const { formatDuration } = require('./util');
const TimelineSeg = ({ const TimelineSeg = ({
duration, cutStart, cutEnd, isActive, segNum, duration, cutStart, cutEnd, isActive, segNum,
onSegClick, color, invertCutSegments, onSegClick, color, invertCutSegments, zoomed,
}) => { }) => {
const markerWidth = 4; const cutSectionWidth = `${((cutEnd - cutStart) / duration) * 100}%`;
const cutSectionWidth = `${Math.max(((cutEnd - cutStart) / duration) * 100, 1)}%`;
const strongColor = color.lighten(0.5).string(); const strongColor = color.lighten(0.5).string();
const strongBgColor = color.lighten(0.5).alpha(0.5).string(); const strongBgColor = color.lighten(0.5).alpha(0.5).string();
const startTimePos = `${(cutStart / duration) * 100}%`; const startTimePos = `${(cutStart / duration) * 100}%`;
const markerBorder = isActive ? `2px solid ${strongColor}` : undefined; const markerBorder = `2px solid ${isActive ? strongColor : 'transparent'}`;
const backgroundColor = isActive ? strongBgColor : color.alpha(0.5).string(); const backgroundColor = isActive ? strongBgColor : color.alpha(0.5).string();
const markerBorderRadius = 5; const markerBorderRadius = 5;
@ -32,19 +31,12 @@ const TimelineSeg = ({
justifyContent: 'space-between', justifyContent: 'space-between',
background: backgroundColor, background: backgroundColor,
originX: 0, originX: 0,
borderRadius: markerBorderRadius, boxSizing: 'border-box',
};
const startMarkerStyle = {
height: '100%',
width: markerWidth,
borderLeft: markerBorder, borderLeft: markerBorder,
borderTopLeftRadius: markerBorderRadius, borderTopLeftRadius: markerBorderRadius,
borderBottomLeftRadius: markerBorderRadius, borderBottomLeftRadius: markerBorderRadius,
};
const endMarkerStyle = {
height: '100%',
width: markerWidth,
borderRight: markerBorder, borderRight: markerBorder,
borderTopRightRadius: markerBorderRadius, borderTopRightRadius: markerBorderRadius,
borderBottomRightRadius: markerBorderRadius, borderBottomRightRadius: markerBorderRadius,
@ -63,9 +55,7 @@ const TimelineSeg = ({
onClick={onThisSegClick} onClick={onThisSegClick}
title={cutEnd > cutStart ? formatDuration({ seconds: cutEnd - cutStart }) : undefined} title={cutEnd > cutStart ? formatDuration({ seconds: cutEnd - cutStart }) : undefined}
> >
<div style={startMarkerStyle} /> <div style={{ alignSelf: 'flex-start', flexShrink: 1, fontSize: 10, minWidth: 0, overflow: 'hidden' }}>{segNum + 1}</div>
<div style={{ alignSelf: 'flex-start', flexShrink: 1, fontSize: 10 }}>{segNum + 1}</div>
<AnimatePresence> <AnimatePresence>
{invertCutSegments && ( {invertCutSegments && (
@ -84,10 +74,6 @@ const TimelineSeg = ({
</AnimatePresence> </AnimatePresence>
<div style={{ flexGrow: 1 }} /> <div style={{ flexGrow: 1 }} />
{cutEnd > cutStart && (
<div style={endMarkerStyle} />
)}
</motion.div> </motion.div>
); );
}; };

@ -96,3 +96,7 @@ input, button, textarea, :focus {
.dragging-helper-class { .dragging-helper-class {
color: rgba(0,0,0,0.5); color: rgba(0,0,0,0.5);
} }
#timeline-scroller::-webkit-scrollbar {
display: none;
}

@ -105,6 +105,7 @@ const App = memo(() => {
const [copyStreamIdsByFile, setCopyStreamIdsByFile] = useState({}); const [copyStreamIdsByFile, setCopyStreamIdsByFile] = useState({});
const [muted, setMuted] = useState(false); const [muted, setMuted] = useState(false);
const [streamsSelectorShown, setStreamsSelectorShown] = useState(false); const [streamsSelectorShown, setStreamsSelectorShown] = useState(false);
const [zoom, setZoom] = useState(1);
// Global state // Global state
const [captureFormat, setCaptureFormat] = useState('jpeg'); const [captureFormat, setCaptureFormat] = useState('jpeg');
@ -118,7 +119,8 @@ const App = memo(() => {
const videoRef = useRef(); const videoRef = useRef();
const timelineWrapperRef = useRef(); const timelineWrapperRef = useRef();
const timelineScrollerRef = useRef();
const timelineScrollerSkipEventRef = useRef();
function setCopyStreamIdsForPath(path, cb) { function setCopyStreamIdsForPath(path, cb) {
setCopyStreamIdsByFile((old) => { setCopyStreamIdsByFile((old) => {
@ -187,6 +189,7 @@ const App = memo(() => {
setMuted(false); setMuted(false);
setInvertCutSegments(false); setInvertCutSegments(false);
setStreamsSelectorShown(false); setStreamsSelectorShown(false);
setZoom(1);
}, []); }, []);
useEffect(() => () => { useEffect(() => () => {
@ -197,11 +200,13 @@ const App = memo(() => {
// Because segments could have undefined start / end // Because segments could have undefined start / end
// (meaning extend to start of timeline or end duration) // (meaning extend to start of timeline or end duration)
function getSegApparentStart(time) { function getSegApparentStart(seg) {
const time = seg.start;
return time !== undefined ? time : 0; return time !== undefined ? time : 0;
} }
const getSegApparentEnd = useCallback((time) => { const getSegApparentEnd = useCallback((seg) => {
const time = seg.end;
if (time !== undefined) return time; if (time !== undefined) return time;
if (duration !== undefined) return duration; if (duration !== undefined) return duration;
return 0; // Haven't gotten duration yet return 0; // Haven't gotten duration yet
@ -209,8 +214,8 @@ const App = memo(() => {
const apparentCutSegments = cutSegments.map(cutSegment => ({ const apparentCutSegments = cutSegments.map(cutSegment => ({
...cutSegment, ...cutSegment,
start: getSegApparentStart(cutSegment.start), start: getSegApparentStart(cutSegment),
end: getSegApparentEnd(cutSegment.end), end: getSegApparentEnd(cutSegment),
})); }));
const invalidSegUuids = apparentCutSegments const invalidSegUuids = apparentCutSegments
@ -269,9 +274,16 @@ const App = memo(() => {
const setCutTime = useCallback((type, time) => { const setCutTime = useCallback((type, time) => {
const cloned = clone(cutSegments); const cloned = clone(cutSegments);
const currentSeg = cloned[currentSegIndex];
if (type === 'start' && time >= getSegApparentEnd(currentSeg)) {
throw new Error('Start time must precede end time');
}
if (type === 'end' && time <= getSegApparentStart(currentSeg)) {
throw new Error('Start time must precede end time');
}
cloned[currentSegIndex][type] = time; cloned[currentSegIndex][type] = time;
setCutSegments(cloned); setCutSegments(cloned);
}, [currentSegIndex, cutSegments]); }, [currentSegIndex, getSegApparentEnd, cutSegments]);
function formatTimecode(sec) { function formatTimecode(sec) {
return formatDuration({ seconds: sec, fps: timecodeShowFrames ? detectedFps : undefined }); return formatDuration({ seconds: sec, fps: timecodeShowFrames ? detectedFps : undefined });
@ -305,15 +317,25 @@ const App = memo(() => {
// https://github.com/mifi/lossless-cut/issues/168 // https://github.com/mifi/lossless-cut/issues/168
// If we are after the end of the last segment in the timeline, // If we are after the end of the last segment in the timeline,
// add a new segment that starts at currentTime // add a new segment that starts at currentTime
if (currentCutSeg.start != null && currentCutSeg.end != null if (currentCutSeg.end != null
&& currentTime > currentCutSeg.end) { && currentTime > currentCutSeg.end) {
addCutSegment(); addCutSegment();
} else { } else {
setCutTime('start', currentTime); try {
setCutTime('start', currentTime);
} catch (err) {
errorToast(err.message);
}
} }
}, [setCutTime, currentTime, currentCutSeg, addCutSegment]); }, [setCutTime, currentTime, currentCutSeg, addCutSegment]);
const setCutEnd = useCallback(() => setCutTime('end', currentTime), [setCutTime, currentTime]); const setCutEnd = useCallback(() => {
try {
setCutTime('end', currentTime);
} catch (err) {
errorToast(err.message);
}
}, [setCutTime, currentTime]);
async function setOutputDir() { async function setOutputDir() {
const { filePaths } = await dialog.showOpenDialog({ properties: ['openDirectory'] }); const { filePaths } = await dialog.showOpenDialog({ properties: ['openDirectory'] });
@ -452,8 +474,34 @@ const App = memo(() => {
if (duration) seekAbs((relX / target.offsetWidth) * duration); if (duration) seekAbs((relX / target.offsetWidth) * duration);
} }
const durationSafe = duration || 1;
const currentTimeWidth = 1;
// Prevent it from overflowing (and causing scroll) when end of timeline
const currentTimePos = currentTime !== undefined && currentTime < durationSafe ? `${(currentTime / durationSafe) * 100}%` : undefined;
const zoomed = zoom > 1;
useEffect(() => {
const { currentTime: ct } = videoRef.current;
timelineScrollerSkipEventRef.current = true;
if (zoom > 1) {
timelineScrollerRef.current.scrollLeft = (ct / durationSafe)
* (timelineWrapperRef.current.offsetWidth - timelineScrollerRef.current.offsetWidth);
}
}, [zoom, durationSafe]);
function onTimelineScroll(e) {
if (timelineScrollerSkipEventRef.current) {
timelineScrollerSkipEventRef.current = false;
return;
}
if (!zoomed) return;
seekAbs((((e.target.scrollLeft + (timelineScrollerRef.current.offsetWidth / 2))
/ timelineWrapperRef.current.offsetWidth) * duration));
}
function onWheel(e) { function onWheel(e) {
seekRel(e.deltaX / 10); if (!zoomed) seekRel(e.deltaX / 10);
} }
const playCommand = useCallback(() => { const playCommand = useCallback(() => {
@ -899,7 +947,11 @@ const App = memo(() => {
set(); set();
const rel = time - startTimeOffset; const rel = time - startTimeOffset;
setCutTime(type, rel); try {
setCutTime(type, rel);
} catch (err) {
console.error('Cannot set cut time', err);
}
seekAbs(rel); seekAbs(rel);
}; };
@ -923,9 +975,6 @@ const App = memo(() => {
const otherFormatsMap = fromPairs(Object.entries(allOutFormats) const otherFormatsMap = fromPairs(Object.entries(allOutFormats)
.filter(([f]) => ![...commonFormats, detectedFileFormat].includes(f))); .filter(([f]) => ![...commonFormats, detectedFileFormat].includes(f)));
const durationSafe = duration || 1;
const currentTimePos = currentTime !== undefined && `${(currentTime / durationSafe) * 100}%`;
const segColor = (currentCutSeg || {}).color; const segColor = (currentCutSeg || {}).color;
const segBgColor = segColor.alpha(0.5).string(); const segBgColor = segColor.alpha(0.5).string();
@ -1104,6 +1153,18 @@ const App = memo(() => {
); );
} }
useEffect(() => {
const keyScrollPreventer = (e) => {
// https://stackoverflow.com/questions/8916620/disable-arrow-key-scrolling-in-users-browser
if (e.target === document.body && [32, 37, 38, 39, 40].indexOf(e.keyCode) > -1) {
e.preventDefault();
}
};
window.addEventListener('keydown', keyScrollPreventer);
return () => window.removeEventListener('keydown', keyScrollPreventer);
}, []);
const primaryColor = 'hsl(194, 78%, 47%)'; const primaryColor = 'hsl(194, 78%, 47%)';
return ( return (
@ -1271,11 +1332,20 @@ const App = memo(() => {
onPan={handleTap} onPan={handleTap}
options={{ recognizers: {} }} options={{ recognizers: {} }}
> >
<div> <div style={{ position: 'relative' }}>
<div style={{ height: 36, width: '100%', position: 'relative', backgroundColor: '#444' }} ref={timelineWrapperRef} onWheel={onWheel}> <div
{currentTimePos !== undefined && <div style={{ position: 'absolute', bottom: 0, top: 0, left: currentTimePos, zIndex: 3, backgroundColor: 'rgba(255, 255, 255, 1)', width: 1, pointerEvents: 'none' }} />} style={{ overflowX: 'scroll' }}
id="timeline-scroller"
onWheel={onWheel}
onScroll={onTimelineScroll}
ref={timelineScrollerRef}
>
<div
style={{ height: 36, width: `${zoom * 100}%`, position: 'relative', backgroundColor: '#444' }}
ref={timelineWrapperRef}
>
{currentTimePos !== undefined && <div style={{ position: 'absolute', bottom: 0, top: 0, left: currentTimePos, zIndex: 3, backgroundColor: 'rgba(255, 255, 255, 1)', width: currentTimeWidth, pointerEvents: 'none' }} />}
<AnimatePresence>
{apparentCutSegments.map((seg, i) => ( {apparentCutSegments.map((seg, i) => (
<TimelineSeg <TimelineSeg
key={seg.uuid} key={seg.uuid}
@ -1287,24 +1357,25 @@ const App = memo(() => {
cutStart={seg.start} cutStart={seg.start}
cutEnd={seg.end} cutEnd={seg.end}
invertCutSegments={invertCutSegments} invertCutSegments={invertCutSegments}
zoomed={zoomed}
/> />
))} ))}
</AnimatePresence>
{inverseCutSegments && inverseCutSegments.map((seg, i) => (
<InverseCutSegment
// eslint-disable-next-line react/no-array-index-key
key={i}
seg={seg}
duration={durationSafe}
invertCutSegments={invertCutSegments}
/>
))}
<div style={{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', pointerEvents: 'none' }}> {inverseCutSegments && inverseCutSegments.map((seg, i) => (
<div style={{ background: 'rgba(0,0,0,0.4)', borderRadius: 3, padding: '2px 4px', color: 'rgba(255, 255, 255, 0.8)' }}> <InverseCutSegment
{formatTimecode(offsetCurrentTime)} // eslint-disable-next-line react/no-array-index-key
</div> key={i}
seg={seg}
duration={durationSafe}
invertCutSegments={invertCutSegments}
/>
))}
</div>
</div>
<div style={{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', pointerEvents: 'none' }}>
<div style={{ background: 'rgba(0,0,0,0.4)', borderRadius: 3, padding: '2px 4px', color: 'rgba(255, 255, 255, 0.8)' }}>
{formatTimecode(offsetCurrentTime)}
</div> </div>
</div> </div>
</div> </div>
@ -1406,6 +1477,15 @@ const App = memo(() => {
/> />
{renderInvertCutButton()} {renderInvertCutButton()}
<select style={{ width: 80, margin: '0 10px' }} value={zoom.toString()} title="Zoom" onChange={withBlur(e => setZoom(parseInt(e.target.value, 10)))}>
{Array(10).fill().map((unused, z) => {
const val = 2 ** z;
return (
<option key={val} value={String(val)}>Zoom {val}x</option>
);
})}
</select>
</div> </div>
<div className="right-menu" style={{ position: 'absolute', right: 0, bottom: 0, padding: '.3em', display: 'flex', alignItems: 'center' }}> <div className="right-menu" style={{ position: 'absolute', right: 0, bottom: 0, padding: '.3em', display: 'flex', alignItems: 'center' }}>

Loading…
Cancel
Save