@bbc/react-transcript-editor
Version:
A React component to make transcribing audio and video easier and faster.
390 lines (320 loc) • 14.2 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 { // Draft,
Editor, EditorState, // ContentState,
CompositeDecorator, convertFromRaw, convertToRaw, KeyBindingUtil, getDefaultKeyBinding, Modifier } from 'draft-js';
import Word from './Word';
import WrapperBlock from './WrapperBlock';
import sttJsonAdapter from './adapters/index.js';
import styles from './index.module.css';
const {
hasCommandModifier
} = KeyBindingUtil;
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.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, props) => ({
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", () => {
const mediaUrl = this.props.mediaUrl;
const data = convertToRaw(this.state.editorState.getCurrentContent());
localStorage.setItem(`draftJs-${mediaUrl}`, JSON.stringify(data));
const newLastLocalSavedDate = new Date().toString();
localStorage.setItem(`timestamp-${mediaUrl}`, newLastLocalSavedDate);
return newLastLocalSavedDate;
});
_defineProperty(this, "setEditorContentState", data => {
const contentState = convertFromRaw(data); // eslint-disable-next-line no-use-before-define
const editorState = EditorState.createWithContent(contentState, decorator);
this.setState({
editorState
});
});
_defineProperty(this, "setEditorNewContentState", newContentState => {
const newEditorState = EditorState.push(this.state.editorState, newContentState);
this.setState({
editorState: newEditorState
});
});
_defineProperty(this, "getEditorContent", sttType => {
// sttType used in conjunction with adapter/convert
const type = sttType === null ? 'draftjs' : sttType;
const data = convertToRaw(this.state.editorState.getCurrentContent());
return data;
});
_defineProperty(this, "renderBlockWithTimecodes", contentBlock => {
const type = contentBlock.getType();
return {
component: WrapperBlock,
editable: true,
props: {
foo: 'bar',
editorState: this.state.editorState,
// passing in callback function to be able to set state in parent component
setEditorNewContentState: this.setEditorNewContentState,
// to make timecodes clickable
onWordClick: this.props.onWordClick
}
};
});
_defineProperty(this, "getLatestUnplayedWord", () => {
let latest = 'NA';
if (this.state.transcriptData) {
const wordsArray = this.state.transcriptData.retval.words;
const word = wordsArray.find(w => w.start < this.props.currentTime);
latest = word.start;
}
return latest;
});
_defineProperty(this, "getCurrentWord", () => {
const currentWord = {
start: 'NA',
end: 'NA'
};
if (this.state.transcriptData) {
const contentState = this.state.editorState.getCurrentContent();
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') {
console.log('TimedTextEditor: ', this.props.isScrollIntoViewOn);
if (this.props.isScrollIntoViewOn) {
const currentWordElement = document.querySelector(`span.Word[data-start="${currentWord.start}"]`);
currentWordElement.scrollIntoView({
block: 'center',
inline: 'center'
});
}
}
return currentWord;
});
_defineProperty(this, "myKeyBindingFn", e => {
const enterKey = 13;
if (e.keyCode === enterKey) {
return 'split-paragraph';
}
return getDefaultKeyBinding(e);
});
_defineProperty(this, "handleKeyCommand", command => {
// https://github.com/facebook/draft-js/issues/723#issuecomment-367918580
// https://draftjs.org/docs/api-reference-selection-state#start-end-vs-anchor-focus
if (command === 'split-paragraph') {
// on enter key, perform split paragraph at selection point
const currentSelection = this.state.editorState.getSelection();
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'); // TODO: there might be some edge cases where unable to calculate wordStartTime
// eg adding spaces and then new line in the middle
let wordStartTime = 'NA';
let isEndOfParagraph = false;
let entityKey = originalBlock.getEntityAt(currentSelection.getStartOffset());
const startSelectionOffsetKey = currentSelection.getStartOffset(); // length of the plaintext for the ContentBlock
const lengthPlainTextForTheBlock = originalBlock.getLength(); // number of char from selection point to end of paragraph
const remainingCharNumber = lengthPlainTextForTheBlock - startSelectionOffsetKey; // if there is no word entity associated with char
if (entityKey === null) {
// if it's the last char in the paragraph - get previous entity
if (remainingCharNumber === 0) {
for (let j = lengthPlainTextForTheBlock; j > 0; j--) {
entityKey = originalBlock.getEntityAt(j);
if (entityKey !== null) {
isEndOfParagraph = true;
break;
}
}
} // if it's first char or another within the block
else {
let initialSelectionOffset = currentSelection.getStartOffset();
for (let i = 0; i < remainingCharNumber; i++) {
initialSelectionOffset += i;
entityKey = originalBlock.getEntityAt(initialSelectionOffset);
if (entityKey !== null) {
break;
}
}
}
}
if (entityKey) {
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;
}
} else {
// if entity not defined, then stopping some of the edge cases.
// eg if hit enter on timecode or speaker
return 'not-handled';
}
console.log('originalBlockData', wordStartTime, blockSpeaker);
console.log('originalBlockData', originalBlockData); // 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';
}
return 'not-handled';
});
this.state = {
editorState: EditorState.createEmpty(),
transcriptData: this.props.transcriptData,
isEditable: this.props.isEditable,
sttJsonType: this.props.sttJsonType,
inputCount: 0,
currentWord: {}
};
}
componentDidMount() {
this.loadData();
}
static getDerivedStateFromProps(nextProps, prevState) {
if (nextProps.transcriptData !== null) {
return {
transcriptData: nextProps.transcriptData,
isEditable: nextProps.isEditable
};
}
return null;
}
componentDidUpdate(prevProps, prevState) {
if (prevState.transcriptData !== this.state.transcriptData) {
this.loadData();
}
}
loadData() {
if (this.props.transcriptData !== null) {
const blocks = sttJsonAdapter(this.props.transcriptData, this.props.sttJsonType);
this.setEditorContentState(blocks);
}
} // click on words - for navigation
// eslint-disable-next-line class-methods-use-this
// eslint-disable-next-line class-methods-use-this
isPresentInLocalStorage(mediaUrl) {
const data = localStorage.getItem(`draftJs-${mediaUrl}`);
if (data !== null) {
return true;
}
return false;
}
loadLocalSavedData(mediaUrl) {
const data = JSON.parse(localStorage.getItem(`draftJs-${mediaUrl}`));
if (data !== null) {
const lastLocalSavedDate = localStorage.getItem(`timestamp-${mediaUrl}`);
this.setEditorContentState(data);
return lastLocalSavedDate;
}
return '';
} // set DraftJS Editor content state from blocks
// contains blocks and entityMap
/**
* @param {object} data.entityMap - draftJs entity maps - used by convertFromRaw
* @param {object} data.blocks - draftJs blocks - used by convertFromRaw
*/
render() {
const currentWord = this.getCurrentWord();
const highlightColour = 'lightblue';
const unplayedColor = 'grey';
const correctionBorder = '1px dotted blue'; // Time to the nearest half second
const time = Math.round(this.props.currentTime * 2.0) / 2.0;
return React.createElement("section", null, React.createElement("section", {
className: styles.editor,
onDoubleClick: event => this.handleDoubleClick(event) // onClick={ event => this.handleOnClick(event) }
}, React.createElement("style", {
scoped: true
}, `span.Word[data-start="${currentWord.start}"] { background-color: ${highlightColour} }`, `span.Word[data-start="${currentWord.start}"]+span { background-color: ${highlightColour} }`, `span.Word[data-prev-times~="${time}"] { color: ${unplayedColor} }`, `span.Word[data-prev-times~="${Math.floor(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,
keyBindingFn: this.myKeyBindingFn,
handleKeyCommand: this.handleKeyCommand
})));
}
} // 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,
isScrollSyncToggle: PropTypes.func
};
export default TimedTextEditor;