Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

835 extend caption editing area UI #837

Merged
merged 12 commits into from
Dec 4, 2024
222 changes: 133 additions & 89 deletions src/screens/Watch/Components/Transcriptions/CaptionLine/index.js
Original file line number Diff line number Diff line change
@@ -1,143 +1,187 @@
import React, { useRef } from 'react';
import React, { useRef, useState, useEffect } from 'react';
import { isMobile } from 'react-device-detect';
import * as KeyCode from 'keycode-js';

import {
transControl,
timeStrToSec,
prettierTimeStr,
// WEBVTT_DESCRIPTIONS,
} from '../../../Utils';
import { prettierTimeStr } from '../../../Utils';
import './index.scss';

function CaptionLine({ /* isCurrent = false, isEditing = false,
shouldHide = false, */ caption = {}, allowEdit, dispatch, fontSize }) {
let { text, id, /* startTime, */ begin, kind = "web" } = caption;
const ref = useRef();
function CaptionLine({ caption = {}, allowEdit, dispatch, fontSize }) {
const { text, id, begin, end, kind = "web" } = caption;

const blurFromInput = () => {
if (ref && ref.current && typeof ref.current.blur === 'function') {
if (document.activeElement.id === ref.current.id) {
ref.current.innerText = text;
ref.current.blur();
}
}
};
const startTimeRef = useRef();
const endTimeRef = useRef();
const textRef = useRef();

const handleSeek = () => {
const time = timeStrToSec(begin);
dispatch({ type: 'watch/media_setCurrTime', payload: time })
};

const handleChange = () => {
// console.log(target.innerText)
};
const [fullBeginTime, setFullBeginTime] = useState(begin);
const [fullEndTime, setFullEndTime] = useState(end);
const [savedText, setSavedText] = useState(text);

const handleFocus = ({ target }) => {
// console.error(e.target.innerText)
dispatch({ type: 'watch/setTransEditMode', payload: { caption, innerText: target.innerText } })
};
const [displayedStartTime, setDisplayedStartTime] = useState(prettierTimeStr(begin, false));
const [displayedEndTime, setDisplayedEndTime] = useState(prettierTimeStr(end, false));

const handleBlur = () => {
ref.current.innerText = text;
transControl.handleBlur();
const validateTimeFormat = (input) => {
const timeRegex = /^(\d{1,2}:)?\d{1,2}:\d{2}(\.\d+)?$/;
if (!timeRegex.test(input)) {
throw new Error('Invalid time format');
}
return true;
};

// The control flow is that a change to time is saved in handleTimeKeyDown,
// which triggers handleSave, which then changes the displayedTime to the correct truncated time
const handleSave = () => {
dispatch({ type: 'watch/saveCaption', payload: { caption, text: ref.current.innerText } })
dispatch({
type: 'watch/saveCaption',
payload: { caption, text: savedText, begin: fullBeginTime, end: fullEndTime },
});
setDisplayedStartTime(prettierTimeStr(fullBeginTime, false));
setDisplayedEndTime(prettierTimeStr(fullEndTime, false));
};

const handleCancel = () => {
ref.current.innerText = text;
dispatch({ type: 'watch/setCurrEditing', payload: null })
useEffect(() => {
handleSave()
}, [savedText, fullBeginTime, fullEndTime])

// NOTE: ALL editable text boxes reset the value to the original if the textbox loses focus
// Users MUST hit enter for their changes to not be lost
const handleTimeBlur = (setDisplayedTime, originalValue) => {
setDisplayedTime(prettierTimeStr(originalValue, false));
};

const handleKeyDown = (e) => {
if (e.keyCode === KeyCode.KEY_RETURN && ! e.shiftKey) {
e.preventDefault();
handleSave();
blurFromInput();
// Ideally, you could do something like setSavedText(savedText), akin to how handleTextKeyDown
// lazy updates savedText, but this won't trigger a DOM update, so we have to do manually
// update the DOM
const handleTextBlur = () => {
if (textRef.current) {
textRef.current.innerText = savedText
}
};

const timeStr = prettierTimeStr(String(begin));
const hasUnsavedChanges = ref && ref.current && ref.current.innerText !== text;
let roundedTime = Math.round(begin);
let beginTime= Math.floor(roundedTime / 60)
let secondsTime = roundedTime % 60
let secondsTimeString = String(secondsTime);
const handleTimeFocus = (fullTime, setDisplayedTime) => {
setDisplayedTime(prettierTimeStr(fullTime, true));
dispatch({
type: 'watch/setTransEditMode',
payload: { caption, innerText: fullTime },
});
};

const handleTextFocus = () => {
dispatch({
type: 'watch/setTransEditMode',
payload: { caption },
});
};

if (secondsTime < 10) {
secondsTimeString = `0${ String(secondsTime)}`
const handleTimeKeyDown = (e, ref, setFullTime) => {
if (ref.current) {
if (e.keyCode === KeyCode.KEY_ESCAPE) {
e.preventDefault();
ref.current.blur();
} else if (e.keyCode === KeyCode.KEY_RETURN && !e.shiftKey) {
e.preventDefault();
const currentValue = ref.current?.innerText || "";
try {
validateTimeFormat(currentValue);
setFullTime(currentValue);
} catch (error) {
dispatch({
type: 'watch/timestampFailed',
payload: { caption },
});
}
ref.current.blur();
}
}
}
let totalTime = `${String(beginTime) }:${ secondsTimeString}`;
if (begin !== undefined) {
totalTime = prettierTimeStr(begin)

const handleTextKeyDown = (e, ref) => {
if (ref.current) {
if (e.keyCode === KeyCode.KEY_ESCAPE) {
ref.current.blur();
return;
}
if (e.keyCode === KeyCode.KEY_RETURN && !e.shiftKey) {
e.preventDefault();
const currentValue = textRef.current?.innerText || "";
setSavedText(currentValue);
ref.current.blur();
}
}
}

return (
<div
id={`caption-line-${id}`} // {begin === undefined ? `caption-line-${startTime}` :`caption-line-${id}`}
id={`caption-line-${id}`}
className="watch-caption-line"
// current={isCurrent.toString()}
// editing={isEditing.toString()}
// hide={shouldHide.toString()}
kind={kind}
data-unsaved={hasUnsavedChanges}
data-unsaved
>
<div className="caption-line-content">
{/* Time Seeking Button */}
<button
className="plain-btn caption-line-time-display"
onClick={handleSeek}
aria-label={`Jump to ${timeStr}`}
{/* Editable Start Time */}
<div
ref={startTimeRef}
suppressContentEditableWarning
contentEditable={allowEdit && !isMobile}
role="textbox"
tabIndex={0}
id={`caption-line-time-${id}`}
className="caption-line-time-display"
onFocus={() => { handleTimeFocus(fullBeginTime, setDisplayedStartTime) }}
onBlur={() => { handleTimeBlur(setDisplayedStartTime, fullBeginTime) }}
onKeyDown={(e) => { handleTimeKeyDown(e, startTimeRef, setFullBeginTime) }}
spellCheck={false}
>
<span tabIndex="-1">{totalTime}</span>
</button>
{displayedStartTime}
</div>

{/* Editable Text */}
<div
ref={ref}
ref={textRef}
suppressContentEditableWarning
contentEditable={allowEdit && !isMobile}
role="textbox"
tabIndex={0}
id={`caption-line-textarea-${id}`}
className={`caption-line-text-${fontSize}`}
onFocus={handleFocus}
onBlur={handleBlur}
onInput={handleChange}
onKeyDown={handleKeyDown}
spellCheck={false}
>{text}
onFocus={handleTextFocus}
onBlur={() => { handleTextBlur(text) }}
onKeyDown={(e) => { handleTextKeyDown(e, textRef) }}
>
{savedText}
</div>

{/* Editable End Time */}
<div
ref={endTimeRef}
suppressContentEditableWarning
contentEditable={allowEdit && !isMobile}
role="textbox"
tabIndex={0}
id={`caption-line-end-time-${id}`}
className="caption-line-time-display"
onFocus={() => { handleTimeFocus(fullEndTime, setDisplayedEndTime) }}
onBlur={() => { handleTimeBlur(setDisplayedEndTime, fullEndTime) }}
onKeyDown={(e) => { handleTimeKeyDown(e, endTimeRef, setFullEndTime) }}
spellCheck={false}
>
{displayedEndTime}
</div>
</div>


{/* Action Buttons */}
<div className="caption-line-btns">
{hasUnsavedChanges && (
{/* <div className="caption-line-btns">
{true && (
<div className="mt-2 mr-3 caption-line-prompt">Return (save changes). Shift-Return (newline)</div>
)}

{/* Save Button */}
<button
className="plain-btn caption-line-save-btn"
onClick={handleSave}
tabIndex={-1}
aria-hidden
>
<button className="plain-btn caption-line-save-btn" onClick={handleSave}>
Save
</button>
<button
className="plain-btn caption-line-save-btn"
onClick={handleCancel}
tabIndex={-1}
aria-hidden
>
<button className="plain-btn caption-line-cancel-btn" onClick={handleCancel}>
Cancel
</button>
</div>
</div> */}
</div>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
} from '../../../Utils';

export default function TranscriptText({ caption = {}, /* isCurrent = false, */ dispatch }) {
const { text = '', id, begin, kind } = caption;
const { text = '', id, begin, end, kind } = caption;

const handleSeek = () => {
const time = timeStrToSec(begin);
Expand Down
60 changes: 50 additions & 10 deletions src/screens/Watch/Utils/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,18 +43,58 @@ export function isLater(time) {
return ({ begin }) => time <= timeStrToSec(begin);
}

export function prettierTimeStr(str) {
if (typeof str !== 'string') return '';
const strs = str.split(':');
let mins = parseInt(strs[0], 10) * 60 + parseInt(strs[1], 10);
mins = mins.toString();
if (mins.length === 1) mins = `0${mins}`;
let sec = parseInt(strs[2], 10);
sec = sec.toString();
if (sec.length === 1) sec = `0${sec}`;
return `${mins}:${sec}`;
export function prettierTimeStr(time, showMilliseconds = false) {
if (typeof time !== 'string') return '';

const parts = time.split(':').map((part) => parseFloat(part));
let hours = 0;
let mins = 0;
let secs = 0;
let millis = 0;

if (parts.length === 3) {
hours = parts[0];
mins = parts[1];
secs = Math.floor(parts[2]);
millis = Math.round((parts[2] % 1) * 1000);
} else if (parts.length === 2) {
mins = parts[0];
secs = Math.floor(parts[1]);
millis = Math.round((parts[1] % 1) * 1000);
} else if (parts.length === 1) {
secs = Math.floor(parts[0]);
millis = Math.round((parts[0] % 1) * 1000);
}

const format = (num, digits = 2) => String(num).padStart(digits, '0');
const formattedTime = `${format(hours)}:${format(mins)}:${format(secs)}`;

return showMilliseconds
? `${formattedTime}.${format(millis, 3)}`
: formattedTime;
}



// export function prettierTimeStr(str) {
// if (typeof str !== 'string') return '';

// const strs = str.split(':');
// if (strs.length !== 3) return ''; // Ensure the input is in HH:MM:SS format

// let hours = parseInt(strs[0], 10);
// let mins = parseInt(strs[1], 10);
// let sec = parseInt(strs[2], 10);

// // Format minutes and seconds to two digits
// if (hours < 10) hours = `0${hours}`;
// if (mins < 10) mins = `0${mins}`;
// if (sec < 10) sec = `0${sec}`;

// return `${hours}:${mins}:${sec}`;
// }


export function getCCSelectOptions(array = [], operation = (item) => item) {
const options = [];
array.forEach((item) => {
Expand Down
Loading
Loading