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