Skip to content

Commit

Permalink
Merge pull request #33 from bbc/words-highlight-css-selector
Browse files Browse the repository at this point in the history
Added word-level highlighting with css selectors
  • Loading branch information
jamesdools authored Nov 28, 2018
2 parents 9ccc8bb + 18d86e2 commit c9348ad
Show file tree
Hide file tree
Showing 7 changed files with 298 additions and 10 deletions.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const bbcKaldiToDraft = require('./index.js'); // using require, because of testing outside of React app
const bbcKaldiToDraft = require('./index'); // using require, because of testing outside of React app


const kaldiTedTalkTranscript = require('../../../../../sample-data/KateDarling_2018S-bbc-kaldi.json');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ const bbcKaldiToDraft = bbcKaldiJson => {
data: {
speaker: 'TBC'
},
// the entities as ranges are each word in the space-joined text,
// the entities as ranges are each word in the space-joined text,
// so it needs to be compute for each the offset from the beginning of the paragraph and the length
entityRanges: generateEntitiesRanges(paragraph.words, 'punct') // wordAttributeName

Expand Down
226 changes: 225 additions & 1 deletion docs/notes/2018-10-07-draftjs-4-hlight-current-word.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,228 @@ https://github.com/bbc/subtitalizer/blob/master/src/components/TranscriptEditor.
</style>
```

Also style scoped is not quite scoped in some browsers (only firefox has support for it) but that’s not an issue as we’re very specific in that css
Also style scoped is not quite scoped in some browsers (only firefox has support for it) but that’s not an issue as we’re very specific in that css



----
# alternative option - simplified

Simplified version only showing current words approximated to the seconds

At a high level, by this point we have already have the current time available to `TimeTextEditor` from `MediaPlayer` as `this.props.currentTime`.


## selecting words using attribute based on timecode
`span.Word` selects a word element

```css
span.Word[data-start="18.14"] {
background-color: red;
}
```

If you load the demo [loacalhost:3006](http://localhost:3006) and type this in the inspector tools, the word `robot` in the demo text will become red.

However `currentTime` property on HTML5 video and audio element does not progress at regular reliable interval. (cit needed) so it's unlikely that the `this.props.currentTime` will hit exactly `18.14` value and even if it did it would only stay on it for `0.01` sec.

## we can approximate time
However we can approximate time to seconds and ignore the milliseconds.

We can chose two selectors to help with this

> `[attr^=value]` selector
> Selects an element with a certain attribute that starts > with a certain value`
or

>`[attr*=value]` selector
> Selects an element with a certain attribute that contains a certain value; not necessarily space-separated
so we can change our selector be

```css
span.Word[data-start^="18."] {
background-color: red;
}
```

Now two words are selected `robot` and `upside`. Because the first one as we know has timecode of `18.14` while the other one has `18.72`. So both are selected.

we add a `.` after the number so that ignore milliseconds in the matching eg `0.18`. and we use `^` so that it ignores other partial matches such as `118`. (which would have been a problem if we had used `*`)

## we can connect it to `currentTime`
To connect it with currentTime In `TimedTextEditor` we can do a CSS injection adding a style tag in the return of the render function fo the component.

This allows to make the data attribute value we are looking for dynamics. And round the `currentTime` time to int. to enable the comparison explained above.

```js
render() {
return (
<section >
<section
className={ styles.editor }
onDoubleClick={ event => this.handleDoubleClick(event) }
// onClick={ event => this.handleOnClick(event) }
>
<style scoped>
{`span.Word[data-start^="${ parseInt(this.props.currentTime) }."] {
background-color: lightblue;
}` }
</style>
{/* <p> {JSON.stringify(this.state.transcriptData)}</p> */}
<Editor
editorState={ this.state.editorState }
onChange={ this.onChange }
stripPastedStyles
/>
</section>
</section>
);
}
```

This will highlight a couple of words that match to the current time code.

However we notice that when multiple words are selected, only the words are selected and the space in between is blank, doesn't look great.

## Making it as continuos line

To select spaces we used the `+`

>`+` selector
>Selects an element that is a next sibling of another element
```css
span.Word[data-start^="36."]+span {
background-color: lightblue;
}
```

So we can change previous code to be
```js
render() {
return (
<section >
<section
className={ styles.editor }
onDoubleClick={ event => this.handleDoubleClick(event) }
// onClick={ event => this.handleOnClick(event) }
>
<style scoped>
{`span.Word[data-start^="${ parseInt(this.props.currentTime) }."] {
background-color: lightblue;
}` }
{/* To select the spaces in between words */}
{`span.Word[data-start^="${ parseInt(this.props.currentTime) }."]+span {
background-color: lightblue;
}`}
</style>
{/* <p> {JSON.stringify(this.state.transcriptData)}</p> */}
<Editor
editorState={ this.state.editorState }
onChange={ this.onChange }
stripPastedStyles
/>
</section>
</section>
);
}
```

## making previosu words selectable
[see this article](https://www.bram.us/2016/10/13/css-attribute-value-less-than-greater-than-equals-selectors) to make previous words to currentTime hilightable, adding all numbers from zero to start time of that word rounded up, to seconds to make it hilightable.

in `Word.js`
```js
class Word extends PureComponent {
render() {
const data = this.props.entityKey
? this.props.contentState.getEntity(this.props.entityKey).getData()
: {};


let res = '';
for(let i =0; i< parseInt(data.start); i++){
res += ` ${ i }`;
}

return (
<span data-start={ data.start } data-prev-times={ res } data-entity-key={ data.key } className="Word">
{this.props.children}
</span>
);
}
}
```

Then change TimedTextEditor to be
```js
render() {
return (
<section >
<section
className={ styles.editor }
onDoubleClick={ event => this.handleDoubleClick(event) }
// onClick={ event => this.handleOnClick(event) }
>
<style scoped>
{`span.Word[data-start^="${ parseInt(this.props.currentTime) }."] {
background-color: lightblue;
}` }
{/* To select the spaces in between words */}
{`span.Word[data-start^="${ parseInt(this.props.currentTime) }."]+span {
background-color: lightblue;
}`}

{/* To select previous words */}
{`span.Word[data-prev-times~="${ parseInt(this.props.currentTime) }"] {
color: grey;
}`}
</style>
{/* <p> {JSON.stringify(this.state.transcriptData)}</p> */}
<Editor
editorState={ this.state.editorState }
onChange={ this.onChange }
stripPastedStyles
/>
</section>
</section>
);
}
```

However not sure about performance of this last part.
---

Background readings
- https://stackoverflow.com/questions/24173770/css-data-attribute-conditional-value-selector
- https://www.bram.us/2016/10/13/css-attribute-value-less-than-greater-than-equals-selectors/
- https://www.quirksmode.org/css/selectors/

- https://codegolf.stackexchange.com/questions/41666/css-attribute-selector-efficient-greater-than-selector-code
- https://blog.teamtreehouse.com/5-useful-css-selectors



<!--
Tried for previous words, didn't work
let selectAllPreviousWords = '';
const currentTimeInt = parseInt(this.props.currentTime);
for(let i=0; i<= currentTimeInt; i++){
selectAllPreviousWords+=`span.Word[data-start^="${ 10 }."],`
}
selectAllPreviousWords+=`{
background-color: lightblue;
}`
console.log(selectAllPreviousWords)
---
{`${ selectAllPreviousWords }{
background-color: lightblue;
}`}
-->
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@bbc/react-transcript-editor",
"description": "A React component to make transcribing audio and video easier and faster.",
"version": "0.1.4",
"version": "0.1.6",
"keywords": [
"transcript",
"transcriptions",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* ProgressBar */
.progressBar {
width: 100%;
background-color: red;
background-color: grey;
}

17 changes: 16 additions & 1 deletion src/lib/TranscriptEditor/TimedTextEditor/Word.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,23 @@ class Word extends PureComponent {
? this.props.contentState.getEntity(this.props.entityKey).getData()
: {};

const confidence = data.confidence > 0.6 ? 'high' : 'low';

let prevTimes = '';
for (let i = 0; i < data.start; i++) {
prevTimes += `${ i } `;
}

if (data.start % 1 > 0.5) prevTimes += ` ${ Math.floor(data.start) }.5`;

return (
<span data-start={ data.start } data-entity-key={ data.key } className="Word">
<span
data-start={ data.start }
data-end={ data.end }
data-confidence = { confidence }
data-prev-times = { prevTimes }
data-entity-key={ data.key }
className="Word">
{this.props.children}
</span>
);
Expand Down
57 changes: 53 additions & 4 deletions src/lib/TranscriptEditor/TimedTextEditor/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class TimedTextEditor extends React.Component {
isEditable: this.props.isEditable,
sttJsonType: this.props.sttJsonType,
inputCount: 0,
currentWord: {}
};
}

Expand Down Expand Up @@ -123,7 +124,6 @@ class TimedTextEditor extends React.Component {
// contains blocks and entityMap

/**
* @param {object} data - draftJs content
* @param {object} data.entityMap - draftJs entity maps - used by convertFromRaw
* @param {object} data.blocks - draftJs blocks - used by convertFromRaw
*/
Expand All @@ -142,15 +142,63 @@ class TimedTextEditor extends React.Component {
return data;
}

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;
}

getCurrentWord = () => {
const currentWord = {
start: 'NA',
end: 'NA'
};

if (this.state.transcriptData) {
const wordsArray = this.state.transcriptData.retval.words;

const word = wordsArray.find((w, i) => w.start <= this.props.currentTime && w.end >= this.props.currentTime);

if (word) {
currentWord.start = word.start;
currentWord.end = word.end;
}
}

return currentWord;
}

render() {
const currentWord = this.getCurrentWord();
const highlightColour = 'lightblue';
const unplayedColor = 'grey';
const correctionBorder = '1px dotted #ff0000';

// Time to the nearest half second
const time = Math.round(this.props.currentTime * 2.0) / 2.0;

return (
<section >
<section>
<section
className={ styles.editor }
onDoubleClick={ event => this.handleDoubleClick(event) }
// onClick={ event => this.handleOnClick(event) }
>
{/* <p> {JSON.stringify(this.state.transcriptData)}</p> */}
<style scoped>
{`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 } }`}
</style>

<Editor
editorState={ this.state.editorState }
onChange={ this.onChange }
Expand Down Expand Up @@ -188,7 +236,8 @@ TimedTextEditor.propTypes = {
mediaUrl: PropTypes.string,
isEditable: PropTypes.bool,
onWordClick: PropTypes.func,
sttJsonType: PropTypes.string
sttJsonType: PropTypes.string,
currentTime: PropTypes.number
};

export default TimedTextEditor;

0 comments on commit c9348ad

Please sign in to comment.