forked from nikita/muzika-gromche
189 lines
5.0 KiB
Vue
189 lines
5.0 KiB
Vue
<script setup lang="ts">
|
|
import { shallowRef, useTemplateRef } from 'vue';
|
|
|
|
// Any card with a subtle outline and hover effect
|
|
const {
|
|
hoverEnabled = true,
|
|
playheadEnabled = true,
|
|
selected = false,
|
|
} = defineProps<{
|
|
hoverEnabled?: boolean,
|
|
playheadEnabled?: boolean,
|
|
selected?: boolean,
|
|
}>();
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'select'): void;
|
|
(e: 'activate'): void;
|
|
(e: 'playhead', pos: number): void;
|
|
}>();
|
|
|
|
// Timeline / playhead position on hover in range 0..1,
|
|
// or NaN when not hovered or hover is not enabled.
|
|
const playheadPosition01 = shallowRef<number>(NaN);
|
|
const playheadEl = useTemplateRef('playheadEl');
|
|
const card = useTemplateRef('card');
|
|
|
|
// Simply tracks pointer enter/leave
|
|
const isHovered = shallowRef(false);
|
|
// flag to detect a recent touch tap so the following click doesn't also emit 'select'
|
|
const lastTapWasTouch = shallowRef(false);
|
|
// show playhead on touch while pressing
|
|
const isTouchActive = shallowRef(false);
|
|
// Once dragging starts, pointer should no longer cause click on release/up
|
|
const isTouchDragging = shallowRef(false);
|
|
// Playhead is active on mouse hover or touch down, but not after touch up which leaves dangling :hover on mobile.
|
|
|
|
defineExpose({
|
|
playheadPosition01,
|
|
});
|
|
|
|
// Returns false if playhead wasn't updated
|
|
function updatePlayhead(event: PointerEvent): boolean {
|
|
const target = event.currentTarget as HTMLElement | null;
|
|
if (!hoverEnabled || !playheadEnabled || !target) {
|
|
return false;
|
|
}
|
|
|
|
const rect = target.getBoundingClientRect();
|
|
const x = event.clientX - rect.left;
|
|
const pos = rect.width > 0 ? Math.max(0, Math.min(1, x / rect.width)) : 0;
|
|
playheadPosition01.value = pos;
|
|
|
|
if (playheadEl.value) {
|
|
// position the 1px playhead using percentage so it adapts to responsive widths
|
|
playheadEl.value.style.left = `${pos * 100}%`;
|
|
}
|
|
// emit normalized position for parent components to react (e.g. preview color)
|
|
emit('playhead', pos);
|
|
return true;
|
|
}
|
|
|
|
function onPointerEnter(_event: PointerEvent) {
|
|
if (hoverEnabled) {
|
|
isHovered.value = true;
|
|
}
|
|
}
|
|
|
|
function onPointerLeave(_event: PointerEvent) {
|
|
if (hoverEnabled) {
|
|
isHovered.value = false;
|
|
}
|
|
isTouchActive.value = false;
|
|
isTouchDragging.value = false;
|
|
playheadPosition01.value = NaN;
|
|
if (playheadEl.value) {
|
|
playheadEl.value.style.left = "";
|
|
}
|
|
emit('playhead', NaN);
|
|
}
|
|
|
|
function onPointerDown(event: PointerEvent) {
|
|
if (event.pointerType !== 'touch') return;
|
|
if (updatePlayhead(event)) {
|
|
isTouchActive.value = true;
|
|
}
|
|
if (card.value) {
|
|
card.value.setPointerCapture(event.pointerId);
|
|
}
|
|
}
|
|
|
|
function onPointerUp(event: PointerEvent) {
|
|
// Treat a single touch tap as activation
|
|
if (event.pointerType === 'touch') {
|
|
if (!isTouchDragging.value) {
|
|
emit('activate');
|
|
}
|
|
lastTapWasTouch.value = true;
|
|
// keep the flag true briefly so the subsequent click handler can suppress 'select'
|
|
setTimeout(() => { lastTapWasTouch.value = false; }, 50);
|
|
// clear touch-active playhead state
|
|
isTouchActive.value = false;
|
|
isTouchDragging.value = false;
|
|
onPointerLeave(event);
|
|
}
|
|
}
|
|
|
|
function onPointerMove(event: PointerEvent) {
|
|
updatePlayhead(event);
|
|
if (event.pointerType === 'touch') {
|
|
isTouchDragging.value = true;
|
|
}
|
|
}
|
|
|
|
function onClick(event: MouseEvent) {
|
|
// If this click follows a touch tap, suppress the 'select' event
|
|
if (lastTapWasTouch.value) {
|
|
event.preventDefault();
|
|
lastTapWasTouch.value = false;
|
|
return;
|
|
}
|
|
emit('select');
|
|
}
|
|
|
|
function onDblClick(_event: MouseEvent) {
|
|
emit('activate')
|
|
}
|
|
</script>
|
|
<template>
|
|
<div ref="card" class="card card-border tw:min-w-10 tw:min-h-10 tw:grid" :class="{
|
|
'hover-enabled': hoverEnabled,
|
|
'playhead-enabled': hoverEnabled && playheadEnabled,
|
|
'playhead-active': isHovered || isTouchActive,
|
|
selected,
|
|
}" @pointerenter="onPointerEnter" @pointerleave="onPointerLeave" @pointerdown="onPointerDown"
|
|
@pointerup="onPointerUp" @pointermove="onPointerMove" @click="onClick" @dblclick="onDblClick"
|
|
@focusin="emit('select')">
|
|
<!-- content container -->
|
|
<div class="tw:row-span-full tw:col-span-full tw:w-full tw:h-full">
|
|
<slot />
|
|
</div>
|
|
<!-- playhead container -->
|
|
<div class="playhead-container tw:pointer-events-none tw:row-span-full tw:col-span-full">
|
|
<div ref="playheadEl" class="playhead"></div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.card {
|
|
color: var(--inactive-text-color);
|
|
background-color: var(--card-background-color);
|
|
overflow: hidden;
|
|
touch-action: pan-y;
|
|
}
|
|
|
|
.card.hover-enabled:hover,
|
|
.card.selected {
|
|
color: var(--active-text-color);
|
|
}
|
|
|
|
.card.hover-enabled:hover {
|
|
outline: 2px solid var(--card-outline-color);
|
|
}
|
|
|
|
.card.selected,
|
|
.card.selected:hover {
|
|
outline: 2px solid var(--card-outline-selected-color);
|
|
}
|
|
|
|
.playhead-container {
|
|
display: none;
|
|
position: relative;
|
|
}
|
|
|
|
.card.playhead-enabled.playhead-active .playhead-container {
|
|
display: unset;
|
|
}
|
|
|
|
.playhead {
|
|
width: 1px;
|
|
height: 100%;
|
|
/* center the 1px line on the pointer */
|
|
transform: translateX(-50%);
|
|
background-color: var(--timeline-playhead-color);
|
|
position: absolute;
|
|
top: 0;
|
|
}
|
|
</style>
|