import * as React from 'react'; import { useEffect, useState, useRef } from 'react'; import './ProgressBar.scss'; import { MediaSegment } from './RecordingView'; interface ProgressBarProps { videos: MediaSegment[]; setVideos: React.Dispatch>; orderVideos: boolean; progress: number; recording: boolean; doUndo: boolean; setCanUndo?: React.Dispatch>; } interface SegmentBox { endTime: number; startTime: number; order: number; } interface CurrentHover { index: number; minX: number; maxX: number; } export function ProgressBar(props: ProgressBarProps) { const progressBarRef = useRef(null); // the actual list of JSX elements rendered as segments const [segments, setSegments] = useState([]); // array for the order of video segments const [ordered, setOrdered] = useState([]); const [undoStack, setUndoStack] = useState([]); // -1 if no segment is currently being dragged around; else, it is the id of that segment over // NOTE: the id of a segment is its index in the ordered array const [dragged, setDragged] = useState(-1); // length of the time removed from the video, in seconds*100 const [totalRemovedTime, setTotalRemovedTime] = useState(0); // this holds the index of the videoc segment to be removed const [removed, setRemoved] = useState(-1); // update the canUndo props based on undo stack useEffect(() => props.setCanUndo?.(undoStack.length > 0), [undoStack.length]); const handleUndo = () => { // get the last element from the undo if it exists if (undoStack.length === 0) return; // get and remove the last element from the undo stack const last = undoStack.lastElement(); setUndoStack(prevUndo => prevUndo.slice(0, -1)); // update the removed time and place element back into ordered setTotalRemovedTime(prevRemoved => prevRemoved - (last.endTime - last.startTime)); setOrdered(prevOrdered => [...prevOrdered, last]); }; // useEffect for undo - brings back the most recently deleted segment useEffect(() => handleUndo(), [props.doUndo]); // useEffect for recording changes - changes style to disabled and adds the "expanding-segment" useEffect(() => { // get segments segment's html using it's id -> make them appeared disabled (or enabled) segments.forEach(seg => document.getElementById(seg.props.id)?.classList.toggle('segment-disabled', props.recording)); progressBarRef.current?.classList.toggle('progressbar-disabled', props.recording); if (props.recording) setSegments(prevSegments => [ ...prevSegments,
{props.videos.length + 1}
, ]); }, [props.recording]); // useEffect that updates the segmentsJSX, which is rendered // only updated when ordered is updated or if the user is dragging around a segment useEffect(() => { const totalTime = props.progress * 1000 - totalRemovedTime; const segmentsJSX = ordered.map((seg, i) => (
{seg.order + 1}
)); setSegments(segmentsJSX); }, [dragged, ordered]); // useEffect for dragged - update the cursor to be grabbing while grabbing useEffect(() => { progressBarRef.current?.classList.toggle('progressbar-dragging', dragged !== -1); }, [dragged]); // to imporve performance, only want to update the CSS width, not re-render the whole JSXList useEffect(() => { if (!props.recording) return; const totalTime = props.progress * 1000 - totalRemovedTime; let remainingTime = totalTime; segments.forEach((seg, i) => { // for the last segment, we need to set that directly if (i === segments.length - 1) return; // update remaining time remainingTime -= ordered[i].endTime - ordered[i].startTime; // update the width for this segment const htmlId = seg.props.id; const segmentHtml = document.getElementById(htmlId); if (segmentHtml) segmentHtml.style.width = `${((ordered[i].endTime - ordered[i].startTime) / totalTime) * 100}%`; }); // update the width of the expanding segment using the remaining time const segExapandHtml = document.getElementById('segment-expanding'); if (segExapandHtml) segExapandHtml.style.width = ordered.length === 0 ? '100%' : `${(remainingTime / totalTime) * 100}%`; }, [props.progress]); // useEffect for props.videos - update the ordered array when a new video is added useEffect(() => { // this useEffect fired when the videos are being rearragned to the order // in this case, do nothing. if (props.orderVideos) return; const order = props.videos.length - 1; // in this case, a new video is added -> push it onto ordered if (order >= ordered.length) { const { endTime, startTime } = props.videos.lastElement(); setOrdered(prevOrdered => [...prevOrdered, { endTime, startTime, order }]); } // in this case, a video is removed else if (order < ordered.length) { console.warn('warning: video removed from parent'); } }, [props.videos]); // useEffect for props.orderVideos - matched the order array with the videos array before the export useEffect(() => props.setVideos(vids => ordered.map(seg => vids[seg.order])), [props.orderVideos]); // useEffect for removed - handles logic for removing a segment useEffect(() => { if (removed === -1) return; // update total removed time setTotalRemovedTime(prevRemoved => prevRemoved + (ordered[removed].endTime - ordered[removed].startTime)); // put the element on the undo stack setUndoStack(prevUndo => [...prevUndo, ordered[removed]]); // remove the segment from the array setOrdered(prevOrdered => prevOrdered.filter((seg, i) => i !== removed)); // reset to default/nullish state setRemoved(-1); }, [removed]); // returns the new currentHover based on the new index const updateCurrentHover = (segId: number): CurrentHover | null => { // get the segId of the segment that will become the new bounding area const rect = progressBarRef.current?.children[segId].getBoundingClientRect(); if (rect == null) return null; return { index: segId, minX: rect.x, maxX: rect.x + rect.width, }; }; const swapSegments = (oldIndex: number, newIndex: number) => { if (newIndex == null) return; setOrdered(prevOrdered => { const temp = { ...prevOrdered[oldIndex] }; prevOrdered[oldIndex] = prevOrdered[newIndex]; prevOrdered[newIndex] = temp; return prevOrdered; }); // update visually where the segment is hovering over setDragged(newIndex); }; // functions for the floating segment that tracks the cursor while grabbing it const initDetachSegment = (dot: HTMLDivElement, rect: DOMRect) => { dot.classList.add('segment-selected'); dot.style.transitionDuration = '0s'; dot.style.position = 'absolute'; dot.style.zIndex = '999'; dot.style.width = `${rect.width}px`; dot.style.height = `${rect.height}px`; dot.style.left = `${rect.x}px`; dot.style.top = `${rect.y}px`; dot.draggable = false; document.body.append(dot); }; const followCursor = (event: PointerEvent, dot: HTMLDivElement): void => { // event.stopPropagation() const { width, height } = dot.getBoundingClientRect(); dot.style.left = `${event.clientX - width / 2}px`; dot.style.top = `${event.clientY - height / 2}px`; }; // pointerdown event for the progress bar const onPointerDown = (e: React.PointerEvent) => { // don't move the videobox element e.stopPropagation(); // if recording, do nothing if (props.recording) return; // get the segment the user clicked on to be dragged const clickedSegment = e.target as HTMLDivElement & EventTarget; // get the profess bar ro add event listeners // don't do anything if null const progressBar = progressBarRef.current; if (progressBar == null || clickedSegment.id === progressBar.id) return; // if holding shift key, let's remove that segment if (e.shiftKey) { const segId = parseInt(clickedSegment.id.split('-')[1]); setRemoved(segId); return; } // if holding ctrl key and click, let's undo that segment #hiddenfeature lol if (e.ctrlKey) { handleUndo(); return; } // if we're here, the user is dragging a segment around // let the progress bar capture all the pointer events until the user releases (pointerUp) const ptrId = e.pointerId; progressBar.setPointerCapture(ptrId); const rect = clickedSegment.getBoundingClientRect(); // id for segment is like 'segment-1' or 'segment-10', // so this works to get the id const segId = parseInt(clickedSegment.id.split('-')[1]); // set the selected segment to be the one dragged setDragged(segId); // this is the logic for storing the lower X bound and upper X bound to know // whether a swap is needed between two segments let currentHover: CurrentHover = { index: segId, minX: rect.x, maxX: rect.x + rect.width, }; // create the floating segment that tracks the cursor const detchedSegment = document.createElement('div'); initDetachSegment(detchedSegment, rect); const updateSegmentOrder = (event: PointerEvent): void => { event.stopPropagation(); event.preventDefault(); // this fixes a bug where pointerup doesn't fire while cursor is upped while being dragged if (!progressBar.hasPointerCapture(ptrId)) { // eslint-disable-next-line no-use-before-define placeSegmentandCleanup(); return; } followCursor(event, detchedSegment); const curX = event.clientX; // handle the left bound if (curX < currentHover.minX && currentHover.index > 0) { swapSegments(currentHover.index, currentHover.index - 1); currentHover = updateCurrentHover(currentHover.index - 1) ?? currentHover; } // handle the right bound else if (curX > currentHover.maxX && currentHover.index < segments.length - 1) { swapSegments(currentHover.index, currentHover.index + 1); currentHover = updateCurrentHover(currentHover.index + 1) ?? currentHover; } }; // handles when the user is done dragging the segment (pointerUp) const placeSegmentandCleanup = (event?: PointerEvent): void => { event?.stopPropagation(); event?.preventDefault(); // if they put the segment outside of the bounds, remove it if (event && (event.clientX < 0 || event.clientX > document.body.clientWidth || event.clientY < 0 || event.clientY > document.body.clientHeight)) setRemoved(currentHover.index); // remove the update event listener for pointermove progressBar.removeEventListener('pointermove', updateSegmentOrder); // remove the floating segment from the DOM detchedSegment.remove(); // dragged is -1 is equiv to nothing being dragged, so the normal state // so this will place the segment in it's location and update the segment bar setDragged(-1); }; // event listeners that allow the user to drag and release the floating segment progressBar.addEventListener('pointermove', updateSegmentOrder); progressBar.addEventListener('pointerup', placeSegmentandCleanup, { once: true }); }; return (
{segments}
); }