forked from nikita/muzika-gromche
234 lines
8.6 KiB
Vue
234 lines
8.6 KiB
Vue
<script setup lang="ts">
|
|
import type { UseOptionalWidgetStateReturn } from '@/lib/useOptionalWidgetState'
|
|
import type { UseZoomAxis } from '@/lib/useZoomAxis'
|
|
import { useElementBounding, useScroll } from '@vueuse/core'
|
|
import { storeToRefs } from 'pinia'
|
|
import { useId, useTemplateRef, watch } from 'vue'
|
|
import ZoomSlider from '@/components/library/ZoomSlider.vue'
|
|
import ScrollSync from '@/components/scrollsync/ScrollSync.vue'
|
|
import TimelineHeader from '@/components/timeline/header/TimelineHeader.vue'
|
|
import Playhead from '@/components/timeline/Playhead.vue'
|
|
import { onInputKeyStroke } from '@/lib/onInputKeyStroke'
|
|
import { useTimelineScrubbing } from '@/lib/useTimelineScrubbing'
|
|
import { useVeiwportWheel } from '@/lib/useVeiwportWheel'
|
|
import { bindTwoWay, toPx } from '@/lib/vue'
|
|
import { useTimelineStore } from '@/store/TimelineStore'
|
|
import TimelineMarkers from './markers/TimelineMarkers.vue'
|
|
import TimelineTrackHeader from './TimelineTrackHeader.vue'
|
|
import TimelineTrackView from './TimelineTrackView.vue'
|
|
|
|
const {
|
|
rightSidebar,
|
|
} = defineProps<{
|
|
rightSidebar: UseOptionalWidgetStateReturn
|
|
}>()
|
|
|
|
const timeline = useTimelineStore()
|
|
|
|
const {
|
|
headerHeight,
|
|
sidebarWidth,
|
|
viewportScrollOffsetTop,
|
|
viewportScrollOffsetLeft,
|
|
contentWidthIncludingEmptySpacePx,
|
|
visibleTracks,
|
|
} = storeToRefs(timeline)
|
|
// nested composable marked with markRaw
|
|
const viewportZoomHorizontal = timeline.viewportZoomHorizontal as any as UseZoomAxis
|
|
const viewportZoomVertical = timeline.viewportZoomVertical as any as UseZoomAxis
|
|
|
|
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)
|
|
|
|
useVeiwportWheel(timelineRootElement, {
|
|
axisHorizontal: viewportZoomHorizontal,
|
|
axisVertical: viewportZoomVertical,
|
|
scrollOffsetLeft: timelineScrollViewOffsetLeft,
|
|
})
|
|
|
|
// Shift+Z - reset zoom
|
|
onInputKeyStroke(event => event.shiftKey && (event.key === 'Z' || event.key === 'z'), (event) => {
|
|
timeline.zoomToggleBetweenWholeAndLoop()
|
|
event.preventDefault()
|
|
})
|
|
|
|
const scrubbing = useTemplateRef('scrubbing')
|
|
useTimelineScrubbing(scrubbing)
|
|
</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`,
|
|
}"
|
|
>
|
|
<!-- 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 :axis="viewportZoomHorizontal" 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" :key="timelineTrack.name">
|
|
<TimelineTrackHeader :timeline-track />
|
|
</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);"
|
|
>
|
|
<div ref="scrubbing" class="tw:relative tw:h-full" :style="{ width: contentWidthIncludingEmptySpacePx }">
|
|
<TimelineHeader />
|
|
</div>
|
|
|
|
<!-- <Playhead :positionSeconds="timeline.playheadPosition"> -->
|
|
<!-- <Timestamp :seconds="cursorPositionSeconds ?? 0" :beats="cursorPositionBeats ?? 0" v-if="isDragging" /> -->
|
|
<!-- </Playhead> -->
|
|
</ScrollSync>
|
|
|
|
<!-- 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" :key="timelineTrack.name">
|
|
<TimelineTrackView :timeline-track />
|
|
</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
|
|
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>
|
|
|
|
<!-- 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.contentWidthIncludingEmptySpacePx }"
|
|
>
|
|
<!-- actuals playback position -->
|
|
<Playhead :position-seconds="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
|
|
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>
|
|
|
|
<!-- 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);"
|
|
/>
|
|
|
|
<!-- 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 :axis="viewportZoomVertical" orientation="vertical" class="tw:w-full tw:min-h-0" />
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.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>
|