@bbc/react-transcript-editor
Version:
A React component to make transcribing audio and video easier and faster.
507 lines (416 loc) • 19.3 kB
JavaScript
function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
import React from 'react';
import PropTypes from 'prop-types';
import Tooltip from 'react-simple-tooltip';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faQuestionCircle, faMousePointer, faICursor, faUserEdit, faKeyboard, faSave } from '@fortawesome/free-solid-svg-icons';
import { Editor, EditorState, CompositeDecorator, convertFromRaw, convertToRaw, getDefaultKeyBinding, Modifier } from 'draft-js';
import Word from './Word';
import WrapperBlock from './WrapperBlock';
import sttJsonAdapter from '../../Util/adapters/index.js';
import exportAdapter from '../../Util/export-adapters/index.js';
import style from './index.module.css';
class TimedTextEditor extends React.Component {
constructor(props) {
super(props);
_defineProperty(this, "onChange", editorState => {
// https://draftjs.org/docs/api-reference-editor-state#lastchangetype
// https://draftjs.org/docs/api-reference-editor-change-type
// doing editorStateChangeType === 'insert-characters' is triggered even
// outside of draftJS eg when clicking play button so using this instead
// see issue https://github.com/facebook/draft-js/issues/1060
if (this.state.editorState.getCurrentContent() !== editorState.getCurrentContent()) {
if (this.props.isPauseWhileTypingOn) {
if (this.props.isPlaying()) {
this.props.playMedia(false); // Pause video for X seconds
const pauseWhileTypingIntervalInMilliseconds = 3000; // resets timeout
clearTimeout(this.plauseWhileTypingTimeOut);
this.plauseWhileTypingTimeOut = setTimeout(function () {
// after timeout starts playing again
this.props.playMedia(true);
}.bind(this), pauseWhileTypingIntervalInMilliseconds);
}
}
}
if (this.state.isEditable) {
this.setState(prevState => ({
editorState,
inputCount: prevState.inputCount + 1
}), () => {
// Saving every 5 keystrokes
if (this.state.inputCount > 5) {
this.setState({
inputCount: 0
});
this.localSave(this.props.mediaUrl);
}
});
}
});
_defineProperty(this, "handleDoubleClick", event => {
// nativeEvent --> React giving you the DOM event
let element = event.nativeEvent.target; // find the parent in Word that contains span with time-code start attribute
while (!element.hasAttribute('data-start') && element.parentElement) {
element = element.parentElement;
}
if (element.hasAttribute('data-start')) {
const t = parseFloat(element.getAttribute('data-start'));
this.props.onWordClick(t);
}
});
_defineProperty(this, "localSave", () => {
let mediaUrlName = this.props.mediaUrl; // if using local media instead of using random blob name
// that makes it impossible to retrieve from on page refresh
// use file name
if (this.props.mediaUrl.includes('blob')) {
mediaUrlName = this.props.fileName;
}
const data = convertToRaw(this.state.editorState.getCurrentContent());
localStorage.setItem(`draftJs-${mediaUrlName}`, JSON.stringify(data));
const newLastLocalSavedDate = new Date().toString();
localStorage.setItem(`timestamp-${mediaUrlName}`, newLastLocalSavedDate);
return newLastLocalSavedDate;
});
_defineProperty(this, "getWordCount", editorState => {
const plainText = editorState.getCurrentContent().getPlainText('');
const regex = /(?:\r\n|\r|\n)/g; // new line, carriage return, line feed
const cleanString = plainText.replace(regex, ' ').trim(); // replace above characters w/ space
const wordArray = cleanString.match(/\S+/g); // matches words according to whitespace
return wordArray ? wordArray.length : 0;
});
_defineProperty(this, "setEditorContentState", data => {
const contentState = convertFromRaw(data); // eslint-disable-next-line no-use-before-define
const editorState = EditorState.createWithContent(contentState, decorator);
if (this.props.handleAnalyticsEvents !== undefined) {
this.props.handleAnalyticsEvents({
category: 'TimedTextEditor',
action: 'setEditorContentState',
name: 'getWordCount',
value: this.getWordCount(editorState)
});
}
this.setState({
editorState
});
});
_defineProperty(this, "forceRenderDecorator", () => {
// const { editorState, updateEditorState } = this.props;
const contentState = this.state.editorState.getCurrentContent();
const decorator = this.state.editorState.getDecorator();
const newState = EditorState.createWithContent(contentState, decorator); // this.setEditorNewContentState(newState);
const newEditorState = EditorState.push(newState, contentState);
this.setState({
editorState: newEditorState
});
});
_defineProperty(this, "setEditorNewContentState", newContentState => {
const newEditorState = EditorState.push(this.state.editorState, newContentState);
this.setState({
editorState: newEditorState
});
});
_defineProperty(this, "customKeyBindingFn", e => {
const enterKey = 13;
if (e.keyCode === enterKey) {
return 'split-paragraph';
}
return getDefaultKeyBinding(e);
});
_defineProperty(this, "handleKeyCommand", command => {
if (command === 'split-paragraph') {
this.splitParagraph();
}
return 'not-handled';
});
_defineProperty(this, "splitParagraph", () => {
// https://github.com/facebook/draft-js/issues/723#issuecomment-367918580
// https://draftjs.org/docs/api-reference-selection-state#start-end-vs-anchor-focus
const currentSelection = this.state.editorState.getSelection(); // only perform if selection is not selecting a range of words
// in that case, we'd expect delete + enter to achieve same result.
if (currentSelection.isCollapsed()) {
const currentContent = this.state.editorState.getCurrentContent(); // https://draftjs.org/docs/api-reference-modifier#splitblock
const newContentState = Modifier.splitBlock(currentContent, currentSelection); // https://draftjs.org/docs/api-reference-editor-state#push
const splitState = EditorState.push(this.state.editorState, newContentState, 'split-block');
const targetSelection = splitState.getSelection();
const originalBlock = currentContent.blockMap.get(newContentState.selectionBefore.getStartKey());
const originalBlockData = originalBlock.getData();
const blockSpeaker = originalBlockData.get('speaker');
let wordStartTime = 'NA'; // eslint-disable-next-line prefer-const
let isEndOfParagraph = false; // identify the entity (word) at the selection/cursor point on split.
// eslint-disable-next-line prefer-const
let entityKey = originalBlock.getEntityAt(currentSelection.getStartOffset()); // if there is no word entity associated with a char then there is no entity key
// at that selection point
if (entityKey === null) {
const closestEntityToSelection = this.findClosestEntityKeyToSelectionPoint(currentSelection, originalBlock);
entityKey = closestEntityToSelection.entityKey;
isEndOfParagraph = closestEntityToSelection.isEndOfParagraph; // handle edge case when it doesn't find a closest entity (word)
// eg pres enter on an empty line
if (entityKey === null) {
return 'not-handled';
}
} // if there is an entityKey at or close to the selection point
// can get the word startTime. for the new paragraph.
const entityInstance = currentContent.getEntity(entityKey);
const entityData = entityInstance.getData();
if (isEndOfParagraph) {
// if it's end of paragraph use end time of word for new paragraph
wordStartTime = entityData.end;
} else {
wordStartTime = entityData.start;
} // split paragraph
// https://draftjs.org/docs/api-reference-modifier#mergeblockdata
const afterMergeContentState = Modifier.mergeBlockData(splitState.getCurrentContent(), targetSelection, {
'start': wordStartTime,
'speaker': blockSpeaker
});
this.setEditorNewContentState(afterMergeContentState);
return 'handled';
}
return 'not-handled';
});
_defineProperty(this, "findClosestEntityKeyToSelectionPoint", (currentSelection, originalBlock) => {
// set defaults
let entityKey = null;
let isEndOfParagraph = false; // selection offset from beginning of the paragraph block
const startSelectionOffsetKey = currentSelection.getStartOffset(); // length of the plain text for the ContentBlock
const lengthPlainTextForTheBlock = originalBlock.getLength(); // number of char from selection point to end of paragraph
const remainingCharNumber = lengthPlainTextForTheBlock - startSelectionOffsetKey; // if it's the last char in the paragraph - get previous entity
if (remainingCharNumber === 0) {
isEndOfParagraph = true;
for (let j = lengthPlainTextForTheBlock; j > 0; j--) {
entityKey = originalBlock.getEntityAt(j);
if (entityKey !== null) {
// if it finds it then return
return {
entityKey,
isEndOfParagraph
};
}
}
} // if it's first char or another within the block - get next entity
else {
console.log('Main part of paragraph');
let initialSelectionOffset = currentSelection.getStartOffset();
for (let i = 0; i < remainingCharNumber; i++) {
initialSelectionOffset += i;
entityKey = originalBlock.getEntityAt(initialSelectionOffset); // if it finds it then return
if (entityKey !== null) {
return {
entityKey,
isEndOfParagraph
};
}
}
} // cover edge cases where it doesn't find it
return {
entityKey,
isEndOfParagraph
};
});
_defineProperty(this, "renderBlockWithTimecodes", () => {
return {
component: WrapperBlock,
editable: true,
props: {
showSpeakers: this.state.showSpeakers,
showTimecodes: this.state.showTimecodes,
timecodeOffset: this.state.timecodeOffset,
editorState: this.state.editorState,
setEditorNewContentState: this.setEditorNewContentState,
onWordClick: this.props.onWordClick,
handleAnalyticsEvents: this.props.handleAnalyticsEvents
}
};
});
_defineProperty(this, "getCurrentWord", () => {
const currentWord = {
start: 'NA',
end: 'NA'
};
if (this.state.transcriptData) {
const contentState = this.state.editorState.getCurrentContent(); // TODO: using convertToRaw here might be slowing down performance(?)
const contentStateConvertEdToRaw = convertToRaw(contentState);
const entityMap = contentStateConvertEdToRaw.entityMap;
for (var entityKey in entityMap) {
const entity = entityMap[entityKey];
const word = entity.data;
if (word.start <= this.props.currentTime && word.end >= this.props.currentTime) {
currentWord.start = word.start;
currentWord.end = word.end;
}
}
}
if (currentWord.start !== 'NA') {
if (this.props.isScrollIntoViewOn) {
const currentWordElement = document.querySelector(`span.Word[data-start="${currentWord.start}"]`);
currentWordElement.scrollIntoView({
block: 'center',
inline: 'center'
});
}
}
return currentWord;
});
this.state = {
editorState: EditorState.createEmpty(),
transcriptData: this.props.transcriptData,
isEditable: this.props.isEditable,
sttJsonType: this.props.sttJsonType,
timecodeOffset: this.props.timecodeOffset,
showSpeakers: this.props.showSpeakers,
showTimecodes: this.props.showTimecodes,
inputCount: 0,
currentWord: {}
};
}
componentDidMount() {
this.loadData();
}
static getDerivedStateFromProps(nextProps) {
if (nextProps.transcriptData !== null) {
return {
transcriptData: nextProps.transcriptData,
isEditable: nextProps.isEditable,
timecodeOffset: nextProps.timecodeOffset,
showSpeakers: nextProps.showSpeakers,
showTimecodes: nextProps.showTimecodes
};
}
return null;
}
componentDidUpdate(prevProps, prevState) {
if (prevState.transcriptData !== this.state.transcriptData) {
this.loadData();
}
if (prevState.timecodeOffset !== this.state.timecodeOffset || prevState.showSpeakers !== this.state.showSpeakers || prevState.showTimecodes !== this.state.showTimecodes) {
// forcing a re-render is an expensive operation and
// there might be a way of optimising this at a later refactor (?)
// the issue is that WrapperBlock is not update on TimedTextEditor
// state change otherwise.
// for now compromising on this, as setting timecode offset, and
// display preferences for speakers and timecodes are not expected to
// be very frequent operations but rather one time setup in most cases.
this.forceRenderDecorator();
}
}
loadData() {
if (this.props.transcriptData !== null) {
const blocks = sttJsonAdapter(this.props.transcriptData, this.props.sttJsonType);
this.setEditorContentState(blocks);
}
}
getEditorContent(exportFormat) {
const format = exportFormat || 'draftjs';
return exportAdapter(convertToRaw(this.state.editorState.getCurrentContent()), format);
} // click on words - for navigation
// eslint-disable-next-line class-methods-use-this
// eslint-disable-next-line class-methods-use-this
isPresentInLocalStorage(mediaUrl) {
if (mediaUrl !== null) {
let mediaUrlName = mediaUrl;
if (mediaUrl.includes('blob')) {
mediaUrlName = this.props.fileName;
}
const data = localStorage.getItem(`draftJs-${mediaUrlName}`);
if (data !== null) {
return true;
}
return false;
}
return false;
}
loadLocalSavedData(mediaUrl) {
let mediaUrlName = mediaUrl;
if (mediaUrl.includes('blob')) {
mediaUrlName = this.props.fileName;
}
const data = JSON.parse(localStorage.getItem(`draftJs-${mediaUrlName}`));
if (data !== null) {
const lastLocalSavedDate = localStorage.getItem(`timestamp-${mediaUrlName}`);
this.setEditorContentState(data);
return lastLocalSavedDate;
}
return '';
} // originally from
// https://github.com/draft-js-plugins/draft-js-plugins/blob/master/draft-js-counter-plugin/src/WordCounter/index.js#L12
render() {
const helpMessage = React.createElement("div", {
className: style.helpMessage
}, React.createElement("span", null, React.createElement(FontAwesomeIcon, {
className: style.icon,
icon: faMousePointer
}), "Double click on a word or timestamp to jump to that point in the video."), React.createElement("span", null, React.createElement(FontAwesomeIcon, {
className: style.icon,
icon: faICursor
}), "Start typing to edit text."), React.createElement("span", null, React.createElement(FontAwesomeIcon, {
className: style.icon,
icon: faUserEdit
}), "You can add and change names of speakers in your transcript."), React.createElement("span", null, React.createElement(FontAwesomeIcon, {
className: style.icon,
icon: faKeyboard
}), "Use keyboard shortcuts for quick control."), React.createElement("span", null, React.createElement(FontAwesomeIcon, {
className: style.icon,
icon: faSave
}), "Save & export to get a copy to your desktop."));
const tooltip = React.createElement(Tooltip, {
className: style.help,
content: helpMessage,
fadeDuration: 250,
fadeEasing: 'ease-in',
placement: 'bottom',
radius: 5
}, React.createElement(FontAwesomeIcon, {
className: style.icon,
icon: faQuestionCircle
}), "How does this work?");
const currentWord = this.getCurrentWord();
const highlightColour = '#69e3c2';
const unplayedColor = '#767676';
const correctionBorder = '1px dotted blue'; // Time to the nearest half second
const time = Math.round(this.props.currentTime * 4.0) / 4.0;
const editor = React.createElement("section", {
className: style.editor,
onDoubleClick: event => this.handleDoubleClick(event)
}, React.createElement("style", {
scoped: true
}, `span.Word[data-start="${currentWord.start}"] { background-color: ${highlightColour}; text-shadow: 0 0 0.01px black }`, `span.Word[data-start="${currentWord.start}"]+span { background-color: ${highlightColour} }`, `span.Word[data-prev-times~="${Math.floor(time)}"] { color: ${unplayedColor} }`, `span.Word[data-prev-times~="${time}"] { color: ${unplayedColor} }`, `span.Word[data-confidence="low"] { border-bottom: ${correctionBorder} }`), React.createElement(Editor, {
editorState: this.state.editorState,
onChange: this.onChange,
stripPastedStyles: true,
blockRendererFn: this.renderBlockWithTimecodes,
handleKeyCommand: command => this.handleKeyCommand(command),
keyBindingFn: e => this.customKeyBindingFn(e)
}));
return React.createElement("section", null, tooltip, this.props.transcriptData !== null ? editor : null);
}
} // DraftJs decorator to recognize which entity is which
// and know what to apply to what component
const getEntityStrategy = mutability => (contentBlock, callback, contentState) => {
contentBlock.findEntityRanges(character => {
const entityKey = character.getEntity();
if (entityKey === null) {
return false;
}
return contentState.getEntity(entityKey).getMutability() === mutability;
}, callback);
}; // decorator definition - Draftjs
// defines what to use to render the entity
const decorator = new CompositeDecorator([{
strategy: getEntityStrategy('MUTABLE'),
component: Word
}]);
TimedTextEditor.propTypes = {
transcriptData: PropTypes.object,
mediaUrl: PropTypes.string,
isEditable: PropTypes.bool,
onWordClick: PropTypes.func,
sttJsonType: PropTypes.string,
isPlaying: PropTypes.func,
playMedia: PropTypes.func,
currentTime: PropTypes.number,
isScrollIntoViewOn: PropTypes.bool,
isPauseWhileTypingOn: PropTypes.bool,
timecodeOffset: PropTypes.number,
handleAnalyticsEvents: PropTypes.func
};
export default TimedTextEditor;