forked from nikita/muzika-gromche
339 lines
12 KiB
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>
|