source: main/trunk/model-interfaces-dev/atea/macron-restoration/src/components/TranscriptionItemEditor.vue@ 35714

Last change on this file since 35714 was 35714, checked in by cstephen, 13 months ago

Add macroniser scaffolding.
Update translations.

File size: 16.8 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" @click="loadAudio(word.startTime)"
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" :transcriptionID="transcription.id" />
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 async loadAudio(startTime) {
191 await AudioPlayback.load(this.transcription.id, startTime);
192 },
193
194 /**
195 * Gets the minimum viable index that surrounding words can start at.
196 * @param {Number} principleIndex The index of the primary surrounding word.
197 * @param {Number} buffer The maximum number of words to take on each side of the principle word.
198 */
199 getSurroundingWordMinIndex(principleIndex, buffer) {
200 let min = principleIndex;
201 for (let i = principleIndex; i > principleIndex - buffer && i > 0; i--) {
202 min--;
203 }
204
205 return min;
206 },
207
208 getSurroundingWords(index) {
209 const BUFFER = 2; // The number of words to take on each side TODO: Global constant
210
211 const min = this.getSurroundingWordMinIndex(index, BUFFER);
212
213 let max = index + 1;
214 for (let i = index; i < index + BUFFER && i < this.words.length; i++) {
215 max++;
216 }
217
218 this.surroundingWordPrincipleIndex = index - min;
219 return this.words.slice(min, max);
220 },
221
222 onWordUpdated(relativeIndex, word) {
223 const BUFFER = 2; // The number of words to take on each side
224
225 const min = this.getSurroundingWordMinIndex(this.selectedIndex, BUFFER);
226
227 this.words[min + relativeIndex] = word;
228 },
229
230 /**
231 * Invoked when the value of a word changes.
232 * @param {InputEvent} event The input event.
233 * @param {Number} index The index of the word that has changed.
234 */
235 onEditorBeforeInput(event, index) {
236 // https://rawgit.com/w3c/input-events/v1/index.html#interface-InputEvent-Attributes
237 const deletionEvents = [
238 "deleteWordForward", "deleteWordBackward",
239 "deleteSoftLineBackward", "deleteSoftLineForward", "deleteEntireSoftLine",
240 "deleteHardLineBackward", "deleteHardLineForward",
241 "deleteByDrag", "deleteByCut",
242 "deleteContent", "deleteContentBackward", "deleteContentForward"
243 ];
244
245 const backwardDeletionEvents = [
246 "deleteWordBackward", "deleteSoftLineBackward", "deleteHardLineBackward", "deleteContentBackward"
247 ]
248
249 const forwardDeletionEvents = [
250 "deleteWordForward", "deleteSoftLineForward", "deleteHardLineForward", "deleteContentForward"
251 ]
252
253 const insertionEvents = [
254 "insertText", "insertReplacementText",
255 "insertLineBreak", "insertParagraph",
256 "insertOrderedList", "insertUnorderedList", "insertHorizontalRule",
257 "insertFromYank", "insertFromDrop", "insertFromPaste", "insertFromPasteAsQuotation",
258 "insertTranspose", "insertCompositionText", "insertLink"
259 ];
260
261 if (event.inputType === "historyUndo" || event.inputType === "historyRedo") {
262 event.preventDefault();
263 return;
264 }
265
266 /** @type {Word} */
267 const word = this.words[index];
268 const inputIndex = this.$refs[word.id].selectionStart;
269 const inputEndIndex = this.$refs[word.id].selectionEnd;
270
271 if (deletionEvents.includes(event.inputType)) {
272 if (word.word.length === 0) { // Remove the word
273 if (index === 0) {
274 this.mergeWordForward(word, index);
275 }
276 else if (index === this.words.length - 1) {
277 this.mergeWordBackward(word, index);
278 }
279 else if (backwardDeletionEvents.includes(event.inputType)) {
280 this.mergeWordBackward(word, index);
281 }
282 else if (forwardDeletionEvents.includes(event.inputType)) {
283 this.mergeWordForward(word, index);
284 }
285
286 event.preventDefault();
287 }
288 else if (inputIndex === 0 && backwardDeletionEvents.includes(event.inputType) && !inputEndIndex) { // Join with last word
289 if (index === 0) {
290 return;
291 }
292 else {
293 this.mergeWordBackward(word, index);
294 }
295
296 event.preventDefault();
297 }
298 else if (inputIndex === word.word.length && forwardDeletionEvents.includes(event.inputType)) { // Join with next word
299 if (index === this.words.length - 1) {
300 return;
301 }
302 else {
303 this.mergeWordForward(word, index);
304 }
305
306 event.preventDefault();
307 }
308 }
309
310 if (insertionEvents.includes(event.inputType)) {
311 if (event.data === " ") { // ONLY whitespace entered. Split or add new word
312 let newWord;
313
314 if (inputIndex === 0) { // Insert on left side
315 const newEndTime = word.startTime + (word.endTime - word.startTime) / 2;
316 newWord = new Word("", word.startTime, newEndTime);
317
318 this.words.splice(index, 0, newWord);
319 word.startTime = newEndTime;
320 }
321 else if (inputIndex === word.word.length) { // Insert on right side
322 const newStartTime = word.startTime + ((word.endTime - word.startTime) / 2);
323 newWord = new Word("", newStartTime, word.endTime);
324
325 this.words.splice(index + 1, 0, newWord);
326 word.endTime = newStartTime;
327 }
328 else { // Split down the middle
329 const metadataIndex = this.transcription.metadata.findIndex(m => m.start_time === word.startTime) + inputIndex;
330 const newStartTime = this.transcription.metadata[metadataIndex].start_time;
331 newWord = new Word(word.word.slice(inputIndex), newStartTime, word.endTime);
332
333 this.words.splice(index + 1, 0, newWord);
334 word.endTime = newStartTime;
335 word.word = word.word.slice(0, inputIndex);
336 }
337
338 // Prevent the event from being propagated to the newly focused input
339 event.preventDefault();
340
341 // Give the element time to be mounted before focusing
342 setTimeout(
343 () => {
344 this.$refs[newWord.id].focus();
345 this.$refs[newWord.id].selectionStart = 0;
346 this.$refs[newWord.id].selectionEnd = null;
347 },
348 50
349 );
350 }
351 else if (event.data.includes(" ")) {
352 let wordsToAdd = event.data.split(" ");
353 wordsToAdd = wordsToAdd.filter(w => w.trim().length > 0).map(w => w.trim());
354 const sharedTime = (word.endTime - word.startTime) / wordsToAdd.length;
355
356 if (wordsToAdd.length === 0) {
357 return;
358 }
359
360 word.endTime = word.startTime + sharedTime;
361 let newStartTime = word.endTime;
362
363 for (let i = 0; i < wordsToAdd.length; i++) {
364 const newWord = new Word(wordsToAdd[i], newStartTime, newStartTime + sharedTime);
365 newStartTime += sharedTime;
366
367 this.words.splice(index + i + 1, 0, newWord);
368 }
369
370 event.preventDefault();
371 }
372 }
373 },
374
375 mergeWordForward(word, index) {
376 if (this.words.length < 2) {
377 return;
378 }
379
380 this.words[index + 1].startTime = word.startTime;
381 this.words[index + 1].word = word.word + this.words[index + 1].word;
382
383 word.word = "";
384 this.setFocus(index, false);
385 },
386
387 mergeWordBackward(word, index) {
388 if (this.words.length < 2) {
389 return;
390 }
391
392 this.words[index - 1].endTime = word.endTime;
393 this.words[index - 1].word += word.word;
394
395 word.word = "";
396 this.setFocus(index, true);
397 },
398
399 onEditorFocus(index) {
400 if (this.currentPlaybackTime < this.words[index].startTime || this.currentPlaybackTime > this.words[index].endTime) {
401 const wordStartTime = this.words[index].startTime;
402 this.$store.commit("playbackStateSetTime", { id: this.transcription.id, time: wordStartTime });
403 }
404
405 this.selectedIndex = index;
406 },
407
408 onEditorFocusOut(index) {
409 // Remove empty words when they lose focus
410 if (this.words[index].word === "") {
411 this.words.splice(index, 1);
412 }
413 },
414
415 /**
416 * Gets the index of an adjacent word to focus on.
417 * @param {Number} index The index of the currently focused word.
418 * @param {Boolean} focusLeft Whether to focus on the word to the left or right of the current index.
419 */
420 getFocusIndex(index, focusLeft) {
421 let focusIndex = 0;
422
423 if (focusLeft) {
424 focusIndex = index === 0 ? 0 : index - 1;
425 }
426 else {
427 focusIndex = index === this.words.length - 1 ? index : index + 1;
428 }
429
430 return focusIndex;
431 },
432
433 /**
434 * Sets the focus to an adjacent word.
435 * @param {Number} index The index of the currently focused word.
436 * @param {Boolean} focusLeft Whether to focus on the word to the left or right of the current index.
437 */
438 setFocus(index, focusLeft) {
439 const focusIndex = this.getFocusIndex(index, focusLeft);
440 const focusId = this.words[focusIndex].id;
441 this.$refs[focusId].focus();
442
443 if (!focusLeft) {
444 this.$refs[focusId].setSelectionRange(0, 0);
445 }
446 }
447
448 },
449 watch: {
450 currentPlaybackTime(newValue) {
451 this.words[this.lastHighlightedWordIndex].shouldHighlight = false;
452
453 if (this.playbackState.id !== this.transcription.id) {
454 return;
455 }
456
457 for (let i = 0; i < this.words.length; i++) {
458 const word = this.words[i];
459
460 if (word.startTime <= newValue && word.endTime > newValue) {
461 word.shouldHighlight = true;
462
463 if (this.$refs[word.id]) {
464 this.$refs[word.id].focus();
465 }
466
467 this.lastHighlightedWordIndex = i;
468 break;
469 }
470 }
471 }
472 },
473 beforeMount() {
474 this.words = this.getWords();
475 }
476}
477</script>
Note: See TracBrowser for help on using the repository browser.