1 | <template>
|
---|
2 | <div class="word-timing-selector-root">
|
---|
3 | <span>{{ minTimeString }}</span>
|
---|
4 |
|
---|
5 | <div class="words-container" v-width="onWordsContainerWidthChanged">
|
---|
6 | <div class="word" v-for="word in mySurroundingWords" :key="word.id" :style="{ left: `${word.left}px`, width: `${word.length}px` }"
|
---|
7 | :class="{ 'hoisted': shouldHoist(word) }">
|
---|
8 | <span>{{ word.word }}</span>
|
---|
9 | </div>
|
---|
10 |
|
---|
11 | <div class="word-attachment-container" :style="{ left: leftHandleLeft }">
|
---|
12 | <span>{{ wordStartTimeString }}</span>
|
---|
13 | <span v-if="isMounted" class="material-icons mdi-m word-handle" @mousedown="canAdjustWordStartTime = true">
|
---|
14 | first_page
|
---|
15 | </span>
|
---|
16 | </div>
|
---|
17 |
|
---|
18 | <div class="word-attachment-container" :style="{ left: rightHandleLeft }">
|
---|
19 | <span v-if="isMounted" class="material-icons mdi-m word-handle" @mousedown="canAdjustWordEndTime = true">
|
---|
20 | last_page
|
---|
21 | </span>
|
---|
22 | <span>{{ wordEndTimeString }}</span>
|
---|
23 | </div>
|
---|
24 | </div>
|
---|
25 |
|
---|
26 | <span>{{ maxTimeString }}</span>
|
---|
27 | </div>
|
---|
28 | </template>
|
---|
29 |
|
---|
30 | <style scoped lang="scss">
|
---|
31 | .word-timing-selector-root {
|
---|
32 | display: flex;
|
---|
33 | align-items: flex-end;
|
---|
34 | justify-content: center;
|
---|
35 | gap: 1em;
|
---|
36 | height: 3em;
|
---|
37 | }
|
---|
38 |
|
---|
39 | .words-container {
|
---|
40 | flex-grow: 0.6;
|
---|
41 | position: relative;
|
---|
42 | height: 1.5em;
|
---|
43 | }
|
---|
44 |
|
---|
45 | .word {
|
---|
46 | position: absolute;
|
---|
47 | text-align: center;
|
---|
48 |
|
---|
49 | user-select: none;
|
---|
50 | text-overflow: ellipsis;
|
---|
51 | white-space: nowrap;
|
---|
52 |
|
---|
53 | overflow: hidden;
|
---|
54 | background-color: rgba(var(--bg-color-raw), 0.3);
|
---|
55 | border: 1px solid rgba(var(--bg-color-raw), 0.6);
|
---|
56 | }
|
---|
57 |
|
---|
58 | .word-attachment-container {
|
---|
59 | position: absolute;
|
---|
60 | top: -105%;
|
---|
61 | display: flex;
|
---|
62 | align-items: center
|
---|
63 | }
|
---|
64 |
|
---|
65 | .word-handle {
|
---|
66 | @extend .theme-flat;
|
---|
67 |
|
---|
68 | color: var(--fg-color);
|
---|
69 | cursor: pointer;
|
---|
70 | user-select: none;
|
---|
71 |
|
---|
72 | animation: pulse 0.8s linear 0s infinite alternate;
|
---|
73 | }
|
---|
74 |
|
---|
75 | @keyframes pulse {
|
---|
76 | from {
|
---|
77 | opacity: 1;
|
---|
78 | }
|
---|
79 | to {
|
---|
80 | opacity: 0.2;
|
---|
81 | }
|
---|
82 | }
|
---|
83 |
|
---|
84 | .hoisted {
|
---|
85 | top: -100%;
|
---|
86 | background-color: var(--highlighted-word-bg);
|
---|
87 | }
|
---|
88 | </style>
|
---|
89 |
|
---|
90 | <script>
|
---|
91 | import { Word as TWord } from "./TranscriptionItemEditor.vue"
|
---|
92 | import Util from "../js/Util"
|
---|
93 |
|
---|
94 | class Word {
|
---|
95 | constructor(word, startTime, endTime, left, length) {
|
---|
96 | this.id = Util.generateUuid();
|
---|
97 | this.word = word;
|
---|
98 | this.startTime = startTime;
|
---|
99 | this.endTime = endTime;
|
---|
100 | this.left = left;
|
---|
101 | this.length = length;
|
---|
102 | }
|
---|
103 |
|
---|
104 | right() {
|
---|
105 | return this.left + this.length;
|
---|
106 | }
|
---|
107 | }
|
---|
108 |
|
---|
109 | export default {
|
---|
110 | name: "WordTimingSelector",
|
---|
111 | props: {
|
---|
112 | /** @type {Array<TWord>} */
|
---|
113 | surroundingWords: Array,
|
---|
114 | wordIndex: Number
|
---|
115 | },
|
---|
116 | data() {
|
---|
117 | return {
|
---|
118 | isMounted: false,
|
---|
119 | canAdjustWordStartTime: false,
|
---|
120 | canAdjustWordEndTime: false,
|
---|
121 | wordsContainerWidth: 0,
|
---|
122 | isProduction: false
|
---|
123 | }
|
---|
124 | },
|
---|
125 | emits: [ "update:surroundingWords" ],
|
---|
126 | computed: {
|
---|
127 | word() {
|
---|
128 | return this.surroundingWords[this.wordIndex];
|
---|
129 | },
|
---|
130 | myWord() {
|
---|
131 | return this.mySurroundingWords[this.wordIndex];
|
---|
132 | },
|
---|
133 | minTimeString() {
|
---|
134 | if (this.surroundingWords.length === 0) {
|
---|
135 | return 0;
|
---|
136 | }
|
---|
137 |
|
---|
138 | return Util.formatSecondsTimeString(this.surroundingWords[0].startTime, false, 2);
|
---|
139 | },
|
---|
140 | maxTimeString() {
|
---|
141 | if (this.surroundingWords.length === 0) {
|
---|
142 | return 0;
|
---|
143 | }
|
---|
144 |
|
---|
145 | return Util.formatSecondsTimeString(this.surroundingWords[this.surroundingWords.length - 1].endTime, false, 2);
|
---|
146 | },
|
---|
147 | wordStartTimeString() {
|
---|
148 | return Util.formatSecondsTimeString(this.word.startTime, false, 2);
|
---|
149 | },
|
---|
150 | wordEndTimeString() {
|
---|
151 | return Util.formatSecondsTimeString(this.word.endTime, false, 2);
|
---|
152 | },
|
---|
153 | leftHandleLeft() {
|
---|
154 | // The time component of the handles is a fixed width, so we can get away with this horrid hardcoded 5.5em
|
---|
155 | return `calc(${this.myWord.left}px - 5.5em)`;
|
---|
156 | },
|
---|
157 | rightHandleLeft() {
|
---|
158 | return `calc(${this.myWord.left}px + ${this.myWord.length}px)`;
|
---|
159 | },
|
---|
160 | audioSnippetLength() {
|
---|
161 | if (this.surroundingWords.length === 0) {
|
---|
162 | return 0;
|
---|
163 | }
|
---|
164 | else {
|
---|
165 | let length = this.surroundingWords[this.surroundingWords.length - 1].endTime;
|
---|
166 |
|
---|
167 | // Removes the buffer from 0s
|
---|
168 | if (this.wordIndex !== 0) {
|
---|
169 | length -= this.surroundingWords[0].startTime;
|
---|
170 | }
|
---|
171 |
|
---|
172 | return length;
|
---|
173 | }
|
---|
174 | },
|
---|
175 | /**
|
---|
176 | * Gets the factor used to scale word's audio parameters into on-screen positional values.
|
---|
177 | * @returns {Number} The scaling factor.
|
---|
178 | */
|
---|
179 | scalingFactor() {
|
---|
180 | return this.wordsContainerWidth / this.audioSnippetLength;
|
---|
181 | },
|
---|
182 | /**
|
---|
183 | * Gets the required pixel offset of a word's left position.
|
---|
184 | * @returns {Number} The offset.
|
---|
185 | */
|
---|
186 | wordLeftOffsetPx() {
|
---|
187 | if (this.wordIndex === 0) {
|
---|
188 | return 0;
|
---|
189 | }
|
---|
190 | else {
|
---|
191 | return this.surroundingWords[0].startTime * this.scalingFactor;
|
---|
192 | }
|
---|
193 | },
|
---|
194 | mySurroundingWords() {
|
---|
195 | const myWords = [];
|
---|
196 |
|
---|
197 | if (this.surroundingWords.length === 0) {
|
---|
198 | return myWords;
|
---|
199 | }
|
---|
200 |
|
---|
201 | for (const word of this.surroundingWords) {
|
---|
202 | myWords.push(this.convertWord(word));
|
---|
203 | }
|
---|
204 |
|
---|
205 | return myWords;
|
---|
206 | }
|
---|
207 | },
|
---|
208 | methods: {
|
---|
209 | onWordsContainerWidthChanged(newValue) {
|
---|
210 | this.wordsContainerWidth = newValue;
|
---|
211 | },
|
---|
212 | scaleTime(time) {
|
---|
213 | return time * this.scalingFactor;
|
---|
214 | },
|
---|
215 | timeToPosition(time) {
|
---|
216 | return time * this.scalingFactor - this.wordLeftOffsetPx;
|
---|
217 | },
|
---|
218 | convertWord(word) {
|
---|
219 | const left = this.timeToPosition(word.startTime);
|
---|
220 | const length = (word.endTime - word.startTime) * this.scalingFactor;
|
---|
221 |
|
---|
222 | return new Word(word.word, word.startTime, word.endTime, left, length);
|
---|
223 | },
|
---|
224 | shouldHoist(word) {
|
---|
225 | return word === this.myWord;
|
---|
226 | },
|
---|
227 | onDocumentMouseMove(event) {
|
---|
228 | if (this.isProduction) {
|
---|
229 | return;
|
---|
230 | }
|
---|
231 |
|
---|
232 | if (this.canAdjustWordStartTime) {
|
---|
233 | this.onMinHandleMouseMove(event);
|
---|
234 | }
|
---|
235 | else if (this.canAdjustWordEndTime) {
|
---|
236 | this.onMaxHandleMouseMove(event);
|
---|
237 | }
|
---|
238 | },
|
---|
239 | /**
|
---|
240 | * @param {MouseEvent} event
|
---|
241 | */
|
---|
242 | onMinHandleMouseMove(event) {
|
---|
243 | if (event.buttons < 1) {
|
---|
244 | return;
|
---|
245 | }
|
---|
246 |
|
---|
247 | // const word = this.mySurroundingWords[this.wordIndex];
|
---|
248 |
|
---|
249 | // if (event.movementX < 0 && word.left + event.movementX < 0) {
|
---|
250 | // return;
|
---|
251 | // }
|
---|
252 |
|
---|
253 | // if (event.movementX > 0 && word.left + event.movementX >= word.right()) {
|
---|
254 | // return;
|
---|
255 | // }
|
---|
256 |
|
---|
257 | // word.left += event.movementX;
|
---|
258 | // word.length -= event.movementX;
|
---|
259 |
|
---|
260 | this.adjustWordStartTime(event.movementX);
|
---|
261 |
|
---|
262 | this.updateSurroundingWords();
|
---|
263 | },
|
---|
264 | onMaxHandleMouseMove(event) {
|
---|
265 | if (event.buttons < 1) {
|
---|
266 | return;
|
---|
267 | }
|
---|
268 |
|
---|
269 | const word = this.myWord;
|
---|
270 |
|
---|
271 | const lastWord = this.mySurroundingWords[this.mySurroundingWords.length - 1];
|
---|
272 |
|
---|
273 | if (event.movementX > 0 && word.right() + event.movementX > lastWord.right()) {
|
---|
274 | return;
|
---|
275 | }
|
---|
276 |
|
---|
277 | if (event.movementX < 0 && word.right() + event.movementX <= word.left) {
|
---|
278 | return;
|
---|
279 | }
|
---|
280 |
|
---|
281 | word.length += event.movementX;
|
---|
282 |
|
---|
283 | this.updateSurroundingWords();
|
---|
284 | },
|
---|
285 | onDocumentMouseUp() {
|
---|
286 | this.canAdjustWordStartTime = false;
|
---|
287 | this.canAdjustWordEndTime = false;
|
---|
288 | },
|
---|
289 | adjustWordStartTime(amount) {
|
---|
290 | const word = this.myWord;
|
---|
291 | word.left += amount;
|
---|
292 | word.length -= amount;
|
---|
293 |
|
---|
294 | if (amount < 0) {
|
---|
295 | if (this.wordIndex === 0) {
|
---|
296 | if (word.left > 0) {
|
---|
297 | return;
|
---|
298 | }
|
---|
299 |
|
---|
300 | const diff = 0 - word.left;
|
---|
301 | word.left = 0;
|
---|
302 | word.length -= diff;
|
---|
303 | }
|
---|
304 | else {
|
---|
305 | // Adjust previous word
|
---|
306 | const previousWord = this.mySurroundingWords[this.wordIndex - 1];
|
---|
307 |
|
---|
308 | if (word.left > previousWord.right()) {
|
---|
309 | return;
|
---|
310 | }
|
---|
311 |
|
---|
312 | const diff = word.left - previousWord.right(); // Will be negative
|
---|
313 | word.left -= diff;
|
---|
314 | word.length += diff;
|
---|
315 |
|
---|
316 | // TODO: This logic needs all words to be reactively updated to work
|
---|
317 |
|
---|
318 | // previousWord.length += amount;
|
---|
319 | // const leftBoundary = previousWord.left + this.timeToPosition(0.1);
|
---|
320 |
|
---|
321 | // if (previousWord.right() > leftBoundary) {
|
---|
322 | // return;
|
---|
323 | // }
|
---|
324 |
|
---|
325 | // const diff = leftBoundary - previousWord.right(); // Will be negative
|
---|
326 | // word.left -= diff;
|
---|
327 | // word.length += diff;
|
---|
328 | // previousWord.length -= diff;
|
---|
329 | }
|
---|
330 | }
|
---|
331 | else {
|
---|
332 | if (word.left > (word.right() - this.scaleTime(0.1))) {
|
---|
333 | const diff = word.left - (word.right() - this.scaleTime(0.1)) // Will be positive
|
---|
334 | word.left -= diff;
|
---|
335 | word.length += diff;
|
---|
336 | }
|
---|
337 | }
|
---|
338 | },
|
---|
339 | updateSurroundingWords() {
|
---|
340 | const updatedWords = [];
|
---|
341 |
|
---|
342 | for (let i = 0; i < this.mySurroundingWords.length; i++) {
|
---|
343 | updatedWords.push(
|
---|
344 | this.convertMyWord(this.mySurroundingWords[i], this.surroundingWords[i])
|
---|
345 | );
|
---|
346 | }
|
---|
347 |
|
---|
348 | console.log(updatedWords);
|
---|
349 | this.$emit("update:surroundingWords", updatedWords);
|
---|
350 | },
|
---|
351 | convertMyWord(myWord, actualWord) {
|
---|
352 | const startTime = (myWord.left + this.wordLeftOffsetPx) / this.scalingFactor;
|
---|
353 | const endTime = (myWord.right() + this.wordLeftOffsetPx) / this.scalingFactor;
|
---|
354 |
|
---|
355 | return new TWord(actualWord.word, startTime, endTime, actualWord.id, actualWord.shouldHighlight, actualWord.deletionAttempts);
|
---|
356 | }
|
---|
357 | },
|
---|
358 | beforeMount() {
|
---|
359 | this.isProduction = process.env.NODE_ENV === "production";
|
---|
360 | },
|
---|
361 | mounted() {
|
---|
362 | this.isMounted = true;
|
---|
363 |
|
---|
364 | document.addEventListener("mousemove", this.onDocumentMouseMove);
|
---|
365 | document.addEventListener("mouseup", this.onDocumentMouseUp);
|
---|
366 | },
|
---|
367 | beforeUnmount() {
|
---|
368 | document.removeEventListener("mousemove", this.onDocumentMouseMove);
|
---|
369 | document.removeEventListener("mouseup", this.onDocumentMouseUp);
|
---|
370 | }
|
---|
371 | }
|
---|
372 | </script>
|
---|