This commit is contained in:
th committed 2026-04-19 21:40:09 +02:00
1 parent e851d52aef
commit fcefd258eb
6 files changed
+533 -91

No files matched your search

+305 -83
View File
@@ -5,6 +5,8 @@ import {
PhCheck,
PhCopy,
PhDotsSixVertical,
PhLock,
PhLockOpen,
PhMagnet,
PhPauseCircle,
PhTrash,
@@ -206,7 +208,7 @@ function onLaneDblClick(e: MouseEvent, track: TimelineLayerTrack) {
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const time = snapTime(ratio * duration.value)
const layer = store.activeLayers.find((l) => l.id === track.layerId)
if (!layer) return
if (!layer || layer.locked) return
const sampled = sampleLayer(layer, time)
const props: Partial<AnimatableProps> = {}
if (track.kind === 'position') {
@@ -247,6 +249,8 @@ function onKeyframeClick(e: MouseEvent, layerId: string, time: number) {
function onKeyframeDblClick(e: MouseEvent, layerId: string, keyframeId: string) {
e.stopPropagation()
if (kfDrag.value?.moved) return
const layer = store.activeLayers.find((l) => l.id === layerId)
if (layer?.locked) return
store.removeKeyframe(layerId, keyframeId)
}
@@ -325,6 +329,8 @@ const kfDrag = ref<KfDrag | null>(null)
function onKeyframePointerDown(e: PointerEvent, layerId: string, keyframeId: string) {
if (e.button !== 0) return
const layer = store.activeLayers.find((l) => l.id === layerId)
if (layer?.locked) return
const kfEl = e.currentTarget as HTMLElement
const lane = kfEl.parentElement
if (!lane) return
@@ -482,12 +488,28 @@ function duplicateFromMenu() {
}
interface TrackMenuState {
layerId: string
kind: 'layer' | 'clip'
trackId: string
x: number
y: number
separated: boolean
currentColor: string | null
locked: boolean
}
const TRACK_COLORS: Array<{ value: string | null; label: string }> = [
{ value: null, label: 'Keine' },
{ value: '#ff6b6b', label: 'Rot' },
{ value: '#ffa94d', label: 'Orange' },
{ value: '#ffd43b', label: 'Gelb' },
{ value: '#51cf66', label: 'Grün' },
{ value: '#4dc4ff', label: 'Türkis' },
{ value: '#6ea8ff', label: 'Blau' },
{ value: '#b197fc', label: 'Lila' },
{ value: '#ff8cc6', label: 'Pink' },
{ value: '#adb5bd', label: 'Grau' },
]
const dragTrackId = ref<string | null>(null)
const dropIndex = ref<number | null>(null)
@@ -537,16 +559,7 @@ function onTrackDragEnd() {
dropIndex.value = null
}
interface ClipDrag {
kind: 'new' | 'move'
clipId: string
instanceId?: string
grabOffsetSec: number
}
const clipDrag = ref<ClipDrag | null>(null)
function pixelTimeFromEvent(e: DragEvent, el: HTMLElement): number {
function pixelTimeFromEvent(e: DragEvent | PointerEvent, el: HTMLElement): number {
const rect = el.getBoundingClientRect()
if (rect.width <= 0) return 0
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
@@ -554,60 +567,76 @@ function pixelTimeFromEvent(e: DragEvent, el: HTMLElement): number {
}
function onClipLaneDragOver(e: DragEvent, _clipTrackId: string) {
if (!clipDrag.value) {
if (e.dataTransfer?.types.includes('application/x-animatr-clip')) {
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
}
return
if (e.dataTransfer?.types.includes('application/x-animatr-clip')) {
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
}
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = clipDrag.value.kind === 'new' ? 'copy' : 'move'
}
function onClipLaneDrop(e: DragEvent, clipTrackId: string) {
e.preventDefault()
const track = store.scene.clipTracks?.find((t) => t.id === clipTrackId)
if (track?.locked) return
const el = e.currentTarget as HTMLElement
const time = pixelTimeFromEvent(e, el)
const cd = clipDrag.value
if (cd) {
const start = Math.max(0, time - cd.grabOffsetSec)
if (cd.kind === 'move' && cd.instanceId) {
store.moveClipInstance(cd.instanceId, start, clipTrackId)
} else {
store.addClipInstance(cd.clipId, start, clipTrackId)
}
clipDrag.value = null
return
}
const clipId = e.dataTransfer?.getData('application/x-animatr-clip')
if (clipId) {
store.addClipInstance(clipId, time, clipTrackId)
}
}
function onClipBlockDragStart(e: DragEvent, ci: ClipInstance) {
if (clipTrim.value) {
e.preventDefault()
return
}
if (!e.dataTransfer) return
interface ClipMoveState {
instanceId: string
pointerId: number
startX: number
initialStartTime: number
moved: boolean
}
const clipMove = ref<ClipMoveState | null>(null)
function onClipBlockPointerDown(e: PointerEvent, ci: ClipInstance) {
if (e.button !== 0) return
const track = store.scene.clipTracks?.find((t) => t.id === ci.clipTrackId)
if (track?.locked) return
// Let handles handle their own pointerdowns
const target = e.target as HTMLElement | null
if (target?.classList.contains('clip-handle')) return
e.preventDefault()
e.stopPropagation()
const el = e.currentTarget as HTMLElement
const rect = el.getBoundingClientRect()
const visible = visibleDurationOf(ci)
const grabOffsetSec =
rect.width > 0 ? ((e.clientX - rect.left) / rect.width) * visible : 0
clipDrag.value = { kind: 'move', clipId: ci.clipId, instanceId: ci.id, grabOffsetSec }
e.dataTransfer.effectAllowed = 'move'
try {
e.dataTransfer.setData('application/x-animatr-clip-move', ci.id)
} catch {
/* ignore */
el.setPointerCapture(e.pointerId)
clipMove.value = {
instanceId: ci.id,
pointerId: e.pointerId,
startX: e.clientX,
initialStartTime: ci.startTime,
moved: false,
}
}
function onClipBlockDragEnd() {
clipDrag.value = null
function onClipBlockPointerMove(e: PointerEvent) {
const m = clipMove.value
if (!m || m.pointerId !== e.pointerId) return
const deltaPx = e.clientX - m.startX
if (!m.moved && Math.abs(deltaPx) < 2) return
m.moved = true
const pps = effectivePxPerSec.value
if (pps <= 0) return
const deltaSec = deltaPx / pps
const newStart = Math.max(0, m.initialStartTime + deltaSec)
store.moveClipInstance(m.instanceId, newStart)
}
function onClipBlockPointerUp(e: PointerEvent) {
const m = clipMove.value
if (!m || m.pointerId !== e.pointerId) return
const el = e.currentTarget as HTMLElement
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
const wasMoved = m.moved
clipMove.value = null
// suppress click/dblclick if we actually dragged
if (wasMoved) e.preventDefault()
}
function addClipTrack() {
@@ -656,6 +685,8 @@ const clipTrim = ref<ClipTrimState | null>(null)
function onClipHandlePointerDown(e: PointerEvent, ci: ClipInstance, side: 'start' | 'end') {
if (e.button !== 0) return
const track = store.scene.clipTracks?.find((t) => t.id === ci.clipTrackId)
if (track?.locked) return
e.preventDefault()
e.stopPropagation()
const el = e.currentTarget as HTMLElement
@@ -715,26 +746,66 @@ const trackMenuRef = ref<HTMLDivElement | null>(null)
function onTrackContextMenu(e: MouseEvent, layerId: string) {
e.preventDefault()
e.stopPropagation()
const layer = store.scene.layers.find((l) => l.id === layerId)
const layer = store.activeLayers.find((l) => l.id === layerId)
if (!layer) return
store.selectLayer(layerId)
trackMenu.value = {
layerId,
kind: 'layer',
trackId: layerId,
x: e.clientX,
y: e.clientY,
separated: !!layer.separated,
currentColor: layer.color ?? null,
locked: !!layer.locked,
}
}
function onClipTrackContextMenu(e: MouseEvent, clipTrackId: string) {
e.preventDefault()
e.stopPropagation()
const track = store.scene.clipTracks?.find((t) => t.id === clipTrackId)
if (!track) return
trackMenu.value = {
kind: 'clip',
trackId: clipTrackId,
x: e.clientX,
y: e.clientY,
separated: false,
currentColor: track.color ?? null,
locked: !!track.locked,
}
}
function toggleLocked() {
if (!trackMenu.value) return
store.setTrackLocked(trackMenu.value.trackId, !trackMenu.value.locked)
trackMenu.value = null
}
function applyTrackColor(color: string | null) {
if (!trackMenu.value) return
store.setTrackColor(trackMenu.value.trackId, color)
trackMenu.value = null
}
function trackColorStyle(color: string | null | undefined): Record<string, string> | undefined {
if (!color) return undefined
return { '--track-color': color }
}
function toggleSeparated() {
if (!trackMenu.value) return
store.setLayerSeparated(trackMenu.value.layerId, !trackMenu.value.separated)
store.setLayerSeparated(trackMenu.value.trackId, !trackMenu.value.separated)
trackMenu.value = null
}
function deleteLayer() {
function deleteTrack() {
if (!trackMenu.value) return
store.removeLayer(trackMenu.value.layerId)
if (trackMenu.value.kind === 'layer') {
store.removeLayer(trackMenu.value.trackId)
} else {
store.removeClipTrack(trackMenu.value.trackId)
}
trackMenu.value = null
}
@@ -876,7 +947,10 @@ onBeforeUnmount(() => {
'drop-before': isDropBefore(track),
'drop-after': isDropAfter(track, tracks[idx + 1]),
dragging: track.isParent && dragTrackId === track.layerId,
colored: !!track.layer.color,
locked: !!track.layer.locked,
}"
:style="trackColorStyle(track.layer.color)"
@dragover="track.isParent ? onTrackDragOver($event, track.orderIndex) : null"
@drop="track.isParent ? onTrackDrop($event) : null"
@dragleave="onTrackDragLeave"
@@ -895,6 +969,11 @@ onBeforeUnmount(() => {
@dragend="onTrackDragEnd"
>
<PhDotsSixVertical v-if="track.isParent" :size="12" class="drag-handle" />
<PhLock
v-if="track.isParent && track.layer.locked"
:size="11"
class="lock-badge"
/>
<PhArrowsSplit
v-if="track.isParent && track.layer.separated"
:size="11"
@@ -947,7 +1026,10 @@ onBeforeUnmount(() => {
'drop-before': isDropBefore(track),
'drop-after': isDropAfter(track, tracks[idx + 1]),
dragging: dragTrackId === track.clipTrackId,
colored: !!clipTrackById.get(track.clipTrackId)?.color,
locked: !!clipTrackById.get(track.clipTrackId)?.locked,
}"
:style="trackColorStyle(clipTrackById.get(track.clipTrackId)?.color)"
@dragover="onTrackDragOver($event, track.orderIndex)"
@drop="onTrackDrop($event)"
@dragleave="onTrackDragLeave"
@@ -955,10 +1037,16 @@ onBeforeUnmount(() => {
<span
class="track-name clip-track-name"
draggable="true"
@contextmenu="onClipTrackContextMenu($event, track.clipTrackId)"
@dragstart="onTrackDragStart($event, track.clipTrackId)"
@dragend="onTrackDragEnd"
>
<PhDotsSixVertical :size="12" class="drag-handle" />
<PhLock
v-if="clipTrackById.get(track.clipTrackId)?.locked"
:size="11"
class="lock-badge"
/>
<PhFilmStrip :size="11" class="name-badge" />
{{ track.name }}
</span>
@@ -976,12 +1064,15 @@ onBeforeUnmount(() => {
'trimmed-start': (ci.trimStart ?? 0) > 0,
'trimmed-end': (ci.trimEnd ?? 0) > 0,
trimming: clipTrim?.instanceId === ci.id,
moving: clipMove?.instanceId === ci.id,
}"
:style="clipBlockStyle(ci)"
:title="trimTooltip(ci)"
draggable="true"
@dragstart.stop="onClipBlockDragStart($event, ci)"
@dragend="onClipBlockDragEnd"
@pointerdown="onClipBlockPointerDown($event, ci)"
@pointermove="onClipBlockPointerMove"
@pointerup="onClipBlockPointerUp"
@pointercancel="onClipBlockPointerUp"
@dragstart.prevent.stop
@dblclick="onClipBlockDblClick(ci)"
@contextmenu="onClipBlockContextMenu($event, ci)"
>
@@ -1084,16 +1175,51 @@ onBeforeUnmount(() => {
class="kf-menu"
:style="{ left: trackMenu.x + 'px', top: trackMenu.y + 'px' }"
>
<button class="kf-menu-item" @click="toggleSeparated">
<span class="check">
<PhCheck v-if="trackMenu.separated" :size="12" weight="bold" />
</span>
Separate Position and Rotation
</button>
<div class="kf-menu-section">
<div class="kf-menu-header">Farbe</div>
<div class="color-tiles">
<button
v-for="c in TRACK_COLORS"
:key="c.value ?? 'none'"
class="color-tile"
:class="{ selected: trackMenu.currentColor === c.value, none: c.value === null }"
:style="c.value ? { background: c.value } : undefined"
:title="c.label"
@click="applyTrackColor(c.value)"
>
<PhCheck
v-if="trackMenu.currentColor === c.value && c.value !== null"
:size="10"
weight="bold"
/>
</button>
</div>
</div>
<div class="kf-menu-divider"></div>
<button class="kf-menu-item danger" @click="deleteLayer">
<button class="kf-menu-item" @click="toggleLocked">
<span class="check">
<PhCheck v-if="trackMenu.locked" :size="12" weight="bold" />
</span>
<component :is="trackMenu.locked ? PhLockOpen : PhLock" :size="12" />
{{ trackMenu.locked ? 'Entsperren' : 'Sperren' }}
</button>
<template v-if="trackMenu.kind === 'layer'">
<div class="kf-menu-divider"></div>
<button
class="kf-menu-item"
:disabled="trackMenu.locked"
@click="toggleSeparated"
>
<span class="check">
<PhCheck v-if="trackMenu.separated" :size="12" weight="bold" />
</span>
Separate Position and Rotation
</button>
</template>
<div class="kf-menu-divider"></div>
<button class="kf-menu-item danger" @click="deleteTrack">
<PhTrash :size="12" />
Layer löschen
{{ trackMenu.kind === 'layer' ? 'Layer löschen' : 'Clip-Spur löschen' }}
</button>
</div>
</div>
@@ -1324,6 +1450,51 @@ onBeforeUnmount(() => {
opacity: 0.75;
}
.track.locked {
height: 15px;
}
.track.clip-track.locked {
height: 17px;
}
.track.sub-track.locked {
height: 13px;
}
.track.locked .track-name {
font-size: 10px;
}
.track.locked .track-lane {
background-image: repeating-linear-gradient(
45deg,
rgba(255, 255, 255, 0.04) 0,
rgba(255, 255, 255, 0.04) 4px,
transparent 4px,
transparent 8px
);
cursor: not-allowed;
}
.track.locked .kf,
.track.locked .kf-seg,
.track.locked .clip-block {
opacity: 0.55;
cursor: not-allowed;
}
.track.locked .drag-handle {
display: none;
}
.lock-badge {
color: var(--text-dim);
margin-right: 4px;
flex-shrink: 0;
opacity: 0.85;
}
.track-name {
position: sticky;
left: 0;
@@ -1357,6 +1528,10 @@ onBeforeUnmount(() => {
var(--panel);
}
.track.colored > .track-name {
box-shadow: inset 3px 0 0 0 var(--track-color);
}
.track.sub-track .track-name {
padding-left: 24px;
font-size: 11px;
@@ -1441,20 +1616,20 @@ onBeforeUnmount(() => {
margin-top: -1.5px;
border-radius: 999px;
pointer-events: none;
background: var(--accent);
background: var(--track-color, var(--accent));
opacity: 0.55;
}
.kf-seg.easing-linear {
background: var(--accent);
background: var(--track-color, var(--accent));
}
.kf-seg.easing-easeIn {
background: linear-gradient(
90deg,
rgba(110, 168, 255, 0.15) 0%,
rgba(110, 168, 255, 0.25) 30%,
rgba(110, 168, 255, 1) 100%
color-mix(in srgb, var(--track-color, var(--accent)) 15%, transparent) 0%,
color-mix(in srgb, var(--track-color, var(--accent)) 25%, transparent) 30%,
var(--track-color, var(--accent)) 100%
);
opacity: 0.9;
}
@@ -1462,9 +1637,9 @@ onBeforeUnmount(() => {
.kf-seg.easing-easeOut {
background: linear-gradient(
90deg,
rgba(110, 168, 255, 1) 0%,
rgba(110, 168, 255, 0.25) 70%,
rgba(110, 168, 255, 0.15) 100%
var(--track-color, var(--accent)) 0%,
color-mix(in srgb, var(--track-color, var(--accent)) 25%, transparent) 70%,
color-mix(in srgb, var(--track-color, var(--accent)) 15%, transparent) 100%
);
opacity: 0.9;
}
@@ -1472,9 +1647,9 @@ onBeforeUnmount(() => {
.kf-seg.easing-easeInOut {
background: linear-gradient(
90deg,
rgba(110, 168, 255, 0.15) 0%,
rgba(110, 168, 255, 1) 50%,
rgba(110, 168, 255, 0.15) 100%
color-mix(in srgb, var(--track-color, var(--accent)) 15%, transparent) 0%,
var(--track-color, var(--accent)) 50%,
color-mix(in srgb, var(--track-color, var(--accent)) 15%, transparent) 100%
);
opacity: 0.95;
}
@@ -1497,7 +1672,7 @@ onBeforeUnmount(() => {
height: 11px;
margin-left: -5.5px;
margin-top: -5.5px;
background: var(--accent);
background: var(--track-color, var(--accent));
transform: rotate(45deg);
border-radius: 2px;
cursor: grab;
@@ -1524,7 +1699,7 @@ onBeforeUnmount(() => {
transform: none;
border-radius: 50%;
background: var(--panel);
border: 2px solid var(--accent);
border: 2px solid var(--track-color, var(--accent));
box-shadow: none;
}
@@ -1574,10 +1749,10 @@ onBeforeUnmount(() => {
align-items: center;
gap: 4px;
padding: 0 8px;
background: var(--accent-soft);
border: 1px solid var(--accent);
background: color-mix(in srgb, var(--track-color, var(--accent)) 14%, transparent);
border: 1px solid var(--track-color, var(--accent));
border-radius: var(--radius-sm);
color: var(--accent);
color: var(--track-color, var(--accent));
font-size: 11px;
font-weight: 500;
white-space: nowrap;
@@ -1589,7 +1764,7 @@ onBeforeUnmount(() => {
}
.clip-block:hover {
background: var(--accent-soft-strong);
background: color-mix(in srgb, var(--track-color, var(--accent)) 22%, transparent);
}
.clip-block:active {
@@ -1647,7 +1822,7 @@ onBeforeUnmount(() => {
bottom: 20%;
width: 2px;
border-radius: 2px;
background: var(--accent);
background: var(--track-color, var(--accent));
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3);
}
@@ -1682,6 +1857,12 @@ onBeforeUnmount(() => {
box-shadow: 0 0 0 1px #ffc857;
}
.clip-block.moving {
box-shadow: 0 0 0 1px var(--track-color, var(--accent)), 0 4px 12px rgba(0, 0, 0, 0.3);
cursor: grabbing;
z-index: 4;
}
.clip-handle:hover,
.clip-handle:active {
background: rgba(255, 255, 255, 0.25) !important;
@@ -1792,4 +1973,45 @@ onBeforeUnmount(() => {
background: var(--border);
margin: 4px 0;
}
.color-tiles {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 4px;
padding: 4px 10px 6px;
}
.color-tile {
width: 24px;
height: 24px;
padding: 0;
border: 1px solid var(--border);
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
color: #000;
cursor: pointer;
transition: transform 100ms var(--ease), box-shadow 100ms var(--ease);
}
.color-tile:hover {
transform: scale(1.1);
border-color: var(--text-dim);
}
.color-tile.selected {
border-color: var(--text);
box-shadow: 0 0 0 2px var(--panel), 0 0 0 3px var(--accent);
}
.color-tile.none {
background:
linear-gradient(45deg, transparent calc(50% - 1px), var(--danger) calc(50% - 1px), var(--danger) calc(50% + 1px), transparent calc(50% + 1px)),
var(--panel-alt);
}
.color-tile.none.selected {
box-shadow: 0 0 0 2px var(--panel), 0 0 0 3px var(--accent);
}
</style>