1
0
Fork 0
muzika-gromche/Frontend/src/components/timeline/Timeline.vue

339 lines
12 KiB
Vue

<script setup lang="ts">
import Playhead from '@/components/timeline/Playhead.vue';
// import Timestamp from '@/components/timeline/Timestamp.vue';
// import { dummyAudioTrackForTesting, secondsToBeats } from '@/lib/AudioTrack';
import ZoomSlider from '@/components/library/ZoomSlider.vue';
import ScrollSync from '@/components/scrollsync/ScrollSync.vue';
import TimelineHeader from '@/components/timeline/header/TimelineHeader.vue';
import { onInputKeyStroke } from '@/lib/onInputKeyStroke';
import { useOptionalWidgetState, type UseOptionalWidgetStateReturn } from '@/lib/useOptionalWidgetState';
import { bindTwoWay, toPx } from '@/lib/vue';
import { DEFAULT_ZOOM_HORIZONTAL, DEFAULT_ZOOM_VERTICAL, useTimelineStore } from '@/store/TimelineStore';
import { useTrackStore } from '@/store/TrackStore';
import { useElementBounding, useEventListener, useScroll } from '@vueuse/core';
import { storeToRefs } from 'pinia';
import { computed, useId, useTemplateRef, watch } from 'vue';
import TimelineTrackHeader from './TimelineTrackHeader.vue';
import TimelineTrackView from './TimelineTrackView.vue';
import TimelineMarkers from './markers/TimelineMarkers.vue';
const {
rightSidebar,
} = defineProps<{
rightSidebar: UseOptionalWidgetStateReturn,
}>();
const trackStore = useTrackStore();
const timeline = useTimelineStore();
const audioTrack = computed(() => trackStore.currentAudioTrack!);
const {
headerHeight, sidebarWidth,
viewportZoomHorizontal, viewportZoomVertical,
viewportScrollOffsetTop, viewportScrollOffsetLeft,
} = storeToRefs(timeline);
// const visibleTracks = computed(() => timeline.visibleTracks.slice(0, 3));
const visibleTracks = computed(() => timeline.visibleTracks.slice(0, 10));
// const playbackPositionSeconds = defineModel<number | null>('playbackPositionSeconds', { default: null });
// const playbackPositionBeats = computed<number | null>(() => {
// if (playbackPositionSeconds.value === null) {
// return null;
// }
// return secondsToBeats(audioTrack.value!, playbackPositionSeconds.value);
// });
// const playbackPosition = computed<number>(() => {
// if (playbackPositionSeconds.value === null) {
// return 0;
// }
// return playbackPositionSeconds.value / timelineTotalDurationSeconds.value;
// });
//
// const cursorPositionSeconds = shallowRef<number | null>(0)
// const cursorPositionBeats = computed<number | null>(() => {
// if (cursorPositionSeconds.value === null) {
// return null;
// }
// return secondsToBeats(audioTrack.value!, cursorPositionSeconds.value);
// });
// const cursorPosition = computed<number>(() => {
// if (cursorPositionSeconds.value === null) {
// return 0;
// }
// return cursorPositionSeconds.value / timelineTotalDurationSeconds.value;
// });
// const timelineEl = useTemplateRef('timeline');
//
// function _cursorToPositionSeconds(e: MouseEvent): number | null {
// if (audioTrack.value === null || timelineEl.value === null) {
// return null;
// }
// // clientX is broken in Firefox: https://bugzilla.mozilla.org/show_bug.cgi?id=505521#c80
// const x = e.pageX - timelineEl.value.offsetLeft;
// const rect = timelineEl.value.getBoundingClientRect();
// const position = x / rect.width;
// const positionClamped = Math.max(0, Math.min(1, position));
// return positionClamped * timelineTotalDurationSeconds.value;
// }
//
// const isDragging = shallowRef(false);
//
// function timelinePointerDown(event: PointerEvent) {
// const tl = timelineEl.value;
// if (tl && !isDragging.value) {
// isDragging.value = true;
// tl.setPointerCapture(event.pointerId);
// timelinePointerMove(event);
// }
// }
// function timelinePointerUp(event: PointerEvent) {
// timelinePointerMove(event);
// if (isDragging.value) {
// isDragging.value = false;
// }
// }
// function timelinePointerMove(event: PointerEvent) {
// // preview mouse position
// console.log("MOVE", cursorPositionSeconds.value);
// cursorPositionSeconds.value = _cursorToPositionSeconds(event);
// if (isDragging.value) {
// // apply mouse position
// playbackPositionSeconds.value = cursorPositionSeconds.value;
// }
// }
// function timelinePointerLeave(_event: PointerEvent) {
// console.log("LEAVE", isDragging.value);
// if (!isDragging.value) {
// cursorPositionSeconds.value = null;
// }
// }
// Questionable thin vertical sidebar on the right, contains vertical zoom slider.
// Not sure I want this to remain, so used a boolean flag to hide.
const timelineScrollGroup = useId();
const timelineRootElement = useTemplateRef('timelineRootElement');
const timelineScrollView = useTemplateRef<InstanceType<typeof ScrollSync>>('timelineScrollView');
const timelineScrollViewBounding = useElementBounding(timelineScrollView);
watch(timelineScrollViewBounding.width, (value) => {
timeline.viewportWidth = value;
});
watch(timelineScrollViewBounding.height, (value) => {
timeline.viewportHeight = value;
});
const {
arrivedState: timelineScrollViewArrivedState,
x: timelineScrollViewOffsetLeft,
y: timelineScrollViewOffsetTop,
} = useScroll(() => timelineScrollView.value?.$el);
bindTwoWay(timelineScrollViewOffsetTop, viewportScrollOffsetTop);
bindTwoWay(timelineScrollViewOffsetLeft, viewportScrollOffsetLeft);
function scrollZoomHandler(event: WheelEvent) {
// Note: Math.random() prevents console output history from collapsing same entries.
// console.log("WHEEEEEL", Math.random().toFixed(3), event.deltaX, event.deltaY, event.target, event);
// TODO: Ignore Ctrl key because it intercepts touchpad pinch to zoom?
// TODO: what if the user doesn't use a touchpad, and thus has
// no way to scroll horizontally other than by dragging a scrollbar?
const ignoreCtrlWheel = false;
if (event.shiftKey) {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
viewportZoomVertical.value -= event.deltaY / 100;
}
else if (event.altKey) {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
viewportZoomHorizontal.value -= event.deltaY / 100;
}
else if (event.ctrlKey && !ignoreCtrlWheel) {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
viewportScrollOffsetLeft.value += event.deltaY;
}
}
useEventListener(timelineRootElement, "wheel", scrollZoomHandler, { passive: false, });
// Shift+Z - reset zoom
onInputKeyStroke((event) => event.shiftKey && (event.key === 'Z' || event.key === 'z'), (event) => {
timeline.zoomToggleBetweenWholeAndLoop();
event.preventDefault();
});
</script>
<template>
<div ref="timelineRootElement" class="tw:w-full tw:grid tw:gap-0" :style="{
'grid-template-columns': `${toPx(sidebarWidth)} 1fr ${rightSidebar.visible.value ? rightSidebar.width.string.value : ''}`,
'grid-template-rows': `${toPx(headerHeight)} 1fr`,
}" style="border-top: var(--view-separator-border);">
<!-- top left corner, contains zoom controls -->
<div class="toolbar-background tw:max-w-full tw:flex tw:flex-row tw:flex-nowrap tw:items-center"
style="grid-row: 1; grid-column: 1; border-right: var(--view-separator-border); border-bottom: var(--view-separator-border);">
<ZoomSlider v-model:zoom="viewportZoomHorizontal" :default-zoom="DEFAULT_ZOOM_HORIZONTAL" extended
class="tw:flex-1" />
</div>
<!-- left sidebar with timeline track names -->
<ScrollSync :group="timelineScrollGroup" :vertical="true" class="toolbar-background scrollbar-none"
style="grid-row: 2; grid-column: 1; border-right: var(--view-separator-border);">
<template v-for="timelineTrack in visibleTracks">
<TimelineTrackHeader :timelineTrack />
</template>
</ScrollSync>
<!-- header with timestamps -->
<ScrollSync :group="timelineScrollGroup" :horizontal="true" class="timeline-background scrollbar-none tw:relative"
style="grid-row: 1; grid-column: 2; border-bottom: var(--view-separator-border);">
<TimelineHeader />
<!-- <Playhead :positionSeconds="timeline.playheadPosition"> -->
<!-- <Timestamp :seconds="cursorPositionSeconds ?? 0" :beats="cursorPositionBeats ?? 0" v-if="isDragging" /> -->
<!-- </Playhead> -->
</ScrollSync>
<!-- TODO -->
<!-- <div ref="timeline" class="timeline" @pointerdown="timelinePointerDown" @pointerup="timelinePointerUp"
@pointermove="timelinePointerMove" @pointerleave="timelinePointerLeave"> -->
<!-- timeline content -->
<ScrollSync ref="timelineScrollView" :group="timelineScrollGroup" :horizontal="true" :vertical="true"
class="tw:size-full timeline-background tw:relative" style="grid-row: 2; grid-column: 2;">
<!-- timeline content wrapper for good measure -->
<div class="tw:relative tw:overflow-hidden tw:min-h-full"
:style="{ width: timeline.contentWidthIncludingEmptySpacePx }">
<!-- timeline markers -->
<TimelineMarkers />
<!-- timeline tracks -->
<div>
<template v-for="timelineTrack in visibleTracks">
<TimelineTrackView :timelineTrack />
</template>
</div>
</div>
</ScrollSync>
<!-- horizontal bars of scroll shadow, on top of sidebar and content, but under playhead-->
<div class="tw:size-full tw:relative tw:pointer-events-none" style="grid-row: 2; grid-column: 1 / 3;">
<div class="tw:absolute tw:top-0 tw:left-0 tw:h-0 tw:w-full"
:class="{ 'tw:invisible': timelineScrollViewArrivedState.top }">
<div class="tw:h-4 tw:w-full shadow-bottom"></div>
</div>
<div class="tw:absolute tw:bottom-4 tw:left-0 tw:h-0 tw:w-full"
:class="{ 'tw:invisible': timelineScrollViewArrivedState.bottom }">
<div class="tw:h-4 tw:w-full shadow-top"></div>
</div>
</div>
<!-- playhead -->
<ScrollSync :group="timelineScrollGroup" :horizontal="true" class="tw:size-full tw:pointer-events-none"
style="grid-row: 1 / 3; grid-column: 2;">
<div class="tw:h-full tw:relative tw:overflow-hidden" :style="{ width: timeline.contentWidthPx }">
<!-- actuals playback position -->
<Playhead :positionSeconds="timeline.playheadPosition" :knob="true">
<!-- <Timestamp :seconds="cursorPositionSeconds ?? 0" :beats="cursorPositionBeats ?? 0" v-if="isDragging" /> -->
</Playhead>
</div>
</ScrollSync>
<!-- cursor on hover -->
<!-- <Playhead :position="cursorPosition" :timelineWidth="timelineWidth" :knob="false"
:hidden="cursorPositionSeconds === null || isDragging">
<Timestamp :seconds="cursorPositionSeconds ?? 0" :beats="cursorPositionBeats ?? 0" />
</Playhead> -->
<!-- vertical bars of scroll shadow, on top of header, content AND playhead -->
<div class="tw:size-full tw:relative tw:pointer-events-none" style="grid-row: 1 / -1; grid-column: 2;">
<div class="tw:absolute tw:top-0 tw:left-0 tw:w-0 tw:h-full"
:class="{ 'tw:invisible': timelineScrollViewArrivedState.left }">
<div class="tw:w-4 tw:h-full shadow-right"></div>
</div>
<div class="tw:absolute tw:top-0 tw:right-4 tw:w-0 tw:h-full"
:class="{ 'tw:invisible': timelineScrollViewArrivedState.right }">
<div class="tw:w-4 tw:h-full shadow-left"></div>
</div>
</div>
<!-- empty cell at the top right -->
<div v-if="rightSidebar.visible.value" class="toolbar-background"
style="grid-row: 1; grid-column: 3; border-bottom: var(--view-separator-border); border-left: var(--view-separator-border);">
</div>
<!-- right sidebar with vertical zoom slider -->
<div v-if="rightSidebar.visible.value"
class="toolbar-background tw:size-full tw:min-h-0 tw:py-2 tw:flex tw:flex-col tw:items-center"
style="grid-row: 2; grid-column: 3; border-left: var(--view-separator-border);">
<ZoomSlider v-model:zoom="viewportZoomVertical" orientation="vertical" :default-zoom="DEFAULT_ZOOM_VERTICAL"
class="tw:w-full tw:min-h-0" />
</div>
</div>
</template>
<style scoped>
/* .timeline {
background-color: var(--timeline-background-color);
position: relative;
user-select: none;
touch-action: none;
overflow: hidden;
} */
.shadow-top,
.shadow-right,
.shadow-bottom,
.shadow-left {
--shadow-darkest: rgba(0, 0, 0, 0.3);
}
.shadow-top {
background-image: linear-gradient(to top, var(--shadow-darkest) 0%, rgba(0, 0, 0, .0) 100%);
}
.shadow-right {
background-image: linear-gradient(to right, var(--shadow-darkest) 0%, rgba(0, 0, 0, .0) 100%);
}
.shadow-bottom {
background-image: linear-gradient(to bottom, var(--shadow-darkest) 0%, rgba(0, 0, 0, .0) 100%);
}
.shadow-left {
background-image: linear-gradient(to left, var(--shadow-darkest) 0%, rgba(0, 0, 0, .0) 100%);
}
</style>