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
16<template>
17  <div class="timelines-container">
18
19    <div class="timeline-icons" @mousedown="mousedownHandler">
20      <div
21        v-for="file in timelineFiles"
22        :key="file.filename"
23        class="trace-icon"
24        :class="{disabled: file.timelineDisabled}"
25        @click="toggleTimeline(file)"
26        style="cursor: pointer;"
27      >
28        <i class="material-icons">
29          {{ TRACE_ICONS[file.type] }}
30          <md-tooltip md-direction="bottom">{{ file.type }}</md-tooltip>
31        </i>
32      </div>
33    </div>
34
35    <div class="timelines-wrapper" ref="timelinesWrapper">
36      <md-list class="timelines" @mousedown="mousedownHandler" ref="timelines">
37        <md-list-item
38        v-for="file in timelineFiles"
39        :key="file.filename"
40        >
41          <timeline
42            :timeline="Object.freeze(file.timeline)"
43            :selected-index="file.selectedIndex"
44            :scale="scale"
45            :crop="crop"
46            :disabled="file.timelineDisabled"
47            class="timeline"
48          />
49        </md-list-item>
50      </md-list>
51
52      <div
53        class="selection"
54        :style="selectionStyle"
55      />
56
57      <div
58        v-show="this.cropIntent"
59        class="selection-intent"
60        :style="selectionIntentStyle"
61      />
62    </div>
63  </div>
64</template>
65<script>
66import Timeline from './Timeline.vue';
67import {TRACE_ICONS} from '@/decode.js';
68
69export default {
70  name: 'Timelines',
71  props: ['timelineFiles', 'scale', 'crop', 'cropIntent'],
72  data() {
73    return {
74      // Distances of sides from top left corner of wrapping div in pixels
75      selectionPosition: {
76        top: 0,
77        left: 0,
78        bottom: 0,
79        right: 0,
80      },
81      TRACE_ICONS,
82    };
83  },
84  computed: {
85    /**
86     * Used to check whether or not a selection box should be displayed.
87     * @return {bool} true if any of the positions are non nullish values
88     */
89    isEmptySelection() {
90      return this.selectionPosition.top ||
91        this.selectionPosition.left ||
92        this.selectionPosition.bottom ||
93        this.selectionPosition.right;
94    },
95    /**
96     * Generates the style of the selection box.
97     * @return {object} an object containing the style of the selection box.
98     */
99    selectionStyle() {
100      return {
101        top: `${this.selectionPosition.top}px`,
102        left: `${this.selectionPosition.left}px`,
103        height:
104          `${this.selectionPosition.bottom - this.selectionPosition.top}px`,
105        width:
106          `${this.selectionPosition.right - this.selectionPosition.left}px`,
107      };
108    },
109    /**
110     * Generates the dynamic style of the selection intent box.
111     * @return {object} an object containing the style of the selection intent
112     *                  box.
113     */
114    selectionIntentStyle() {
115      if (!(this.cropIntent && this.$refs.timelinesWrapper)) {
116        return {
117          left: 0,
118          width: 0,
119        };
120      }
121
122      const activeCropLeft = this.crop?.left ?? 0;
123      const activeCropRight = this.crop?.right ?? 1;
124      const timelineWidth =
125        this.$refs.timelinesWrapper.getBoundingClientRect().width;
126
127      const r = timelineWidth / (activeCropRight - activeCropLeft);
128
129      let left = 0;
130      let boderLeft = 'none';
131      if (this.cropIntent.left > activeCropLeft) {
132        left = (this.cropIntent.left - activeCropLeft) * r;
133        boderLeft = null;
134      }
135
136      let right = timelineWidth;
137      let borderRight = 'none';
138      if (this.cropIntent.right < activeCropRight) {
139        right = timelineWidth - (activeCropRight - this.cropIntent.right) * r;
140        borderRight = null;
141      }
142
143      return {
144        'left': `${left}px`,
145        'width': `${right - left}px`,
146        'border-left': boderLeft,
147        'border-right': borderRight,
148      };
149    },
150  },
151  methods: {
152    /**
153     * Adds an overlay to make sure element selection can't happen and the
154     * crosshair cursor style is maintained wherever the curso is on the screen
155     * while a selection is taking place.
156     */
157    addOverlay() {
158      if (this.overlay) {
159        return;
160      }
161
162      this.overlay = document.createElement('div');
163      Object.assign(this.overlay.style, {
164        'position': 'fixed',
165        'top': 0,
166        'left': 0,
167        'height': '100vh',
168        'width': '100vw',
169        'z-index': 100,
170        'cursor': 'crosshair',
171      });
172
173      document.body.appendChild(this.overlay);
174    },
175
176    /**
177     * Removes the overlay that is added by a call to addOverlay.
178     */
179    removeOverlay() {
180      if (!this.overlay) {
181        return;
182      }
183
184      document.body.removeChild(this.overlay);
185      delete this.overlay;
186    },
187
188    /**
189     * Generates an object that can is used to update the position and style of
190     * the selection box when a selection is being made. The object contains
191     * three functions which all take a DOM event as a parameter.
192     *
193     * - init: setup the initial drag position of the selection base on the
194     *         mousedown event
195     * - update: updates the selection box's coordinates based on the mousemouve
196     *           event
197     * - reset: clears the selection box, shold be called when the mouseup event
198     *          occurs or when we want to no longer display the selection box.
199     * @return {null}
200     */
201    selectionPositionsUpdater() {
202      let startClientX; let startClientY; let x; let y;
203
204      return {
205        init: (e) => {
206          startClientX = e.clientX;
207          startClientY = e.clientY;
208          x = startClientX -
209            this.$refs.timelines.$el.getBoundingClientRect().left;
210          y = startClientY -
211            this.$refs.timelines.$el.getBoundingClientRect().top;
212        },
213        update: (e) => {
214          let left; let right; let top; let bottom;
215
216          const xDiff = e.clientX - startClientX;
217          if (xDiff > 0) {
218            left = x;
219            right = x + xDiff;
220          } else {
221            left = x + xDiff;
222            right = x;
223          }
224
225          const yDiff = e.clientY - startClientY;
226          if (yDiff > 0) {
227            top = y;
228            bottom = y + yDiff;
229          } else {
230            top = y + yDiff;
231            bottom = y;
232          }
233
234          if (left < 0) {
235            left = 0;
236          }
237          if (top < 0) {
238            top = 0;
239          }
240          if (right > this.$refs.timelines.$el.getBoundingClientRect().width) {
241            right = this.$refs.timelines.$el.getBoundingClientRect().width;
242          }
243
244          if (bottom >
245            this.$refs.timelines.$el.getBoundingClientRect().height) {
246            bottom = this.$refs.timelines.$el.getBoundingClientRect().height;
247          }
248
249          this.$set(this.selectionPosition, 'left', left);
250          this.$set(this.selectionPosition, 'right', right);
251          this.$set(this.selectionPosition, 'top', top);
252          this.$set(this.selectionPosition, 'bottom', bottom);
253        },
254        reset: (e) => {
255          this.$set(this.selectionPosition, 'left', 0);
256          this.$set(this.selectionPosition, 'right', 0);
257          this.$set(this.selectionPosition, 'top', 0);
258          this.$set(this.selectionPosition, 'bottom', 0);
259        },
260      };
261    },
262
263    /**
264     * Handles the mousedown event indicating the start of a selection.
265     * Adds listeners to handles mousemove and mouseup event to detect the
266     * selection and update the selection box's coordinates.
267     * @param {event} e
268     */
269    mousedownHandler(e) {
270      const selectionPositionsUpdater = this.selectionPositionsUpdater();
271      selectionPositionsUpdater.init(e);
272
273      let dragged = false;
274
275      const mousemoveHandler = (e) => {
276        if (!dragged) {
277          dragged = true;
278          this.addOverlay();
279        }
280
281        selectionPositionsUpdater.update(e);
282      };
283      document.addEventListener('mousemove', mousemoveHandler);
284
285      const mouseupHandler = (e) => {
286        document.removeEventListener('mousemove', mousemoveHandler);
287        document.removeEventListener('mouseup', mouseupHandler);
288
289        if (dragged) {
290          this.removeOverlay();
291          selectionPositionsUpdater.update(e);
292          this.zoomToSelection();
293        }
294        selectionPositionsUpdater.reset();
295      };
296      document.addEventListener('mouseup', mouseupHandler);
297    },
298
299    /**
300     * Update the crop values to zoom into the timeline based on the currently
301     * set selection box coordinates.
302     */
303    zoomToSelection() {
304      const left = this.crop?.left ?? 0;
305      const right = this.crop?.right ?? 1;
306
307      const ratio =
308        (this.selectionPosition.right - this.selectionPosition.left) /
309        this.$refs.timelines.$el.getBoundingClientRect().width;
310
311      const newCropWidth = ratio * (right - left);
312      const newLeft = left + (this.selectionPosition.left /
313        this.$refs.timelines.$el.getBoundingClientRect().width) *
314          (right - left);
315
316      if (this.crop) {
317        this.$set(this.crop, 'left', newLeft);
318        this.$set(this.crop, 'right', newLeft + newCropWidth);
319      } else {
320        this.$emit('crop', {
321          left: newLeft,
322          right: newLeft + newCropWidth,
323        });
324      }
325    },
326
327    toggleTimeline(file) {
328      this.$set(file, 'timelineDisabled', !file.timelineDisabled);
329    },
330  },
331  components: {
332    Timeline,
333  },
334};
335</script>
336<style scoped>
337.timelines-container {
338  display: flex;
339}
340
341.timelines-container .timelines-wrapper {
342  flex-grow: 1;
343  cursor: crosshair;
344  position: relative;
345}
346
347.timelines-wrapper {
348  overflow: hidden;
349}
350
351.selection, .selection-intent {
352  position: absolute;
353  z-index: 100;
354  background: rgba(255, 36, 36, 0.5);
355  pointer-events: none;
356}
357
358.selection-intent {
359  top: 0;
360  height: 100%;
361  margin-left: -3px;
362  border-left: 3px #1261A0 solid;
363  border-right: 3px #1261A0 solid;
364}
365
366.timeline-icons {
367  display: flex;
368  flex-direction: column;
369  justify-content: space-evenly;
370  margin-left: 15px;
371}
372
373.trace-icon.disabled {
374  color: gray;
375}
376</style>
377