1<!-- Copyright (C) 2020 The Android Open Source Project 2 3 Licensed under the Apache License, Version 2.0 (the "License"); 4 you may not use this file except in compliance with the License. 5 You may obtain a copy of the License at 6 7 http://www.apache.org/licenses/LICENSE-2.0 8 9 Unless required by applicable law or agreed to in writing, software 10 distributed under the License is distributed on an "AS IS" BASIS, 11 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 See the License for the specific language governing permissions and 13 limitations under the License. 14--> 15<template> 16 <div class="wrapper"> 17 <svg 18 width="100%" 19 height="20" 20 class="timeline-svg" 21 :class="{disabled: disabled}" 22 ref="timeline" 23 > 24 <rect 25 :x="`${block.startPos}%`" 26 y="0" 27 :width="`${block.width}%`" 28 :height="pointHeight" 29 :rx="corner" 30 v-for="(block, idx) in timelineBlocks" 31 :key="idx" 32 class="point" 33 /> 34 <rect 35 v-if="selectedWidth >= 0" 36 v-show="showSelection" 37 :x="selectionAreaStart" 38 y="0" 39 :width="selectedWidth" 40 :height="pointHeight" 41 :rx="corner" 42 class="point selection" 43 ref="selectedSection" 44 /> 45 <rect 46 v-else 47 v-show="showSelection" 48 :x="selectionAreaEnd" 49 y="0" 50 :width="-selectedWidth" 51 :height="pointHeight" 52 :rx="corner" 53 class="point selection" 54 ref="selectedSection" 55 /> 56 57 <rect 58 v-show="showSelection" 59 :x="selectionAreaStart - 2" 60 y="0" 61 :width="4" 62 :height="pointHeight" 63 :rx="corner" 64 class="point selection-edge" 65 ref="leftResizeDragger" 66 /> 67 68 <rect 69 v-show="showSelection" 70 :x="selectionAreaEnd - 2" 71 y="0" 72 :width="4" 73 :height="pointHeight" 74 :rx="corner" 75 class="point selection-edge" 76 ref="rightResizeDragger" 77 /> 78 </svg> 79 </div> 80</template> 81<script> 82import TimelineMixin from './mixins/Timeline'; 83 84export default { 85 name: 'timelineSelection', 86 props: ['startTimestamp', 'endTimestamp', 'cropArea', 'disabled'], 87 data() { 88 return { 89 pointHeight: 15, 90 corner: 2, 91 selectionStartPosition: 0, 92 selectionEndPosition: 0, 93 selecting: false, 94 dragged: false, 95 draggingSelection: false, 96 }; 97 }, 98 mixins: [TimelineMixin], 99 watch: { 100 selectionStartPosition() { 101 // Send crop intent rather than final crop value while we are selecting 102 if ((this.selecting && this.dragged)) { 103 this.emitCropIntent(); 104 return; 105 } 106 107 this.emitCropDetails(); 108 }, 109 selectionEndPosition() { 110 // Send crop intent rather than final crop value while we are selecting 111 if ((this.selecting && this.dragged)) { 112 this.emitCropIntent(); 113 return; 114 } 115 116 this.emitCropDetails(); 117 }, 118 }, 119 methods: { 120 /** 121 * Create an object that can be injected and removed from the DOM to change 122 * the cursor style. The object is a mask over the entire screen. It is 123 * done this way as opposed to injecting a style targeting all elements for 124 * performance reasons, otherwise recalculate style would be very slow. 125 * This makes sure that regardless of the cursor style of other elements, 126 * the cursor style will be set to what we want over the entire screen. 127 * @param {string} cursor - The cursor type to apply to the entire page. 128 * @return An object that can be injected and removed from the DOM which 129 * changes the cursor style for the entire page. 130 */ 131 createCursorStyle(cursor) { 132 const cursorMask = document.createElement('div'); 133 cursorMask.style.cursor = cursor; 134 cursorMask.style.height = '100vh'; 135 cursorMask.style.width = '100vw'; 136 cursorMask.style.position = 'fixed'; 137 cursorMask.style.top = '0'; 138 cursorMask.style.left = '0'; 139 cursorMask.style['z-index'] = '1000'; 140 141 return { 142 inject: () => { 143 document.body.appendChild(cursorMask); 144 }, 145 remove: () => { 146 try { 147 document.body.removeChild(cursorMask); 148 } catch (e) {} 149 }, 150 }; 151 }, 152 153 setupCreateSelectionListeners() { 154 const cursorStyle = this.createCursorStyle('crosshair'); 155 156 this.timelineSvgMouseDownEventListener = (e) => { 157 e.stopPropagation(); 158 this.selecting = true; 159 this.dragged = false; 160 this.mouseDownX = e.offsetX; 161 this.mouseDownClientX = e.clientX; 162 163 cursorStyle.inject(); 164 }; 165 166 this.createSelectionMouseMoveEventListener = (e) => { 167 if (this.selecting) { 168 if (!this.dragged) { 169 this.selectionStartX = this.mouseDownX; 170 } 171 172 this.dragged = true; 173 const draggedAmount = e.clientX - this.mouseDownClientX; 174 175 if (draggedAmount >= 0) { 176 this.selectionStartPosition = this.selectionStartX; 177 178 const endX = this.selectionStartX + draggedAmount; 179 if (endX <= this.$refs.timeline.clientWidth) { 180 this.selectionEndPosition = endX; 181 } else { 182 this.selectionEndPosition = this.$refs.timeline.clientWidth; 183 } 184 185 this.$emit('showVideoAt', this.absolutePositionAsTimestamp(this.selectionEndPosition)); 186 } else { 187 this.selectionEndPosition = this.selectionStartX; 188 189 const startX = this.selectionStartX + draggedAmount; 190 if (startX >= 0) { 191 this.selectionStartPosition = startX; 192 } else { 193 this.selectionStartPosition = 0; 194 } 195 196 this.$emit('showVideoAt', this.absolutePositionAsTimestamp(this.selectionStartPosition)); 197 } 198 } 199 }; 200 201 this.createSelectionMouseUpEventListener = (e) => { 202 this.selecting = false; 203 cursorStyle.remove(); 204 this.$emit('resetVideoTimestamp'); 205 if (this.dragged) { 206 // Clear crop intent, we now have a set crop value 207 this.clearCropIntent(); 208 // Notify of final crop value 209 this.emitCropDetails(); 210 } 211 this.dragged = false; 212 }; 213 214 this.$refs.timeline 215 .addEventListener('mousedown', this.timelineSvgMouseDownEventListener); 216 document 217 .addEventListener('mousemove', this.createSelectionMouseMoveEventListener); 218 document 219 .addEventListener('mouseup', this.createSelectionMouseUpEventListener); 220 }, 221 222 teardownCreateSelectionListeners() { 223 this.$refs.timeline 224 .removeEventListener('mousedown', this.timelineSvgMouseDownEventListener); 225 document 226 .removeEventListener('mousemove', this.createSelectionMouseMoveEventListener); 227 document 228 .removeEventListener('mouseup', this.createSelectionMouseUpEventListener); 229 }, 230 231 setupDragSelectionListeners() { 232 const cursorStyle = this.createCursorStyle('move'); 233 234 this.selectedSectionMouseDownListener = (e) => { 235 e.stopPropagation(); 236 this.draggingSelectionStartX = e.clientX; 237 this.selectionStartPosition = this.selectionAreaStart; 238 this.selectionEndPosition = this.selectionAreaEnd; 239 this.draggingSelectionStartPos = this.selectionAreaStart; 240 this.draggingSelectionEndPos = this.selectionAreaEnd; 241 242 // Keep this after fetching selectionAreaStart and selectionAreaEnd. 243 this.draggingSelection = true; 244 245 cursorStyle.inject(); 246 }; 247 248 this.dragSelectionMouseMoveEventListener = (e) => { 249 if (this.draggingSelection) { 250 const dragAmount = e.clientX - this.draggingSelectionStartX; 251 252 const newStartPos = this.draggingSelectionStartPos + dragAmount; 253 const newEndPos = this.draggingSelectionEndPos + dragAmount; 254 if (newStartPos >= 0 && newEndPos <= this.$refs.timeline.clientWidth) { 255 this.selectionStartPosition = newStartPos; 256 this.selectionEndPosition = newEndPos; 257 } else { 258 if (newStartPos < 0) { 259 this.selectionStartPosition = 0; 260 this.selectionEndPosition = newEndPos - (newStartPos /* negative overflown amount*/); 261 } else { 262 const overflownAmount = newEndPos - this.$refs.timeline.clientWidth; 263 this.selectionEndPosition = this.$refs.timeline.clientWidth; 264 this.selectionStartPosition = newStartPos - overflownAmount; 265 } 266 } 267 } 268 }; 269 270 this.dragSelectionMouseUpEventListener = (e) => { 271 this.draggingSelection = false; 272 cursorStyle.remove(); 273 }; 274 275 this.$refs.selectedSection 276 .addEventListener('mousedown', this.selectedSectionMouseDownListener); 277 document 278 .addEventListener('mousemove', this.dragSelectionMouseMoveEventListener); 279 document 280 .addEventListener('mouseup', this.dragSelectionMouseUpEventListener); 281 }, 282 283 teardownDragSelectionListeners() { 284 this.$refs.selectedSection 285 .removeEventListener('mousedown', this.selectedSectionMouseDownListener); 286 document 287 .removeEventListener('mousemove', this.dragSelectionMouseMoveEventListener); 288 document 289 .removeEventListener('mouseup', this.dragSelectionMouseUpEventListener); 290 }, 291 292 setupResizeSelectionListeners() { 293 const cursorStyle = this.createCursorStyle('ew-resize'); 294 295 this.leftResizeDraggerMouseDownEventListener = (e) => { 296 e.stopPropagation(); 297 this.resizeStartX = e.clientX; 298 this.selectionStartPosition = this.selectionAreaStart; 299 this.selectionEndPosition = this.selectionAreaEnd; 300 this.resizeStartPos = this.selectionAreaStart; 301 this.resizeingLeft = true; 302 303 cursorStyle.inject(); 304 this.$emit('showVideoAt', this.absolutePositionAsTimestamp(this.selectionAreaStart)); 305 }; 306 307 this.rightResizeDraggerMouseDownEventListener = (e) => { 308 e.stopPropagation(); 309 this.resizeStartX = e.clientX; 310 this.selectionStartPosition = this.selectionAreaStart; 311 this.selectionEndPosition = this.selectionAreaEnd; 312 this.resizeEndPos = this.selectionAreaEnd; 313 this.resizeingRight = true; 314 315 cursorStyle.inject(); 316 this.$emit('showVideoAt', this.absolutePositionAsTimestamp(this.selectionAreaEnd)); 317 }; 318 319 this.resizeMouseMoveEventListener = (e) => { 320 if (this.resizeingLeft) { 321 const moveAmount = e.clientX - this.resizeStartX; 322 let newStartPos = this.resizeStartPos + moveAmount; 323 if (newStartPos >= this.selectionEndPosition) { 324 newStartPos = this.selectionEndPosition; 325 } 326 if (newStartPos < 0) { 327 newStartPos = 0; 328 } 329 330 this.selectionStartPosition = newStartPos; 331 332 this.$emit('showVideoAt', this.absolutePositionAsTimestamp(this.selectionStartPosition)); 333 } 334 335 if (this.resizeingRight) { 336 const moveAmount = e.clientX - this.resizeStartX; 337 let newEndPos = this.resizeEndPos + moveAmount; 338 if (newEndPos <= this.selectionStartPosition) { 339 newEndPos = this.selectionStartPosition; 340 } 341 if (newEndPos > this.$refs.timeline.clientWidth) { 342 newEndPos = this.$refs.timeline.clientWidth; 343 } 344 345 this.selectionEndPosition = newEndPos; 346 this.$emit('showVideoAt', this.absolutePositionAsTimestamp(this.selectionEndPosition)); 347 } 348 }; 349 350 this.resizeSelectionMouseUpEventListener = (e) => { 351 this.resizeingLeft = false; 352 this.resizeingRight = false; 353 cursorStyle.remove(); 354 this.$emit('resetVideoTimestamp'); 355 }; 356 357 this.$refs.leftResizeDragger 358 .addEventListener('mousedown', this.leftResizeDraggerMouseDownEventListener); 359 this.$refs.rightResizeDragger 360 .addEventListener('mousedown', this.rightResizeDraggerMouseDownEventListener); 361 document 362 .addEventListener('mousemove', this.resizeMouseMoveEventListener); 363 document 364 .addEventListener('mouseup', this.resizeSelectionMouseUpEventListener); 365 }, 366 367 teardownResizeSelectionListeners() { 368 this.$refs.leftResizeDragger 369 .removeEventListener('mousedown', this.leftResizeDraggerMouseDownEventListener); 370 this.$refs.rightResizeDragger 371 .removeEventListener('mousedown', this.rightResizeDraggerMouseDownEventListener); 372 document 373 .removeEventListener('mousemove', this.resizeMouseMoveEventListener); 374 document 375 .removeEventListener('mouseup', this.resizeSelectionMouseUpEventListener); 376 }, 377 378 emitCropDetails() { 379 const width = this.$refs.timeline.clientWidth; 380 this.$emit('crop', { 381 left: this.selectionStartPosition / width, 382 right: this.selectionEndPosition / width, 383 }); 384 }, 385 386 emitCropIntent() { 387 const width = this.$refs.timeline.clientWidth; 388 this.$emit('cropIntent', { 389 left: this.selectionStartPosition / width, 390 right: this.selectionEndPosition / width 391 }); 392 }, 393 394 clearCropIntent() { 395 this.$emit('cropIntent', null); 396 } 397 }, 398 computed: { 399 selected() { 400 return this.timeline[this.selectedIndex]; 401 }, 402 selectedWidth() { 403 return this.selectionAreaEnd - this.selectionAreaStart; 404 }, 405 showSelection() { 406 return this.selectionAreaStart || this.selectionAreaEnd; 407 }, 408 selectionAreaStart() { 409 if ((this.selecting && this.dragged) || this.draggingSelection) { 410 return this.selectionStartPosition; 411 } 412 413 if (this.cropArea && this.$refs.timeline) { 414 return this.cropArea.left * this.$refs.timeline.clientWidth; 415 } 416 417 return 0; 418 }, 419 selectionAreaEnd() { 420 if ((this.selecting && this.dragged) || this.draggingSelection) { 421 return this.selectionEndPosition; 422 } 423 424 if (this.cropArea && this.$refs.timeline) { 425 return this.cropArea.right * this.$refs.timeline.clientWidth; 426 } 427 428 return 0; 429 }, 430 }, 431 mounted() { 432 this.setupCreateSelectionListeners(); 433 this.setupDragSelectionListeners(); 434 this.setupResizeSelectionListeners(); 435 }, 436 beforeDestroy() { 437 this.teardownCreateSelectionListeners(); 438 this.teardownDragSelectionListeners(); 439 this.teardownResizeSelectionListeners(); 440 }, 441}; 442</script> 443<style scoped> 444.wrapper { 445 padding: 0 15px; 446} 447 448.timeline-svg { 449 cursor: crosshair; 450} 451.timeline-svg .point { 452 fill: #BDBDBD; 453} 454.timeline-svg .point.selection { 455 fill: rgba(240, 59, 59, 0.596); 456 cursor: move; 457} 458 459.timeline-svg .point.selection-edge { 460 fill: rgba(27, 123, 212, 0.596); 461 cursor: ew-resize; 462} 463</style> 464