363 lines
10 KiB
Vue
363 lines
10 KiB
Vue
<script setup>
|
||
// Der Zeitstrahl der Aufnahme rund um die Marke. Ein Balken zeigt das
|
||
// Fenster des Clips; an seinen Enden zieht man Anfang und Ende, in der
|
||
// Mitte schiebt man das ganze Fenster. Daneben zieht man den Strahl
|
||
// selbst nach links und rechts. Zeiten in Sekunden relativ zur Marke:
|
||
// 0 ist der Moment, an dem C gedrückt wurde.
|
||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||
|
||
const props = defineProps({
|
||
// Stücke der Aufnahme: [{ von, bis }] relativ zur Marke
|
||
segmente: { type: Array, default: () => [] },
|
||
maxVorher: { type: Number, default: 4 * 3600 },
|
||
minDauer: { type: Number, default: 3 },
|
||
maxDauer: { type: Number, default: 180 },
|
||
// Uhrzeit der Marke, für die Beschriftung
|
||
marke: { type: String, default: '' },
|
||
})
|
||
|
||
const vorher = defineModel('vorher', { type: Number, default: 30 })
|
||
const dauer = defineModel('dauer', { type: Number, default: 35 })
|
||
const emit = defineEmits(['ende'])
|
||
|
||
// Der sichtbare Ausschnitt des Strahls, zoombar
|
||
const ZOOMS = [120, 300, 900, 1800, 7200, 14400]
|
||
const zoom = ref(1)
|
||
const mitte = ref(0)
|
||
const spanne = computed(() => ZOOMS[zoom.value])
|
||
const links = computed(() => mitte.value - spanne.value / 2)
|
||
const rechts = computed(() => mitte.value + spanne.value / 2)
|
||
|
||
const strahl = ref(null)
|
||
|
||
function prozent(t) {
|
||
return ((t - links.value) / spanne.value) * 100
|
||
}
|
||
|
||
const start = computed(() => -vorher.value)
|
||
const ende = computed(() => -vorher.value + dauer.value)
|
||
|
||
// Wo die Aufnahme überhaupt Material hat — so weit lässt sich der Strahl schieben
|
||
const grenzen = computed(() => {
|
||
if (!props.segmente.length) return { von: -props.maxVorher, bis: props.maxVorher }
|
||
return {
|
||
von: Math.min(...props.segmente.map((s) => s.von)),
|
||
bis: Math.max(...props.segmente.map((s) => s.bis)),
|
||
}
|
||
})
|
||
|
||
// Das Stück der Aufnahme, in dem das Fenster liegt. Zwischen zwei
|
||
// Stücken ist Loch — dort gibt es kein Bild, dorthin darf das Fenster
|
||
// nicht. Liegt es in einem Loch, gilt das nähere Stück.
|
||
function abstand(s, t) {
|
||
return t < s.von ? s.von - t : t > s.bis ? t - s.bis : 0
|
||
}
|
||
|
||
function stueckUm(t) {
|
||
if (!props.segmente.length) return { von: -props.maxVorher, bis: props.maxVorher }
|
||
const s = props.segmente.reduce((a, b) => (abstand(a, t) <= abstand(b, t) ? a : b))
|
||
return { von: Math.max(s.von, -props.maxVorher), bis: Math.min(s.bis, props.maxVorher) }
|
||
}
|
||
|
||
function zoomen(schritt) {
|
||
zoom.value = Math.max(0, Math.min(ZOOMS.length - 1, zoom.value + schritt))
|
||
mitte.value = (start.value + ende.value) / 2
|
||
}
|
||
|
||
// Beschriftung: runde Schritte, etwa sechs bis acht Striche
|
||
const striche = computed(() => {
|
||
const schritt = [10, 30, 60, 300, 900, 1800][zoom.value]
|
||
const aus = []
|
||
for (let t = Math.ceil(links.value / schritt) * schritt; t <= rechts.value; t += schritt) {
|
||
aus.push({ t, text: uhrzeit(t) })
|
||
}
|
||
return aus
|
||
})
|
||
|
||
function uhrzeit(t) {
|
||
if (!props.marke) return `${t >= 0 ? '+' : '−'}${Math.abs(t)} s`
|
||
const d = new Date(new Date(props.marke).getTime() + t * 1000)
|
||
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||
}
|
||
|
||
// ── Ziehen ────────────────────────────────────────────────────────────
|
||
let griff = null
|
||
let ausgang = null
|
||
// Ob sich die Hand seit dem Anfassen bewegt hat — sonst war es ein Klick
|
||
let bewegt = false
|
||
|
||
function zeitBei(clientX) {
|
||
const box = strahl.value.getBoundingClientRect()
|
||
return links.value + ((clientX - box.left) / box.width) * spanne.value
|
||
}
|
||
|
||
// Das Fenster an eine Stelle setzen: sein Anfang liegt dort, es bleibt im Stück
|
||
function setzen(t) {
|
||
const g = stueckUm(t)
|
||
const d = Math.max(props.minDauer, Math.min(dauer.value, g.bis - g.von))
|
||
const s = Math.max(g.von, Math.min(g.bis - d, t))
|
||
vorher.value = Math.round(-s)
|
||
dauer.value = Math.round(d)
|
||
emit('ende')
|
||
}
|
||
|
||
function anfassen(e, was) {
|
||
griff = was
|
||
bewegt = false
|
||
ausgang = {
|
||
x: e.clientX,
|
||
vorher: vorher.value,
|
||
dauer: dauer.value,
|
||
mitte: mitte.value,
|
||
// Das Stück, in dem das Fenster beim Anfassen liegt — darin bleibt es
|
||
stueck: stueckUm(-vorher.value + dauer.value / 2),
|
||
}
|
||
e.currentTarget.setPointerCapture(e.pointerId)
|
||
}
|
||
|
||
function ziehen(e) {
|
||
if (!griff || !strahl.value) return
|
||
const dt = ((e.clientX - ausgang.x) / strahl.value.clientWidth) * spanne.value
|
||
if (Math.abs(e.clientX - ausgang.x) > 4) bewegt = true
|
||
const g = ausgang.stueck
|
||
if (griff === 'bahn') {
|
||
// Den Strahl selbst schieben — die Zeiten laufen der Hand entgegen
|
||
const r = grenzen.value
|
||
mitte.value = Math.max(r.von, Math.min(r.bis, ausgang.mitte - dt))
|
||
} else if (griff === 'links') {
|
||
// Der Anfang wandert, das Ende bleibt stehen
|
||
const altesEnde = -ausgang.vorher + ausgang.dauer
|
||
let neuerStart = -ausgang.vorher + dt
|
||
neuerStart = Math.max(g.von, neuerStart)
|
||
neuerStart = Math.min(
|
||
altesEnde - props.minDauer,
|
||
Math.max(altesEnde - props.maxDauer, neuerStart),
|
||
)
|
||
vorher.value = Math.round(-neuerStart)
|
||
dauer.value = Math.round(altesEnde - neuerStart)
|
||
} else if (griff === 'rechts') {
|
||
let neuesEnde = -ausgang.vorher + ausgang.dauer + dt
|
||
neuesEnde = Math.min(g.bis, neuesEnde)
|
||
const d = Math.max(props.minDauer, Math.min(props.maxDauer, neuesEnde + ausgang.vorher))
|
||
dauer.value = Math.round(d)
|
||
} else {
|
||
// Das ganze Fenster verschieben, aber nicht aus dem Stück hinaus
|
||
let neuerStart = -ausgang.vorher + dt
|
||
neuerStart = Math.max(g.von, Math.min(g.bis - ausgang.dauer, neuerStart))
|
||
vorher.value = Math.round(-neuerStart)
|
||
}
|
||
}
|
||
|
||
function loslassen(e) {
|
||
if (!griff) return
|
||
const war = griff
|
||
griff = null
|
||
e.currentTarget.releasePointerCapture?.(e.pointerId)
|
||
if (war === 'bahn') {
|
||
// Ein Klick ohne Ziehen setzt das Fenster hierhin; Schieben braucht keine neue Vorschau
|
||
if (!bewegt) setzen(zeitBei(e.clientX))
|
||
return
|
||
}
|
||
emit('ende')
|
||
}
|
||
|
||
// Mausrad: zoomen, der Punkt unter dem Zeiger bleibt stehen
|
||
function rad(e) {
|
||
const neu = Math.max(0, Math.min(ZOOMS.length - 1, zoom.value + (e.deltaY > 0 ? 1 : -1)))
|
||
if (neu === zoom.value || !strahl.value) return
|
||
const box = strahl.value.getBoundingClientRect()
|
||
const anteil = (e.clientX - box.left) / box.width
|
||
const t = links.value + anteil * spanne.value
|
||
zoom.value = neu
|
||
const r = grenzen.value
|
||
mitte.value = Math.max(r.von, Math.min(r.bis, t - (anteil - 0.5) * ZOOMS[neu]))
|
||
}
|
||
|
||
// Pfeiltasten: 1 s, mit Shift 10 s. Die Vorschau lädt erst, wenn Ruhe ist
|
||
let tastenTimer = null
|
||
|
||
function taste(e) {
|
||
if (e.target instanceof Element && e.target.matches('input, textarea')) return
|
||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
|
||
e.preventDefault()
|
||
const schritt = (e.shiftKey ? 10 : 1) * (e.key === 'ArrowLeft' ? -1 : 1)
|
||
const g = stueckUm(start.value + dauer.value / 2)
|
||
const s = Math.max(g.von, Math.min(g.bis - dauer.value, start.value + schritt))
|
||
vorher.value = Math.round(-s)
|
||
clearTimeout(tastenTimer)
|
||
tastenTimer = setTimeout(() => emit('ende'), 400)
|
||
}
|
||
|
||
onMounted(() => window.addEventListener('keydown', taste))
|
||
onUnmounted(() => {
|
||
window.removeEventListener('keydown', taste)
|
||
clearTimeout(tastenTimer)
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="trimmer">
|
||
<div class="kopf">
|
||
<span class="fenster">
|
||
{{ uhrzeit(start) }} – {{ uhrzeit(ende) }} · <b>{{ dauer }} s</b>
|
||
</span>
|
||
<span class="zoom">
|
||
<button type="button" :disabled="zoom === 0" title="Näher" @click="zoomen(-1)">+</button>
|
||
<button
|
||
type="button"
|
||
:disabled="zoom === ZOOMS.length - 1"
|
||
title="Weiter"
|
||
@click="zoomen(1)"
|
||
>
|
||
−
|
||
</button>
|
||
</span>
|
||
</div>
|
||
|
||
<div
|
||
ref="strahl"
|
||
class="strahl"
|
||
@pointerdown="anfassen($event, 'bahn')"
|
||
@wheel.prevent="rad"
|
||
@pointermove="ziehen"
|
||
@pointerup="loslassen"
|
||
@pointercancel="loslassen"
|
||
>
|
||
<!-- Wo Aufnahme liegt -->
|
||
<span
|
||
v-for="(s, i) in segmente"
|
||
:key="i"
|
||
class="aufnahme"
|
||
:style="{ left: prozent(s.von) + '%', width: prozent(s.bis) - prozent(s.von) + '%' }"
|
||
></span>
|
||
|
||
<!-- Die Marke: der Moment, an dem C gedrückt wurde -->
|
||
<span class="marke" :style="{ left: prozent(0) + '%' }" title="Marke"></span>
|
||
|
||
<!-- Das Fenster des Clips mit seinen Griffen -->
|
||
<span
|
||
class="bereich"
|
||
:style="{ left: prozent(start) + '%', width: prozent(ende) - prozent(start) + '%' }"
|
||
@pointerdown.stop="anfassen($event, 'mitte')"
|
||
>
|
||
<span class="griff links" @pointerdown.stop="anfassen($event, 'links')"></span>
|
||
<span class="griff rechts" @pointerdown.stop="anfassen($event, 'rechts')"></span>
|
||
</span>
|
||
|
||
<span v-for="s in striche" :key="s.t" class="strich" :style="{ left: prozent(s.t) + '%' }">
|
||
<span class="text">{{ s.text }}</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.trimmer {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.4rem;
|
||
user-select: none;
|
||
}
|
||
|
||
.kopf {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
color: var(--muted);
|
||
font-size: 0.82rem;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.fenster b {
|
||
color: var(--text);
|
||
}
|
||
|
||
.zoom button {
|
||
width: 1.7rem;
|
||
height: 1.7rem;
|
||
color: var(--text);
|
||
border: 1px solid var(--line);
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.zoom button:disabled {
|
||
opacity: 0.4;
|
||
}
|
||
|
||
.strahl {
|
||
position: relative;
|
||
height: 3.4rem;
|
||
overflow: hidden;
|
||
background: var(--bg);
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
cursor: grab;
|
||
touch-action: none;
|
||
}
|
||
|
||
.aufnahme {
|
||
position: absolute;
|
||
top: 0.4rem;
|
||
height: 1.6rem;
|
||
background: rgba(255, 255, 255, 0.1);
|
||
}
|
||
|
||
.marke {
|
||
position: absolute;
|
||
top: 0;
|
||
bottom: 0;
|
||
width: 2px;
|
||
margin-left: -1px;
|
||
background: #f5c518;
|
||
}
|
||
|
||
.bereich {
|
||
position: absolute;
|
||
top: 0.4rem;
|
||
/* Bei vier Stunden Ansicht wäre das Fenster ein Strich — so bleibt es greifbar */
|
||
min-width: 2.2rem;
|
||
height: 1.6rem;
|
||
background: rgba(229, 9, 20, 0.35);
|
||
border: 2px solid var(--accent);
|
||
border-radius: 4px;
|
||
cursor: grab;
|
||
}
|
||
|
||
.griff {
|
||
position: absolute;
|
||
top: -2px;
|
||
bottom: -2px;
|
||
width: 0.7rem;
|
||
background: var(--accent);
|
||
cursor: ew-resize;
|
||
}
|
||
|
||
.griff.links {
|
||
left: -2px;
|
||
border-radius: 4px 0 0 4px;
|
||
}
|
||
|
||
.griff.rechts {
|
||
right: -2px;
|
||
border-radius: 0 4px 4px 0;
|
||
}
|
||
|
||
.strich {
|
||
position: absolute;
|
||
top: 2.2rem;
|
||
bottom: 0;
|
||
width: 1px;
|
||
background: var(--line);
|
||
pointer-events: none;
|
||
}
|
||
|
||
.text {
|
||
position: absolute;
|
||
top: 0.3rem;
|
||
left: 0.25rem;
|
||
color: var(--muted);
|
||
font-size: 0.68rem;
|
||
white-space: nowrap;
|
||
}
|
||
</style>
|