source: main/trunk/model-interfaces-dev/atea/korero-maori-asr/src/components/TranscriptionItemEditor.vue@ 35608

Last change on this file since 35608 was 35608, checked in by cstephen, 3 years ago

Implement full support for word time editing

File size: 16.6 KB
Line 
1<template>
2<div>
3 <div class="text-container words-container" :hidden="enableEditing">
4 <ul class="list-view" v-if="!enableEditing">
5 <li v-for="word in words" :key="word.id" class="word-container" @click="playAudio(word.startTime)">
6 <span class="word-highlight word" :class="{ 'word-highlight-applied': word.shouldHighlight }">
7 {{ word.word }}
8 </span>
9 </li>
10 </ul>
11
12 <ul class="list-view" v-else>
13 <li v-for="(word, index) in words" :key="word.id" class="word-container">
14 <input :size="word.word.length === 0 ? 1 : word.word.length" :ref="word.id"
15 class="editor-word" v-model="word.word" type="text" :class="{ 'word-highlight-applied': word.shouldHighlight }"
16 @beforeinput="onEditorBeforeInput($event, index)" @focusout="onEditorFocusOut(index)" @focus="onEditorFocus(index)" />
17 <span>&nbsp;</span>
18 </li>
19 </ul>
20 </div>
21
22 <word-timing-selector class="word-timing-selector" @wordUpdated="onWordUpdated" v-if="enableEditing"
23 :surroundingWords="surroundingWords" :wordIndex="surroundingWordPrincipleIndex" />
24</div>
25</template>
26
27<style scoped lang="scss">
28.word {
29 padding: 0.25em;
30}
31
32.words-container {
33 transition-duration: var(--transition-duration);
34
35 &[hidden] {
36 background-color: inherit;
37 }
38}
39
40.word-container {
41 font: var(--monospace-font);
42 display: inline-block;
43 line-height: 2em;
44}
45
46.word-highlight-applied {
47 background-color: var(--highlighted-word-bg);
48}
49
50.word-highlight {
51 &:hover {
52 @extend .word-highlight-applied;
53 }
54}
55
56.editor-word {
57 border: 1px solid black;
58 border-radius: 2px;
59 padding: 2px;
60 font-family: inherit;
61 font-size: 1rem;
62}
63
64.word-timing-selector {
65 margin: 0.5em auto;
66}
67</style>
68
69<script>
70import { mapState } from "vuex";
71import { TranscriptionViewModel } from "../main";
72import AudioPlayback from "../js/AudioPlaybackModule"
73import WordTimingSelector from "./WordTimingSelector.vue"
74import Util from "../js/Util";
75
76export class Word {
77 /**
78 * Initialises a new instance of the Word class.
79 * @param {String} word The word.
80 * @param {Number} startTime The time within the audio that this word starts being spoken.
81 * @param {Number} endTime The time within the audio that this word finishes being spoken.
82 */
83 constructor(word, startTime, endTime, id = null, shouldHighlight = false, deletionAttempts = 0) {
84 /** @type {String} The ID of this word. */
85 this.id = id ?? Util.generateUuid();
86
87 /** @type {String} The word. */
88 this.word = word;
89
90 /** @type {Number} The time within the audio that this word starts being spoken. */
91 this.startTime = startTime;
92
93 /** @type {Number} The time within the audio that this word finishes being spoken. */
94 this.endTime = endTime;
95
96 /** @type {Boolean} A value indicating whether this word should be highlighted. */
97 this.shouldHighlight = shouldHighlight;
98
99 /** @type {Number} The number of times the user has tried to delete this word. */
100 this.deletionAttempts = deletionAttempts;
101 }
102}
103
104export default {
105 name: "TranscriptionItemEditor",
106 components: {
107 WordTimingSelector
108 },
109 props: {
110 transcription: TranscriptionViewModel,
111 enableEditing: Boolean
112 },
113 data() {
114 return {
115 words: [],
116 lastHighlightedWordIndex: 0,
117 selectedIndex: 0,
118 surroundingWordPrincipleIndex: 0
119 }
120 },
121 computed: {
122 currentPlaybackTime: {
123 get() {
124 return this.$store.getters.transcriptionPlaybackTime(this.transcription.id);
125 },
126 set(value) {
127 this.$store.commit("playbackStateSetTime", { id: this.transcription.id, time: value });
128 }
129 },
130 surroundingWords() {
131 return this.getSurroundingWords(this.selectedIndex);
132 },
133 ...mapState({
134 playbackState: state => state.playbackState,
135 translations: state => state.translations
136 })
137 },
138 methods: {
139
140 /**
141 * Gets the words in a transcription.
142 * @param {TranscriptionViewModel} transcription The transcription.
143 * @returns {Word[]}
144 */
145 getWords() {
146 const metadata = this.transcription.metadata;
147
148 if (metadata.length === 0) {
149 return;
150 }
151
152 /** @type {Word[]} */
153 const words = [];
154
155 let currentWord = "";
156 let currStartTime = metadata[0].start_time;
157
158 for (let i = 0; i < metadata.length; i++) {
159 const mChar = metadata[i];
160
161 if (mChar.char === " ") {
162 words.push(new Word(currentWord, currStartTime, mChar.start_time));
163 currentWord = "";
164
165 if (i + 1 < metadata.length) {
166 currStartTime = metadata[i + 1].start_time;
167 }
168 else {
169 break;
170 }
171 }
172 else {
173 currentWord += mChar.char;
174 }
175 }
176
177 // Push the last word, as most transcriptions will not end in a space (hence breaking the above algorithm)
178 if (currentWord.length > 0) {
179 const newWord = new Word(currentWord, currStartTime, metadata[metadata.length - 1].start_time)
180 words.push(newWord);
181 }
182
183 return words;
184 },
185
186 async playAudio(startTime) {
187 await AudioPlayback.play(this.transcription.id, startTime);
188 },
189
190 /**
191 * Gets the minimum viable index that surrounding words can start at.
192 * @param {Number} principleIndex The index of the primary surrounding word.
193 * @param {Number} buffer The maximum number of words to take on each side of the principle word.
194 */
195 getSurroundingWordMinIndex(principleIndex, buffer) {
196 let min = principleIndex;
197 for (let i = principleIndex; i > principleIndex - buffer && i > 0; i--) {
198 min--;
199 }
200
201 return min;
202 },
203
204 getSurroundingWords(index) {
205 const BUFFER = 2; // The number of words to take on each side TODO: Global constant
206
207 const min = this.getSurroundingWordMinIndex(index, BUFFER);
208
209 let max = index + 1;
210 for (let i = index; i < index + BUFFER && i < this.words.length; i++) {
211 max++;
212 }
213
214 this.surroundingWordPrincipleIndex = index - min;
215 return this.words.slice(min, max);
216 },
217
218 onWordUpdated(relativeIndex, word) {
219 const BUFFER = 2; // The number of words to take on each side
220
221 const min = this.getSurroundingWordMinIndex(this.selectedIndex, BUFFER);
222
223 this.words[min + relativeIndex] = word;
224 },
225
226 /**
227 * Invoked when the value of a word changes.
228 * @param {InputEvent} event The input event.
229 * @param {Number} index The index of the word that has changed.
230 */
231 onEditorBeforeInput(event, index) {
232 // https://rawgit.com/w3c/input-events/v1/index.html#interface-InputEvent-Attributes
233 const deletionEvents = [
234 "deleteWordForward", "deleteWordBackward",
235 "deleteSoftLineBackward", "deleteSoftLineForward", "deleteEntireSoftLine",
236 "deleteHardLineBackward", "deleteHardLineForward",
237 "deleteByDrag", "deleteByCut",
238 "deleteContent", "deleteContentBackward", "deleteContentForward"
239 ];
240
241 const backwardDeletionEvents = [
242 "deleteWordBackward", "deleteSoftLineBackward", "deleteHardLineBackward", "deleteContentBackward"
243 ]
244
245 const forwardDeletionEvents = [
246 "deleteWordForward", "deleteSoftLineForward", "deleteHardLineForward", "deleteContentForward"
247 ]
248
249 const insertionEvents = [
250 "insertText", "insertReplacementText",
251 "insertLineBreak", "insertParagraph",
252 "insertOrderedList", "insertUnorderedList", "insertHorizontalRule",
253 "insertFromYank", "insertFromDrop", "insertFromPaste", "insertFromPasteAsQuotation",
254 "insertTranspose", "insertCompositionText", "insertLink"
255 ];
256
257 if (event.inputType === "historyUndo" || event.inputType === "historyRedo") {
258 event.preventDefault();
259 return;
260 }
261
262 /** @type {Word} */
263 const word = this.words[index];
264 const inputIndex = this.$refs[word.id].selectionStart;
265 const inputEndIndex = this.$refs[word.id].selectionEnd;
266
267 if (deletionEvents.includes(event.inputType)) {
268 if (word.word.length === 0) { // Remove the word
269 if (index === 0) {
270 this.mergeWordForward(word, index);
271 }
272 else if (index === this.words.length - 1) {
273 this.mergeWordBackward(word, index);
274 }
275 else if (backwardDeletionEvents.includes(event.inputType)) {
276 this.mergeWordBackward(word, index);
277 }
278 else if (forwardDeletionEvents.includes(event.inputType)) {
279 this.mergeWordForward(word, index);
280 }
281
282 event.preventDefault();
283 }
284 else if (inputIndex === 0 && backwardDeletionEvents.includes(event.inputType) && !inputEndIndex) { // Join with last word
285 if (index === 0) {
286 return;
287 }
288 else {
289 this.mergeWordBackward(word, index);
290 }
291
292 event.preventDefault();
293 }
294 else if (inputIndex === word.word.length && forwardDeletionEvents.includes(event.inputType)) { // Join with next word
295 if (index === this.words.length - 1) {
296 return;
297 }
298 else {
299 this.mergeWordForward(word, index);
300 }
301
302 event.preventDefault();
303 }
304 }
305
306 if (insertionEvents.includes(event.inputType)) {
307 if (event.data === " ") { // ONLY whitespace entered. Split or add new word
308 let newWord;
309
310 if (inputIndex === 0) { // Insert on left side
311 const newEndTime = word.startTime + (word.endTime - word.startTime) / 2;
312 newWord = new Word("", word.startTime, newEndTime);
313
314 this.words.splice(index, 0, newWord);
315 word.startTime = newEndTime;
316 }
317 else if (inputIndex === word.word.length) { // Insert on right side
318 const newStartTime = word.startTime + ((word.endTime - word.startTime) / 2);
319 newWord = new Word("", newStartTime, word.endTime);
320
321 this.words.splice(index + 1, 0, newWord);
322 word.endTime = newStartTime;
323 }
324 else { // Split down the middle
325 const metadataIndex = this.transcription.metadata.findIndex(m => m.start_time === word.startTime) + inputIndex;
326 const newStartTime = this.transcription.metadata[metadataIndex].start_time;
327 newWord = new Word(word.word.slice(inputIndex), newStartTime, word.endTime);
328
329 this.words.splice(index + 1, 0, newWord);
330 word.endTime = newStartTime;
331 word.word = word.word.slice(0, inputIndex);
332 }
333
334 // Prevent the event from being propagated to the newly focused input
335 event.preventDefault();
336
337 // Give the element time to be mounted before focusing
338 setTimeout(
339 () => {
340 this.$refs[newWord.id].focus();
341 this.$refs[newWord.id].selectionStart = 0;
342 this.$refs[newWord.id].selectionEnd = null;
343 },
344 50
345 );
346 }
347 else if (event.data.includes(" ")) {
348 let wordsToAdd = event.data.split(" ");
349 wordsToAdd = wordsToAdd.filter(w => w.trim().length > 0).map(w => w.trim());
350 const sharedTime = (word.endTime - word.startTime) / wordsToAdd.length;
351
352 if (wordsToAdd.length === 0) {
353 return;
354 }
355
356 word.endTime = word.startTime + sharedTime;
357 let newStartTime = word.endTime;
358
359 for (let i = 0; i < wordsToAdd.length; i++) {
360 const newWord = new Word(wordsToAdd[i], newStartTime, newStartTime + sharedTime);
361 newStartTime += sharedTime;
362
363 this.words.splice(index + i + 1, 0, newWord);
364 }
365
366 event.preventDefault();
367 }
368 }
369 },
370
371 mergeWordForward(word, index) {
372 if (this.words.length < 2) {
373 return;
374 }
375
376 this.words[index + 1].startTime = word.startTime;
377 this.words[index + 1].word = word.word + this.words[index + 1].word;
378
379 word.word = "";
380 this.setFocus(index, false);
381 },
382
383 mergeWordBackward(word, index) {
384 if (this.words.length < 2) {
385 return;
386 }
387
388 this.words[index - 1].endTime = word.endTime;
389 this.words[index - 1].word += word.word;
390
391 word.word = "";
392 this.setFocus(index, true);
393 },
394
395 onEditorFocus(index) {
396 if (this.currentPlaybackTime < this.words[index].startTime || this.currentPlaybackTime > this.words[index].endTime) {
397 const wordStartTime = this.words[index].startTime;
398 this.$store.commit("playbackStateSetTime", { id: this.transcription.id, time: wordStartTime });
399 }
400
401 this.selectedIndex = index;
402 },
403
404 onEditorFocusOut(index) {
405 // Remove empty words when they lose focus
406 if (this.words[index].word === "") {
407 this.words.splice(index, 1);
408 }
409 },
410
411 /**
412 * Gets the index of an adjacent word to focus on.
413 * @param {Number} index The index of the currently focused word.
414 * @param {Boolean} focusLeft Whether to focus on the word to the left or right of the current index.
415 */
416 getFocusIndex(index, focusLeft) {
417 let focusIndex = 0;
418
419 if (focusLeft) {
420 focusIndex = index === 0 ? 0 : index - 1;
421 }
422 else {
423 focusIndex = index === this.words.length - 1 ? index : index + 1;
424 }
425
426 return focusIndex;
427 },
428
429 /**
430 * Sets the focus to an adjacent word.
431 * @param {Number} index The index of the currently focused word.
432 * @param {Boolean} focusLeft Whether to focus on the word to the left or right of the current index.
433 */
434 setFocus(index, focusLeft) {
435 const focusIndex = this.getFocusIndex(index, focusLeft);
436 const focusId = this.words[focusIndex].id;
437 this.$refs[focusId].focus();
438
439 if (!focusLeft) {
440 this.$refs[focusId].setSelectionRange(0, 0);
441 }
442 }
443
444 },
445 watch: {
446 currentPlaybackTime(newValue) {
447 this.words[this.lastHighlightedWordIndex].shouldHighlight = false;
448
449 if (this.playbackState.id !== this.transcription.id) {
450 return;
451 }
452
453 for (let i = 0; i < this.words.length; i++) {
454 const word = this.words[i];
455
456 if (word.startTime <= newValue && word.endTime > newValue) {
457 word.shouldHighlight = true;
458
459 if (this.$refs[word.id]) {
460 this.$refs[word.id].focus();
461 }
462
463 this.lastHighlightedWordIndex = i;
464 break;
465 }
466 }
467 }
468 },
469 beforeMount() {
470 this.words = this.getWords();
471 }
472}
473</script>
Note: See TracBrowser for help on using the repository browser.