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

+126 -5
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import {
PhArrowClockwise,
PhArrowCounterClockwise,
@@ -47,6 +47,78 @@ const exportOpen = ref(false)
const testsOpen = ref(false)
const welcomeOpen = 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 savedToast = ref<string | null>(null)
const currentProjectId = ref<string | null>(null)
@@ -189,13 +261,17 @@ function onKeyDown(e: KeyboardEvent) {
onMounted(() => {
document.addEventListener('keydown', onKeyDown)
window.addEventListener('resize', onWindowResize)
bootWelcome()
})
onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
onBeforeUnmount(() => {
document.removeEventListener('keydown', onKeyDown)
window.removeEventListener('resize', onWindowResize)
})
</script>
<template>
<div class="app">
<div class="app" :style="appStyle">
<header class="topbar">
<div class="brand-area">
<span class="brand">
@@ -279,7 +355,18 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
</aside>
<main class="stage"><CanvasStage /></main>
<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" />
<ExportDialog v-model="exportOpen" />
<TestOverlay v-model="testsOpen" />
@@ -303,7 +390,7 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
.app {
display: grid;
grid-template-columns: 260px 1fr 300px;
grid-template-rows: 48px 1fr 220px;
grid-template-rows: 48px 1fr var(--timeline-height, 220px);
grid-template-areas:
'top top top'
'side stage props'
@@ -480,6 +567,40 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
background: var(--panel);
border-top: 1px solid var(--border);
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 {
+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>
+61
View File
@@ -1114,6 +1114,67 @@ export function registerAllTests(): void {
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)', () => {
const s = makeFreshStore()
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)
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) {
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return
+4
View File
@@ -69,6 +69,8 @@ export interface Layer {
rotationKeyframes?: Keyframe[]
offsetX?: number
offsetY?: number
color?: string | null
locked?: boolean
}
export interface Clip {
@@ -81,6 +83,8 @@ export interface Clip {
export interface ClipTrack {
id: string
name?: string
color?: string | null
locked?: boolean
}
export interface ClipInstance {
+11 -3
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
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