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