bak
This commit is contained in:
1 parent
e851d52aef
commit
fcefd258eb
6 files changed
+523
-81
No files matched your search
+126
-5
@@ -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
@@ -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>
|
||||||
@@ -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' })
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in new issue
Block a user