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

No files matched your search

+126 -5
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { import {
PhArrowClockwise, PhArrowClockwise,
PhArrowCounterClockwise, PhArrowCounterClockwise,
@@ -47,6 +47,78 @@ const exportOpen = ref(false)
const testsOpen = ref(false) const testsOpen = ref(false)
const welcomeOpen = ref(false) const welcomeOpen = ref(false)
const fullscreenOpen = ref(false) const fullscreenOpen = ref(false)
const TIMELINE_HEIGHT_KEY = 'animatr:timelineHeight'
const DEFAULT_TIMELINE_HEIGHT = 220
const MIN_TIMELINE_HEIGHT = 120
const MAX_TIMELINE_HEIGHT_MARGIN = 160
function readStoredTimelineHeight(): number {
try {
const raw = localStorage.getItem(TIMELINE_HEIGHT_KEY)
if (raw === null) return DEFAULT_TIMELINE_HEIGHT
const n = Number.parseInt(raw, 10)
if (Number.isFinite(n) && n >= MIN_TIMELINE_HEIGHT) return n
} catch {
/* ignore */
}
return DEFAULT_TIMELINE_HEIGHT
}
const timelineHeight = ref(readStoredTimelineHeight())
function maxTimelineHeight(): number {
return Math.max(MIN_TIMELINE_HEIGHT, window.innerHeight - MAX_TIMELINE_HEIGHT_MARGIN)
}
const appStyle = computed(() => ({
'--timeline-height': `${timelineHeight.value}px`,
}))
let resizeStartY = 0
let resizeStartHeight = 0
let resizePointerId = -1
const resizing = ref(false)
function onTimelineResizeStart(e: PointerEvent) {
if (e.button !== 0) return
e.preventDefault()
const el = e.currentTarget as HTMLElement
el.setPointerCapture(e.pointerId)
resizePointerId = e.pointerId
resizeStartY = e.clientY
resizeStartHeight = timelineHeight.value
resizing.value = true
document.body.style.userSelect = 'none'
}
function onTimelineResizeMove(e: PointerEvent) {
if (!resizing.value || e.pointerId !== resizePointerId) return
const dy = resizeStartY - e.clientY
const next = Math.max(
MIN_TIMELINE_HEIGHT,
Math.min(maxTimelineHeight(), resizeStartHeight + dy),
)
timelineHeight.value = next
}
function onTimelineResizeEnd(e: PointerEvent) {
if (!resizing.value || e.pointerId !== resizePointerId) return
const el = e.currentTarget as HTMLElement
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
resizePointerId = -1
resizing.value = false
document.body.style.userSelect = ''
try {
localStorage.setItem(TIMELINE_HEIGHT_KEY, String(timelineHeight.value))
} catch {
/* ignore */
}
}
function onWindowResize() {
const max = maxTimelineHeight()
if (timelineHeight.value > max) timelineHeight.value = max
}
const loadError = ref<string | null>(null) const loadError = ref<string | null>(null)
const savedToast = ref<string | null>(null) const savedToast = ref<string | null>(null)
const currentProjectId = ref<string | null>(null) const currentProjectId = ref<string | null>(null)
@@ -189,13 +261,17 @@ function onKeyDown(e: KeyboardEvent) {
onMounted(() => { onMounted(() => {
document.addEventListener('keydown', onKeyDown) document.addEventListener('keydown', onKeyDown)
window.addEventListener('resize', onWindowResize)
bootWelcome() bootWelcome()
}) })
onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown)) onBeforeUnmount(() => {
document.removeEventListener('keydown', onKeyDown)
window.removeEventListener('resize', onWindowResize)
})
</script> </script>
<template> <template>
<div class="app"> <div class="app" :style="appStyle">
<header class="topbar"> <header class="topbar">
<div class="brand-area"> <div class="brand-area">
<span class="brand"> <span class="brand">
@@ -279,7 +355,18 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
</aside> </aside>
<main class="stage"><CanvasStage /></main> <main class="stage"><CanvasStage /></main>
<aside class="properties"><PropertiesPanel /></aside> <aside class="properties"><PropertiesPanel /></aside>
<footer class="timeline"><Timeline /></footer> <footer class="timeline">
<div
class="tl-resize"
:class="{ resizing }"
title="Timeline-Höhe ziehen"
@pointerdown="onTimelineResizeStart"
@pointermove="onTimelineResizeMove"
@pointerup="onTimelineResizeEnd"
@pointercancel="onTimelineResizeEnd"
></div>
<Timeline />
</footer>
<DocumentSettings v-model="settingsOpen" /> <DocumentSettings v-model="settingsOpen" />
<ExportDialog v-model="exportOpen" /> <ExportDialog v-model="exportOpen" />
<TestOverlay v-model="testsOpen" /> <TestOverlay v-model="testsOpen" />
@@ -303,7 +390,7 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
.app { .app {
display: grid; display: grid;
grid-template-columns: 260px 1fr 300px; grid-template-columns: 260px 1fr 300px;
grid-template-rows: 48px 1fr 220px; grid-template-rows: 48px 1fr var(--timeline-height, 220px);
grid-template-areas: grid-template-areas:
'top top top' 'top top top'
'side stage props' 'side stage props'
@@ -480,6 +567,40 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
background: var(--panel); background: var(--panel);
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
overflow: hidden; overflow: hidden;
position: relative;
}
.tl-resize {
position: absolute;
top: -7px;
left: 0;
right: 0;
height: 14px;
cursor: ns-resize;
z-index: 20;
touch-action: none;
background: transparent;
transition: background 120ms var(--ease);
}
.tl-resize::after {
content: '';
position: absolute;
left: 0;
right: 0;
top: 7px;
height: 1px;
background: transparent;
transition: background 120ms var(--ease);
}
.tl-resize:hover::after,
.tl-resize.resizing::after {
background: var(--accent);
}
.tl-resize.resizing {
background: rgba(110, 168, 255, 0.08);
} }
.project-name { .project-name {
+296 -74
View File
@@ -5,6 +5,8 @@ import {
PhCheck, PhCheck,
PhCopy, PhCopy,
PhDotsSixVertical, PhDotsSixVertical,
PhLock,
PhLockOpen,
PhMagnet, PhMagnet,
PhPauseCircle, PhPauseCircle,
PhTrash, 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 ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const time = snapTime(ratio * duration.value) const time = snapTime(ratio * duration.value)
const layer = store.activeLayers.find((l) => l.id === track.layerId) const layer = store.activeLayers.find((l) => l.id === track.layerId)
if (!layer) return if (!layer || layer.locked) return
const sampled = sampleLayer(layer, time) const sampled = sampleLayer(layer, time)
const props: Partial<AnimatableProps> = {} const props: Partial<AnimatableProps> = {}
if (track.kind === 'position') { if (track.kind === 'position') {
@@ -247,6 +249,8 @@ function onKeyframeClick(e: MouseEvent, layerId: string, time: number) {
function onKeyframeDblClick(e: MouseEvent, layerId: string, keyframeId: string) { function onKeyframeDblClick(e: MouseEvent, layerId: string, keyframeId: string) {
e.stopPropagation() e.stopPropagation()
if (kfDrag.value?.moved) return if (kfDrag.value?.moved) return
const layer = store.activeLayers.find((l) => l.id === layerId)
if (layer?.locked) return
store.removeKeyframe(layerId, keyframeId) store.removeKeyframe(layerId, keyframeId)
} }
@@ -325,6 +329,8 @@ const kfDrag = ref<KfDrag | null>(null)
function onKeyframePointerDown(e: PointerEvent, layerId: string, keyframeId: string) { function onKeyframePointerDown(e: PointerEvent, layerId: string, keyframeId: string) {
if (e.button !== 0) return 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 kfEl = e.currentTarget as HTMLElement
const lane = kfEl.parentElement const lane = kfEl.parentElement
if (!lane) return if (!lane) return
@@ -482,12 +488,28 @@ function duplicateFromMenu() {
} }
interface TrackMenuState { interface TrackMenuState {
layerId: string kind: 'layer' | 'clip'
trackId: string
x: number x: number
y: number y: number
separated: boolean 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 dragTrackId = ref<string | null>(null)
const dropIndex = ref<number | null>(null) const dropIndex = ref<number | null>(null)
@@ -537,16 +559,7 @@ function onTrackDragEnd() {
dropIndex.value = null dropIndex.value = null
} }
interface ClipDrag { function pixelTimeFromEvent(e: DragEvent | PointerEvent, el: HTMLElement): number {
kind: 'new' | 'move'
clipId: string
instanceId?: string
grabOffsetSec: number
}
const clipDrag = ref<ClipDrag | null>(null)
function pixelTimeFromEvent(e: DragEvent, el: HTMLElement): number {
const rect = el.getBoundingClientRect() const rect = el.getBoundingClientRect()
if (rect.width <= 0) return 0 if (rect.width <= 0) return 0
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) 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) { function onClipLaneDragOver(e: DragEvent, _clipTrackId: string) {
if (!clipDrag.value) {
if (e.dataTransfer?.types.includes('application/x-animatr-clip')) { if (e.dataTransfer?.types.includes('application/x-animatr-clip')) {
e.preventDefault() e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy' if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
} }
return
}
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = clipDrag.value.kind === 'new' ? 'copy' : 'move'
} }
function onClipLaneDrop(e: DragEvent, clipTrackId: string) { function onClipLaneDrop(e: DragEvent, clipTrackId: string) {
e.preventDefault() e.preventDefault()
const track = store.scene.clipTracks?.find((t) => t.id === clipTrackId)
if (track?.locked) return
const el = e.currentTarget as HTMLElement const el = e.currentTarget as HTMLElement
const time = pixelTimeFromEvent(e, el) 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') const clipId = e.dataTransfer?.getData('application/x-animatr-clip')
if (clipId) { if (clipId) {
store.addClipInstance(clipId, time, clipTrackId) store.addClipInstance(clipId, time, clipTrackId)
} }
} }
function onClipBlockDragStart(e: DragEvent, ci: ClipInstance) { interface ClipMoveState {
if (clipTrim.value) { 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.preventDefault()
return e.stopPropagation()
}
if (!e.dataTransfer) return
const el = e.currentTarget as HTMLElement const el = e.currentTarget as HTMLElement
const rect = el.getBoundingClientRect() el.setPointerCapture(e.pointerId)
const visible = visibleDurationOf(ci) clipMove.value = {
const grabOffsetSec = instanceId: ci.id,
rect.width > 0 ? ((e.clientX - rect.left) / rect.width) * visible : 0 pointerId: e.pointerId,
clipDrag.value = { kind: 'move', clipId: ci.clipId, instanceId: ci.id, grabOffsetSec } startX: e.clientX,
e.dataTransfer.effectAllowed = 'move' initialStartTime: ci.startTime,
try { moved: false,
e.dataTransfer.setData('application/x-animatr-clip-move', ci.id)
} catch {
/* ignore */
} }
} }
function onClipBlockDragEnd() { function onClipBlockPointerMove(e: PointerEvent) {
clipDrag.value = null 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() { function addClipTrack() {
@@ -656,6 +685,8 @@ const clipTrim = ref<ClipTrimState | null>(null)
function onClipHandlePointerDown(e: PointerEvent, ci: ClipInstance, side: 'start' | 'end') { function onClipHandlePointerDown(e: PointerEvent, ci: ClipInstance, side: 'start' | 'end') {
if (e.button !== 0) return if (e.button !== 0) return
const track = store.scene.clipTracks?.find((t) => t.id === ci.clipTrackId)
if (track?.locked) return
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
const el = e.currentTarget as HTMLElement const el = e.currentTarget as HTMLElement
@@ -715,26 +746,66 @@ const trackMenuRef = ref<HTMLDivElement | null>(null)
function onTrackContextMenu(e: MouseEvent, layerId: string) { function onTrackContextMenu(e: MouseEvent, layerId: string) {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
const layer = store.scene.layers.find((l) => l.id === layerId) const layer = store.activeLayers.find((l) => l.id === layerId)
if (!layer) return if (!layer) return
store.selectLayer(layerId) store.selectLayer(layerId)
trackMenu.value = { trackMenu.value = {
layerId, kind: 'layer',
trackId: layerId,
x: e.clientX, x: e.clientX,
y: e.clientY, y: e.clientY,
separated: !!layer.separated, 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() { function toggleSeparated() {
if (!trackMenu.value) return if (!trackMenu.value) return
store.setLayerSeparated(trackMenu.value.layerId, !trackMenu.value.separated) store.setLayerSeparated(trackMenu.value.trackId, !trackMenu.value.separated)
trackMenu.value = null trackMenu.value = null
} }
function deleteLayer() { function deleteTrack() {
if (!trackMenu.value) return 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 trackMenu.value = null
} }
@@ -876,7 +947,10 @@ onBeforeUnmount(() => {
'drop-before': isDropBefore(track), 'drop-before': isDropBefore(track),
'drop-after': isDropAfter(track, tracks[idx + 1]), 'drop-after': isDropAfter(track, tracks[idx + 1]),
dragging: track.isParent && dragTrackId === track.layerId, 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" @dragover="track.isParent ? onTrackDragOver($event, track.orderIndex) : null"
@drop="track.isParent ? onTrackDrop($event) : null" @drop="track.isParent ? onTrackDrop($event) : null"
@dragleave="onTrackDragLeave" @dragleave="onTrackDragLeave"
@@ -895,6 +969,11 @@ onBeforeUnmount(() => {
@dragend="onTrackDragEnd" @dragend="onTrackDragEnd"
> >
<PhDotsSixVertical v-if="track.isParent" :size="12" class="drag-handle" /> <PhDotsSixVertical v-if="track.isParent" :size="12" class="drag-handle" />
<PhLock
v-if="track.isParent && track.layer.locked"
:size="11"
class="lock-badge"
/>
<PhArrowsSplit <PhArrowsSplit
v-if="track.isParent && track.layer.separated" v-if="track.isParent && track.layer.separated"
:size="11" :size="11"
@@ -947,7 +1026,10 @@ onBeforeUnmount(() => {
'drop-before': isDropBefore(track), 'drop-before': isDropBefore(track),
'drop-after': isDropAfter(track, tracks[idx + 1]), 'drop-after': isDropAfter(track, tracks[idx + 1]),
dragging: dragTrackId === track.clipTrackId, 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)" @dragover="onTrackDragOver($event, track.orderIndex)"
@drop="onTrackDrop($event)" @drop="onTrackDrop($event)"
@dragleave="onTrackDragLeave" @dragleave="onTrackDragLeave"
@@ -955,10 +1037,16 @@ onBeforeUnmount(() => {
<span <span
class="track-name clip-track-name" class="track-name clip-track-name"
draggable="true" draggable="true"
@contextmenu="onClipTrackContextMenu($event, track.clipTrackId)"
@dragstart="onTrackDragStart($event, track.clipTrackId)" @dragstart="onTrackDragStart($event, track.clipTrackId)"
@dragend="onTrackDragEnd" @dragend="onTrackDragEnd"
> >
<PhDotsSixVertical :size="12" class="drag-handle" /> <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" /> <PhFilmStrip :size="11" class="name-badge" />
{{ track.name }} {{ track.name }}
</span> </span>
@@ -976,12 +1064,15 @@ onBeforeUnmount(() => {
'trimmed-start': (ci.trimStart ?? 0) > 0, 'trimmed-start': (ci.trimStart ?? 0) > 0,
'trimmed-end': (ci.trimEnd ?? 0) > 0, 'trimmed-end': (ci.trimEnd ?? 0) > 0,
trimming: clipTrim?.instanceId === ci.id, trimming: clipTrim?.instanceId === ci.id,
moving: clipMove?.instanceId === ci.id,
}" }"
:style="clipBlockStyle(ci)" :style="clipBlockStyle(ci)"
:title="trimTooltip(ci)" :title="trimTooltip(ci)"
draggable="true" @pointerdown="onClipBlockPointerDown($event, ci)"
@dragstart.stop="onClipBlockDragStart($event, ci)" @pointermove="onClipBlockPointerMove"
@dragend="onClipBlockDragEnd" @pointerup="onClipBlockPointerUp"
@pointercancel="onClipBlockPointerUp"
@dragstart.prevent.stop
@dblclick="onClipBlockDblClick(ci)" @dblclick="onClipBlockDblClick(ci)"
@contextmenu="onClipBlockContextMenu($event, ci)" @contextmenu="onClipBlockContextMenu($event, ci)"
> >
@@ -1084,16 +1175,51 @@ onBeforeUnmount(() => {
class="kf-menu" class="kf-menu"
:style="{ left: trackMenu.x + 'px', top: trackMenu.y + 'px' }" :style="{ left: trackMenu.x + 'px', top: trackMenu.y + 'px' }"
> >
<button class="kf-menu-item" @click="toggleSeparated"> <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" @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"> <span class="check">
<PhCheck v-if="trackMenu.separated" :size="12" weight="bold" /> <PhCheck v-if="trackMenu.separated" :size="12" weight="bold" />
</span> </span>
Separate Position and Rotation Separate Position and Rotation
</button> </button>
</template>
<div class="kf-menu-divider"></div> <div class="kf-menu-divider"></div>
<button class="kf-menu-item danger" @click="deleteLayer"> <button class="kf-menu-item danger" @click="deleteTrack">
<PhTrash :size="12" /> <PhTrash :size="12" />
Layer löschen {{ trackMenu.kind === 'layer' ? 'Layer löschen' : 'Clip-Spur löschen' }}
</button> </button>
</div> </div>
</div> </div>
@@ -1324,6 +1450,51 @@ onBeforeUnmount(() => {
opacity: 0.75; 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 { .track-name {
position: sticky; position: sticky;
left: 0; left: 0;
@@ -1357,6 +1528,10 @@ onBeforeUnmount(() => {
var(--panel); var(--panel);
} }
.track.colored > .track-name {
box-shadow: inset 3px 0 0 0 var(--track-color);
}
.track.sub-track .track-name { .track.sub-track .track-name {
padding-left: 24px; padding-left: 24px;
font-size: 11px; font-size: 11px;
@@ -1441,20 +1616,20 @@ onBeforeUnmount(() => {
margin-top: -1.5px; margin-top: -1.5px;
border-radius: 999px; border-radius: 999px;
pointer-events: none; pointer-events: none;
background: var(--accent); background: var(--track-color, var(--accent));
opacity: 0.55; opacity: 0.55;
} }
.kf-seg.easing-linear { .kf-seg.easing-linear {
background: var(--accent); background: var(--track-color, var(--accent));
} }
.kf-seg.easing-easeIn { .kf-seg.easing-easeIn {
background: linear-gradient( background: linear-gradient(
90deg, 90deg,
rgba(110, 168, 255, 0.15) 0%, color-mix(in srgb, var(--track-color, var(--accent)) 15%, transparent) 0%,
rgba(110, 168, 255, 0.25) 30%, color-mix(in srgb, var(--track-color, var(--accent)) 25%, transparent) 30%,
rgba(110, 168, 255, 1) 100% var(--track-color, var(--accent)) 100%
); );
opacity: 0.9; opacity: 0.9;
} }
@@ -1462,9 +1637,9 @@ onBeforeUnmount(() => {
.kf-seg.easing-easeOut { .kf-seg.easing-easeOut {
background: linear-gradient( background: linear-gradient(
90deg, 90deg,
rgba(110, 168, 255, 1) 0%, var(--track-color, var(--accent)) 0%,
rgba(110, 168, 255, 0.25) 70%, color-mix(in srgb, var(--track-color, var(--accent)) 25%, transparent) 70%,
rgba(110, 168, 255, 0.15) 100% color-mix(in srgb, var(--track-color, var(--accent)) 15%, transparent) 100%
); );
opacity: 0.9; opacity: 0.9;
} }
@@ -1472,9 +1647,9 @@ onBeforeUnmount(() => {
.kf-seg.easing-easeInOut { .kf-seg.easing-easeInOut {
background: linear-gradient( background: linear-gradient(
90deg, 90deg,
rgba(110, 168, 255, 0.15) 0%, color-mix(in srgb, var(--track-color, var(--accent)) 15%, transparent) 0%,
rgba(110, 168, 255, 1) 50%, var(--track-color, var(--accent)) 50%,
rgba(110, 168, 255, 0.15) 100% color-mix(in srgb, var(--track-color, var(--accent)) 15%, transparent) 100%
); );
opacity: 0.95; opacity: 0.95;
} }
@@ -1497,7 +1672,7 @@ onBeforeUnmount(() => {
height: 11px; height: 11px;
margin-left: -5.5px; margin-left: -5.5px;
margin-top: -5.5px; margin-top: -5.5px;
background: var(--accent); background: var(--track-color, var(--accent));
transform: rotate(45deg); transform: rotate(45deg);
border-radius: 2px; border-radius: 2px;
cursor: grab; cursor: grab;
@@ -1524,7 +1699,7 @@ onBeforeUnmount(() => {
transform: none; transform: none;
border-radius: 50%; border-radius: 50%;
background: var(--panel); background: var(--panel);
border: 2px solid var(--accent); border: 2px solid var(--track-color, var(--accent));
box-shadow: none; box-shadow: none;
} }
@@ -1574,10 +1749,10 @@ onBeforeUnmount(() => {
align-items: center; align-items: center;
gap: 4px; gap: 4px;
padding: 0 8px; padding: 0 8px;
background: var(--accent-soft); background: color-mix(in srgb, var(--track-color, var(--accent)) 14%, transparent);
border: 1px solid var(--accent); border: 1px solid var(--track-color, var(--accent));
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
color: var(--accent); color: var(--track-color, var(--accent));
font-size: 11px; font-size: 11px;
font-weight: 500; font-weight: 500;
white-space: nowrap; white-space: nowrap;
@@ -1589,7 +1764,7 @@ onBeforeUnmount(() => {
} }
.clip-block:hover { .clip-block:hover {
background: var(--accent-soft-strong); background: color-mix(in srgb, var(--track-color, var(--accent)) 22%, transparent);
} }
.clip-block:active { .clip-block:active {
@@ -1647,7 +1822,7 @@ onBeforeUnmount(() => {
bottom: 20%; bottom: 20%;
width: 2px; width: 2px;
border-radius: 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); box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3);
} }
@@ -1682,6 +1857,12 @@ onBeforeUnmount(() => {
box-shadow: 0 0 0 1px #ffc857; 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:hover,
.clip-handle:active { .clip-handle:active {
background: rgba(255, 255, 255, 0.25) !important; background: rgba(255, 255, 255, 0.25) !important;
@@ -1792,4 +1973,45 @@ onBeforeUnmount(() => {
background: var(--border); background: var(--border);
margin: 4px 0; 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> </style>
+61
View File
@@ -1114,6 +1114,67 @@ export function registerAllTests(): void {
expect(ci.trimEnd).toBe(0) expect(ci.trimEnd).toBe(0)
}) })
test('setTrackColor applies to layer and can be cleared with null', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Col', width: 20, height: 20, fill: '#123' })
const l = s.addLayer(a.id)
s.setTrackColor(l.id, '#ff6b6b')
expect(l.color).toBe('#ff6b6b')
s.setTrackColor(l.id, null)
expect(l.color).toBe(undefined)
})
test('setTrackColor applies to clip track', () => {
const s = makeFreshStore()
s.addClip('X')
const t = s.scene.clipTracks![0]
s.setTrackColor(t.id, '#51cf66')
expect(t.color).toBe('#51cf66')
s.setTrackColor(t.id, null)
expect(t.color).toBe(undefined)
})
test('setTrackLocked toggles layer lock flag', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'L', width: 10, height: 10, fill: '#111' })
const l = s.addLayer(a.id)
s.setTrackLocked(l.id, true)
expect(l.locked).toBe(true)
s.setTrackLocked(l.id, false)
expect(l.locked).toBe(undefined)
})
test('setTrackLocked toggles clip-track lock flag', () => {
const s = makeFreshStore()
s.addClip('L')
const t = s.scene.clipTracks![0]
s.setTrackLocked(t.id, true)
expect(t.locked).toBe(true)
s.setTrackLocked(t.id, false)
expect(t.locked).toBe(undefined)
})
test('setTrackColor on unknown id is a no-op', () => {
const s = makeFreshStore()
s.setTrackColor('nope', '#ff0000')
// just verify no throw, no layer/clipTrack changed
expect(s.scene.layers).toHaveLength(0)
expect(s.scene.clipTracks ?? []).toHaveLength(0)
})
test('moveClipInstance without clipTrackId keeps the same track (x-only move)', () => {
const s = makeFreshStore()
const c = s.addClip('MoveX')
const t1 = s.scene.clipTracks![0]
const t2 = s.addClipTrack()
const ci = s.addClipInstance(c.id, 0, t1.id)!
// sanity: distinct tracks
expect(t1.id === t2.id).toBe(false)
s.moveClipInstance(ci.id, 5)
expect(ci.startTime).toBe(5)
expect(ci.clipTrackId).toBe(t1.id)
})
test('moveTrack reorders the unified stack (layer above clip track)', () => { test('moveTrack reorders the unified stack (layer above clip track)', () => {
const s = makeFreshStore() const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'M', width: 10, height: 10, fill: '#000' }) const a = s.addAsset({ kind: 'rect', name: 'M', width: 10, height: 10, fill: '#000' })
+26
View File
@@ -396,6 +396,32 @@ export const useSceneStore = defineStore('scene', {
sortKfs(arr) sortKfs(arr)
return newKf.id return newKf.id
}, },
setTrackColor(trackId: string, color: string | null) {
const layer = this.activeLayers.find((l) => l.id === trackId)
if (layer) {
if (color === null) delete layer.color
else layer.color = color
return
}
const clipTrack = this.scene.clipTracks?.find((t) => t.id === trackId)
if (clipTrack) {
if (color === null) delete clipTrack.color
else clipTrack.color = color
}
},
setTrackLocked(trackId: string, locked: boolean) {
const layer = this.activeLayers.find((l) => l.id === trackId)
if (layer) {
if (locked) layer.locked = true
else delete layer.locked
return
}
const clipTrack = this.scene.clipTracks?.find((t) => t.id === trackId)
if (clipTrack) {
if (locked) clipTrack.locked = true
else delete clipTrack.locked
}
},
setLayerSeparated(layerId: string, separated: boolean) { setLayerSeparated(layerId: string, separated: boolean) {
const layer = this.activeLayers.find((l) => l.id === layerId) const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return if (!layer) return
+4
View File
@@ -69,6 +69,8 @@ export interface Layer {
rotationKeyframes?: Keyframe[] rotationKeyframes?: Keyframe[]
offsetX?: number offsetX?: number
offsetY?: number offsetY?: number
color?: string | null
locked?: boolean
} }
export interface Clip { export interface Clip {
@@ -81,6 +83,8 @@ export interface Clip {
export interface ClipTrack { export interface ClipTrack {
id: string id: string
name?: string name?: string
color?: string | null
locked?: boolean
} }
export interface ClipInstance { export interface ClipInstance {
+10 -2
View File
@@ -40,8 +40,16 @@ für user-interaktionen (wie neues text-objekt) brauchen wir eigene popups. kein
den zurück pfeil bitte als phosphor icon machen den zurück pfeil bitte als phosphor icon machen
spuren muss man einfärben können. wir bieten die grundfarben als color tiles bei rechtsklick auf die spur an gilt auch für clipspuren spuren muss man einfärben können. wir bieten die grundfarben als color tiles bei rechtsklick auf die spur an gilt auch für clipspuren
clips draggen darf nur auf der x-achse gehen. y verschiebung darf nicht möglich sein - das ist irreführend clips draggen darf nur auf der x-achse gehen. y verschiebung darf nicht möglich sein - das ist irreführend
das timeline panel muss man per anfasser höher ziehen können
spuren muss man locken können. sie dürfen dann nur noch die halbe höhe haben und an der zeitlinie muss erkennbar sein das nicht bearbeitet werden kann
ALLE texte der gui bitte in englisch
per box select muss man über mehrere spuren keyframe multiselection durchführen können um sie zusammen in der zeit zu verschieben