- Click-and-drag on the empty stage starts a layer marquee with touch (not enclose) semantics — every unlocked layer whose bbox overlaps the drag rect joins the selection. Tiny clicks still deselect; shift adds to the existing selection on both clicks and marquees. Polygon-vertex lasso stays available when a single polygon is the active selection. - Konva transformer now wraps every selected layer in one shared bounding box (polygons fold in for mixed selections; vertex handles + the vertex lasso suppress to avoid fighting the box). rotateEnabled flips off on multi to skip the ambiguous shared- pivot rotation. Drag of any selected node mirrors the delta onto every sibling Konva node so the whole group travels together; transformend persists each layer's final pose, not just the anchor's. - liveLayerPose became a per-id map (liveLayerPoses) so the reactive shape configs don't snap non-focused siblings back to their sampled pre-transform pose mid-gesture. - Plain click on a member of an existing multi-selection no longer collapses to single-select before the drag begins. - Arrow-key nudge + Timeline + keyframe button + Properties panel all fan out across the multi-selection: every editable layer gets the value, kind-specific panel sections (text, polygon, fill, stroke, blur, shadow, mask, button) collapse to leave just Transform + Opacity, and the trash button labels with the selection count. - Click on empty stage with a polygon selected now deselects the layer too instead of leaving the polygon stuck in the panel. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5871 lines
177 KiB
Vue
5871 lines
177 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch, type ComponentPublicInstance } from 'vue'
|
||
import {
|
||
PhArrowsSplit,
|
||
PhCaretDown,
|
||
PhCaretRight,
|
||
PhCheck,
|
||
PhCode,
|
||
PhCopy,
|
||
PhDotsSixVertical,
|
||
PhFilmStrip,
|
||
PhFolder,
|
||
PhFolderMinus,
|
||
PhFolderPlus,
|
||
PhLock,
|
||
PhLockOpen,
|
||
PhEye,
|
||
PhEyeSlash,
|
||
PhMagnet,
|
||
PhMaskHappy,
|
||
PhCircle,
|
||
PhImage,
|
||
PhMusicNote,
|
||
PhPauseCircle,
|
||
PhPencilSimpleLine,
|
||
PhPlus,
|
||
PhPolygon,
|
||
PhScissors,
|
||
PhSpeakerSlash,
|
||
PhSquare,
|
||
PhTextT,
|
||
PhTrash,
|
||
} from '@phosphor-icons/vue'
|
||
import { useSceneStore } from '@/stores/scene'
|
||
import { useClipartStore } from '@/stores/clipart'
|
||
import { importAudioFile } from '@/lib/fileLoader'
|
||
import { audioDragSource, clearAudioDragSource } from '@/composables/useAudioDragSource'
|
||
import { clearClipDragSource, clipDragSource } from '@/composables/useClipDragSource'
|
||
import { drawWaveform, getWaveformSlot, slicePeaksForClip } from '@/composables/useAudioWaveform'
|
||
import { sampleLayer } from '@/lib/interpolate'
|
||
import { snapToCandidates } from '@/lib/timelineSnap'
|
||
import { confirmDialog, promptDialog } from '@/components/ui'
|
||
import ScriptFrameDialog from './ScriptFrameDialog.vue'
|
||
import { t } from '@/i18n'
|
||
import { kfSelectionKey, useKfSelection, type KfSelectionKey } from '@/composables/useKfSelection'
|
||
import { showLocked } from '@/composables/useShowLocked'
|
||
import type {
|
||
AnimatableProps,
|
||
Asset,
|
||
AudioAsset,
|
||
AudioClip,
|
||
Clip,
|
||
ClipInstance,
|
||
Easing,
|
||
HitShape,
|
||
Keyframe,
|
||
Layer,
|
||
TrackGroup,
|
||
} from '@/types/scene'
|
||
import { BUTTON_STATE_TIMES } from '@/types/scene'
|
||
|
||
interface TimelineLayerTrack {
|
||
type: 'layer'
|
||
layerId: string
|
||
layer: Layer
|
||
kfs: Keyframe[]
|
||
kind: 'main' | 'position' | 'rotation'
|
||
label: string
|
||
isParent: boolean
|
||
orderIndex: number
|
||
inGroup: boolean
|
||
parentGroupId?: string
|
||
groupChildIndex?: number
|
||
}
|
||
|
||
interface TimelineClipTrack {
|
||
type: 'clip'
|
||
clipTrackId: string
|
||
name: string
|
||
orderIndex: number
|
||
inGroup: boolean
|
||
parentGroupId?: string
|
||
groupChildIndex?: number
|
||
}
|
||
|
||
interface TimelineGroupTrack {
|
||
type: 'group'
|
||
groupId: string
|
||
name: string
|
||
collapsed: boolean
|
||
color: string | null
|
||
locked: boolean
|
||
editorHidden: boolean
|
||
orderIndex: number
|
||
childCount: number
|
||
}
|
||
|
||
interface TimelineScriptTrack {
|
||
type: 'script'
|
||
scriptTrackId: string
|
||
name: string
|
||
orderIndex: number
|
||
locked: boolean
|
||
color: string | null
|
||
inGroup: boolean
|
||
parentGroupId?: string
|
||
groupChildIndex?: number
|
||
}
|
||
|
||
interface TimelineAudioTrack {
|
||
type: 'audio'
|
||
audioTrackId: string
|
||
name: string
|
||
orderIndex: number
|
||
locked: boolean
|
||
muted: boolean
|
||
color: string | null
|
||
inGroup: boolean
|
||
parentGroupId?: string
|
||
groupChildIndex?: number
|
||
}
|
||
|
||
type TimelineTrack =
|
||
| TimelineLayerTrack
|
||
| TimelineClipTrack
|
||
| TimelineGroupTrack
|
||
| TimelineScriptTrack
|
||
| TimelineAudioTrack
|
||
|
||
const store = useSceneStore()
|
||
const clipart = useClipartStore()
|
||
|
||
const duration = computed(() =>
|
||
store.activeButtonAssetId ? 5 : store.activeDuration,
|
||
)
|
||
const inClipEdit = computed(() => store.activeClipId !== null)
|
||
|
||
// Only surface the "drop a clip to add a new track" dropzone when the user
|
||
// has at least one clip in the library — otherwise the hint is confusing
|
||
// (you can't drop what you don't have).
|
||
const showClipDropzone = computed(
|
||
() =>
|
||
!inClipEdit.value &&
|
||
!store.activeButtonAssetId &&
|
||
(store.scene.clips?.length ?? 0) > 0,
|
||
)
|
||
|
||
// Keep the dropzone hint horizontally centred in the *visible* ruler area
|
||
// (right of the sticky name-spacer) regardless of horizontal scroll. The
|
||
// dropzone itself is as wide as .tl-content, so its natural flex-center
|
||
// is not at the visual center — we position the hint absolutely at
|
||
// `scrollLeft + trackNameWidth + (visibleRulerWidth / 2)`.
|
||
const dropzoneHintStyle = computed(() => {
|
||
const tn = trackNameWidth.value
|
||
const vp = viewportWidth.value
|
||
if (vp <= 0) return {}
|
||
const visibleRulerW = Math.max(0, vp - tn)
|
||
const leftPx = scrollLeft.value + tn + visibleRulerW / 2
|
||
return { left: `${leftPx}px` }
|
||
})
|
||
const clipInstancesByTrack = computed<Record<string, ClipInstance[]>>(() => {
|
||
const out: Record<string, ClipInstance[]> = {}
|
||
for (const ci of store.scene.clipInstances ?? []) {
|
||
;(out[ci.clipTrackId] ||= []).push(ci)
|
||
}
|
||
return out
|
||
})
|
||
const clipById = computed(() => {
|
||
const m = new Map<string, Clip>()
|
||
for (const c of store.scene.clips ?? []) m.set(c.id, c)
|
||
return m
|
||
})
|
||
const clipTrackById = computed(() => {
|
||
const m = new Map<string, NonNullable<typeof store.scene.clipTracks>[number]>()
|
||
for (const t of store.scene.clipTracks ?? []) m.set(t.id, t)
|
||
return m
|
||
})
|
||
const groupById = computed(() => {
|
||
const m = new Map<string, TrackGroup>()
|
||
for (const g of store.scene.groups ?? []) m.set(g.id, g)
|
||
return m
|
||
})
|
||
const scriptTrackById = computed(() => {
|
||
const m = new Map<string, NonNullable<typeof store.scene.scriptTracks>[number]>()
|
||
for (const t of store.scene.scriptTracks ?? []) m.set(t.id, t)
|
||
return m
|
||
})
|
||
const scriptFramesByTrack = computed<Record<string, typeof store.scene.scriptFrames>>(() => {
|
||
const out: Record<string, NonNullable<typeof store.scene.scriptFrames>> = {}
|
||
for (const f of store.scene.scriptFrames ?? []) {
|
||
;(out[f.trackId] ||= []).push(f)
|
||
}
|
||
return out
|
||
})
|
||
const audioTrackById = computed(() => {
|
||
const m = new Map<string, NonNullable<typeof store.scene.audioTracks>[number]>()
|
||
for (const t of store.scene.audioTracks ?? []) m.set(t.id, t)
|
||
return m
|
||
})
|
||
const audioClipsByTrack = computed<Record<string, AudioClip[]>>(() => {
|
||
const out: Record<string, AudioClip[]> = {}
|
||
for (const c of store.scene.audioClips ?? []) {
|
||
;(out[c.audioTrackId] ||= []).push(c)
|
||
}
|
||
return out
|
||
})
|
||
const audioAssetById = computed(() => {
|
||
const m = new Map<string, AudioAsset>()
|
||
for (const a of store.scene.assets) {
|
||
if (a.kind === 'audio') m.set(a.id, a)
|
||
}
|
||
return m
|
||
})
|
||
// Bump the storage key so clients with an old (narrower) stored value pick
|
||
// up the new default that matches the assets-sidebar column width (260px),
|
||
// which keeps the timeline track-name column visually aligned with it.
|
||
const TRACK_NAME_WIDTH_KEY = 'animatr:trackNameWidth:v2'
|
||
const TRACK_NAME_WIDTH_DEFAULT = 260
|
||
const TRACK_NAME_WIDTH_MIN = 80
|
||
const TRACK_NAME_WIDTH_MAX = 400
|
||
|
||
function readStoredTrackNameWidth(): number {
|
||
try {
|
||
const raw = localStorage.getItem(TRACK_NAME_WIDTH_KEY)
|
||
if (raw === null) return TRACK_NAME_WIDTH_DEFAULT
|
||
const n = Number.parseInt(raw, 10)
|
||
if (Number.isFinite(n) && n >= TRACK_NAME_WIDTH_MIN) return Math.min(n, TRACK_NAME_WIDTH_MAX)
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
return TRACK_NAME_WIDTH_DEFAULT
|
||
}
|
||
|
||
const trackNameWidth = ref(readStoredTrackNameWidth())
|
||
|
||
const timelineRootStyle = computed(() => ({
|
||
'--track-name-width': `${trackNameWidth.value}px`,
|
||
}))
|
||
|
||
let nameResizeStartX = 0
|
||
let nameResizeStartW = 0
|
||
let nameResizePointerId = -1
|
||
const nameResizing = ref(false)
|
||
|
||
function onNameResizeStart(e: PointerEvent) {
|
||
if (e.button !== 0) return
|
||
e.preventDefault()
|
||
const el = e.currentTarget as HTMLElement
|
||
el.setPointerCapture(e.pointerId)
|
||
nameResizePointerId = e.pointerId
|
||
nameResizeStartX = e.clientX
|
||
nameResizeStartW = trackNameWidth.value
|
||
nameResizing.value = true
|
||
document.body.style.userSelect = 'none'
|
||
}
|
||
|
||
function onNameResizeMove(e: PointerEvent) {
|
||
if (!nameResizing.value || e.pointerId !== nameResizePointerId) return
|
||
const dx = e.clientX - nameResizeStartX
|
||
trackNameWidth.value = Math.max(
|
||
TRACK_NAME_WIDTH_MIN,
|
||
Math.min(TRACK_NAME_WIDTH_MAX, nameResizeStartW + dx),
|
||
)
|
||
}
|
||
|
||
function onNameResizeEnd(e: PointerEvent) {
|
||
if (!nameResizing.value || e.pointerId !== nameResizePointerId) return
|
||
const el = e.currentTarget as HTMLElement
|
||
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
|
||
nameResizePointerId = -1
|
||
nameResizing.value = false
|
||
document.body.style.userSelect = ''
|
||
try {
|
||
localStorage.setItem(TRACK_NAME_WIDTH_KEY, String(trackNameWidth.value))
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
const MIN_PX_PER_SEC = 2
|
||
const MAX_PX_PER_SEC = 2000
|
||
|
||
// Fixed 100px per state-time unit in button-edit mode. Hoisted here so the
|
||
// effectivePxPerSec computed below can reference it.
|
||
const BUTTON_STATE_WIDTH = 100
|
||
|
||
const scrollEl = ref<HTMLDivElement | null>(null)
|
||
const viewportWidth = ref(0)
|
||
// Tracks horizontal scroll offset of the timeline so we can hide the
|
||
// scrub-handle behind the sticky name-spacer when the playhead has
|
||
// scrolled off the left edge of the visible ruler area.
|
||
const scrollLeft = ref(0)
|
||
// pxPerSec / autoFit mirror the store's editor.timeline* fields so
|
||
// the timeline zoom / fit preference round-trips with the project
|
||
// file. Thin writableComputed shims keep the rest of this
|
||
// component's code (`pxPerSec.value = …`, `autoFit.value`) unchanged.
|
||
const pxPerSec = computed<number>({
|
||
get: () => store.editor.timelinePxPerSec,
|
||
set: (v) => { store.editor.timelinePxPerSec = v },
|
||
})
|
||
const autoFit = computed<boolean>({
|
||
get: () => store.editor.timelineAutoFit,
|
||
set: (v) => { store.editor.timelineAutoFit = v },
|
||
})
|
||
|
||
function clamp(v: number, lo: number, hi: number) {
|
||
return Math.max(lo, Math.min(hi, v))
|
||
}
|
||
|
||
const fitPxPerSec = computed(() => {
|
||
const avail = Math.max(60, viewportWidth.value - trackNameWidth.value)
|
||
const d = Math.max(0.1, duration.value)
|
||
return avail / d
|
||
})
|
||
|
||
const effectivePxPerSec = computed(() => {
|
||
// Button-edit uses a fixed 100px per state unit so the kf dots on tracks
|
||
// line up with the state labels on the scrubber above.
|
||
if (store.activeButtonAssetId) return BUTTON_STATE_WIDTH
|
||
if (autoFit.value || pxPerSec.value <= 0) return fitPxPerSec.value
|
||
return pxPerSec.value
|
||
})
|
||
|
||
const laneWidth = computed(() => duration.value * effectivePxPerSec.value)
|
||
const totalWidth = computed(() => trackNameWidth.value + laneWidth.value)
|
||
|
||
type DisplayMode = 'seconds' | 'frames'
|
||
const displayMode = computed<DisplayMode>({
|
||
get: () => store.editor.timelineDisplayMode,
|
||
set: (v) => { store.editor.timelineDisplayMode = v },
|
||
})
|
||
const snapEnabled = computed<boolean>({
|
||
get: () => store.editor.timelineSnap,
|
||
set: (v) => { store.editor.timelineSnap = v },
|
||
})
|
||
|
||
|
||
const SNAP_PX = 8
|
||
|
||
function allKeyframeTimes(ignoreKeyframeId?: string): number[] {
|
||
const out: number[] = []
|
||
for (const layer of store.scene.layers) {
|
||
for (const k of layer.keyframes) if (k.id !== ignoreKeyframeId) out.push(k.time)
|
||
if (layer.positionKeyframes)
|
||
for (const k of layer.positionKeyframes) if (k.id !== ignoreKeyframeId) out.push(k.time)
|
||
if (layer.rotationKeyframes)
|
||
for (const k of layer.rotationKeyframes) if (k.id !== ignoreKeyframeId) out.push(k.time)
|
||
}
|
||
return out
|
||
}
|
||
|
||
interface SnapTimeOpts {
|
||
ignoreKeyframeId?: string
|
||
/** True when snapping the playhead itself — omits the playhead
|
||
* from the candidate list so the scrub doesn't stick to its own
|
||
* previous position. Clip drags, clip trims, keyframe drags and
|
||
* marker drags all pick up the playhead as a magnet by default. */
|
||
ignorePlayhead?: boolean
|
||
}
|
||
|
||
function snapTime(t: number, opts: SnapTimeOpts = {}): number {
|
||
const candidates = allKeyframeTimes(opts.ignoreKeyframeId)
|
||
if (!opts.ignorePlayhead) candidates.push(store.playhead)
|
||
return snapToCandidates(t, candidates, {
|
||
enabled: snapEnabled.value,
|
||
pxPerSec: effectivePxPerSec.value,
|
||
pixelThreshold: SNAP_PX,
|
||
duration: duration.value,
|
||
})
|
||
}
|
||
|
||
const TICK_CANDIDATES_SEC = [0.1, 0.2, 0.5, 1, 2, 5, 10, 30, 60, 300]
|
||
const TICK_CANDIDATES_FRAMES = [1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 1800]
|
||
|
||
const fps = computed(() => (store.scene.fps > 0 ? store.scene.fps : 60))
|
||
|
||
const tickStep = computed(() => {
|
||
const pps = effectivePxPerSec.value
|
||
if (displayMode.value === 'frames') {
|
||
const pxPerFrame = pps / fps.value
|
||
for (const f of TICK_CANDIDATES_FRAMES) {
|
||
if (f * pxPerFrame >= 55) return f / fps.value
|
||
}
|
||
return TICK_CANDIDATES_FRAMES[TICK_CANDIDATES_FRAMES.length - 1] / fps.value
|
||
}
|
||
for (const s of TICK_CANDIDATES_SEC) {
|
||
if (s * pps >= 55) return s
|
||
}
|
||
return TICK_CANDIDATES_SEC[TICK_CANDIDATES_SEC.length - 1]
|
||
})
|
||
|
||
const ticks = computed(() => {
|
||
const d = duration.value
|
||
const step = tickStep.value
|
||
const out: number[] = []
|
||
for (let t = 0; t <= d + 1e-6; t += step) {
|
||
out.push(Math.round(t * 1000) / 1000)
|
||
}
|
||
if (out[out.length - 1] < d - 1e-6) out.push(d)
|
||
return out
|
||
})
|
||
|
||
function pxFromTime(t: number) {
|
||
return t * effectivePxPerSec.value
|
||
}
|
||
|
||
// True when the playhead has scrolled off the left side of the visible ruler
|
||
// area (so the handle's centre sits behind the sticky name-spacer column).
|
||
// Used to drop the handle's z-index below the name-spacer so it doesn't
|
||
// render over the toggle-row buttons. When in view, the handle sits above
|
||
// the name-spacer so its left half isn't clipped at t=0.
|
||
const scrubHandleObscured = computed(
|
||
() => scrollLeft.value > pxFromTime(store.playhead),
|
||
)
|
||
|
||
function groupKeyframeTimes(groupId: string): number[] {
|
||
const group = groupById.value.get(groupId)
|
||
if (!group) return []
|
||
const times: number[] = []
|
||
const layerById = new Map<string, Layer>()
|
||
for (const l of store.scene.layers) layerById.set(l.id, l)
|
||
for (const childId of group.childIds) {
|
||
const layer = layerById.get(childId)
|
||
if (layer) {
|
||
for (const k of layer.keyframes) times.push(k.time)
|
||
for (const k of layer.positionKeyframes ?? []) times.push(k.time)
|
||
for (const k of layer.rotationKeyframes ?? []) times.push(k.time)
|
||
continue
|
||
}
|
||
const ct = clipTrackById.value.get(childId)
|
||
if (ct) {
|
||
for (const ci of clipInstancesByTrack.value[ct.id] ?? []) {
|
||
times.push(ci.startTime)
|
||
}
|
||
}
|
||
}
|
||
return times
|
||
}
|
||
|
||
function formatTick(t: number) {
|
||
if (displayMode.value === 'frames') {
|
||
return `${Math.round(t * fps.value)}`
|
||
}
|
||
if (Number.isInteger(t)) return `${t}s`
|
||
return `${t.toFixed(2).replace(/0+$/, '').replace(/\.$/, '')}s`
|
||
}
|
||
|
||
function formatKfTime(t: number) {
|
||
if (displayMode.value === 'frames') return `${Math.round(t * fps.value)} fr`
|
||
return `${t.toFixed(2)}s`
|
||
}
|
||
|
||
function seekFromEvent(e: PointerEvent, el: HTMLElement) {
|
||
const rect = el.getBoundingClientRect()
|
||
if (rect.width <= 0) return
|
||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||
store.setPlayhead(snapTime(ratio * duration.value, { ignorePlayhead: true }))
|
||
}
|
||
|
||
function onScrubPointerDown(e: PointerEvent) {
|
||
const el = e.currentTarget as HTMLElement
|
||
el.setPointerCapture(e.pointerId)
|
||
seekFromEvent(e, el)
|
||
}
|
||
|
||
function onScrubPointerMove(e: PointerEvent) {
|
||
const el = e.currentTarget as HTMLElement
|
||
if (!el.hasPointerCapture(e.pointerId)) return
|
||
seekFromEvent(e, el)
|
||
}
|
||
|
||
function onScrubPointerUp(e: PointerEvent) {
|
||
const el = e.currentTarget as HTMLElement
|
||
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
|
||
}
|
||
|
||
function bracketTimeFromEvent(e: PointerEvent, el: HTMLElement): number {
|
||
const ruler = el.parentElement as HTMLElement | null
|
||
if (!ruler) return 0
|
||
const rect = ruler.getBoundingClientRect()
|
||
if (rect.width <= 0) return 0
|
||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||
return snapTime(ratio * duration.value)
|
||
}
|
||
|
||
function onInBracketPointerDown(e: PointerEvent) {
|
||
e.stopPropagation()
|
||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||
}
|
||
|
||
function onInBracketPointerMove(e: PointerEvent) {
|
||
const el = e.currentTarget as HTMLElement
|
||
if (!el.hasPointerCapture(e.pointerId)) return
|
||
e.stopPropagation()
|
||
const t = bracketTimeFromEvent(e, el)
|
||
store.setPlaybackRange(Math.min(t, store.playbackOut), null)
|
||
}
|
||
|
||
function onInBracketPointerUp(e: PointerEvent) {
|
||
const el = e.currentTarget as HTMLElement
|
||
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
|
||
}
|
||
|
||
function onOutBracketPointerDown(e: PointerEvent) {
|
||
e.stopPropagation()
|
||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||
}
|
||
|
||
function onOutBracketPointerMove(e: PointerEvent) {
|
||
const el = e.currentTarget as HTMLElement
|
||
if (!el.hasPointerCapture(e.pointerId)) return
|
||
e.stopPropagation()
|
||
const t = bracketTimeFromEvent(e, el)
|
||
store.setPlaybackRange(null, Math.max(t, store.playbackIn))
|
||
}
|
||
|
||
function onOutBracketPointerUp(e: PointerEvent) {
|
||
const el = e.currentTarget as HTMLElement
|
||
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
|
||
}
|
||
|
||
// -------- Markers -----------------------------------------------------
|
||
|
||
const selectedMarkerId = ref<string | null>(null)
|
||
const markerDrag = ref<{ id: string; startX: number; moved: boolean } | null>(null)
|
||
|
||
function onOutsideMarkerPointerDown(e: PointerEvent) {
|
||
const el = e.target as HTMLElement | null
|
||
// Leave selection alone when the click lands on the marker itself
|
||
// (or any menu/dialog — treat anything inside a popup as "not outside").
|
||
if (el?.closest('.timeline-marker')) return
|
||
if (el?.closest('.dialog, .kf-menu, .track-menu')) return
|
||
selectedMarkerId.value = null
|
||
}
|
||
|
||
watch(selectedMarkerId, (id) => {
|
||
if (id) {
|
||
document.addEventListener('pointerdown', onOutsideMarkerPointerDown, true)
|
||
} else {
|
||
document.removeEventListener('pointerdown', onOutsideMarkerPointerDown, true)
|
||
}
|
||
})
|
||
|
||
function markerTimeFromEvent(e: PointerEvent, markerEl: HTMLElement): number {
|
||
const ruler = markerEl.parentElement as HTMLElement | null
|
||
if (!ruler) return 0
|
||
const rect = ruler.getBoundingClientRect()
|
||
if (rect.width <= 0) return 0
|
||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||
return snapTime(ratio * duration.value)
|
||
}
|
||
|
||
function onMarkerPointerDown(e: PointerEvent, markerId: string) {
|
||
e.stopPropagation()
|
||
selectedMarkerId.value = markerId
|
||
markerDrag.value = { id: markerId, startX: e.clientX, moved: false }
|
||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||
}
|
||
|
||
function onMarkerPointerMove(e: PointerEvent, markerId: string) {
|
||
const el = e.currentTarget as HTMLElement
|
||
if (!el.hasPointerCapture(e.pointerId)) return
|
||
const drag = markerDrag.value
|
||
if (!drag || drag.id !== markerId) return
|
||
e.stopPropagation()
|
||
if (!drag.moved && Math.abs(e.clientX - drag.startX) > 3) drag.moved = true
|
||
if (drag.moved) {
|
||
store.setMarkerTime(markerId, markerTimeFromEvent(e, el))
|
||
}
|
||
}
|
||
|
||
function onMarkerPointerUp(e: PointerEvent, markerId: string) {
|
||
const el = e.currentTarget as HTMLElement
|
||
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
|
||
markerDrag.value = null
|
||
void markerId
|
||
}
|
||
|
||
async function renameMarkerPrompt(markerId: string) {
|
||
const marker = store.scene.markers?.find((m) => m.id === markerId)
|
||
if (!marker) return
|
||
const next = await promptDialog({
|
||
title: t('timeline.renameMarkerTitle'),
|
||
message: t('timeline.renameMarkerMessage'),
|
||
defaultValue: marker.name,
|
||
confirmLabel: t('timeline.renameMarkerConfirm'),
|
||
})
|
||
if (next === null) return
|
||
const trimmed = next.trim()
|
||
if (!trimmed) return
|
||
const ok = store.renameMarker(markerId, trimmed)
|
||
if (!ok) {
|
||
await confirmDialog({
|
||
title: t('timeline.renameMarkerTitle'),
|
||
message: t('timeline.renameMarkerCollision', { name: trimmed }),
|
||
confirmLabel: t('timeline.renameMarkerRetry'),
|
||
})
|
||
// Let the user pick again.
|
||
renameMarkerPrompt(markerId)
|
||
}
|
||
}
|
||
|
||
async function addMarkerAtPlayhead() {
|
||
const marker = store.addMarker(store.playhead)
|
||
selectedMarkerId.value = marker.id
|
||
await nextTick()
|
||
// Open rename prompt immediately so the user can name the marker.
|
||
renameMarkerPrompt(marker.id)
|
||
}
|
||
|
||
function onLaneClick(e: MouseEvent) {
|
||
if (kfDrag.value) return
|
||
const el = e.currentTarget as HTMLElement
|
||
const rect = el.getBoundingClientRect()
|
||
if (rect.width <= 0) return
|
||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||
store.setPlayhead(snapTime(ratio * duration.value, { ignorePlayhead: true }))
|
||
}
|
||
|
||
const canCreateKf = computed(() => {
|
||
// Button is available as long as at least one selected layer is
|
||
// unlocked — the multi-select handler skips the locked ones per
|
||
// id, so a partially-locked selection still writes the usable
|
||
// frames.
|
||
const ids = store.selectedLayerIds.length
|
||
? store.selectedLayerIds
|
||
: store.selectedLayerId
|
||
? [store.selectedLayerId]
|
||
: []
|
||
if (!ids.length) return false
|
||
for (const id of ids) {
|
||
const layer = store.activeLayers.find((l) => l.id === id)
|
||
if (!layer) continue
|
||
if (layer.locked) continue
|
||
if (lockedGroupChildIds.value.has(id)) continue
|
||
return true
|
||
}
|
||
return false
|
||
})
|
||
|
||
function addKeyframeForSelected() {
|
||
// Fan out across the whole multi-selection so the user can pin a
|
||
// frame on every grouped object at once. Each layer gets its own
|
||
// sampled snapshot so the keyframe matches the current pose.
|
||
// Falls back to the single-selection anchor when no multi is set.
|
||
const ids = store.selectedLayerIds.length
|
||
? store.selectedLayerIds
|
||
: store.selectedLayerId
|
||
? [store.selectedLayerId]
|
||
: []
|
||
if (!ids.length) return
|
||
// Snap the playhead itself to the nearest keyframe before adding —
|
||
// the current playhead position is NOT a candidate (it's the value
|
||
// we're rounding).
|
||
const time = snapTime(store.playhead, { ignorePlayhead: true })
|
||
for (const id of ids) {
|
||
const layer = store.activeLayers.find((l) => l.id === id)
|
||
if (!layer || isLayerIdLocked(id)) continue
|
||
const sampled = sampleLayer(layer, time)
|
||
const props: Partial<AnimatableProps> = {
|
||
scaleX: sampled.scaleX,
|
||
scaleY: sampled.scaleY,
|
||
opacity: sampled.opacity,
|
||
}
|
||
if (sampled.fill) props.fill = sampled.fill
|
||
if (!layer.separated) {
|
||
props.x = sampled.x
|
||
props.y = sampled.y
|
||
props.rotation = sampled.rotation
|
||
}
|
||
store.upsertKeyframe(id, time, props)
|
||
}
|
||
}
|
||
|
||
function onLaneDblClick(e: MouseEvent, track: TimelineLayerTrack) {
|
||
e.stopPropagation()
|
||
const el = e.currentTarget as HTMLElement
|
||
const rect = el.getBoundingClientRect()
|
||
if (rect.width <= 0) return
|
||
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 || isLayerIdLocked(track.layerId)) return
|
||
const sampled = sampleLayer(layer, time)
|
||
const props: Partial<AnimatableProps> = {}
|
||
if (track.kind === 'position') {
|
||
props.x = sampled.x
|
||
props.y = sampled.y
|
||
} else if (track.kind === 'rotation') {
|
||
props.rotation = sampled.rotation
|
||
} else if (layer.separated) {
|
||
props.scaleX = sampled.scaleX
|
||
props.scaleY = sampled.scaleY
|
||
props.opacity = sampled.opacity
|
||
if (sampled.fill) props.fill = sampled.fill
|
||
} else {
|
||
props.x = sampled.x
|
||
props.y = sampled.y
|
||
props.scaleX = sampled.scaleX
|
||
props.scaleY = sampled.scaleY
|
||
props.rotation = sampled.rotation
|
||
props.opacity = sampled.opacity
|
||
if (sampled.fill) props.fill = sampled.fill
|
||
}
|
||
store.selectLayer(track.layerId)
|
||
store.setPlayhead(time)
|
||
store.upsertKeyframe(track.layerId, time, props)
|
||
}
|
||
|
||
function onKeyframeClick(e: MouseEvent, layerId: string, time: number) {
|
||
e.stopPropagation()
|
||
if (kfDrag.value?.moved) {
|
||
kfDrag.value = null
|
||
return
|
||
}
|
||
kfDrag.value = null
|
||
store.selectLayer(layerId)
|
||
store.setPlayhead(time)
|
||
}
|
||
|
||
function onKeyframeDblClick(e: MouseEvent, layerId: string, keyframeId: string) {
|
||
e.stopPropagation()
|
||
if (kfDrag.value?.moved) return
|
||
if (isLayerIdLocked(layerId)) return
|
||
store.removeKeyframe(layerId, keyframeId)
|
||
}
|
||
|
||
function emitLayerTrack(
|
||
layer: Layer,
|
||
orderIndex: number,
|
||
inGroup: boolean,
|
||
out: TimelineTrack[],
|
||
parentGroupId?: string,
|
||
groupChildIndex?: number,
|
||
) {
|
||
const base = { type: 'layer' as const, layerId: layer.id, layer, orderIndex, inGroup, parentGroupId, groupChildIndex }
|
||
if (!layer.separated) {
|
||
out.push({ ...base, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true })
|
||
} else {
|
||
out.push({ ...base, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true })
|
||
out.push({ ...base, kfs: layer.positionKeyframes ?? [], kind: 'position', label: t('timeline.position'), isParent: false })
|
||
out.push({ ...base, kfs: layer.rotationKeyframes ?? [], kind: 'rotation', label: t('timeline.rotation'), isParent: false })
|
||
}
|
||
}
|
||
|
||
const assetById = computed(() => {
|
||
const m = new Map<string, Asset>()
|
||
for (const a of store.scene.assets) m.set(a.id, a)
|
||
return m
|
||
})
|
||
|
||
function assetIconFor(layer: Layer) {
|
||
const asset = assetById.value.get(layer.assetId)
|
||
if (!asset) return null
|
||
if (asset.kind === 'rect') return PhSquare
|
||
if (asset.kind === 'circle') return PhCircle
|
||
if (asset.kind === 'image') return PhImage
|
||
if (asset.kind === 'text') return PhTextT
|
||
if (asset.kind === 'polygon') return asset.closed === false ? PhPencilSimpleLine : PhPolygon
|
||
return null
|
||
}
|
||
|
||
const lockedGroupChildIds = computed(() => {
|
||
const s = new Set<string>()
|
||
for (const g of store.scene.groups ?? []) {
|
||
if (g.locked) for (const cid of g.childIds) s.add(cid)
|
||
}
|
||
return s
|
||
})
|
||
|
||
/** Effective lock for a bare layer id — direct flag OR reached by a
|
||
* locked parent group. Keyframe interactions consult this so edits
|
||
* are blocked the moment the containing group is locked, without
|
||
* having to look up the TimelineTrack wrapper. */
|
||
function isLayerIdLocked(layerId: string): boolean {
|
||
const layer = store.activeLayers.find((l) => l.id === layerId)
|
||
if (!layer) return false
|
||
if (layer.locked) return true
|
||
return lockedGroupChildIds.value.has(layerId)
|
||
}
|
||
|
||
function isTrackEffectivelyLocked(track: TimelineTrack): boolean {
|
||
if (track.type === 'layer') {
|
||
return !!track.layer.locked || lockedGroupChildIds.value.has(track.layerId)
|
||
}
|
||
if (track.type === 'clip') {
|
||
return (
|
||
!!clipTrackById.value.get(track.clipTrackId)?.locked ||
|
||
lockedGroupChildIds.value.has(track.clipTrackId)
|
||
)
|
||
}
|
||
return track.locked
|
||
}
|
||
|
||
const tracks = computed<TimelineTrack[]>(() => {
|
||
const out: TimelineTrack[] = []
|
||
// Button-asset edit: show only the active state's layers. 'hit' mode
|
||
// has no layer stack — timeline is empty (user edits hit shapes on
|
||
// the stage).
|
||
if (store.activeButtonAssetId) {
|
||
// Hit mode is a separate stage — it owns the timeline, shows hit shapes
|
||
// as tracks (rendered below, outside the main track v-for), and layers
|
||
// from the 4 content states do not appear here.
|
||
if (store.activeButtonEditMode === 'hit') return out
|
||
store.activeLayers.forEach((layer, orderIndex) => {
|
||
emitLayerTrack(layer, orderIndex, false, out)
|
||
})
|
||
return out
|
||
}
|
||
if (store.activeClipId) {
|
||
store.activeLayers.forEach((layer, orderIndex) => {
|
||
emitLayerTrack(layer, orderIndex, false, out)
|
||
})
|
||
return out
|
||
}
|
||
const order = store.scene.trackOrder ?? []
|
||
const layerById = new Map<string, Layer>()
|
||
for (const l of store.scene.layers) layerById.set(l.id, l)
|
||
order.forEach((id, orderIndex) => {
|
||
const layer = layerById.get(id)
|
||
if (layer) {
|
||
emitLayerTrack(layer, orderIndex, false, out)
|
||
return
|
||
}
|
||
const ct = clipTrackById.value.get(id)
|
||
if (ct) {
|
||
out.push({ type: 'clip', clipTrackId: ct.id, name: ct.name ?? t('timeline.clipTrackName'), orderIndex, inGroup: false })
|
||
return
|
||
}
|
||
const st = scriptTrackById.value.get(id)
|
||
if (st) {
|
||
out.push({
|
||
type: 'script',
|
||
scriptTrackId: st.id,
|
||
name: st.name ?? t('timeline.scriptTrackName'),
|
||
orderIndex,
|
||
locked: !!st.locked,
|
||
color: st.color ?? null,
|
||
inGroup: false,
|
||
})
|
||
return
|
||
}
|
||
const at = audioTrackById.value.get(id)
|
||
if (at) {
|
||
out.push({
|
||
type: 'audio',
|
||
audioTrackId: at.id,
|
||
name: at.name ?? t('timeline.audioTrackName'),
|
||
orderIndex,
|
||
locked: !!at.locked,
|
||
muted: !!at.muted,
|
||
color: at.color ?? null,
|
||
inGroup: false,
|
||
})
|
||
return
|
||
}
|
||
const grp = groupById.value.get(id)
|
||
if (grp) {
|
||
out.push({
|
||
type: 'group',
|
||
groupId: grp.id,
|
||
name: grp.name,
|
||
collapsed: !!grp.collapsed,
|
||
color: grp.color ?? null,
|
||
locked: !!grp.locked,
|
||
editorHidden: !!grp.editorHidden,
|
||
orderIndex,
|
||
childCount: grp.childIds.length,
|
||
})
|
||
if (!grp.collapsed) {
|
||
grp.childIds.forEach((cid, childIndex) => {
|
||
const cl = layerById.get(cid)
|
||
if (cl) {
|
||
emitLayerTrack(cl, -1, true, out, grp.id, childIndex)
|
||
return
|
||
}
|
||
const cct = clipTrackById.value.get(cid)
|
||
if (cct) {
|
||
out.push({
|
||
type: 'clip',
|
||
clipTrackId: cct.id,
|
||
name: cct.name ?? t('timeline.clipTrackName'),
|
||
orderIndex: -1,
|
||
inGroup: true,
|
||
parentGroupId: grp.id,
|
||
groupChildIndex: childIndex,
|
||
})
|
||
return
|
||
}
|
||
const cst = scriptTrackById.value.get(cid)
|
||
if (cst) {
|
||
out.push({
|
||
type: 'script',
|
||
scriptTrackId: cst.id,
|
||
name: cst.name ?? t('timeline.scriptTrackName'),
|
||
orderIndex: -1,
|
||
locked: !!cst.locked,
|
||
color: cst.color ?? null,
|
||
inGroup: true,
|
||
parentGroupId: grp.id,
|
||
groupChildIndex: childIndex,
|
||
})
|
||
return
|
||
}
|
||
const cat = audioTrackById.value.get(cid)
|
||
if (cat) {
|
||
out.push({
|
||
type: 'audio',
|
||
audioTrackId: cat.id,
|
||
name: cat.name ?? t('timeline.audioTrackName'),
|
||
orderIndex: -1,
|
||
locked: !!cat.locked,
|
||
muted: !!cat.muted,
|
||
color: cat.color ?? null,
|
||
inGroup: true,
|
||
parentGroupId: grp.id,
|
||
groupChildIndex: childIndex,
|
||
})
|
||
}
|
||
})
|
||
}
|
||
}
|
||
})
|
||
if (showLocked.value) return out
|
||
const lockedGroupChildIds = new Set<string>()
|
||
const lockedGroupIds = new Set<string>()
|
||
for (const g of store.scene.groups ?? []) {
|
||
if (g.locked) {
|
||
lockedGroupIds.add(g.id)
|
||
for (const cid of g.childIds) lockedGroupChildIds.add(cid)
|
||
}
|
||
}
|
||
return out.filter((tr) => {
|
||
if (tr.type === 'layer') {
|
||
if (tr.layer.locked) return false
|
||
if (lockedGroupChildIds.has(tr.layerId)) return false
|
||
return true
|
||
}
|
||
if (tr.type === 'clip') {
|
||
if (clipTrackById.value.get(tr.clipTrackId)?.locked) return false
|
||
if (lockedGroupChildIds.has(tr.clipTrackId)) return false
|
||
return true
|
||
}
|
||
if (tr.type === 'script') {
|
||
if (tr.locked) return false
|
||
if (lockedGroupChildIds.has(tr.scriptTrackId)) return false
|
||
return true
|
||
}
|
||
if (tr.type === 'audio') {
|
||
if (tr.locked) return false
|
||
if (lockedGroupChildIds.has(tr.audioTrackId)) return false
|
||
return true
|
||
}
|
||
// group: hide when locked
|
||
return !lockedGroupIds.has(tr.groupId)
|
||
})
|
||
})
|
||
|
||
function trackDndId(track: TimelineTrack): string {
|
||
if (track.type === 'layer') return track.layerId
|
||
if (track.type === 'clip') return track.clipTrackId
|
||
if (track.type === 'script') return track.scriptTrackId
|
||
if (track.type === 'audio') return track.audioTrackId
|
||
return track.groupId
|
||
}
|
||
|
||
function isTrackHead(track: TimelineTrack): boolean {
|
||
if (track.type === 'layer') return track.isParent
|
||
return true
|
||
}
|
||
|
||
function trackChildIndex(track: TimelineTrack): number | null {
|
||
if (
|
||
track.type === 'layer' ||
|
||
track.type === 'clip' ||
|
||
track.type === 'script' ||
|
||
track.type === 'audio'
|
||
) {
|
||
return track.groupChildIndex ?? null
|
||
}
|
||
return null
|
||
}
|
||
|
||
function trackParentGroupId(track: TimelineTrack): string | null {
|
||
if (
|
||
track.type === 'layer' ||
|
||
track.type === 'clip' ||
|
||
track.type === 'script' ||
|
||
track.type === 'audio'
|
||
) {
|
||
return track.parentGroupId ?? null
|
||
}
|
||
return null
|
||
}
|
||
|
||
function isGroupable(track: TimelineTrack): track is
|
||
| TimelineLayerTrack
|
||
| TimelineClipTrack
|
||
| TimelineScriptTrack
|
||
| TimelineAudioTrack {
|
||
return (
|
||
track.type === 'layer' ||
|
||
track.type === 'clip' ||
|
||
track.type === 'script' ||
|
||
track.type === 'audio'
|
||
)
|
||
}
|
||
|
||
function isDropBefore(track: TimelineTrack): boolean {
|
||
if (dropIndex.value === null) return false
|
||
if (!isTrackHead(track)) return false
|
||
if (dragTrackId.value === trackDndId(track)) return false
|
||
if (dropWithinGroupId.value) {
|
||
if (trackParentGroupId(track) !== dropWithinGroupId.value) return false
|
||
return dropIndex.value === trackChildIndex(track)
|
||
}
|
||
if (isGroupable(track) && track.inGroup) return false
|
||
return dropIndex.value === track.orderIndex
|
||
}
|
||
|
||
function isDropAfter(track: TimelineTrack, nextTrack: TimelineTrack | undefined): boolean {
|
||
if (dropIndex.value === null) return false
|
||
if (dragTrackId.value === trackDndId(track)) return false
|
||
if (dropWithinGroupId.value) {
|
||
if (trackParentGroupId(track) !== dropWithinGroupId.value) return false
|
||
const ci = trackChildIndex(track)
|
||
if (ci === null) return false
|
||
if (dropIndex.value !== ci + 1) return false
|
||
if (nextTrack && trackChildIndex(nextTrack) === ci) return false
|
||
return true
|
||
}
|
||
if (isGroupable(track) && track.inGroup) return false
|
||
if (dropIndex.value !== track.orderIndex + 1) return false
|
||
if (nextTrack && nextTrack.orderIndex === track.orderIndex) return false
|
||
return true
|
||
}
|
||
|
||
type SelectionKey = KfSelectionKey
|
||
const { selectedKfs, selectedKfSet } = useKfSelection()
|
||
const selectionKey = kfSelectionKey
|
||
|
||
function isKfSelected(layerId: string, kind: 'main' | 'position' | 'rotation', keyframeId: string): boolean {
|
||
return selectedKfSet.value.has(`${layerId}:${kind}:${keyframeId}`)
|
||
}
|
||
|
||
function clearKfSelection() {
|
||
selectedKfs.value = []
|
||
}
|
||
|
||
interface SelectionSnapshotEntry {
|
||
layerId: string
|
||
keyframeId: string
|
||
kind: 'main' | 'position' | 'rotation'
|
||
initialTime: number
|
||
}
|
||
|
||
function snapshotSelection(): SelectionSnapshotEntry[] {
|
||
const out: SelectionSnapshotEntry[] = []
|
||
for (const s of selectedKfs.value) {
|
||
const layer = store.activeLayers.find((l) => l.id === s.layerId)
|
||
if (!layer) continue
|
||
const arr =
|
||
s.kind === 'position'
|
||
? layer.positionKeyframes
|
||
: s.kind === 'rotation'
|
||
? layer.rotationKeyframes
|
||
: layer.keyframes
|
||
const kf = arr?.find((k) => k.id === s.keyframeId)
|
||
if (kf) out.push({ ...s, initialTime: kf.time })
|
||
}
|
||
return out
|
||
}
|
||
|
||
interface KfDrag {
|
||
layerId: string
|
||
keyframeId: string
|
||
kind: 'main' | 'position' | 'rotation'
|
||
lane: HTMLElement
|
||
pointerId: number
|
||
startX: number
|
||
moved: boolean
|
||
initialTime: number
|
||
snapshot: SelectionSnapshotEntry[]
|
||
}
|
||
|
||
const kfDrag = ref<KfDrag | null>(null)
|
||
|
||
function onKeyframePointerDown(
|
||
e: PointerEvent,
|
||
layerId: string,
|
||
keyframeId: string,
|
||
kind: 'main' | 'position' | 'rotation',
|
||
) {
|
||
if (e.button !== 0) return
|
||
const layer = store.activeLayers.find((l) => l.id === layerId)
|
||
if (isLayerIdLocked(layerId)) return
|
||
const kfEl = e.currentTarget as HTMLElement
|
||
const lane = kfEl.parentElement
|
||
if (!lane) return
|
||
|
||
// If this keyframe isn't in the current selection, replace selection with just this one.
|
||
const key = `${layerId}:${kind}:${keyframeId}`
|
||
if (!selectedKfSet.value.has(key)) {
|
||
selectedKfs.value = [{ layerId, kind, keyframeId }]
|
||
}
|
||
|
||
const arr =
|
||
kind === 'position'
|
||
? layer?.positionKeyframes
|
||
: kind === 'rotation'
|
||
? layer?.rotationKeyframes
|
||
: layer?.keyframes
|
||
const srcKf = arr?.find((k) => k.id === keyframeId)
|
||
const initialTime = srcKf?.time ?? 0
|
||
|
||
e.stopPropagation()
|
||
kfEl.setPointerCapture(e.pointerId)
|
||
kfDrag.value = {
|
||
layerId,
|
||
keyframeId,
|
||
kind,
|
||
lane,
|
||
pointerId: e.pointerId,
|
||
startX: e.clientX,
|
||
moved: false,
|
||
initialTime,
|
||
snapshot: snapshotSelection(),
|
||
}
|
||
store.selectLayer(layerId)
|
||
}
|
||
|
||
function onKeyframePointerMove(e: PointerEvent) {
|
||
const d = kfDrag.value
|
||
if (!d || d.pointerId !== e.pointerId) return
|
||
if (!d.moved && Math.abs(e.clientX - d.startX) < 3) return
|
||
d.moved = true
|
||
const rect = d.lane.getBoundingClientRect()
|
||
if (rect.width <= 0) return
|
||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||
const rawNewTime = ratio * duration.value
|
||
// For snapping, ignore all currently selected kfs so we don't snap to ourselves
|
||
const ignoreIds = new Set<string>()
|
||
for (const s of d.snapshot) ignoreIds.add(s.keyframeId)
|
||
// Include the playhead as a snap magnet so dragged keyframes
|
||
// stick to it the same way they stick to sibling keyframes.
|
||
const snappedPrimary = snapToCandidates(
|
||
rawNewTime,
|
||
[...allKeyframeTimesExcluding(ignoreIds), store.playhead],
|
||
{
|
||
enabled: snapEnabled.value,
|
||
pxPerSec: effectivePxPerSec.value,
|
||
pixelThreshold: SNAP_PX,
|
||
duration: duration.value,
|
||
},
|
||
)
|
||
// Group delta clamped so nothing leaves [0, duration]
|
||
let delta = snappedPrimary - d.initialTime
|
||
let minDelta = -Infinity
|
||
let maxDelta = Infinity
|
||
for (const s of d.snapshot) {
|
||
if (-s.initialTime > minDelta) minDelta = -s.initialTime
|
||
if (duration.value - s.initialTime < maxDelta) maxDelta = duration.value - s.initialTime
|
||
}
|
||
delta = Math.max(minDelta, Math.min(maxDelta, delta))
|
||
|
||
if (d.snapshot.length <= 1) {
|
||
store.setKeyframeTime(d.layerId, d.keyframeId, d.initialTime + delta)
|
||
} else {
|
||
for (const s of d.snapshot) {
|
||
store.setKeyframeTime(s.layerId, s.keyframeId, s.initialTime + delta)
|
||
}
|
||
}
|
||
store.setPlayhead(d.initialTime + delta)
|
||
}
|
||
|
||
function allKeyframeTimesExcluding(ignoreIds: Set<string>): number[] {
|
||
const out: number[] = []
|
||
for (const layer of store.activeLayers) {
|
||
for (const k of layer.keyframes) if (!ignoreIds.has(k.id)) out.push(k.time)
|
||
if (layer.positionKeyframes)
|
||
for (const k of layer.positionKeyframes) if (!ignoreIds.has(k.id)) out.push(k.time)
|
||
if (layer.rotationKeyframes)
|
||
for (const k of layer.rotationKeyframes) if (!ignoreIds.has(k.id)) out.push(k.time)
|
||
}
|
||
return out
|
||
}
|
||
|
||
function onKeyframePointerUp(e: PointerEvent) {
|
||
const d = kfDrag.value
|
||
if (!d || d.pointerId !== e.pointerId) return
|
||
const el = e.currentTarget as HTMLElement
|
||
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
|
||
if (!d.moved) {
|
||
kfDrag.value = null
|
||
}
|
||
}
|
||
|
||
// ---- Script frame dragging ------------------------------------------------
|
||
|
||
interface SfDrag {
|
||
frameId: string
|
||
lane: HTMLElement
|
||
pointerId: number
|
||
startX: number
|
||
moved: boolean
|
||
initialTime: number
|
||
}
|
||
|
||
const sfDrag = ref<SfDrag | null>(null)
|
||
|
||
function onScriptFramePointerDown(e: PointerEvent, frameId: string) {
|
||
if (e.button !== 0) return
|
||
const el = e.currentTarget as HTMLElement
|
||
const lane = el.parentElement
|
||
if (!lane) return
|
||
const frame = store.scene.scriptFrames?.find((f) => f.id === frameId)
|
||
if (!frame) return
|
||
e.stopPropagation()
|
||
el.setPointerCapture(e.pointerId)
|
||
sfDrag.value = {
|
||
frameId,
|
||
lane,
|
||
pointerId: e.pointerId,
|
||
startX: e.clientX,
|
||
moved: false,
|
||
initialTime: frame.time,
|
||
}
|
||
}
|
||
|
||
function onScriptFramePointerMove(e: PointerEvent) {
|
||
const d = sfDrag.value
|
||
if (!d || d.pointerId !== e.pointerId) return
|
||
if (!d.moved && Math.abs(e.clientX - d.startX) < 3) return
|
||
d.moved = true
|
||
const rect = d.lane.getBoundingClientRect()
|
||
if (rect.width <= 0) return
|
||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||
const newTime = snapTime(ratio * duration.value)
|
||
store.setScriptFrameTime(d.frameId, newTime)
|
||
}
|
||
|
||
function onScriptFramePointerUp(e: PointerEvent) {
|
||
const d = sfDrag.value
|
||
if (!d || d.pointerId !== e.pointerId) return
|
||
const el = e.currentTarget as HTMLElement
|
||
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
|
||
if (!d.moved) {
|
||
openScriptFrame(d.frameId)
|
||
}
|
||
sfDrag.value = null
|
||
}
|
||
|
||
interface ScriptFrameMenuState {
|
||
frameId: string
|
||
x: number
|
||
y: number
|
||
}
|
||
|
||
const scriptFrameMenu = ref<ScriptFrameMenuState | null>(null)
|
||
const scriptFrameMenuRef = ref<HTMLDivElement | null>(null)
|
||
|
||
function onScriptFrameContextMenu(e: MouseEvent, frameId: string) {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
scriptFrameMenu.value = { frameId, x: e.clientX, y: e.clientY }
|
||
}
|
||
|
||
function duplicateScriptFrameFromMenu() {
|
||
const m = scriptFrameMenu.value
|
||
if (!m) return
|
||
const newId = store.duplicateScriptFrame(m.frameId)
|
||
scriptFrameMenu.value = null
|
||
if (newId) openScriptFrame(newId)
|
||
}
|
||
|
||
function deleteScriptFrameFromMenu() {
|
||
const m = scriptFrameMenu.value
|
||
if (!m) return
|
||
store.removeScriptFrame(m.frameId)
|
||
scriptFrameMenu.value = null
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface KfSegment {
|
||
id: string
|
||
fromTime: number
|
||
toTime: number
|
||
easing: Easing
|
||
held: boolean
|
||
/** True when this segment is the "still-frame tail" extending past
|
||
* the last keyframe to the end of the timeline. Rendered dimmer so
|
||
* it reads as an after-image rather than an interpolation. */
|
||
tail?: boolean
|
||
}
|
||
|
||
function segmentsOf(kfs: Keyframe[], duration: number, buttonMode = false): KfSegment[] {
|
||
if (kfs.length === 0) return []
|
||
const sorted = [...kfs].sort((a, b) => a.time - b.time)
|
||
const out: KfSegment[] = []
|
||
|
||
// Button-state timelines have discrete state frames rather than a
|
||
// continuous timeline. A still-frame kf only renders on its own
|
||
// state; the layer is NOT held into the following states. The
|
||
// connector line must stop at the still, not span past it.
|
||
if (buttonMode) {
|
||
for (let i = 0; i < sorted.length - 1; i++) {
|
||
const a = sorted[i]
|
||
const b = sorted[i + 1]
|
||
// Stills terminate any interpolation — no connector emerges from
|
||
// a hold kf toward the next state.
|
||
if (a.hold) continue
|
||
out.push({
|
||
id: `${a.id}>${b.id}`,
|
||
fromTime: a.time,
|
||
toTime: b.time,
|
||
easing: b.easing,
|
||
held: false,
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// Single keyframe means the layer is always visible (no animation
|
||
// range), so the line should span the entire timeline as a solid
|
||
// dim "on-stage" indicator — same treatment as a still-frame tail.
|
||
if (sorted.length === 1) {
|
||
const only = sorted[0]
|
||
if (duration > 0) {
|
||
out.push({
|
||
id: `${only.id}>full`,
|
||
fromTime: 0,
|
||
toTime: duration,
|
||
easing: only.easing,
|
||
held: false,
|
||
tail: true,
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
for (let i = 0; i < sorted.length - 1; i++) {
|
||
const a = sorted[i]
|
||
const b = sorted[i + 1]
|
||
out.push({
|
||
id: `${a.id}>${b.id}`,
|
||
fromTime: a.time,
|
||
toTime: b.time,
|
||
easing: b.easing,
|
||
held: !!a.hold,
|
||
})
|
||
}
|
||
// Last-keyframe "still frame" tail: when the final keyframe is held,
|
||
// extend the on-stage indicator to the end of the timeline. Solid
|
||
// and dim — the value isn't interpolating, it's just idling.
|
||
const last = sorted[sorted.length - 1]
|
||
if (last.hold && duration > last.time) {
|
||
out.push({
|
||
id: `${last.id}>tail`,
|
||
fromTime: last.time,
|
||
toTime: duration,
|
||
easing: last.easing,
|
||
held: false,
|
||
tail: true,
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
function segmentStyle(seg: KfSegment) {
|
||
return {
|
||
left: `${pxFromTime(seg.fromTime)}px`,
|
||
width: `${pxFromTime(seg.toTime - seg.fromTime)}px`,
|
||
}
|
||
}
|
||
|
||
// Button-editor only: list the 4 state-slot times that this track has
|
||
// NO keyframe on. The template renders a "+" placeholder over each
|
||
// empty slot; clicking it duplicates the nearest keyframe (prev by
|
||
// preference, next if there's nothing earlier) onto that slot.
|
||
const BUTTON_SLOT_TIMES = [
|
||
BUTTON_STATE_TIMES.normal,
|
||
BUTTON_STATE_TIMES.hover,
|
||
BUTTON_STATE_TIMES.pressed,
|
||
BUTTON_STATE_TIMES.disabled,
|
||
] as const
|
||
|
||
function emptyButtonSlots(kfs: Keyframe[]): number[] {
|
||
if (!store.activeButtonAssetId) return []
|
||
if (kfs.length === 0) return []
|
||
const EPS = 1e-3
|
||
const has = (t: number) => kfs.some((k) => Math.abs(k.time - t) < EPS)
|
||
return BUTTON_SLOT_TIMES.filter((t) => !has(t))
|
||
}
|
||
|
||
function addKfFromPrev(layerId: string, kfs: Keyframe[], time: number) {
|
||
if (!kfs.length) return
|
||
let prev: Keyframe | null = null
|
||
let next: Keyframe | null = null
|
||
for (const k of kfs) {
|
||
if (k.time < time) {
|
||
if (!prev || k.time > prev.time) prev = k
|
||
} else if (k.time > time) {
|
||
if (!next || k.time < next.time) next = k
|
||
}
|
||
}
|
||
const source = prev ?? next
|
||
if (!source) return
|
||
store.duplicateKeyframeAt(layerId, source.id, time)
|
||
// Move the editor onto the slot we just populated, so the stage reflects it.
|
||
if (store.activeButtonAssetId) {
|
||
if (Math.abs(time - BUTTON_STATE_TIMES.normal) < 1e-3) store.setActiveButtonState('normal')
|
||
else if (Math.abs(time - BUTTON_STATE_TIMES.hover) < 1e-3) store.setActiveButtonState('hover')
|
||
else if (Math.abs(time - BUTTON_STATE_TIMES.pressed) < 1e-3) store.setActiveButtonState('pressed')
|
||
else if (Math.abs(time - BUTTON_STATE_TIMES.disabled) < 1e-3) store.setActiveButtonState('disabled')
|
||
}
|
||
}
|
||
|
||
const EASING_OPTIONS: { value: Easing; label: string }[] = [
|
||
{ value: 'linear', label: t('timeline.easingLinear') },
|
||
{ value: 'easeIn', label: t('timeline.easingEaseIn') },
|
||
{ value: 'easeOut', label: t('timeline.easingEaseOut') },
|
||
{ value: 'easeInOut', label: t('timeline.easingEaseInOut') },
|
||
]
|
||
|
||
interface KfMenuState {
|
||
layerId: string
|
||
keyframeId: string
|
||
time: number
|
||
x: number
|
||
y: number
|
||
currentEasing: Easing
|
||
isFirst: boolean
|
||
isLast: boolean
|
||
isHeld: boolean
|
||
}
|
||
|
||
const menu = ref<KfMenuState | null>(null)
|
||
const menuRef = ref<HTMLDivElement | null>(null)
|
||
|
||
function onKeyframeContextMenu(
|
||
e: MouseEvent,
|
||
layerId: string,
|
||
keyframeId: string,
|
||
kfs: Keyframe[],
|
||
) {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
if (isLayerIdLocked(layerId)) return
|
||
const layer = store.activeLayers.find((l) => l.id === layerId)
|
||
const kf = kfs.find((k) => k.id === keyframeId)
|
||
if (!layer || !kf) return
|
||
const sorted = [...kfs].sort((a, b) => a.time - b.time)
|
||
menu.value = {
|
||
layerId,
|
||
keyframeId: kf.id,
|
||
time: kf.time,
|
||
x: e.clientX,
|
||
y: e.clientY,
|
||
currentEasing: kf.easing,
|
||
isFirst: sorted[0]?.id === kf.id,
|
||
isLast: sorted[sorted.length - 1]?.id === kf.id,
|
||
isHeld: !!kf.hold,
|
||
}
|
||
store.selectLayer(layerId)
|
||
}
|
||
|
||
function toggleHold() {
|
||
if (!menu.value) return
|
||
store.setKeyframeHold(menu.value.layerId, menu.value.keyframeId, !menu.value.isHeld)
|
||
menu.value = null
|
||
}
|
||
|
||
function applyEasing(easing: Easing) {
|
||
if (!menu.value) return
|
||
store.setKeyframeEasing(menu.value.layerId, menu.value.keyframeId, easing)
|
||
menu.value = null
|
||
}
|
||
|
||
function deleteFromMenu() {
|
||
if (!menu.value) return
|
||
store.removeKeyframe(menu.value.layerId, menu.value.keyframeId)
|
||
menu.value = null
|
||
}
|
||
|
||
function duplicateFromMenu() {
|
||
if (!menu.value) return
|
||
const newId = store.duplicateKeyframe(menu.value.layerId, menu.value.keyframeId)
|
||
if (newId) {
|
||
const layer = store.activeLayers.find((l) => l.id === menu.value!.layerId)
|
||
const all = layer
|
||
? [
|
||
...layer.keyframes,
|
||
...(layer.positionKeyframes ?? []),
|
||
...(layer.rotationKeyframes ?? []),
|
||
]
|
||
: []
|
||
const newKf = all.find((k) => k.id === newId)
|
||
if (newKf) store.setPlayhead(newKf.time)
|
||
}
|
||
menu.value = null
|
||
}
|
||
|
||
interface TrackMenuState {
|
||
kind: 'layer' | 'clip' | 'group' | 'script' | 'audio'
|
||
trackId: string
|
||
x: number
|
||
y: number
|
||
separated: boolean
|
||
currentColor: string | null
|
||
locked: boolean
|
||
muted?: boolean
|
||
}
|
||
|
||
const TRACK_COLORS: Array<{ value: string | null; label: string }> = [
|
||
{ value: null, label: t('timeline.colors.none') },
|
||
{ value: '#ff6b6b', label: t('timeline.colors.red') },
|
||
{ value: '#ffa94d', label: t('timeline.colors.orange') },
|
||
{ value: '#ffd43b', label: t('timeline.colors.yellow') },
|
||
{ value: '#51cf66', label: t('timeline.colors.green') },
|
||
{ value: '#4dc4ff', label: t('timeline.colors.cyan') },
|
||
{ value: '#6ea8ff', label: t('timeline.colors.blue') },
|
||
{ value: '#b197fc', label: t('timeline.colors.purple') },
|
||
{ value: '#ff8cc6', label: t('timeline.colors.pink') },
|
||
{ value: '#adb5bd', label: t('timeline.colors.gray') },
|
||
]
|
||
|
||
const dragTrackId = ref<string | null>(null)
|
||
const dropIndex = ref<number | null>(null)
|
||
const dropWithinGroupId = ref<string | null>(null)
|
||
|
||
function onTrackDragStart(e: DragEvent, trackId: string) {
|
||
if (!e.dataTransfer) return
|
||
dragTrackId.value = trackId
|
||
e.dataTransfer.effectAllowed = 'move'
|
||
try {
|
||
e.dataTransfer.setData('text/plain', trackId)
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
function onTrackDragOver(e: DragEvent, orderIndex: number) {
|
||
if (!dragTrackId.value) return
|
||
if (!e.dataTransfer) return
|
||
e.preventDefault()
|
||
e.dataTransfer.dropEffect = 'move'
|
||
const el = e.currentTarget as HTMLElement
|
||
const rect = el.getBoundingClientRect()
|
||
const midY = rect.top + rect.height / 2
|
||
dropIndex.value = e.clientY < midY ? orderIndex : orderIndex + 1
|
||
dropWithinGroupId.value = null
|
||
}
|
||
|
||
function onGroupChildDragOver(e: DragEvent, groupId: string, childIndex: number) {
|
||
if (!dragTrackId.value) return
|
||
if (dragTrackId.value === groupId) return
|
||
// Groups themselves cannot be nested inside another group
|
||
if (groupById.value.has(dragTrackId.value)) return
|
||
if (!e.dataTransfer) return
|
||
e.preventDefault()
|
||
e.dataTransfer.dropEffect = 'move'
|
||
const el = e.currentTarget as HTMLElement
|
||
const rect = el.getBoundingClientRect()
|
||
const midY = rect.top + rect.height / 2
|
||
dropIndex.value = e.clientY < midY ? childIndex : childIndex + 1
|
||
dropWithinGroupId.value = groupId
|
||
dropGroupId.value = null
|
||
}
|
||
|
||
function onGroupChildDrop(e: DragEvent) {
|
||
e.preventDefault()
|
||
const srcId = dragTrackId.value
|
||
const target = dropIndex.value
|
||
const groupId = dropWithinGroupId.value
|
||
dragTrackId.value = null
|
||
dropIndex.value = null
|
||
dropWithinGroupId.value = null
|
||
dropGroupId.value = null
|
||
if (!srcId || target === null || !groupId) return
|
||
if (srcId === groupId) return
|
||
if (groupById.value.has(srcId)) return
|
||
const group = groupById.value.get(groupId)
|
||
if (!group) return
|
||
const from = group.childIds.indexOf(srcId)
|
||
let to = target
|
||
// If source is already inside this group and before the target index,
|
||
// removal will shift subsequent positions down by one.
|
||
if (from >= 0 && from < to) to -= 1
|
||
store.moveTrackIntoGroup(srcId, groupId, to)
|
||
}
|
||
|
||
function onTrackDragLeave() {
|
||
/* keep dropIndex to avoid flicker */
|
||
}
|
||
|
||
function onTrackDrop(e: DragEvent) {
|
||
e.preventDefault()
|
||
const srcId = dragTrackId.value
|
||
const target = dropIndex.value
|
||
dragTrackId.value = null
|
||
dropIndex.value = null
|
||
if (!srcId || target === null) return
|
||
const order = store.scene.trackOrder ?? []
|
||
const from = order.indexOf(srcId)
|
||
if (from < 0) return
|
||
let to = target
|
||
if (from < to) to -= 1
|
||
store.moveTrack(from, to)
|
||
}
|
||
|
||
function onTrackDragEnd() {
|
||
dragTrackId.value = null
|
||
dropIndex.value = null
|
||
dropGroupId.value = null
|
||
dropWithinGroupId.value = null
|
||
}
|
||
|
||
const dropGroupId = ref<string | null>(null)
|
||
|
||
function onGroupBodyDragOver(e: DragEvent, groupId: string) {
|
||
if (!dragTrackId.value) return
|
||
// A group can't be moved into itself
|
||
if (dragTrackId.value === groupId) return
|
||
// A group itself can't be moved into another group (keep groups top-level)
|
||
if (groupById.value.has(dragTrackId.value)) return
|
||
e.preventDefault()
|
||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
|
||
dropGroupId.value = groupId
|
||
dropIndex.value = null
|
||
}
|
||
|
||
function onGroupBodyDragLeave() {
|
||
// kept across frames; cleared on drop or dragend
|
||
}
|
||
|
||
function onGroupBodyDrop(e: DragEvent, groupId: string) {
|
||
e.preventDefault()
|
||
const srcId = dragTrackId.value
|
||
dragTrackId.value = null
|
||
dropIndex.value = null
|
||
dropGroupId.value = null
|
||
if (!srcId) return
|
||
if (srcId === groupId) return
|
||
if (groupById.value.has(srcId)) return
|
||
store.moveTrackIntoGroup(srcId, groupId)
|
||
}
|
||
|
||
function toggleGroupCollapsed(groupId: string) {
|
||
store.toggleGroupCollapsed(groupId)
|
||
}
|
||
|
||
function addGroup() {
|
||
// Drop the group directly above whatever the user is currently
|
||
// working on — layer selection is the only active-track signal
|
||
// today. Falls back to the top of the stack when nothing's active.
|
||
store.addTrackGroup(undefined, store.selectedLayerId ?? null)
|
||
}
|
||
|
||
async function renameGroupPrompt(groupId: string, current: string) {
|
||
const next = await promptDialog({
|
||
title: t('timeline.renameGroupDialog.title'),
|
||
defaultValue: current,
|
||
confirmLabel: t('timeline.renameGroupDialog.confirm'),
|
||
})
|
||
if (next === null) return
|
||
store.renameGroup(groupId, next)
|
||
}
|
||
|
||
async function ungroup(groupId: string) {
|
||
store.removeTrackGroup(groupId)
|
||
}
|
||
|
||
interface BoxSelectState {
|
||
pointerId: number
|
||
originClientX: number
|
||
originClientY: number
|
||
currentClientX: number
|
||
currentClientY: number
|
||
tracksEl: HTMLElement
|
||
moved: boolean
|
||
additive: boolean
|
||
}
|
||
|
||
const boxSelect = ref<BoxSelectState | null>(null)
|
||
|
||
const boxRectStyle = computed(() => {
|
||
const b = boxSelect.value
|
||
if (!b || !b.moved) return null
|
||
const tRect = b.tracksEl.getBoundingClientRect()
|
||
const left = Math.min(b.originClientX, b.currentClientX) - tRect.left
|
||
const top = Math.min(b.originClientY, b.currentClientY) - tRect.top
|
||
const width = Math.abs(b.currentClientX - b.originClientX)
|
||
const height = Math.abs(b.currentClientY - b.originClientY)
|
||
return {
|
||
left: left + 'px',
|
||
top: top + 'px',
|
||
width: width + 'px',
|
||
height: height + 'px',
|
||
}
|
||
})
|
||
|
||
function onTracksPointerDown(e: PointerEvent) {
|
||
if (e.button !== 0) return
|
||
const target = e.target as HTMLElement | null
|
||
if (!target) return
|
||
if (
|
||
target.closest(
|
||
'.kf, .clip-block, .clip-handle, .track-name, .drag-handle, .add-clip-btn, .playhead, .script-lane, .script-frame',
|
||
)
|
||
) {
|
||
return
|
||
}
|
||
const tracksEl = e.currentTarget as HTMLElement
|
||
tracksEl.setPointerCapture(e.pointerId)
|
||
boxSelect.value = {
|
||
pointerId: e.pointerId,
|
||
originClientX: e.clientX,
|
||
originClientY: e.clientY,
|
||
currentClientX: e.clientX,
|
||
currentClientY: e.clientY,
|
||
tracksEl,
|
||
moved: false,
|
||
additive: e.shiftKey,
|
||
}
|
||
}
|
||
|
||
function onTracksPointerMove(e: PointerEvent) {
|
||
const b = boxSelect.value
|
||
if (!b || b.pointerId !== e.pointerId) return
|
||
b.currentClientX = e.clientX
|
||
b.currentClientY = e.clientY
|
||
if (!b.moved) {
|
||
const dx = b.currentClientX - b.originClientX
|
||
const dy = b.currentClientY - b.originClientY
|
||
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) b.moved = true
|
||
}
|
||
}
|
||
|
||
function onTracksPointerUp(e: PointerEvent) {
|
||
const b = boxSelect.value
|
||
if (!b || b.pointerId !== e.pointerId) return
|
||
if (b.tracksEl.hasPointerCapture(e.pointerId)) b.tracksEl.releasePointerCapture(e.pointerId)
|
||
if (b.moved) {
|
||
commitBoxSelection(b)
|
||
} else if (!b.additive) {
|
||
// bare click on empty space without Shift → clear selection
|
||
clearKfSelection()
|
||
}
|
||
boxSelect.value = null
|
||
}
|
||
|
||
function commitBoxSelection(b: BoxSelectState) {
|
||
const left = Math.min(b.originClientX, b.currentClientX)
|
||
const right = Math.max(b.originClientX, b.currentClientX)
|
||
const top = Math.min(b.originClientY, b.currentClientY)
|
||
const bottom = Math.max(b.originClientY, b.currentClientY)
|
||
|
||
const nodes = b.tracksEl.querySelectorAll<HTMLElement>('.kf[data-kf-id]')
|
||
const hits: SelectionKey[] = []
|
||
for (const node of Array.from(nodes)) {
|
||
const r = node.getBoundingClientRect()
|
||
const cx = r.left + r.width / 2
|
||
const cy = r.top + r.height / 2
|
||
if (cx < left || cx > right || cy < top || cy > bottom) continue
|
||
const layerId = node.dataset.layerId
|
||
const kind = node.dataset.kind as 'main' | 'position' | 'rotation' | undefined
|
||
const keyframeId = node.dataset.kfId
|
||
if (!layerId || !kind || !keyframeId) continue
|
||
hits.push({ layerId, kind, keyframeId })
|
||
}
|
||
if (b.additive) {
|
||
const seen = new Set(selectedKfs.value.map(selectionKey))
|
||
const merged = [...selectedKfs.value]
|
||
for (const h of hits) {
|
||
const k = selectionKey(h)
|
||
if (!seen.has(k)) {
|
||
seen.add(k)
|
||
merged.push(h)
|
||
}
|
||
}
|
||
selectedKfs.value = merged
|
||
} else {
|
||
selectedKfs.value = hits
|
||
}
|
||
}
|
||
|
||
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))
|
||
return ratio * duration.value
|
||
}
|
||
|
||
interface ClipInstanceGhost {
|
||
targetTrackId: string
|
||
startTime: number
|
||
length: number
|
||
}
|
||
|
||
/** Hover-preview for dropping a library clip onto a clip lane. Mirrors
|
||
* `audioClipGhost`. Cleared whenever the cursor leaves the lane or
|
||
* the drop lands. */
|
||
const clipInstanceGhost = ref<ClipInstanceGhost | null>(null)
|
||
|
||
/** Full-window collision: does [start, start+length] overlap any
|
||
* existing clip instance on this track? Stricter than a point-in-clip
|
||
* check — catches the case where the drop point is free but the
|
||
* dropped tail would land on top of a neighbour. Mirrors
|
||
* `audioWindowCollides`. */
|
||
function clipWindowCollides(
|
||
trackId: string,
|
||
start: number,
|
||
length: number,
|
||
): boolean {
|
||
const end = start + length
|
||
const siblings = clipInstancesByTrack.value[trackId] ?? []
|
||
for (const ci of siblings) {
|
||
const cEnd = ci.startTime + visibleDurationOf(ci)
|
||
if (cEnd > start && ci.startTime < end) return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
function onClipLaneDragOver(e: DragEvent, clipTrackId: string) {
|
||
const types = e.dataTransfer?.types
|
||
if (!types?.includes('application/x-animatr-clip')) return
|
||
e.preventDefault()
|
||
if (!e.dataTransfer) return
|
||
const el = e.currentTarget as HTMLElement
|
||
const time = pixelTimeFromEvent(e, el)
|
||
const track = store.scene.clipTracks?.find((t) => t.id === clipTrackId)
|
||
const src = clipDragSource.value
|
||
const length = Math.max(0, src?.duration ?? 0)
|
||
const collides =
|
||
track?.locked || clipWindowCollides(clipTrackId, time, length)
|
||
e.dataTransfer.dropEffect = collides ? 'none' : 'copy'
|
||
if (!collides && src) {
|
||
clipInstanceGhost.value = {
|
||
targetTrackId: clipTrackId,
|
||
startTime: time,
|
||
length,
|
||
}
|
||
} else {
|
||
clipInstanceGhost.value = null
|
||
}
|
||
}
|
||
|
||
function onClipLaneDragLeave() {
|
||
clipInstanceGhost.value = null
|
||
}
|
||
|
||
function onClipLaneDrop(e: DragEvent, clipTrackId: string) {
|
||
e.preventDefault()
|
||
clipInstanceGhost.value = null
|
||
const track = store.scene.clipTracks?.find((t) => t.id === clipTrackId)
|
||
if (track?.locked) {
|
||
clearClipDragSource()
|
||
return
|
||
}
|
||
const el = e.currentTarget as HTMLElement
|
||
const time = pixelTimeFromEvent(e, el)
|
||
const clipId = e.dataTransfer?.getData('application/x-animatr-clip')
|
||
// The library payload is authoritative for the window check — the
|
||
// composable source may have been cleared by a racing dragend.
|
||
const clip = clipId ? store.project.clips.find((c) => c.id === clipId) : null
|
||
const length = clip ? Math.max(0, clip.duration) : 0
|
||
clearClipDragSource()
|
||
if (!clipId) return
|
||
if (clipWindowCollides(clipTrackId, time, length)) return
|
||
store.addClipInstance(clipId, time, clipTrackId)
|
||
}
|
||
|
||
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
|
||
el.setPointerCapture(e.pointerId)
|
||
clipMove.value = {
|
||
instanceId: ci.id,
|
||
pointerId: e.pointerId,
|
||
startX: e.clientX,
|
||
initialStartTime: ci.startTime,
|
||
moved: false,
|
||
}
|
||
}
|
||
|
||
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
|
||
// Snap the clip's left edge to the playhead (and keyframes) when
|
||
// snapping is on. The cascade in setClipInstancePosition still
|
||
// runs afterwards, so colliding with a neighbour on the track
|
||
// behaves the same as it did before.
|
||
const newStart = Math.max(0, snapTime(m.initialStartTime + deltaSec))
|
||
store.setClipInstancePosition(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
|
||
const instanceId = m.instanceId
|
||
clipMove.value = null
|
||
if (wasMoved) {
|
||
// suppress click/dblclick if we actually dragged
|
||
e.preventDefault()
|
||
return
|
||
}
|
||
// Pure click: select the instance so the canvas transformer attaches
|
||
// to its wrapper group. Without this, users have no way to grab a
|
||
// clip whose current-playhead content happens to be empty.
|
||
store.selectClipInstance(instanceId)
|
||
}
|
||
|
||
const clipDragOverDropzone = ref(false)
|
||
|
||
function onNewTrackDragEnter(e: DragEvent) {
|
||
if (!e.dataTransfer?.types.includes('application/x-animatr-clip')) return
|
||
e.preventDefault()
|
||
clipDragOverDropzone.value = true
|
||
}
|
||
|
||
function onNewTrackDragOver(e: DragEvent) {
|
||
if (!e.dataTransfer?.types.includes('application/x-animatr-clip')) return
|
||
e.preventDefault()
|
||
e.dataTransfer.dropEffect = 'copy'
|
||
clipDragOverDropzone.value = true
|
||
}
|
||
|
||
function onNewTrackDragLeave() {
|
||
clipDragOverDropzone.value = false
|
||
}
|
||
|
||
function onNewTrackDrop(e: DragEvent) {
|
||
clipDragOverDropzone.value = false
|
||
const clipId = e.dataTransfer?.getData('application/x-animatr-clip')
|
||
if (!clipId) return
|
||
e.preventDefault()
|
||
const el = e.currentTarget as HTMLElement
|
||
const time = pixelTimeFromLane(e, el)
|
||
const track = store.addClipTrack()
|
||
store.addClipInstance(clipId, time, track.id)
|
||
}
|
||
|
||
function pixelTimeFromLane(e: DragEvent, el: HTMLElement): number {
|
||
// Account for sticky track-name column offset: compute based on the full tl-content lane.
|
||
const rect = el.getBoundingClientRect()
|
||
if (rect.width <= 0) return 0
|
||
const NAME_COL = trackNameWidth.value
|
||
const laneLeft = rect.left + NAME_COL
|
||
const laneWidth = rect.width - NAME_COL
|
||
if (laneWidth <= 0) return 0
|
||
const ratio = Math.max(0, Math.min(1, (e.clientX - laneLeft) / laneWidth))
|
||
return ratio * duration.value
|
||
}
|
||
|
||
function clipDurationOf(ci: ClipInstance): number {
|
||
return clipById.value.get(ci.clipId)?.duration ?? 1
|
||
}
|
||
|
||
function visibleDurationOf(ci: ClipInstance): number {
|
||
return Math.max(
|
||
0.05,
|
||
clipDurationOf(ci) - (ci.trimStart ?? 0) - (ci.trimEnd ?? 0),
|
||
)
|
||
}
|
||
|
||
function clipBlockStyle(ci: ClipInstance) {
|
||
return {
|
||
left: pxFromTime(ci.startTime) + 'px',
|
||
width: Math.max(6, pxFromTime(visibleDurationOf(ci))) + 'px',
|
||
}
|
||
}
|
||
|
||
// ---- Audio clips ----
|
||
|
||
const AUDIO_CLIP_DND_MIME = 'application/x-animatr-audio-asset'
|
||
|
||
function audibleDurationOf(clip: AudioClip): number {
|
||
const asset = audioAssetById.value.get(clip.audioAssetId)
|
||
if (!asset) return 0
|
||
return Math.max(
|
||
0.05,
|
||
asset.duration - (clip.trimStart ?? 0) - (clip.trimEnd ?? 0),
|
||
)
|
||
}
|
||
|
||
function audioClipBlockStyle(clip: AudioClip) {
|
||
return {
|
||
left: pxFromTime(clip.startTime) + 'px',
|
||
width: Math.max(6, pxFromTime(audibleDurationOf(clip))) + 'px',
|
||
}
|
||
}
|
||
|
||
function audioClipTooltip(clip: AudioClip): string {
|
||
const asset = audioAssetById.value.get(clip.audioAssetId)
|
||
if (!asset) return ''
|
||
return `${asset.name} · ${audibleDurationOf(clip).toFixed(2)}s`
|
||
}
|
||
|
||
/** Resolved peaks for a clip's asset — undefined while the decoder
|
||
* is still working (first render shows an empty block; waveform
|
||
* fades in once computation finishes). */
|
||
function clipPeaks(clip: AudioClip): Float32Array | undefined {
|
||
const asset = audioAssetById.value.get(clip.audioAssetId)
|
||
if (!asset || !asset.src) return undefined
|
||
const slot = getWaveformSlot(asset.id, asset.src)
|
||
if (!slot || slot.state !== 'ready') return undefined
|
||
return slicePeaksForClip(slot.peaks, asset.duration, clip.trimStart ?? 0, clip.trimEnd ?? 0)
|
||
}
|
||
|
||
/** Canvas ref-callback: redraw on every Vue update so the bars track
|
||
* the clip's current width (resizes on zoom + trim changes). Cheap
|
||
* because peaks are cached and the draw itself is a few hundred
|
||
* fillRects. */
|
||
function waveformColorFor(clip: AudioClip): string | undefined {
|
||
// Honor the track color so tinted audio tracks get a matching
|
||
// waveform fill. Accept #rgb/#rrggbb and append a fixed 55% alpha
|
||
// (matches the default sky-blue tint). Non-hex values fall back to
|
||
// the drawWaveform default.
|
||
const track = audioTrackById.value.get(clip.audioTrackId)
|
||
const color = track?.color
|
||
if (!color) return undefined
|
||
const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i.exec(color)
|
||
if (short) {
|
||
const [, r, g, b] = short
|
||
return `#${r}${r}${g}${g}${b}${b}8c`
|
||
}
|
||
const full = /^#([0-9a-f]{6})$/i.exec(color)
|
||
if (full) return `#${full[1]}8c`
|
||
return color
|
||
}
|
||
|
||
function renderWaveformCanvas(clip: AudioClip) {
|
||
return (raw: Element | ComponentPublicInstance | null) => {
|
||
if (!raw || !(raw instanceof HTMLCanvasElement)) return
|
||
const el = raw
|
||
const peaks = clipPeaks(clip)
|
||
if (!peaks) return
|
||
const parent = el.parentElement
|
||
if (!parent) return
|
||
const pr = window.devicePixelRatio || 1
|
||
const cssW = Math.max(1, parent.clientWidth - 2)
|
||
const cssH = Math.max(1, parent.clientHeight - 4)
|
||
const pxW = Math.max(1, Math.round(cssW * pr))
|
||
const pxH = Math.max(1, Math.round(cssH * pr))
|
||
if (el.width !== pxW) el.width = pxW
|
||
if (el.height !== pxH) el.height = pxH
|
||
el.style.width = cssW + 'px'
|
||
el.style.height = cssH + 'px'
|
||
drawWaveform(el, peaks, { pixelRatio: pr, color: waveformColorFor(clip) })
|
||
}
|
||
}
|
||
|
||
/** Would a hypothetical audible block spanning [start, start+length] on
|
||
* `trackId` collide with an existing clip? Clips on the same track
|
||
* must not overlap — the user has to pick a second track for parallel
|
||
* playback. `exceptId` is the moving clip's own id (so it doesn't
|
||
* self-collide during a drag). */
|
||
function audioWindowCollides(
|
||
trackId: string,
|
||
start: number,
|
||
length: number,
|
||
exceptId?: string,
|
||
): boolean {
|
||
const end = start + length
|
||
const siblings = audioClipsByTrack.value[trackId] ?? []
|
||
for (const c of siblings) {
|
||
if (c.id === exceptId) continue
|
||
const asset = audioAssetById.value.get(c.audioAssetId)
|
||
if (!asset) continue
|
||
const cEnd = c.startTime + Math.max(0, asset.duration - (c.trimStart ?? 0) - (c.trimEnd ?? 0))
|
||
if (cEnd > start && c.startTime < end) return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
/** Lighter check used during `dragover`: does the drop point land on top
|
||
* of any existing clip? Use during drag-to-create when the dragged
|
||
* item's duration isn't yet known (clipart before decode, or internal
|
||
* DnD whose getData is locked during dragover). */
|
||
function audioPointInAnyClip(trackId: string, t: number): boolean {
|
||
const siblings = audioClipsByTrack.value[trackId] ?? []
|
||
for (const c of siblings) {
|
||
const asset = audioAssetById.value.get(c.audioAssetId)
|
||
if (!asset) continue
|
||
const end = c.startTime + Math.max(0, asset.duration - (c.trimStart ?? 0) - (c.trimEnd ?? 0))
|
||
if (t >= c.startTime && t < end) return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
function onAudioLaneDragOver(e: DragEvent, audioTrackId: string) {
|
||
const types = e.dataTransfer?.types
|
||
if (!types) return
|
||
// Accept both internal-asset drops and clipart-file drops (images are
|
||
// filtered out at drop time — we only import audio clipart here).
|
||
if (!types.includes(AUDIO_CLIP_DND_MIME) && !types.includes('application/x-animatr-clipart')) return
|
||
e.preventDefault()
|
||
if (!e.dataTransfer) return
|
||
const el = e.currentTarget as HTMLElement
|
||
const time = pixelTimeFromEvent(e, el)
|
||
const collides = audioPointInAnyClip(audioTrackId, time)
|
||
e.dataTransfer.dropEffect = collides ? 'none' : 'copy'
|
||
// Show a ghost preview on the hovered lane. The source info (name +
|
||
// duration) comes from whichever library row the user started
|
||
// dragging from — see useAudioDragSource. 0-length drops (clipart
|
||
// pre-decode) render at the minimum 6px width so the ghost stays
|
||
// visible as a drop target.
|
||
const src = audioDragSource.value
|
||
if (!collides && src) {
|
||
audioClipGhost.value = {
|
||
clipId: '__external__',
|
||
targetTrackId: audioTrackId,
|
||
startTime: time,
|
||
length: Math.max(0, src.duration),
|
||
}
|
||
} else {
|
||
audioClipGhost.value = null
|
||
}
|
||
}
|
||
|
||
function onAudioLaneDragLeave() {
|
||
audioClipGhost.value = null
|
||
}
|
||
|
||
async function onAudioLaneDrop(e: DragEvent, audioTrackId: string) {
|
||
e.preventDefault()
|
||
audioClipGhost.value = null
|
||
clearAudioDragSource()
|
||
const track = audioTrackById.value.get(audioTrackId)
|
||
if (!track || track.locked) return
|
||
const el = e.currentTarget as HTMLElement
|
||
const time = pixelTimeFromEvent(e, el)
|
||
|
||
const assetId = e.dataTransfer?.getData(AUDIO_CLIP_DND_MIME)
|
||
if (assetId) {
|
||
const asset = audioAssetById.value.get(assetId)
|
||
const length = asset ? Math.max(0, asset.duration) : 0
|
||
// Length-based collision: a drop near an existing clip could still
|
||
// have its tail overlap the neighbour even if the drop *point* is
|
||
// free. Skip silently — the dragover cursor already warned.
|
||
if (audioWindowCollides(audioTrackId, time, length)) return
|
||
store.addAudioClip(assetId, time, audioTrackId)
|
||
return
|
||
}
|
||
|
||
// Clipart folder drop — import the file as an AudioAsset first, then
|
||
// place it on the lane. Images get silently ignored (user can drop
|
||
// them on the stage instead).
|
||
const clipartIdx = e.dataTransfer?.getData('application/x-animatr-clipart')
|
||
if (clipartIdx) {
|
||
const idx = Number.parseInt(clipartIdx, 10)
|
||
const item = Number.isFinite(idx) ? clipart.items[idx] : null
|
||
if (!item || item.kind !== 'audio') return
|
||
const res = await importAudioFile(store, item.file)
|
||
if (!res?.assetId) return
|
||
const newAsset = audioAssetById.value.get(res.assetId)
|
||
const length = newAsset ? Math.max(0, newAsset.duration) : 0
|
||
if (audioWindowCollides(audioTrackId, time, length)) {
|
||
// Decoded length turned out to collide. Drop is refused; the
|
||
// library asset still sticks around for the user to place manually.
|
||
return
|
||
}
|
||
store.addAudioClip(res.assetId, time, audioTrackId)
|
||
}
|
||
}
|
||
|
||
interface AudioClipMenuState {
|
||
clipId: string
|
||
x: number
|
||
y: number
|
||
}
|
||
|
||
const audioClipMenu = ref<AudioClipMenuState | null>(null)
|
||
const audioClipMenuRef = ref<HTMLDivElement | null>(null)
|
||
|
||
function onAudioClipContextMenu(e: MouseEvent, clip: AudioClip) {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
audioClipMenu.value = { clipId: clip.id, x: e.clientX, y: e.clientY }
|
||
}
|
||
|
||
function duplicateAudioClipFromMenu() {
|
||
const m = audioClipMenu.value
|
||
if (!m) return
|
||
store.duplicateAudioClip(m.clipId)
|
||
audioClipMenu.value = null
|
||
}
|
||
|
||
function deleteAudioClipFromMenu() {
|
||
const m = audioClipMenu.value
|
||
if (!m) return
|
||
store.removeAudioClip(m.clipId)
|
||
audioClipMenu.value = null
|
||
}
|
||
|
||
interface AudioClipMoveState {
|
||
clipId: string
|
||
pointerId: number
|
||
startX: number
|
||
initialStartTime: number
|
||
initialTrackId: string
|
||
moved: boolean
|
||
}
|
||
|
||
const audioClipMove = ref<AudioClipMoveState | null>(null)
|
||
|
||
interface AudioClipGhost {
|
||
clipId: string
|
||
targetTrackId: string
|
||
startTime: number
|
||
length: number
|
||
}
|
||
|
||
/** While dragging a clip over a DIFFERENT audio track, this holds a
|
||
* preview "where it will land" on that target lane. Rendered as a
|
||
* dashed block in the template so the user sees the drop target
|
||
* before committing via pointerup. Cleared whenever the cursor
|
||
* returns to the clip's original lane or leaves the audio area. */
|
||
const audioClipGhost = ref<AudioClipGhost | null>(null)
|
||
|
||
function onAudioClipPointerDown(e: PointerEvent, clip: AudioClip) {
|
||
if (e.button !== 0) return
|
||
const track = audioTrackById.value.get(clip.audioTrackId)
|
||
if (track?.locked) return
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
const el = e.currentTarget as HTMLElement
|
||
el.setPointerCapture(e.pointerId)
|
||
audioClipMove.value = {
|
||
clipId: clip.id,
|
||
pointerId: e.pointerId,
|
||
startX: e.clientX,
|
||
initialStartTime: clip.startTime,
|
||
initialTrackId: clip.audioTrackId,
|
||
moved: false,
|
||
}
|
||
}
|
||
|
||
/** Walk the DOM under the pointer to discover which audio track (if
|
||
* any) the cursor is currently over. Returns the track id, or null
|
||
* when the cursor has drifted off the audio lanes entirely. Used on
|
||
* pointerup to decide whether a drag should swap tracks. */
|
||
function audioTrackIdFromPointer(clientX: number, clientY: number): string | null {
|
||
const el = document.elementFromPoint(clientX, clientY)
|
||
if (!el) return null
|
||
const host = (el as Element).closest('[data-audio-track-id]') as HTMLElement | null
|
||
if (!host) return null
|
||
const id = host.dataset.audioTrackId
|
||
if (!id) return null
|
||
// Only treat it as a valid target if the track exists in the store
|
||
// (defensive against stale DOM in the middle of re-renders).
|
||
return audioTrackById.value.get(id) ? id : null
|
||
}
|
||
|
||
function onAudioClipPointerMove(e: PointerEvent) {
|
||
const m = audioClipMove.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)
|
||
const clip = store.scene.audioClips?.find((c) => c.id === m.clipId)
|
||
if (!clip) return
|
||
// Horizontal drag always moves the clip on its CURRENT track with
|
||
// cascade push. Track-switch is deferred to pointerup (to keep the
|
||
// pointer capture anchored on a stable DOM element).
|
||
store.setAudioClipPosition(m.clipId, newStart, clip.audioTrackId)
|
||
|
||
// If the cursor is hovering over a different audio track, preview
|
||
// the drop on that lane as a dashed ghost. Users see where release
|
||
// will land BEFORE they commit.
|
||
const hovered = audioTrackIdFromPointer(e.clientX, e.clientY)
|
||
const canSwap =
|
||
hovered &&
|
||
hovered !== m.initialTrackId &&
|
||
store.scene.audioTracks &&
|
||
store.scene.audioTracks.length > 1
|
||
if (canSwap) {
|
||
const asset = audioAssetById.value.get(clip.audioAssetId)
|
||
const length = asset
|
||
? Math.max(0, asset.duration - (clip.trimStart ?? 0) - (clip.trimEnd ?? 0))
|
||
: 0
|
||
audioClipGhost.value = {
|
||
clipId: clip.id,
|
||
targetTrackId: hovered!,
|
||
startTime: newStart,
|
||
length,
|
||
}
|
||
} else {
|
||
audioClipGhost.value = null
|
||
}
|
||
}
|
||
|
||
function onAudioClipPointerUp(e: PointerEvent) {
|
||
const m = audioClipMove.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
|
||
const ghost = audioClipGhost.value
|
||
if (wasMoved && ghost && ghost.clipId === m.clipId) {
|
||
// Commit to the previewed target lane with cascade push.
|
||
store.setAudioClipPosition(m.clipId, ghost.startTime, ghost.targetTrackId)
|
||
}
|
||
audioClipMove.value = null
|
||
audioClipGhost.value = null
|
||
}
|
||
|
||
interface AudioClipTrimState {
|
||
clipId: string
|
||
side: 'start' | 'end'
|
||
pointerId: number
|
||
startX: number
|
||
initialStartTime: number
|
||
initialTrimStart: number
|
||
initialTrimEnd: number
|
||
}
|
||
|
||
const audioClipTrim = ref<AudioClipTrimState | null>(null)
|
||
|
||
function onAudioClipHandlePointerDown(
|
||
e: PointerEvent,
|
||
clip: AudioClip,
|
||
side: 'start' | 'end',
|
||
) {
|
||
if (e.button !== 0) return
|
||
const track = audioTrackById.value.get(clip.audioTrackId)
|
||
if (track?.locked) return
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
const el = e.currentTarget as HTMLElement
|
||
el.setPointerCapture(e.pointerId)
|
||
audioClipTrim.value = {
|
||
clipId: clip.id,
|
||
side,
|
||
pointerId: e.pointerId,
|
||
startX: e.clientX,
|
||
initialStartTime: clip.startTime,
|
||
initialTrimStart: clip.trimStart ?? 0,
|
||
initialTrimEnd: clip.trimEnd ?? 0,
|
||
}
|
||
}
|
||
|
||
function onAudioClipHandlePointerMove(e: PointerEvent) {
|
||
const tr = audioClipTrim.value
|
||
if (!tr || tr.pointerId !== e.pointerId) return
|
||
const pps = effectivePxPerSec.value
|
||
if (pps <= 0) return
|
||
const rawDelta = (e.clientX - tr.startX) / pps
|
||
const clip = store.scene.audioClips?.find((c) => c.id === tr.clipId)
|
||
if (!clip) return
|
||
const asset = audioAssetById.value.get(clip.audioAssetId)
|
||
if (!asset) return
|
||
const MIN_VISIBLE = 0.05
|
||
const maxBudget = asset.duration - MIN_VISIBLE
|
||
if (tr.side === 'start') {
|
||
// Clamp locally so overshooting the natural start doesn't slide
|
||
// the whole clip — matches the regular-clip handle behavior.
|
||
const minDelta = Math.max(-tr.initialTrimStart, -tr.initialStartTime)
|
||
const maxDelta = maxBudget - tr.initialTrimStart - tr.initialTrimEnd
|
||
const delta = Math.min(Math.max(rawDelta, minDelta), maxDelta)
|
||
store.trimAudioClip(tr.clipId, {
|
||
trimStart: tr.initialTrimStart + delta,
|
||
startTime: tr.initialStartTime + delta,
|
||
})
|
||
} else {
|
||
store.trimAudioClip(tr.clipId, {
|
||
trimEnd: tr.initialTrimEnd - rawDelta,
|
||
})
|
||
}
|
||
}
|
||
|
||
function onAudioClipHandlePointerUp(e: PointerEvent) {
|
||
const tr = audioClipTrim.value
|
||
if (!tr || tr.pointerId !== e.pointerId) return
|
||
const el = e.currentTarget as HTMLElement
|
||
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
|
||
audioClipTrim.value = null
|
||
}
|
||
|
||
// -------- Button asset edit bar --------------------------------------
|
||
|
||
const BUTTON_EDIT_STATES = ['normal', 'hover', 'pressed', 'disabled'] as const
|
||
type ButtonContentState = (typeof BUTTON_EDIT_STATES)[number]
|
||
|
||
function stateLabel(st: ButtonContentState): string {
|
||
return st[0].toUpperCase() + st.slice(1)
|
||
}
|
||
|
||
const activeButtonAssetName = computed(() => {
|
||
const id = store.activeButtonAssetId
|
||
if (!id) return ''
|
||
const asset = store.scene.assets.find((a) => a.id === id)
|
||
return asset && asset.kind === 'button' ? asset.name : ''
|
||
})
|
||
|
||
// Visual "state timeline" — each state sits at a fixed 100px increment so
|
||
// the track-lane kf dots (at pxFromTime(t) = t * 100) line up with the
|
||
// state label directly above. Hit has no kf semantics; it sits one slot
|
||
// past 'disabled' as a navigation shortcut only.
|
||
function stateTickLeft(idx: number): number {
|
||
return (idx + 1) * BUTTON_STATE_WIDTH
|
||
}
|
||
const BUTTON_STATE_TOTAL_WIDTH = BUTTON_STATE_WIDTH * (BUTTON_EDIT_STATES.length + 1)
|
||
|
||
const currentStateLeft = computed(() => {
|
||
const st = store.activeButtonState
|
||
if (!st) return 0
|
||
const idx = BUTTON_EDIT_STATES.indexOf(st as ButtonContentState)
|
||
return idx >= 0 ? stateTickLeft(idx) : 0
|
||
})
|
||
|
||
function updateStateFromPointer(e: PointerEvent) {
|
||
const el = e.currentTarget as HTMLElement | null
|
||
if (!el) return
|
||
const r = el.getBoundingClientRect()
|
||
const x = e.clientX - r.left
|
||
const idx = Math.max(
|
||
0,
|
||
Math.min(
|
||
BUTTON_EDIT_STATES.length - 1,
|
||
Math.round((x - BUTTON_STATE_WIDTH) / BUTTON_STATE_WIDTH),
|
||
),
|
||
)
|
||
store.setActiveButtonState(BUTTON_EDIT_STATES[idx])
|
||
}
|
||
|
||
const activeButtonHitShapes = computed(() => {
|
||
const id = store.activeButtonAssetId
|
||
if (!id) return []
|
||
const asset = store.scene.assets.find((a) => a.id === id)
|
||
return asset && asset.kind === 'button' ? asset.hitArea : []
|
||
})
|
||
|
||
function hitShapeLabel(shape: HitShape, idx: number): string {
|
||
const n = idx + 1
|
||
if (shape.kind === 'rect') return `Rect ${n} · ${Math.round(shape.width)}×${Math.round(shape.height)}`
|
||
if (shape.kind === 'circle') return `Circle ${n} · r=${Math.round(shape.radius)}`
|
||
return `Polygon ${n} · ${shape.points.length} pts`
|
||
}
|
||
|
||
function onDeleteHitShape(idx: number) {
|
||
const id = store.activeButtonAssetId
|
||
if (!id) return
|
||
store.removeButtonHitShape(id, idx)
|
||
}
|
||
|
||
function onStateScrubPointerDown(e: PointerEvent) {
|
||
const el = e.currentTarget as HTMLElement
|
||
el.setPointerCapture(e.pointerId)
|
||
updateStateFromPointer(e)
|
||
}
|
||
|
||
function onStateScrubPointerMove(e: PointerEvent) {
|
||
const el = e.currentTarget as HTMLElement
|
||
if (!el.hasPointerCapture(e.pointerId)) return
|
||
updateStateFromPointer(e)
|
||
}
|
||
|
||
function onStateScrubPointerUp(e: PointerEvent) {
|
||
const el = e.currentTarget as HTMLElement
|
||
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
|
||
}
|
||
|
||
// Script frame dialog state
|
||
const scriptDialogOpen = ref(false)
|
||
const scriptDialogFrameId = ref<string | null>(null)
|
||
|
||
function openScriptFrame(frameId: string) {
|
||
scriptDialogFrameId.value = frameId
|
||
scriptDialogOpen.value = true
|
||
}
|
||
|
||
function onScriptLaneClick(e: MouseEvent, trackId: string) {
|
||
// Only handle clicks on the bare lane (not on an existing frame).
|
||
if ((e.target as HTMLElement).closest('.script-frame')) return
|
||
const el = e.currentTarget as HTMLElement
|
||
const rect = el.getBoundingClientRect()
|
||
if (rect.width <= 0) return
|
||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||
const time = snapTime(ratio * duration.value)
|
||
const frame = store.addScriptFrame(trackId, time)
|
||
if (frame) openScriptFrame(frame.id)
|
||
}
|
||
|
||
function trimTooltip(ci: ClipInstance): string {
|
||
const name = clipById.value.get(ci.clipId)?.name ?? '?'
|
||
const dur = visibleDurationOf(ci).toFixed(2)
|
||
const trims: string[] = []
|
||
if ((ci.trimStart ?? 0) > 0) trims.push(t('timeline.trimStart', { value: (ci.trimStart ?? 0).toFixed(2) }))
|
||
if ((ci.trimEnd ?? 0) > 0) trims.push(t('timeline.trimEnd', { value: (ci.trimEnd ?? 0).toFixed(2) }))
|
||
const trimsStr = trims.length ? ` · ${trims.join(' · ')}` : ''
|
||
return t('timeline.trimTooltip', { name, duration: dur, trims: trimsStr })
|
||
}
|
||
|
||
interface ClipTrimState {
|
||
instanceId: string
|
||
side: 'start' | 'end'
|
||
pointerId: number
|
||
startX: number
|
||
initialStartTime: number
|
||
initialTrimStart: number
|
||
initialTrimEnd: number
|
||
}
|
||
|
||
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
|
||
el.setPointerCapture(e.pointerId)
|
||
clipTrim.value = {
|
||
instanceId: ci.id,
|
||
side,
|
||
pointerId: e.pointerId,
|
||
startX: e.clientX,
|
||
initialStartTime: ci.startTime,
|
||
initialTrimStart: ci.trimStart ?? 0,
|
||
initialTrimEnd: ci.trimEnd ?? 0,
|
||
}
|
||
}
|
||
|
||
function onClipHandlePointerMove(e: PointerEvent) {
|
||
const t = clipTrim.value
|
||
if (!t || t.pointerId !== e.pointerId) return
|
||
const pps = effectivePxPerSec.value
|
||
if (pps <= 0) return
|
||
const rawDelta = (e.clientX - t.startX) / pps
|
||
const inst = store.scene.clipInstances?.find((ci) => ci.id === t.instanceId)
|
||
const clip = inst ? store.scene.clips?.find((c) => c.id === inst.clipId) : null
|
||
if (!clip) return
|
||
if (t.side === 'start') {
|
||
// Compute the target left-edge time, snap it to the playhead /
|
||
// keyframes, then derive the delta. Clamping below makes sure
|
||
// we don't shift startTime past 0 or eat past the clip's
|
||
// available budget — same bounds as before.
|
||
const targetEdge = t.initialStartTime + rawDelta
|
||
const snappedEdge = snapTime(targetEdge)
|
||
const snappedDelta = snappedEdge - t.initialStartTime
|
||
const MIN_VISIBLE = 0.05
|
||
const maxBudget = clip.duration - MIN_VISIBLE
|
||
const minDelta = Math.max(-t.initialTrimStart, -t.initialStartTime)
|
||
const maxDelta = maxBudget - t.initialTrimStart - t.initialTrimEnd
|
||
const delta = Math.min(Math.max(snappedDelta, minDelta), maxDelta)
|
||
store.trimClipInstance(t.instanceId, {
|
||
trimStart: t.initialTrimStart + delta,
|
||
startTime: t.initialStartTime + delta,
|
||
})
|
||
} else {
|
||
// shift right edge right = less trimEnd (reveal more). Snap the
|
||
// edge position, then derive trimEnd.
|
||
const initialRightEdge = t.initialStartTime + clip.duration - t.initialTrimEnd
|
||
const snappedEdge = snapTime(initialRightEdge + rawDelta)
|
||
const newTrimEnd = clip.duration - (snappedEdge - t.initialStartTime)
|
||
store.trimClipInstance(t.instanceId, {
|
||
trimEnd: newTrimEnd,
|
||
})
|
||
}
|
||
}
|
||
|
||
function onClipHandlePointerUp(e: PointerEvent) {
|
||
const t = clipTrim.value
|
||
if (!t || t.pointerId !== e.pointerId) return
|
||
const el = e.currentTarget as HTMLElement
|
||
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
|
||
clipTrim.value = null
|
||
}
|
||
|
||
function onClipBlockDblClick(ci: ClipInstance) {
|
||
store.enterClip(ci.clipId)
|
||
}
|
||
|
||
interface ClipInstanceMenuState {
|
||
instanceId: string
|
||
x: number
|
||
y: number
|
||
}
|
||
|
||
const clipInstanceMenu = ref<ClipInstanceMenuState | null>(null)
|
||
const clipInstanceMenuRef = ref<HTMLDivElement | null>(null)
|
||
|
||
function onClipBlockContextMenu(e: MouseEvent, ci: ClipInstance) {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
clipInstanceMenu.value = { instanceId: ci.id, x: e.clientX, y: e.clientY }
|
||
}
|
||
|
||
function duplicateClipInstanceFromMenu() {
|
||
const m = clipInstanceMenu.value
|
||
if (!m) return
|
||
store.duplicateClipInstance(m.instanceId)
|
||
clipInstanceMenu.value = null
|
||
}
|
||
|
||
function deleteClipInstanceFromMenu() {
|
||
const m = clipInstanceMenu.value
|
||
if (!m) return
|
||
store.removeClipInstance(m.instanceId)
|
||
clipInstanceMenu.value = null
|
||
}
|
||
|
||
const trackMenu = ref<TrackMenuState | null>(null)
|
||
const trackMenuRef = ref<HTMLDivElement | null>(null)
|
||
|
||
function onLayerTrackNameClick(e: MouseEvent, track: TimelineLayerTrack) {
|
||
if (isTrackEffectivelyLocked(track)) return
|
||
if (e.shiftKey && track.isParent) {
|
||
store.toggleLayerSelection(track.layerId)
|
||
return
|
||
}
|
||
store.selectLayer(track.layerId)
|
||
}
|
||
|
||
function onTrackContextMenu(e: MouseEvent, layerId: string) {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
const layer = store.activeLayers.find((l) => l.id === layerId)
|
||
if (!layer) return
|
||
// If the right-clicked layer isn't already in the multi-selection,
|
||
// treat the right-click as a single-select so the menu actions
|
||
// operate on what the user actually clicked. Multi-selected ids
|
||
// persist so "Group selected" has a real list to work with.
|
||
if (!store.selectedLayerIds.includes(layerId)) {
|
||
store.selectLayer(layerId)
|
||
}
|
||
trackMenu.value = {
|
||
kind: 'layer',
|
||
trackId: layerId,
|
||
x: e.clientX,
|
||
y: e.clientY,
|
||
separated: !!layer.separated,
|
||
currentColor: layer.color ?? null,
|
||
locked: !!layer.locked,
|
||
}
|
||
}
|
||
|
||
function onGroupContextMenu(e: MouseEvent, groupId: string) {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
const group = groupById.value.get(groupId)
|
||
if (!group) return
|
||
trackMenu.value = {
|
||
kind: 'group',
|
||
trackId: groupId,
|
||
x: e.clientX,
|
||
y: e.clientY,
|
||
separated: false,
|
||
currentColor: group.color ?? null,
|
||
locked: false,
|
||
}
|
||
}
|
||
|
||
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 onScriptTrackContextMenu(e: MouseEvent, scriptTrackId: string) {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
const track = store.scene.scriptTracks?.find((t) => t.id === scriptTrackId)
|
||
if (!track) return
|
||
trackMenu.value = {
|
||
kind: 'script',
|
||
trackId: scriptTrackId,
|
||
x: e.clientX,
|
||
y: e.clientY,
|
||
separated: false,
|
||
currentColor: track.color ?? null,
|
||
locked: !!track.locked,
|
||
}
|
||
}
|
||
|
||
function onAudioTrackContextMenu(e: MouseEvent, audioTrackId: string) {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
const track = audioTrackById.value.get(audioTrackId)
|
||
if (!track) return
|
||
trackMenu.value = {
|
||
kind: 'audio',
|
||
trackId: audioTrackId,
|
||
x: e.clientX,
|
||
y: e.clientY,
|
||
separated: false,
|
||
currentColor: track.color ?? null,
|
||
locked: !!track.locked,
|
||
muted: !!track.muted,
|
||
}
|
||
}
|
||
|
||
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.trackId, !trackMenu.value.separated)
|
||
trackMenu.value = null
|
||
}
|
||
|
||
function groupSelectedFromMenu() {
|
||
store.groupSelectedLayers()
|
||
trackMenu.value = null
|
||
}
|
||
|
||
function convertSelectedToClipFromMenu() {
|
||
store.convertSelectedLayersToClip()
|
||
trackMenu.value = null
|
||
}
|
||
|
||
/** True only for a context-menu tied to an SVG-backed image layer.
|
||
* Gates the "Split SVG" entry so the menu doesn't show a dead action
|
||
* on raster layers. */
|
||
const canSplitSvgFromMenu = computed(() => {
|
||
const m = trackMenu.value
|
||
if (!m || m.kind !== 'layer') return false
|
||
const layer = store.scene.layers.find((l) => l.id === m.trackId)
|
||
if (!layer) return false
|
||
const asset = store.scene.assets.find((a) => a.id === layer.assetId)
|
||
if (!asset || asset.kind !== 'image') return false
|
||
return asset.src.startsWith('data:image/svg+xml')
|
||
})
|
||
|
||
function splitSvgFromMenu() {
|
||
const m = trackMenu.value
|
||
if (!m || m.kind !== 'layer') return
|
||
store.splitLayerSvg(m.trackId)
|
||
trackMenu.value = null
|
||
}
|
||
|
||
function deleteTrack() {
|
||
if (!trackMenu.value) return
|
||
if (trackMenu.value.kind === 'layer') {
|
||
store.removeLayer(trackMenu.value.trackId)
|
||
} else if (trackMenu.value.kind === 'clip') {
|
||
store.removeClipTrack(trackMenu.value.trackId)
|
||
} else if (trackMenu.value.kind === 'audio') {
|
||
store.removeAudioTrack(trackMenu.value.trackId)
|
||
} else if (trackMenu.value.kind === 'script') {
|
||
store.removeScriptTrack(trackMenu.value.trackId)
|
||
} else {
|
||
// Group: delete the group AND every track inside it.
|
||
store.removeTrackGroupWithChildren(trackMenu.value.trackId)
|
||
}
|
||
trackMenu.value = null
|
||
}
|
||
|
||
function ungroupFromMenu() {
|
||
if (!trackMenu.value || trackMenu.value.kind !== 'group') return
|
||
store.removeTrackGroup(trackMenu.value.trackId)
|
||
trackMenu.value = null
|
||
}
|
||
|
||
function duplicateTrackFromMenu() {
|
||
if (!trackMenu.value) return
|
||
const newId = store.duplicateTrack(trackMenu.value.trackId)
|
||
trackMenu.value = null
|
||
if (newId && store.scene.layers.some((l) => l.id === newId)) {
|
||
// Only layers have a meaningful per-track selection model; clip /
|
||
// script tracks aren't selected in the same sense.
|
||
store.selectLayer(newId)
|
||
}
|
||
}
|
||
|
||
async function renameFromMenu() {
|
||
if (!trackMenu.value) return
|
||
if (trackMenu.value.kind === 'layer') {
|
||
const layer = store.activeLayers.find((l) => l.id === trackMenu.value!.trackId)
|
||
if (!layer) return
|
||
const id = trackMenu.value.trackId
|
||
const current = layer.name
|
||
trackMenu.value = null
|
||
const next = await promptDialog({
|
||
title: t('timeline.renameLayerDialog.title'),
|
||
defaultValue: current,
|
||
confirmLabel: t('timeline.renameLayerDialog.confirm'),
|
||
})
|
||
if (next === null) return
|
||
store.setLayerName(id, next)
|
||
return
|
||
}
|
||
if (trackMenu.value.kind === 'clip') {
|
||
const track = store.scene.clipTracks?.find((t) => t.id === trackMenu.value!.trackId)
|
||
if (!track) return
|
||
const id = trackMenu.value.trackId
|
||
const current = track.name ?? t('timeline.clipTrackName')
|
||
trackMenu.value = null
|
||
const next = await promptDialog({
|
||
title: t('timeline.renameClipTrackDialog.title'),
|
||
defaultValue: current,
|
||
confirmLabel: t('timeline.renameClipTrackDialog.confirm'),
|
||
})
|
||
if (next === null) return
|
||
store.setClipTrackName(id, next)
|
||
return
|
||
}
|
||
if (trackMenu.value.kind === 'group') {
|
||
const group = groupById.value.get(trackMenu.value.trackId)
|
||
if (!group) return
|
||
const id = trackMenu.value.trackId
|
||
const current = group.name
|
||
trackMenu.value = null
|
||
const next = await promptDialog({
|
||
title: t('timeline.renameGroupDialog.title'),
|
||
defaultValue: current,
|
||
confirmLabel: t('timeline.renameGroupDialog.confirm'),
|
||
})
|
||
if (next === null) return
|
||
store.renameGroup(id, next)
|
||
return
|
||
}
|
||
if (trackMenu.value.kind === 'script') {
|
||
const track = scriptTrackById.value.get(trackMenu.value.trackId)
|
||
if (!track) return
|
||
const id = trackMenu.value.trackId
|
||
const current = track.name ?? t('timeline.scriptTrackName')
|
||
trackMenu.value = null
|
||
const next = await promptDialog({
|
||
title: t('timeline.renameScriptTrackDialog.title'),
|
||
defaultValue: current,
|
||
confirmLabel: t('timeline.renameScriptTrackDialog.confirm'),
|
||
})
|
||
if (next === null) return
|
||
store.setScriptTrackName(id, next)
|
||
}
|
||
if (trackMenu.value?.kind === 'audio') {
|
||
const track = audioTrackById.value.get(trackMenu.value.trackId)
|
||
if (!track) return
|
||
const id = trackMenu.value.trackId
|
||
const current = track.name ?? t('timeline.audioTrackName')
|
||
trackMenu.value = null
|
||
const next = await promptDialog({
|
||
title: t('timeline.renameAudioTrackDialog.title'),
|
||
defaultValue: current,
|
||
confirmLabel: t('timeline.renameAudioTrackDialog.confirm'),
|
||
})
|
||
if (next === null) return
|
||
store.setAudioTrackName(id, next)
|
||
}
|
||
}
|
||
|
||
function toggleAudioMutedFromMenu() {
|
||
if (!trackMenu.value || trackMenu.value.kind !== 'audio') return
|
||
store.setAudioTrackMuted(trackMenu.value.trackId, !trackMenu.value.muted)
|
||
trackMenu.value = null
|
||
}
|
||
|
||
function onDocumentPointerDown(e: PointerEvent) {
|
||
if (menu.value && menuRef.value && e.target instanceof Node) {
|
||
if (!menuRef.value.contains(e.target)) menu.value = null
|
||
} else if (menu.value) {
|
||
menu.value = null
|
||
}
|
||
if (trackMenu.value && trackMenuRef.value && e.target instanceof Node) {
|
||
if (!trackMenuRef.value.contains(e.target)) trackMenu.value = null
|
||
} else if (trackMenu.value) {
|
||
trackMenu.value = null
|
||
}
|
||
if (scriptFrameMenu.value && scriptFrameMenuRef.value && e.target instanceof Node) {
|
||
if (!scriptFrameMenuRef.value.contains(e.target)) scriptFrameMenu.value = null
|
||
} else if (scriptFrameMenu.value) {
|
||
scriptFrameMenu.value = null
|
||
}
|
||
if (clipInstanceMenu.value && clipInstanceMenuRef.value && e.target instanceof Node) {
|
||
if (!clipInstanceMenuRef.value.contains(e.target)) clipInstanceMenu.value = null
|
||
} else if (clipInstanceMenu.value) {
|
||
clipInstanceMenu.value = null
|
||
}
|
||
if (audioClipMenu.value && audioClipMenuRef.value && e.target instanceof Node) {
|
||
if (!audioClipMenuRef.value.contains(e.target)) audioClipMenu.value = null
|
||
} else if (audioClipMenu.value) {
|
||
audioClipMenu.value = null
|
||
}
|
||
}
|
||
|
||
function onKeyDown(e: KeyboardEvent) {
|
||
if (e.key === 'Escape') {
|
||
menu.value = null
|
||
trackMenu.value = null
|
||
scriptFrameMenu.value = null
|
||
clipInstanceMenu.value = null
|
||
audioClipMenu.value = null
|
||
}
|
||
}
|
||
|
||
function isEditableTarget(target: EventTarget | null): boolean {
|
||
if (!(target instanceof HTMLElement)) return false
|
||
const tag = target.tagName
|
||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true
|
||
if (target.isContentEditable) return true
|
||
return false
|
||
}
|
||
|
||
function onDocumentKeyDown(e: KeyboardEvent) {
|
||
if (e.key === 'Escape') {
|
||
if (selectedKfs.value.length) {
|
||
selectedKfs.value = []
|
||
return
|
||
}
|
||
if (selectedMarkerId.value) {
|
||
selectedMarkerId.value = null
|
||
return
|
||
}
|
||
}
|
||
if (isEditableTarget(e.target)) return
|
||
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||
if (selectedKfs.value.length) {
|
||
e.preventDefault()
|
||
const targets = [...selectedKfs.value]
|
||
selectedKfs.value = []
|
||
for (const k of targets) store.removeKeyframe(k.layerId, k.keyframeId)
|
||
return
|
||
}
|
||
if (selectedMarkerId.value) {
|
||
e.preventDefault()
|
||
const id = selectedMarkerId.value
|
||
selectedMarkerId.value = null
|
||
store.removeMarker(id)
|
||
return
|
||
}
|
||
// Selected-track deletion. Only layer tracks carry a selection
|
||
// today (clip / script / group tracks don't set selectedLayerId).
|
||
// Skip the delete when the layer is locked directly or through its
|
||
// parent group — Backspace should respect the same locks that block
|
||
// mouse interaction.
|
||
if (store.selectedLayerId) {
|
||
const layerId = store.selectedLayerId
|
||
const layer = store.activeLayers.find((l) => l.id === layerId)
|
||
if (!layer) return
|
||
if (layer.locked || lockedGroupChildIds.value.has(layerId)) return
|
||
e.preventDefault()
|
||
store.removeLayer(layerId)
|
||
return
|
||
}
|
||
}
|
||
if (
|
||
e.key.toLowerCase() === 'm' &&
|
||
!e.ctrlKey &&
|
||
!e.metaKey &&
|
||
!e.altKey &&
|
||
!e.shiftKey
|
||
) {
|
||
e.preventDefault()
|
||
addMarkerAtPlayhead()
|
||
}
|
||
}
|
||
|
||
watch(
|
||
[menu, trackMenu, scriptFrameMenu, clipInstanceMenu, audioClipMenu],
|
||
([m, tm, sm, cim, acm]) => {
|
||
if (m || tm || sm || cim || acm) {
|
||
document.addEventListener('pointerdown', onDocumentPointerDown, true)
|
||
document.addEventListener('keydown', onKeyDown)
|
||
} else {
|
||
document.removeEventListener('pointerdown', onDocumentPointerDown, true)
|
||
document.removeEventListener('keydown', onKeyDown)
|
||
}
|
||
},
|
||
)
|
||
|
||
async function clampMenuIntoViewport(
|
||
getEl: () => HTMLElement | null,
|
||
x: number,
|
||
y: number,
|
||
) {
|
||
await nextTick()
|
||
const el = getEl()
|
||
if (!el) return
|
||
const rect = el.getBoundingClientRect()
|
||
const vw = window.innerWidth
|
||
const vh = window.innerHeight
|
||
const margin = 8
|
||
let left = x
|
||
let top = y
|
||
if (left + rect.width + margin > vw) left = Math.max(margin, vw - rect.width - margin)
|
||
if (top + rect.height + margin > vh) top = Math.max(margin, vh - rect.height - margin)
|
||
el.style.left = `${left}px`
|
||
el.style.top = `${top}px`
|
||
}
|
||
|
||
watch(menu, (m) => {
|
||
if (!m) return
|
||
clampMenuIntoViewport(() => menuRef.value, m.x, m.y)
|
||
})
|
||
|
||
watch(trackMenu, (tm) => {
|
||
if (!tm) return
|
||
clampMenuIntoViewport(() => trackMenuRef.value, tm.x, tm.y)
|
||
})
|
||
|
||
watch(scriptFrameMenu, (sm) => {
|
||
if (!sm) return
|
||
clampMenuIntoViewport(() => scriptFrameMenuRef.value, sm.x, sm.y)
|
||
})
|
||
|
||
watch(clipInstanceMenu, (cim) => {
|
||
if (!cim) return
|
||
clampMenuIntoViewport(() => clipInstanceMenuRef.value, cim.x, cim.y)
|
||
})
|
||
|
||
watch(audioClipMenu, (acm) => {
|
||
if (!acm) return
|
||
clampMenuIntoViewport(() => audioClipMenuRef.value, acm.x, acm.y)
|
||
})
|
||
|
||
function onWheel(e: WheelEvent) {
|
||
if (!scrollEl.value) return
|
||
if (e.ctrlKey || e.metaKey) {
|
||
e.preventDefault()
|
||
const rect = scrollEl.value.getBoundingClientRect()
|
||
const cursorInViewport = e.clientX - rect.left
|
||
const laneCursor = cursorInViewport - trackNameWidth.value + scrollEl.value.scrollLeft
|
||
const currentPps = effectivePxPerSec.value
|
||
const timeAtCursor = Math.max(0, laneCursor) / currentPps
|
||
const factor = Math.pow(1.0015, -e.deltaY)
|
||
const newPps = clamp(currentPps * factor, MIN_PX_PER_SEC, MAX_PX_PER_SEC)
|
||
autoFit.value = false
|
||
pxPerSec.value = newPps
|
||
requestAnimationFrame(() => {
|
||
if (!scrollEl.value) return
|
||
const targetLane = timeAtCursor * newPps
|
||
scrollEl.value.scrollLeft = Math.max(0, targetLane - (cursorInViewport - trackNameWidth.value))
|
||
})
|
||
return
|
||
}
|
||
if (e.shiftKey) {
|
||
e.preventDefault()
|
||
scrollEl.value.scrollLeft += e.deltaY !== 0 ? e.deltaY : e.deltaX
|
||
}
|
||
}
|
||
|
||
let ro: ResizeObserver | null = null
|
||
|
||
function onScrollCapture() {
|
||
if (scrollEl.value) scrollLeft.value = scrollEl.value.scrollLeft
|
||
}
|
||
|
||
onMounted(() => {
|
||
if (!scrollEl.value) return
|
||
ro = new ResizeObserver((entries) => {
|
||
const rect = entries[0].contentRect
|
||
viewportWidth.value = rect.width
|
||
})
|
||
ro.observe(scrollEl.value)
|
||
scrollEl.value.addEventListener('scroll', onScrollCapture, { passive: true })
|
||
// Seed with current value.
|
||
scrollLeft.value = scrollEl.value.scrollLeft
|
||
document.addEventListener('keydown', onDocumentKeyDown)
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
ro?.disconnect()
|
||
ro = null
|
||
if (scrollEl.value) scrollEl.value.removeEventListener('scroll', onScrollCapture)
|
||
document.removeEventListener('pointerdown', onDocumentPointerDown, true)
|
||
document.removeEventListener('pointerdown', onOutsideMarkerPointerDown, true)
|
||
document.removeEventListener('keydown', onKeyDown)
|
||
document.removeEventListener('keydown', onDocumentKeyDown)
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="timeline" :style="timelineRootStyle">
|
||
<div ref="scrollEl" class="tl-scroll" @wheel="onWheel">
|
||
<div class="tl-content" :style="{ width: totalWidth + 'px' }">
|
||
<div v-if="store.activeButtonAssetId" class="scrub-bar button-scrub-bar">
|
||
<div class="name-spacer">
|
||
<div class="button-mode-toggle">
|
||
<button
|
||
class="mode-btn"
|
||
:class="{ active: store.activeButtonEditMode === 'states' }"
|
||
v-tooltip="t('timeline.buttonModeStates')"
|
||
@click="store.setButtonEditMode('states')"
|
||
>{{ t('timeline.buttonModeStatesShort') }}</button>
|
||
<button
|
||
class="mode-btn hit"
|
||
:class="{ active: store.activeButtonEditMode === 'hit' }"
|
||
v-tooltip="t('timeline.buttonModeHit')"
|
||
@click="store.setButtonEditMode('hit')"
|
||
>{{ t('timeline.buttonModeHitShort') }}</button>
|
||
</div>
|
||
</div>
|
||
<div
|
||
v-if="store.activeButtonEditMode === 'states'"
|
||
class="scrub-ruler button-state-ruler"
|
||
:style="{ width: BUTTON_STATE_TOTAL_WIDTH + 'px' }"
|
||
@pointerdown="onStateScrubPointerDown"
|
||
@pointermove="onStateScrubPointerMove"
|
||
@pointerup="onStateScrubPointerUp"
|
||
@pointercancel="onStateScrubPointerUp"
|
||
>
|
||
<span
|
||
v-for="(st, idx) in BUTTON_EDIT_STATES"
|
||
:key="st"
|
||
class="tick button-state-tick"
|
||
:class="{
|
||
active: store.activeButtonState === st,
|
||
}"
|
||
:style="{ left: stateTickLeft(idx) + 'px' }"
|
||
>
|
||
<span class="tick-line"></span>
|
||
<span class="tick-label">{{ stateLabel(st) }}</span>
|
||
</span>
|
||
<span
|
||
class="scrub-handle"
|
||
:style="{ left: currentStateLeft + 'px' }"
|
||
>
|
||
<span class="scrub-handle-head"></span>
|
||
<span class="scrub-handle-line"></span>
|
||
</span>
|
||
</div>
|
||
<div v-else class="hit-edit-header">
|
||
<span class="hit-edit-title">{{ t('timeline.hitAreaHeader') }}</span>
|
||
<span class="hit-edit-hint">{{ t('timeline.hitAreaHint') }}</span>
|
||
</div>
|
||
</div>
|
||
<div v-else class="scrub-bar">
|
||
<div class="name-spacer">
|
||
<div class="mode-toggle" v-tooltip="t('timeline.toggleTimeUnit')">
|
||
<button
|
||
class="mode-btn"
|
||
:class="{ active: displayMode === 'seconds' }"
|
||
@click="displayMode = 'seconds'"
|
||
>s</button>
|
||
<button
|
||
class="mode-btn"
|
||
:class="{ active: displayMode === 'frames' }"
|
||
@click="displayMode = 'frames'"
|
||
>fr</button>
|
||
</div>
|
||
<button
|
||
class="snap-btn"
|
||
:class="{ active: snapEnabled }"
|
||
v-tooltip="snapEnabled ? t('timeline.snapOn') : t('timeline.snapOff')"
|
||
@click="snapEnabled = !snapEnabled"
|
||
>
|
||
<PhMagnet :size="12" :weight="snapEnabled ? 'fill' : 'regular'" />
|
||
</button>
|
||
<button
|
||
class="snap-btn"
|
||
:class="{ active: !showLocked }"
|
||
v-tooltip="showLocked ? t('timeline.hideLocked') : t('timeline.showLocked')"
|
||
@click="showLocked = !showLocked"
|
||
>
|
||
<component
|
||
:is="showLocked ? PhEye : PhEyeSlash"
|
||
:size="12"
|
||
/>
|
||
</button>
|
||
<button
|
||
v-if="!inClipEdit"
|
||
class="snap-btn"
|
||
v-tooltip="t('timeline.addGroupTooltip')"
|
||
@click="addGroup"
|
||
>
|
||
<PhFolderPlus :size="12" />
|
||
</button>
|
||
<button
|
||
v-if="!inClipEdit"
|
||
class="snap-btn"
|
||
v-tooltip="t('timeline.addScriptTrackTooltip')"
|
||
@click="store.addScriptTrack()"
|
||
>
|
||
<PhCode :size="12" />
|
||
</button>
|
||
<button
|
||
v-if="!inClipEdit"
|
||
class="snap-btn"
|
||
v-tooltip="t('timeline.addAudioTrackTooltip')"
|
||
@click="store.addAudioTrack()"
|
||
>
|
||
<PhMusicNote :size="12" />
|
||
</button>
|
||
<button
|
||
class="snap-btn add-kf-btn"
|
||
v-tooltip="t('timeline.addKeyframeTooltip')"
|
||
:disabled="!canCreateKf"
|
||
@click="addKeyframeForSelected"
|
||
>
|
||
<PhPlus :size="12" :weight="'bold'" />
|
||
</button>
|
||
</div>
|
||
<div
|
||
class="scrub-ruler"
|
||
:style="{ width: laneWidth + 'px' }"
|
||
@pointerdown="onScrubPointerDown"
|
||
@pointermove="onScrubPointerMove"
|
||
@pointerup="onScrubPointerUp"
|
||
@pointercancel="onScrubPointerUp"
|
||
>
|
||
<span
|
||
v-for="t in ticks"
|
||
:key="t"
|
||
class="tick"
|
||
:class="{ 'tick-start': t === 0, 'tick-end': Math.abs(t - duration) < 1e-6 }"
|
||
:style="{ left: pxFromTime(t) + 'px' }"
|
||
>
|
||
<span class="tick-line"></span>
|
||
<span class="tick-label">{{ formatTick(t) }}</span>
|
||
</span>
|
||
<span
|
||
v-if="store.playbackIn > 0"
|
||
class="range-dim dim-before"
|
||
:style="{ width: pxFromTime(store.playbackIn) + 'px' }"
|
||
></span>
|
||
<span
|
||
v-if="store.playbackOut < duration"
|
||
class="range-dim dim-after"
|
||
:style="{
|
||
left: pxFromTime(store.playbackOut) + 'px',
|
||
width: pxFromTime(duration - store.playbackOut) + 'px',
|
||
}"
|
||
></span>
|
||
<span
|
||
class="range-bracket bracket-in"
|
||
v-tooltip="t('timeline.inPoint')"
|
||
:style="{ left: pxFromTime(store.playbackIn) + 'px' }"
|
||
@pointerdown="onInBracketPointerDown"
|
||
@pointermove="onInBracketPointerMove"
|
||
@pointerup="onInBracketPointerUp"
|
||
@pointercancel="onInBracketPointerUp"
|
||
></span>
|
||
<span
|
||
class="range-bracket bracket-out"
|
||
v-tooltip="t('timeline.outPoint')"
|
||
:style="{ left: pxFromTime(store.playbackOut) + 'px' }"
|
||
@pointerdown="onOutBracketPointerDown"
|
||
@pointermove="onOutBracketPointerMove"
|
||
@pointerup="onOutBracketPointerUp"
|
||
@pointercancel="onOutBracketPointerUp"
|
||
></span>
|
||
<span
|
||
v-for="marker in store.scene.markers ?? []"
|
||
:key="marker.id"
|
||
class="timeline-marker"
|
||
:class="{ selected: selectedMarkerId === marker.id }"
|
||
:style="{
|
||
left: pxFromTime(marker.time) + 'px',
|
||
...(marker.color ? { '--marker-color': marker.color } : {}),
|
||
}"
|
||
v-tooltip="marker.name"
|
||
@pointerdown="onMarkerPointerDown($event, marker.id)"
|
||
@pointermove="onMarkerPointerMove($event, marker.id)"
|
||
@pointerup="onMarkerPointerUp($event, marker.id)"
|
||
@pointercancel="onMarkerPointerUp($event, marker.id)"
|
||
@dblclick.stop="renameMarkerPrompt(marker.id)"
|
||
>
|
||
<span class="timeline-marker-flag"></span>
|
||
<span class="timeline-marker-label">{{ marker.name }}</span>
|
||
</span>
|
||
<span
|
||
class="scrub-handle"
|
||
:class="{ obscured: scrubHandleObscured }"
|
||
:style="{ left: pxFromTime(store.playhead) + 'px' }"
|
||
>
|
||
<span class="scrub-handle-head"></span>
|
||
<span class="scrub-handle-line"></span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div
|
||
class="tracks"
|
||
@pointerdown="onTracksPointerDown"
|
||
@pointermove="onTracksPointerMove"
|
||
@pointerup="onTracksPointerUp"
|
||
@pointercancel="onTracksPointerUp"
|
||
>
|
||
<div v-if="boxRectStyle" class="box-select-rect" :style="boxRectStyle"></div>
|
||
<template v-for="(track, idx) in tracks" :key="
|
||
track.type === 'layer'
|
||
? `l:${track.layerId}:${track.kind}:${track.inGroup ? 'g' : 't'}`
|
||
: track.type === 'clip'
|
||
? `c:${track.clipTrackId}:${track.inGroup ? 'g' : 't'}`
|
||
: track.type === 'script'
|
||
? `s:${track.scriptTrackId}`
|
||
: track.type === 'audio'
|
||
? `a:${track.audioTrackId}`
|
||
: `gr:${track.groupId}`
|
||
">
|
||
<!-- Layer track -->
|
||
<div
|
||
v-if="track.type === 'layer'"
|
||
class="track"
|
||
:class="{
|
||
'sub-track': !track.isParent,
|
||
inactive: store.selectedLayerId !== null && track.layerId !== store.selectedLayerId,
|
||
'drop-before': isDropBefore(track),
|
||
'drop-after': isDropAfter(track, tracks[idx + 1]),
|
||
dragging: track.isParent && dragTrackId === track.layerId,
|
||
colored: !!track.layer.color,
|
||
locked: isTrackEffectivelyLocked(track),
|
||
'in-group': track.inGroup,
|
||
}"
|
||
:style="trackColorStyle(track.layer.color)"
|
||
@dragover="
|
||
track.isParent && !isTrackEffectivelyLocked(track)
|
||
? (track.inGroup && track.parentGroupId !== undefined && track.groupChildIndex !== undefined
|
||
? onGroupChildDragOver($event, track.parentGroupId, track.groupChildIndex)
|
||
: onTrackDragOver($event, track.orderIndex))
|
||
: null
|
||
"
|
||
@drop="
|
||
track.isParent && !isTrackEffectivelyLocked(track)
|
||
? (track.inGroup ? onGroupChildDrop($event) : onTrackDrop($event))
|
||
: null
|
||
"
|
||
@dragleave="onTrackDragLeave"
|
||
>
|
||
<span
|
||
class="track-name"
|
||
:class="{
|
||
selected:
|
||
track.isParent && store.selectedLayerIds.includes(track.layerId),
|
||
parent: track.isParent,
|
||
sub: !track.isParent,
|
||
}"
|
||
:draggable="track.isParent && !isTrackEffectivelyLocked(track)"
|
||
@click="onLayerTrackNameClick($event, track)"
|
||
@contextmenu="
|
||
track.isParent && !isTrackEffectivelyLocked(track)
|
||
? onTrackContextMenu($event, track.layerId)
|
||
: null
|
||
"
|
||
@dragstart="
|
||
track.isParent && !isTrackEffectivelyLocked(track)
|
||
? onTrackDragStart($event, track.layerId)
|
||
: null
|
||
"
|
||
@dragend="onTrackDragEnd"
|
||
>
|
||
<PhDotsSixVertical v-if="track.isParent" :size="12" class="drag-handle" />
|
||
<PhArrowsSplit
|
||
v-if="track.isParent && track.layer.separated"
|
||
:size="11"
|
||
class="name-badge"
|
||
/>
|
||
<PhMaskHappy
|
||
v-if="track.isParent && track.layer.isMask"
|
||
:size="11"
|
||
class="name-badge"
|
||
:class="{ 'mask-negative': track.layer.maskMode === 'negative' }"
|
||
/>
|
||
<component
|
||
v-else-if="track.isParent && assetIconFor(track.layer)"
|
||
:is="assetIconFor(track.layer)"
|
||
:size="11"
|
||
class="name-badge"
|
||
/>
|
||
<span v-if="!track.isParent" class="sub-indent">↳</span>
|
||
<span class="track-label">{{ track.label }}</span>
|
||
<button
|
||
v-if="track.isParent"
|
||
class="eye-btn"
|
||
:class="{ hidden: track.layer.editorHidden }"
|
||
v-tooltip="track.layer.editorHidden ? t('timeline.showInEditor') : t('timeline.hideInEditor')"
|
||
@click.stop="store.setLayerEditorHidden(track.layerId, !track.layer.editorHidden)"
|
||
@mousedown.stop
|
||
@dragstart.prevent.stop
|
||
>
|
||
<component :is="track.layer.editorHidden ? PhEyeSlash : PhEye" :size="12" />
|
||
</button>
|
||
<button
|
||
v-if="track.isParent && !lockedGroupChildIds.has(track.layerId)"
|
||
class="lock-btn"
|
||
:class="{ locked: track.layer.locked }"
|
||
v-tooltip="track.layer.locked ? t('timeline.unlock') : t('timeline.lock')"
|
||
@click.stop="store.setTrackLocked(track.layerId, !track.layer.locked)"
|
||
@mousedown.stop
|
||
@dragstart.prevent.stop
|
||
>
|
||
<span class="lock-icon rest">
|
||
<component :is="track.layer.locked ? PhLock : PhLockOpen" :size="12" />
|
||
</span>
|
||
<span class="lock-icon alt">
|
||
<component
|
||
:is="track.layer.locked ? PhLockOpen : PhLock"
|
||
:size="12"
|
||
:weight="track.layer.locked ? 'fill' : 'regular'"
|
||
/>
|
||
</span>
|
||
</button>
|
||
</span>
|
||
<div
|
||
class="track-lane"
|
||
:style="{ width: laneWidth + 'px' }"
|
||
@click="onLaneClick"
|
||
@dblclick="onLaneDblClick($event, track)"
|
||
>
|
||
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
|
||
<span
|
||
v-for="seg in segmentsOf(track.kfs, duration, !!store.activeButtonAssetId)"
|
||
:key="seg.id"
|
||
class="kf-seg"
|
||
:class="[seg.held ? 'held' : `easing-${seg.easing}`, { tail: seg.tail }]"
|
||
:style="segmentStyle(seg)"
|
||
/>
|
||
<span
|
||
v-for="kf in track.kfs"
|
||
:key="kf.id"
|
||
class="kf"
|
||
:class="{
|
||
hold: kf.hold,
|
||
selected: isKfSelected(track.layerId, track.kind, kf.id),
|
||
}"
|
||
:style="{ left: pxFromTime(kf.time) + 'px' }"
|
||
:data-layer-id="track.layerId"
|
||
:data-kind="track.kind"
|
||
:data-kf-id="kf.id"
|
||
v-tooltip="
|
||
kf.hold
|
||
? t('timeline.stillFrameTooltip', { time: formatKfTime(kf.time) })
|
||
: t('timeline.keyframeTooltip', { time: formatKfTime(kf.time) })
|
||
"
|
||
@pointerdown="onKeyframePointerDown($event, track.layerId, kf.id, track.kind)"
|
||
@pointermove="onKeyframePointerMove"
|
||
@pointerup="onKeyframePointerUp"
|
||
@pointercancel="onKeyframePointerUp"
|
||
@click="onKeyframeClick($event, track.layerId, kf.time)"
|
||
@dblclick="onKeyframeDblClick($event, track.layerId, kf.id)"
|
||
@contextmenu="onKeyframeContextMenu($event, track.layerId, kf.id, track.kfs)"
|
||
/>
|
||
<button
|
||
v-for="emptyT in emptyButtonSlots(track.kfs)"
|
||
:key="`empty-${track.layerId}-${track.kind}-${emptyT}`"
|
||
type="button"
|
||
class="kf-add"
|
||
:style="{ left: pxFromTime(emptyT) + 'px' }"
|
||
v-tooltip="t('timeline.addKfFromPrev')"
|
||
@mousedown.stop
|
||
@pointerdown.stop
|
||
@click.stop="addKfFromPrev(track.layerId, track.kfs, emptyT)"
|
||
>
|
||
<PhPlus :size="10" weight="bold" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Clip track -->
|
||
<div
|
||
v-else-if="track.type === 'clip'"
|
||
class="track clip-track"
|
||
:class="{
|
||
'drop-before': isDropBefore(track),
|
||
'drop-after': isDropAfter(track, tracks[idx + 1]),
|
||
dragging: dragTrackId === track.clipTrackId,
|
||
colored: !!clipTrackById.get(track.clipTrackId)?.color,
|
||
locked: isTrackEffectivelyLocked(track),
|
||
'in-group': track.inGroup,
|
||
}"
|
||
:style="trackColorStyle(clipTrackById.get(track.clipTrackId)?.color)"
|
||
@dragover="
|
||
!isTrackEffectivelyLocked(track)
|
||
? (track.inGroup && track.parentGroupId !== undefined && track.groupChildIndex !== undefined
|
||
? onGroupChildDragOver($event, track.parentGroupId, track.groupChildIndex)
|
||
: onTrackDragOver($event, track.orderIndex))
|
||
: null
|
||
"
|
||
@drop="
|
||
!isTrackEffectivelyLocked(track)
|
||
? (track.inGroup ? onGroupChildDrop($event) : onTrackDrop($event))
|
||
: null
|
||
"
|
||
@dragleave="onTrackDragLeave"
|
||
>
|
||
<span
|
||
class="track-name clip-track-name"
|
||
:draggable="!isTrackEffectivelyLocked(track)"
|
||
@contextmenu="
|
||
!isTrackEffectivelyLocked(track)
|
||
? onClipTrackContextMenu($event, track.clipTrackId)
|
||
: null
|
||
"
|
||
@dragstart="
|
||
!isTrackEffectivelyLocked(track)
|
||
? onTrackDragStart($event, track.clipTrackId)
|
||
: null
|
||
"
|
||
@dragend="onTrackDragEnd"
|
||
>
|
||
<PhDotsSixVertical :size="12" class="drag-handle" />
|
||
<PhFilmStrip :size="11" class="name-badge" />
|
||
<span class="track-label">{{ track.name }}</span>
|
||
<button
|
||
class="eye-btn"
|
||
:class="{ hidden: !!clipTrackById.get(track.clipTrackId)?.editorHidden }"
|
||
v-tooltip="clipTrackById.get(track.clipTrackId)?.editorHidden ? t('timeline.showInEditor') : t('timeline.hideInEditor')"
|
||
@click.stop="
|
||
store.setClipTrackEditorHidden(
|
||
track.clipTrackId,
|
||
!clipTrackById.get(track.clipTrackId)?.editorHidden,
|
||
)
|
||
"
|
||
@mousedown.stop
|
||
@dragstart.prevent.stop
|
||
>
|
||
<component
|
||
:is="clipTrackById.get(track.clipTrackId)?.editorHidden ? PhEyeSlash : PhEye"
|
||
:size="12"
|
||
/>
|
||
</button>
|
||
<button
|
||
v-if="!lockedGroupChildIds.has(track.clipTrackId)"
|
||
class="lock-btn"
|
||
:class="{ locked: !!clipTrackById.get(track.clipTrackId)?.locked }"
|
||
v-tooltip="clipTrackById.get(track.clipTrackId)?.locked ? t('timeline.unlock') : t('timeline.lock')"
|
||
@click.stop="
|
||
store.setTrackLocked(
|
||
track.clipTrackId,
|
||
!clipTrackById.get(track.clipTrackId)?.locked,
|
||
)
|
||
"
|
||
@mousedown.stop
|
||
@dragstart.prevent.stop
|
||
>
|
||
<span class="lock-icon rest">
|
||
<component
|
||
:is="clipTrackById.get(track.clipTrackId)?.locked ? PhLock : PhLockOpen"
|
||
:size="12"
|
||
/>
|
||
</span>
|
||
<span class="lock-icon alt">
|
||
<component
|
||
:is="clipTrackById.get(track.clipTrackId)?.locked ? PhLockOpen : PhLock"
|
||
:size="12"
|
||
:weight="clipTrackById.get(track.clipTrackId)?.locked ? 'fill' : 'regular'"
|
||
/>
|
||
</span>
|
||
</button>
|
||
</span>
|
||
<div
|
||
class="track-lane clip-lane"
|
||
:style="{ width: laneWidth + 'px' }"
|
||
@dragover.stop="onClipLaneDragOver($event, track.clipTrackId)"
|
||
@dragleave.stop="onClipLaneDragLeave"
|
||
@drop.stop="onClipLaneDrop($event, track.clipTrackId)"
|
||
>
|
||
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
|
||
<span
|
||
v-if="clipInstanceGhost && clipInstanceGhost.targetTrackId === track.clipTrackId"
|
||
class="clip-instance-ghost"
|
||
:style="{
|
||
left: pxFromTime(clipInstanceGhost.startTime) + 'px',
|
||
width: Math.max(6, pxFromTime(clipInstanceGhost.length)) + 'px',
|
||
}"
|
||
></span>
|
||
<span
|
||
v-for="ci in clipInstancesByTrack[track.clipTrackId] ?? []"
|
||
:key="ci.id"
|
||
class="clip-block"
|
||
:class="{
|
||
'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)"
|
||
v-tooltip="trimTooltip(ci)"
|
||
@pointerdown="onClipBlockPointerDown($event, ci)"
|
||
@pointermove="onClipBlockPointerMove"
|
||
@pointerup="onClipBlockPointerUp"
|
||
@pointercancel="onClipBlockPointerUp"
|
||
@dragstart.prevent.stop
|
||
@dblclick="onClipBlockDblClick(ci)"
|
||
@contextmenu="onClipBlockContextMenu($event, ci)"
|
||
>
|
||
<span
|
||
class="clip-handle start"
|
||
draggable="false"
|
||
@pointerdown="onClipHandlePointerDown($event, ci, 'start')"
|
||
@pointermove="onClipHandlePointerMove"
|
||
@pointerup="onClipHandlePointerUp"
|
||
@pointercancel="onClipHandlePointerUp"
|
||
@dragstart.prevent.stop
|
||
@dblclick.stop
|
||
@click.stop
|
||
/>
|
||
<PhFilmStrip :size="11" class="clip-block-icon" />
|
||
<span class="clip-block-name">{{ clipById.get(ci.clipId)?.name ?? '?' }}</span>
|
||
<span
|
||
class="clip-handle end"
|
||
draggable="false"
|
||
@pointerdown="onClipHandlePointerDown($event, ci, 'end')"
|
||
@pointermove="onClipHandlePointerMove"
|
||
@pointerup="onClipHandlePointerUp"
|
||
@pointercancel="onClipHandlePointerUp"
|
||
@dragstart.prevent.stop
|
||
@dblclick.stop
|
||
@click.stop
|
||
/>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Script track -->
|
||
<div
|
||
v-else-if="track.type === 'script'"
|
||
class="track script-track"
|
||
:class="{
|
||
'drop-before': isDropBefore(track),
|
||
'drop-after': isDropAfter(track, tracks[idx + 1]),
|
||
dragging: dragTrackId === track.scriptTrackId,
|
||
colored: !!track.color,
|
||
locked: track.locked,
|
||
'in-group': track.inGroup,
|
||
'sub-track': track.inGroup,
|
||
}"
|
||
:style="trackColorStyle(track.color)"
|
||
@dragover="
|
||
!track.locked
|
||
? (track.inGroup && track.parentGroupId !== undefined && track.groupChildIndex !== undefined
|
||
? onGroupChildDragOver($event, track.parentGroupId, track.groupChildIndex)
|
||
: onTrackDragOver($event, track.orderIndex))
|
||
: null
|
||
"
|
||
@drop="
|
||
!track.locked
|
||
? (track.inGroup ? onGroupChildDrop($event) : onTrackDrop($event))
|
||
: null
|
||
"
|
||
@dragleave="onTrackDragLeave"
|
||
>
|
||
<span
|
||
class="track-name script-track-name"
|
||
:draggable="!track.locked"
|
||
@contextmenu="!track.locked ? onScriptTrackContextMenu($event, track.scriptTrackId) : null"
|
||
@dragstart="!track.locked ? onTrackDragStart($event, track.scriptTrackId) : null"
|
||
@dragend="onTrackDragEnd"
|
||
>
|
||
<PhDotsSixVertical :size="12" class="drag-handle" />
|
||
<PhCode :size="11" class="name-badge" />
|
||
<span class="track-label">{{ track.name }}</span>
|
||
<button
|
||
class="lock-btn"
|
||
:class="{ locked: track.locked }"
|
||
v-tooltip="track.locked ? t('timeline.unlock') : t('timeline.lock')"
|
||
@click.stop="store.setTrackLocked(track.scriptTrackId, !track.locked)"
|
||
@mousedown.stop
|
||
@dragstart.prevent.stop
|
||
>
|
||
<span class="lock-icon rest">
|
||
<component :is="track.locked ? PhLock : PhLockOpen" :size="12" />
|
||
</span>
|
||
<span class="lock-icon alt">
|
||
<component
|
||
:is="track.locked ? PhLockOpen : PhLock"
|
||
:size="12"
|
||
:weight="track.locked ? 'fill' : 'regular'"
|
||
/>
|
||
</span>
|
||
</button>
|
||
</span>
|
||
<div
|
||
class="track-lane script-lane"
|
||
:style="{ width: laneWidth + 'px' }"
|
||
@click="track.locked ? null : onScriptLaneClick($event, track.scriptTrackId)"
|
||
>
|
||
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
|
||
<span
|
||
v-for="frame in scriptFramesByTrack[track.scriptTrackId] ?? []"
|
||
:key="frame.id"
|
||
class="script-frame"
|
||
:style="{ left: pxFromTime(frame.time) + 'px' }"
|
||
v-tooltip="frame.label ?? t('timeline.scriptFrameTooltip', { n: frame.actions.length })"
|
||
@pointerdown="onScriptFramePointerDown($event, frame.id)"
|
||
@pointermove="onScriptFramePointerMove"
|
||
@pointerup="onScriptFramePointerUp"
|
||
@pointercancel="onScriptFramePointerUp"
|
||
@contextmenu="!track.locked ? onScriptFrameContextMenu($event, frame.id) : null"
|
||
>
|
||
<span class="script-frame-dot"></span>
|
||
<span v-if="frame.actions.length > 1" class="script-frame-count">{{ frame.actions.length }}</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Audio track -->
|
||
<div
|
||
v-else-if="track.type === 'audio'"
|
||
class="track audio-track"
|
||
:class="{
|
||
'drop-before': isDropBefore(track),
|
||
'drop-after': isDropAfter(track, tracks[idx + 1]),
|
||
dragging: dragTrackId === track.audioTrackId,
|
||
colored: !!track.color,
|
||
locked: track.locked,
|
||
muted: track.muted,
|
||
'in-group': track.inGroup,
|
||
'sub-track': track.inGroup,
|
||
}"
|
||
:style="trackColorStyle(track.color)"
|
||
:data-audio-track-id="track.audioTrackId"
|
||
@dragover="
|
||
!track.locked
|
||
? (track.inGroup && track.parentGroupId !== undefined && track.groupChildIndex !== undefined
|
||
? onGroupChildDragOver($event, track.parentGroupId, track.groupChildIndex)
|
||
: onTrackDragOver($event, track.orderIndex))
|
||
: null
|
||
"
|
||
@drop="
|
||
!track.locked
|
||
? (track.inGroup ? onGroupChildDrop($event) : onTrackDrop($event))
|
||
: null
|
||
"
|
||
@dragleave="onTrackDragLeave"
|
||
>
|
||
<span
|
||
class="track-name audio-track-name"
|
||
:draggable="!track.locked"
|
||
@contextmenu="!track.locked ? onAudioTrackContextMenu($event, track.audioTrackId) : null"
|
||
@dragstart="!track.locked ? onTrackDragStart($event, track.audioTrackId) : null"
|
||
@dragend="onTrackDragEnd"
|
||
>
|
||
<PhDotsSixVertical :size="12" class="drag-handle" />
|
||
<PhMusicNote :size="11" class="name-badge" />
|
||
<span class="track-label">{{ track.name }}</span>
|
||
<button
|
||
v-if="track.muted"
|
||
class="mute-indicator"
|
||
v-tooltip="t('timeline.audioMuted')"
|
||
@click.stop="store.setAudioTrackMuted(track.audioTrackId, false)"
|
||
@mousedown.stop
|
||
>
|
||
<PhSpeakerSlash :size="12" />
|
||
</button>
|
||
</span>
|
||
<div
|
||
class="track-lane audio-lane"
|
||
:style="{ width: laneWidth + 'px' }"
|
||
@dragover.stop="!track.locked ? onAudioLaneDragOver($event, track.audioTrackId) : null"
|
||
@dragleave.stop="onAudioLaneDragLeave"
|
||
@drop.stop="!track.locked ? onAudioLaneDrop($event, track.audioTrackId) : null"
|
||
>
|
||
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
|
||
<span
|
||
v-for="clip in audioClipsByTrack[track.audioTrackId] ?? []"
|
||
:key="clip.id"
|
||
class="audio-clip-block"
|
||
:class="{
|
||
moving: audioClipMove?.clipId === clip.id,
|
||
'trimmed-start': (clip.trimStart ?? 0) > 0,
|
||
'trimmed-end': (clip.trimEnd ?? 0) > 0,
|
||
trimming: audioClipTrim?.clipId === clip.id,
|
||
}"
|
||
:style="audioClipBlockStyle(clip)"
|
||
v-tooltip="audioClipTooltip(clip)"
|
||
@pointerdown="onAudioClipPointerDown($event, clip)"
|
||
@pointermove="onAudioClipPointerMove"
|
||
@pointerup="onAudioClipPointerUp"
|
||
@pointercancel="onAudioClipPointerUp"
|
||
@contextmenu="onAudioClipContextMenu($event, clip)"
|
||
>
|
||
<span
|
||
class="clip-handle start"
|
||
draggable="false"
|
||
@pointerdown="onAudioClipHandlePointerDown($event, clip, 'start')"
|
||
@pointermove="onAudioClipHandlePointerMove"
|
||
@pointerup="onAudioClipHandlePointerUp"
|
||
@pointercancel="onAudioClipHandlePointerUp"
|
||
@dragstart.prevent.stop
|
||
@dblclick.stop
|
||
@click.stop
|
||
/>
|
||
<canvas
|
||
v-if="clipPeaks(clip)"
|
||
class="audio-clip-wave"
|
||
:ref="renderWaveformCanvas(clip)"
|
||
/>
|
||
<span class="audio-clip-label">
|
||
{{ audioAssetById.get(clip.audioAssetId)?.name ?? '—' }}
|
||
</span>
|
||
<span
|
||
class="clip-handle end"
|
||
draggable="false"
|
||
@pointerdown="onAudioClipHandlePointerDown($event, clip, 'end')"
|
||
@pointermove="onAudioClipHandlePointerMove"
|
||
@pointerup="onAudioClipHandlePointerUp"
|
||
@pointercancel="onAudioClipHandlePointerUp"
|
||
@dragstart.prevent.stop
|
||
@dblclick.stop
|
||
@click.stop
|
||
/>
|
||
</span>
|
||
<span
|
||
v-if="audioClipGhost && audioClipGhost.targetTrackId === track.audioTrackId"
|
||
class="audio-clip-ghost"
|
||
:style="{
|
||
left: pxFromTime(audioClipGhost.startTime) + 'px',
|
||
width: Math.max(6, pxFromTime(audioClipGhost.length)) + 'px',
|
||
}"
|
||
></span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Group header -->
|
||
<div
|
||
v-else-if="track.type === 'group'"
|
||
class="track group-track"
|
||
:class="{
|
||
'drop-before': isDropBefore(track),
|
||
'drop-after': isDropAfter(track, tracks[idx + 1]),
|
||
dragging: dragTrackId === track.groupId,
|
||
colored: !!track.color,
|
||
'drop-into': dropGroupId === track.groupId,
|
||
collapsed: track.collapsed,
|
||
locked: track.locked,
|
||
}"
|
||
:style="trackColorStyle(track.color)"
|
||
@dragover="!track.locked ? onTrackDragOver($event, track.orderIndex) : null"
|
||
@drop="!track.locked ? onTrackDrop($event) : null"
|
||
@dragleave="onTrackDragLeave"
|
||
>
|
||
<span
|
||
class="track-name group-track-name"
|
||
:class="{ 'drop-into': dropGroupId === track.groupId }"
|
||
:draggable="!track.locked"
|
||
@click="toggleGroupCollapsed(track.groupId)"
|
||
@dblclick="!track.locked ? renameGroupPrompt(track.groupId, track.name) : null"
|
||
@contextmenu="!track.locked ? onGroupContextMenu($event, track.groupId) : null"
|
||
@dragstart="!track.locked ? onTrackDragStart($event, track.groupId) : null"
|
||
@dragend="onTrackDragEnd"
|
||
@dragover.stop="!track.locked ? onGroupBodyDragOver($event, track.groupId) : null"
|
||
@drop.stop="!track.locked ? onGroupBodyDrop($event, track.groupId) : null"
|
||
@dragleave="onGroupBodyDragLeave"
|
||
>
|
||
<PhDotsSixVertical :size="12" class="drag-handle" />
|
||
<component
|
||
:is="track.collapsed ? PhCaretRight : PhCaretDown"
|
||
:size="11"
|
||
class="group-caret"
|
||
/>
|
||
<PhFolder :size="12" class="name-badge" />
|
||
<span class="track-label">{{ track.name }}</span>
|
||
<span class="group-count">{{ t('timeline.groupChildCount', { n: track.childCount }) }}</span>
|
||
<button
|
||
class="eye-btn"
|
||
:class="{ hidden: !!track.editorHidden }"
|
||
v-tooltip="track.editorHidden ? t('timeline.showInEditor') : t('timeline.hideInEditor')"
|
||
@click.stop="store.setGroupEditorHidden(track.groupId, !track.editorHidden)"
|
||
@mousedown.stop
|
||
@dragstart.prevent.stop
|
||
>
|
||
<component :is="track.editorHidden ? PhEyeSlash : PhEye" :size="12" />
|
||
</button>
|
||
<button
|
||
class="lock-btn"
|
||
:class="{ locked: track.locked }"
|
||
v-tooltip="track.locked ? t('timeline.unlock') : t('timeline.lock')"
|
||
@click.stop="store.setTrackLocked(track.groupId, !track.locked)"
|
||
@mousedown.stop
|
||
@dragstart.prevent.stop
|
||
>
|
||
<span class="lock-icon rest">
|
||
<component :is="track.locked ? PhLock : PhLockOpen" :size="12" />
|
||
</span>
|
||
<span class="lock-icon alt">
|
||
<component
|
||
:is="track.locked ? PhLockOpen : PhLock"
|
||
:size="12"
|
||
:weight="track.locked ? 'fill' : 'regular'"
|
||
/>
|
||
</span>
|
||
</button>
|
||
</span>
|
||
<div
|
||
class="track-lane group-lane"
|
||
:class="{ 'drop-into': dropGroupId === track.groupId }"
|
||
:style="{ width: laneWidth + 'px' }"
|
||
@dragover="onGroupBodyDragOver($event, track.groupId)"
|
||
@drop="onGroupBodyDrop($event, track.groupId)"
|
||
@dragleave="onGroupBodyDragLeave"
|
||
>
|
||
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
|
||
<span v-if="track.collapsed && track.childCount === 0" class="group-empty-hint">
|
||
{{ t('timeline.groupEmpty') }}
|
||
</span>
|
||
<template v-if="track.collapsed">
|
||
<span
|
||
v-for="(time, kfIdx) in groupKeyframeTimes(track.groupId)"
|
||
:key="`gkf-${kfIdx}`"
|
||
class="group-kf-dot"
|
||
:style="{ left: pxFromTime(time) + 'px' }"
|
||
/>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<template v-if="store.activeButtonEditMode === 'hit'">
|
||
<div
|
||
v-for="(shape, idx) in activeButtonHitShapes"
|
||
:key="`hit-track-${idx}`"
|
||
class="track hit-track"
|
||
>
|
||
<div class="name-col">
|
||
<span class="hit-track-kind">
|
||
<PhSquare v-if="shape.kind === 'rect'" :size="11" />
|
||
<PhCircle v-else-if="shape.kind === 'circle'" :size="11" />
|
||
<PhPolygon v-else :size="11" />
|
||
</span>
|
||
<span class="hit-track-label">{{ hitShapeLabel(shape, idx) }}</span>
|
||
<button
|
||
class="hit-track-delete"
|
||
v-tooltip="t('timeline.hitTrackDelete')"
|
||
@pointerdown.stop
|
||
@click.stop="onDeleteHitShape(idx)"
|
||
>×</button>
|
||
</div>
|
||
<div class="lane hit-lane"></div>
|
||
</div>
|
||
<p v-if="!activeButtonHitShapes.length" class="empty">
|
||
{{ t('timeline.hitAreaEmpty') }}
|
||
</p>
|
||
</template>
|
||
<p
|
||
v-else-if="!tracks.length"
|
||
class="empty"
|
||
>
|
||
{{ store.activeButtonAssetId ? t('timeline.emptyButtonState') : t('timeline.noTracks') }}
|
||
</p>
|
||
<div
|
||
v-if="showClipDropzone"
|
||
class="track clip-track-dropzone"
|
||
:class="{ hot: clipDragOverDropzone }"
|
||
@dragenter="onNewTrackDragEnter"
|
||
@dragover="onNewTrackDragOver"
|
||
@dragleave="onNewTrackDragLeave"
|
||
@drop="onNewTrackDrop"
|
||
>
|
||
<span class="dropzone-hint" :style="dropzoneHintStyle">
|
||
{{ t('timeline.dropNewTrackHint') }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
class="name-col-resize"
|
||
:class="{ resizing: nameResizing }"
|
||
:style="{ left: trackNameWidth + 'px' }"
|
||
v-tooltip="t('timeline.resizeTrackColumn')"
|
||
@pointerdown="onNameResizeStart"
|
||
@pointermove="onNameResizeMove"
|
||
@pointerup="onNameResizeEnd"
|
||
@pointercancel="onNameResizeEnd"
|
||
></div>
|
||
|
||
<div
|
||
v-if="menu"
|
||
ref="menuRef"
|
||
class="kf-menu"
|
||
:style="{ left: menu.x + 'px', top: menu.y + 'px' }"
|
||
>
|
||
<div class="kf-menu-section">
|
||
<div class="kf-menu-header">Easing</div>
|
||
<button
|
||
v-for="opt in EASING_OPTIONS"
|
||
:key="opt.value"
|
||
class="kf-menu-item"
|
||
:class="{ active: menu.currentEasing === opt.value }"
|
||
:disabled="menu.isFirst"
|
||
@click="applyEasing(opt.value)"
|
||
>
|
||
<span class="kf-menu-icon">
|
||
<PhCheck
|
||
v-if="menu.currentEasing === opt.value"
|
||
:size="12"
|
||
weight="bold"
|
||
/>
|
||
</span>
|
||
{{ opt.label }}
|
||
</button>
|
||
<div v-if="menu.isFirst" class="kf-menu-note">{{ t('timeline.firstKfHint') }}</div>
|
||
</div>
|
||
<div class="kf-menu-divider"></div>
|
||
<button
|
||
class="kf-menu-item"
|
||
:class="{ active: menu.isHeld }"
|
||
@click="toggleHold"
|
||
>
|
||
<PhPauseCircle :size="12" class="kf-menu-icon" />
|
||
{{ t('timeline.stillFrame') }}
|
||
<PhCheck
|
||
v-if="menu.isHeld"
|
||
:size="12"
|
||
weight="bold"
|
||
class="kf-menu-trailing-check"
|
||
/>
|
||
</button>
|
||
<div class="kf-menu-divider"></div>
|
||
<button class="kf-menu-item" @click="duplicateFromMenu">
|
||
<PhCopy :size="12" class="kf-menu-icon" />
|
||
{{ t('timeline.duplicate') }}
|
||
</button>
|
||
<button class="kf-menu-item danger" @click="deleteFromMenu">
|
||
<PhTrash :size="12" class="kf-menu-icon" />
|
||
{{ t('timeline.delete') }}
|
||
</button>
|
||
</div>
|
||
|
||
<div
|
||
v-if="trackMenu"
|
||
ref="trackMenuRef"
|
||
class="kf-menu"
|
||
:style="{ left: trackMenu.x + 'px', top: trackMenu.y + 'px' }"
|
||
>
|
||
<div class="kf-menu-section">
|
||
<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"
|
||
v-tooltip="c.label"
|
||
@click="applyTrackColor(c.value)"
|
||
>
|
||
<PhCheck
|
||
v-if="trackMenu.currentColor === c.value && c.value !== null"
|
||
:size="10"
|
||
weight="bold"
|
||
/>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<template v-if="trackMenu.kind === 'group'">
|
||
<div class="kf-menu-divider"></div>
|
||
<button class="kf-menu-item" @click="renameFromMenu">
|
||
<PhPencilSimpleLine :size="12" class="kf-menu-icon" />
|
||
{{ t('timeline.renameGroup') }}
|
||
</button>
|
||
<button class="kf-menu-item" @click="ungroupFromMenu">
|
||
<PhFolderMinus :size="12" class="kf-menu-icon" />
|
||
{{ t('timeline.ungroup') }}
|
||
</button>
|
||
</template>
|
||
<template v-else-if="trackMenu.kind === 'script'">
|
||
<div class="kf-menu-divider"></div>
|
||
<button class="kf-menu-item" @click="renameFromMenu">
|
||
<PhPencilSimpleLine :size="12" class="kf-menu-icon" />
|
||
{{ t('timeline.renameScriptTrack') }}
|
||
</button>
|
||
</template>
|
||
<template v-else-if="trackMenu.kind === 'clip'">
|
||
<div class="kf-menu-divider"></div>
|
||
<button class="kf-menu-item" @click="renameFromMenu">
|
||
<PhPencilSimpleLine :size="12" class="kf-menu-icon" />
|
||
{{ t('timeline.renameClipTrack') }}
|
||
</button>
|
||
</template>
|
||
<template v-else-if="trackMenu.kind === 'audio'">
|
||
<div class="kf-menu-divider"></div>
|
||
<button class="kf-menu-item" @click="renameFromMenu">
|
||
<PhPencilSimpleLine :size="12" class="kf-menu-icon" />
|
||
{{ t('timeline.renameAudioTrack') }}
|
||
</button>
|
||
<button class="kf-menu-item" @click="toggleAudioMutedFromMenu">
|
||
<PhSpeakerSlash :size="12" class="kf-menu-icon" />
|
||
{{ t('timeline.muteAudioTrack') }}
|
||
<PhCheck
|
||
v-if="trackMenu.muted"
|
||
:size="12"
|
||
weight="bold"
|
||
class="kf-menu-trailing-check"
|
||
/>
|
||
</button>
|
||
</template>
|
||
<template v-else>
|
||
<template v-if="trackMenu.kind === 'layer'">
|
||
<div class="kf-menu-divider"></div>
|
||
<button class="kf-menu-item" @click="renameFromMenu">
|
||
<PhPencilSimpleLine :size="12" class="kf-menu-icon" />
|
||
{{ t('timeline.renameLayer') }}
|
||
</button>
|
||
<button
|
||
class="kf-menu-item"
|
||
:disabled="trackMenu.locked"
|
||
@click="toggleSeparated"
|
||
>
|
||
<PhArrowsSplit :size="12" class="kf-menu-icon" />
|
||
{{ t('timeline.separate') }}
|
||
<PhCheck
|
||
v-if="trackMenu.separated"
|
||
:size="12"
|
||
weight="bold"
|
||
class="kf-menu-trailing-check"
|
||
/>
|
||
</button>
|
||
<button
|
||
v-if="store.selectedLayerIds.length >= 2"
|
||
class="kf-menu-item"
|
||
@click="groupSelectedFromMenu"
|
||
>
|
||
<PhFolderPlus :size="12" class="kf-menu-icon" />
|
||
{{ t('timeline.groupSelected', { n: store.selectedLayerIds.length }) }}
|
||
</button>
|
||
<button
|
||
v-if="store.selectedLayerIds.length >= 1"
|
||
class="kf-menu-item"
|
||
@click="convertSelectedToClipFromMenu"
|
||
>
|
||
<PhFilmStrip :size="12" class="kf-menu-icon" />
|
||
{{ t('timeline.convertToClip', { n: store.selectedLayerIds.length }) }}
|
||
</button>
|
||
<button
|
||
v-if="canSplitSvgFromMenu"
|
||
class="kf-menu-item"
|
||
:disabled="trackMenu.locked"
|
||
@click="splitSvgFromMenu"
|
||
>
|
||
<PhScissors :size="12" class="kf-menu-icon" />
|
||
{{ t('timeline.splitSvg') }}
|
||
</button>
|
||
</template>
|
||
</template>
|
||
<template v-if="trackMenu.kind !== 'group'">
|
||
<div class="kf-menu-divider"></div>
|
||
<button
|
||
class="kf-menu-item"
|
||
:disabled="trackMenu.locked"
|
||
@click="duplicateTrackFromMenu"
|
||
>
|
||
<PhCopy :size="12" class="kf-menu-icon" />
|
||
{{ t('timeline.duplicate') }}
|
||
</button>
|
||
</template>
|
||
<div class="kf-menu-divider"></div>
|
||
<button class="kf-menu-item danger" @click="deleteTrack">
|
||
<PhTrash :size="12" class="kf-menu-icon" />
|
||
{{
|
||
trackMenu.kind === 'layer'
|
||
? t('timeline.deleteLayer')
|
||
: trackMenu.kind === 'clip'
|
||
? t('timeline.deleteClipTrack')
|
||
: trackMenu.kind === 'script'
|
||
? t('timeline.deleteScriptTrack')
|
||
: trackMenu.kind === 'audio'
|
||
? t('timeline.deleteAudioTrack')
|
||
: t('timeline.deleteGroup')
|
||
}}
|
||
</button>
|
||
</div>
|
||
|
||
<div
|
||
v-if="scriptFrameMenu"
|
||
ref="scriptFrameMenuRef"
|
||
class="kf-menu"
|
||
:style="{ left: scriptFrameMenu.x + 'px', top: scriptFrameMenu.y + 'px' }"
|
||
>
|
||
<button class="kf-menu-item" @click="duplicateScriptFrameFromMenu">
|
||
<PhCopy :size="12" class="kf-menu-icon" />
|
||
{{ t('timeline.duplicate') }}
|
||
</button>
|
||
<div class="kf-menu-divider"></div>
|
||
<button class="kf-menu-item danger" @click="deleteScriptFrameFromMenu">
|
||
<PhTrash :size="12" class="kf-menu-icon" />
|
||
{{ t('timeline.delete') }}
|
||
</button>
|
||
</div>
|
||
|
||
<div
|
||
v-if="clipInstanceMenu"
|
||
ref="clipInstanceMenuRef"
|
||
class="kf-menu"
|
||
:style="{ left: clipInstanceMenu.x + 'px', top: clipInstanceMenu.y + 'px' }"
|
||
>
|
||
<button class="kf-menu-item" @click="duplicateClipInstanceFromMenu">
|
||
<PhCopy :size="12" class="kf-menu-icon" />
|
||
{{ t('timeline.duplicate') }}
|
||
</button>
|
||
<div class="kf-menu-divider"></div>
|
||
<button class="kf-menu-item danger" @click="deleteClipInstanceFromMenu">
|
||
<PhTrash :size="12" class="kf-menu-icon" />
|
||
{{ t('timeline.delete') }}
|
||
</button>
|
||
</div>
|
||
|
||
<div
|
||
v-if="audioClipMenu"
|
||
ref="audioClipMenuRef"
|
||
class="kf-menu"
|
||
:style="{ left: audioClipMenu.x + 'px', top: audioClipMenu.y + 'px' }"
|
||
>
|
||
<button class="kf-menu-item" @click="duplicateAudioClipFromMenu">
|
||
<PhCopy :size="12" class="kf-menu-icon" />
|
||
{{ t('timeline.duplicate') }}
|
||
</button>
|
||
<div class="kf-menu-divider"></div>
|
||
<button class="kf-menu-item danger" @click="deleteAudioClipFromMenu">
|
||
<PhTrash :size="12" class="kf-menu-icon" />
|
||
{{ t('timeline.delete') }}
|
||
</button>
|
||
</div>
|
||
|
||
<ScriptFrameDialog
|
||
v-model="scriptDialogOpen"
|
||
:frame-id="scriptDialogFrameId"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.timeline {
|
||
--track-name-width: 140px;
|
||
position: relative;
|
||
height: 100%;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.button-scrub-bar {
|
||
background: color-mix(in srgb, var(--accent) 10%, var(--panel));
|
||
}
|
||
|
||
.button-scrub-bar .button-name-label {
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: var(--text);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
min-width: 0;
|
||
flex: 1;
|
||
}
|
||
|
||
.button-state-ruler {
|
||
cursor: pointer;
|
||
}
|
||
|
||
.button-state-tick .tick-line {
|
||
height: 8px;
|
||
background: var(--border);
|
||
}
|
||
|
||
.button-state-tick.active .tick-line {
|
||
background: var(--accent);
|
||
}
|
||
|
||
.button-state-tick .tick-label {
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
text-transform: capitalize;
|
||
padding-top: 4px;
|
||
letter-spacing: 0.01em;
|
||
}
|
||
|
||
.button-state-tick.active .tick-label {
|
||
color: var(--text);
|
||
}
|
||
|
||
.button-mode-toggle {
|
||
display: inline-flex;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
overflow: hidden;
|
||
background: var(--bg);
|
||
margin-left: auto;
|
||
}
|
||
|
||
.button-mode-toggle .mode-btn {
|
||
padding: 3px 8px;
|
||
min-width: 48px;
|
||
font-size: 10px;
|
||
font-weight: 500;
|
||
color: var(--text-dim);
|
||
background: transparent;
|
||
border: none;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.button-mode-toggle .mode-btn:hover {
|
||
background: var(--panel-alt);
|
||
color: var(--text);
|
||
}
|
||
|
||
.button-mode-toggle .mode-btn.active {
|
||
background: var(--accent-soft);
|
||
color: var(--accent);
|
||
}
|
||
|
||
.button-mode-toggle .mode-btn.hit.active {
|
||
background: color-mix(in srgb, #f59e0b 24%, transparent);
|
||
color: #f59e0b;
|
||
}
|
||
|
||
.button-mode-toggle .mode-btn + .mode-btn {
|
||
border-left: 1px solid var(--border);
|
||
}
|
||
|
||
.hit-edit-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 0 12px;
|
||
color: var(--text);
|
||
font-size: 11px;
|
||
flex: 1;
|
||
}
|
||
|
||
.hit-edit-title {
|
||
font-weight: 600;
|
||
color: #f59e0b;
|
||
}
|
||
|
||
.hit-edit-hint {
|
||
color: var(--text-dim);
|
||
}
|
||
|
||
.hit-track .name-col {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 0 10px;
|
||
}
|
||
|
||
.hit-track-kind {
|
||
color: #f59e0b;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.hit-track-label {
|
||
font-size: 11px;
|
||
color: var(--text);
|
||
font-variant-numeric: tabular-nums;
|
||
flex: 1;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.hit-track-delete {
|
||
width: 18px;
|
||
height: 18px;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
background: var(--bg);
|
||
color: var(--text-dim);
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
line-height: 1;
|
||
}
|
||
|
||
.hit-track-delete:hover {
|
||
background: var(--panel-alt);
|
||
color: #ef4444;
|
||
}
|
||
|
||
.hit-lane {
|
||
background: repeating-linear-gradient(
|
||
45deg,
|
||
transparent 0 8px,
|
||
color-mix(in srgb, #f59e0b 8%, transparent) 8px 16px
|
||
);
|
||
}
|
||
|
||
.name-col-resize {
|
||
position: absolute;
|
||
/* Start below the scrub bar so the bar's hit area (scrubber/playhead)
|
||
is never shadowed by the resize strip. */
|
||
top: 28px;
|
||
bottom: 0;
|
||
width: 6px;
|
||
/* Hit area lies entirely inside the name-column side of the boundary
|
||
so the playhead sitting on the lane edge stays clickable. */
|
||
transform: translateX(-6px);
|
||
cursor: ew-resize;
|
||
z-index: 3;
|
||
touch-action: none;
|
||
}
|
||
|
||
.name-col-resize::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
bottom: 0;
|
||
right: 0;
|
||
width: 1px;
|
||
background: transparent;
|
||
transition: background 120ms var(--ease);
|
||
}
|
||
|
||
.name-col-resize:hover::after,
|
||
.name-col-resize.resizing::after {
|
||
background: var(--accent);
|
||
}
|
||
|
||
.name-col-resize.resizing {
|
||
background: rgba(110, 168, 255, 0.08);
|
||
}
|
||
|
||
.tl-scroll {
|
||
width: 100%;
|
||
height: 100%;
|
||
overflow-x: auto;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.tl-content {
|
||
position: relative;
|
||
min-width: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.scrub-bar {
|
||
position: sticky;
|
||
top: 0;
|
||
/* Above sticky track-name columns (z-index 30) so that scrolling rows
|
||
pass *under* the scrub-bar + name-spacer instead of sliding over it. */
|
||
z-index: 40;
|
||
display: flex;
|
||
/* 28px ruler content + 3px padding-top reserved for the scrub handle
|
||
head to poke above the ruler. Total box height: 31px. */
|
||
height: 28px;
|
||
padding-top: 3px;
|
||
background: var(--panel);
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.name-spacer {
|
||
position: sticky;
|
||
left: 0;
|
||
z-index: 30;
|
||
width: var(--track-name-width);
|
||
/* Extend 3px up (into the scrub-bar's padding-top) and 1px down (over
|
||
the scrub-bar's border so the handle line's `bottom: -1px` overshoot
|
||
can't peek through underneath). Together this makes the toggle row
|
||
fully cover the obscured handle. */
|
||
height: calc(100% + 4px);
|
||
margin-top: -3px;
|
||
margin-bottom: -1px;
|
||
background: var(--panel);
|
||
border-right: 1px solid var(--border);
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 0 10px;
|
||
}
|
||
|
||
.snap-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 22px;
|
||
height: 20px;
|
||
padding: 0;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
background: var(--bg);
|
||
color: var(--text-dim);
|
||
cursor: pointer;
|
||
transition: background 140ms var(--ease), color 140ms var(--ease);
|
||
}
|
||
|
||
.snap-btn:hover {
|
||
background: var(--panel-alt);
|
||
color: var(--text);
|
||
}
|
||
|
||
.snap-btn.active {
|
||
background: var(--accent-soft);
|
||
color: var(--accent);
|
||
border-color: var(--accent-soft);
|
||
}
|
||
|
||
.snap-btn:disabled {
|
||
opacity: 0.4;
|
||
cursor: default;
|
||
background: var(--bg);
|
||
color: var(--text-dim);
|
||
}
|
||
|
||
.snap-btn.add-kf-btn {
|
||
margin-left: auto;
|
||
/* Softer-than-the-other-toolbar-buttons treatment: no border, round
|
||
* hit-area, accent-filled on hover — matches the sidebar's section
|
||
* `+` buttons so the two "create new thing" affordances read the
|
||
* same visually. */
|
||
border: none;
|
||
border-radius: 50%;
|
||
background: transparent;
|
||
}
|
||
|
||
.snap-btn.add-kf-btn:hover:not(:disabled) {
|
||
background: var(--accent);
|
||
color: #fff;
|
||
border-color: transparent;
|
||
}
|
||
|
||
.snap-btn.add-kf-btn.active {
|
||
background: var(--accent);
|
||
color: #fff;
|
||
border-color: transparent;
|
||
}
|
||
|
||
.snap-btn.add-kf-btn:disabled {
|
||
background: transparent;
|
||
}
|
||
|
||
.mode-toggle {
|
||
display: inline-flex;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
overflow: hidden;
|
||
background: var(--bg);
|
||
}
|
||
|
||
.mode-btn {
|
||
/* Match the height of the sibling .snap-btn icons so the timeline
|
||
* toolbar sits on one visual baseline — the wrapper .mode-toggle's
|
||
* 1px border is already accounted for by `box-sizing: border-box`
|
||
* higher up, so the inner 20px hit area stays flush with neighbours. */
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 20px;
|
||
padding: 0 8px;
|
||
min-width: 22px;
|
||
background: transparent;
|
||
border: none;
|
||
border-radius: 0;
|
||
color: var(--text-dim);
|
||
font-size: 10px;
|
||
font-weight: 500;
|
||
text-transform: lowercase;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
|
||
.mode-btn:hover:not(:disabled) {
|
||
background: var(--panel-alt);
|
||
color: var(--text);
|
||
}
|
||
|
||
.mode-btn.active {
|
||
background: var(--accent-soft);
|
||
color: var(--accent);
|
||
}
|
||
|
||
.mode-btn + .mode-btn {
|
||
border-left: 1px solid var(--border);
|
||
}
|
||
|
||
.scrub-ruler {
|
||
position: relative;
|
||
height: 100%;
|
||
cursor: ew-resize;
|
||
user-select: none;
|
||
touch-action: none;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.tick {
|
||
position: absolute;
|
||
top: 0;
|
||
bottom: 0;
|
||
transform: translateX(-50%);
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
pointer-events: none;
|
||
color: var(--text-dim);
|
||
}
|
||
|
||
.tick.tick-start {
|
||
transform: translateX(0);
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.tick.tick-end {
|
||
transform: translateX(-100%);
|
||
align-items: flex-end;
|
||
}
|
||
|
||
.tick-line {
|
||
width: 1px;
|
||
height: 6px;
|
||
background: var(--border);
|
||
}
|
||
|
||
.tick-label {
|
||
font-size: 10px;
|
||
line-height: 1;
|
||
padding-top: 3px;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.range-dim {
|
||
position: absolute;
|
||
top: 0;
|
||
bottom: 0;
|
||
background: rgba(0, 0, 0, 0.35);
|
||
pointer-events: none;
|
||
z-index: 1;
|
||
}
|
||
|
||
.range-dim.dim-before {
|
||
left: 0;
|
||
}
|
||
|
||
.range-bracket {
|
||
position: absolute;
|
||
top: 0;
|
||
bottom: 0;
|
||
width: 10px;
|
||
cursor: ew-resize;
|
||
z-index: 19;
|
||
touch-action: none;
|
||
}
|
||
|
||
.range-bracket::before,
|
||
.range-bracket::after {
|
||
content: '';
|
||
position: absolute;
|
||
background: var(--range-bracket);
|
||
transition: background 120ms var(--ease);
|
||
}
|
||
|
||
.range-bracket::before {
|
||
top: 0;
|
||
bottom: 0;
|
||
width: 2px;
|
||
}
|
||
|
||
.range-bracket::after {
|
||
top: 0;
|
||
width: 8px;
|
||
height: 6px;
|
||
}
|
||
|
||
.range-bracket.bracket-in::before {
|
||
left: 0;
|
||
}
|
||
.range-bracket.bracket-in::after {
|
||
left: 0;
|
||
border-radius: 0 2px 2px 0;
|
||
}
|
||
.range-bracket.bracket-in {
|
||
transform: translateX(-0.5px);
|
||
}
|
||
|
||
.range-bracket.bracket-out::before {
|
||
right: 0;
|
||
}
|
||
.range-bracket.bracket-out::after {
|
||
right: 0;
|
||
border-radius: 2px 0 0 2px;
|
||
}
|
||
.range-bracket.bracket-out {
|
||
transform: translateX(calc(0.5px - 10px));
|
||
}
|
||
|
||
.range-bracket:hover::before,
|
||
.range-bracket:hover::after {
|
||
background: var(--range-hover);
|
||
}
|
||
|
||
.timeline-marker {
|
||
position: absolute;
|
||
top: 0;
|
||
bottom: 0;
|
||
/* Above playhead (20) and brackets (19) so marker clicks/drags win
|
||
when the playhead has snapped onto the marker's position. */
|
||
z-index: 21;
|
||
pointer-events: auto;
|
||
cursor: pointer;
|
||
touch-action: none;
|
||
--marker-color: #ef4444;
|
||
}
|
||
|
||
.timeline-marker::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 8px;
|
||
bottom: 0;
|
||
left: 0;
|
||
width: 1px;
|
||
background: var(--marker-color);
|
||
opacity: 0.45;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.timeline-marker-flag {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
width: 8px;
|
||
height: 10px;
|
||
background: var(--marker-color);
|
||
clip-path: polygon(0 0, 100% 0, 100% 70%, 0 100%);
|
||
}
|
||
|
||
.timeline-marker-label {
|
||
position: absolute;
|
||
top: -1px;
|
||
left: 10px;
|
||
font-size: 10px;
|
||
font-weight: 500;
|
||
color: var(--marker-color);
|
||
white-space: nowrap;
|
||
pointer-events: none;
|
||
max-width: 120px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.timeline-marker:hover .timeline-marker-flag {
|
||
box-shadow: 0 0 0 1.5px var(--range-hover);
|
||
}
|
||
|
||
/* Selected marker: override to orange (incl. per-marker color overrides)
|
||
so the selection is obvious against the default red. */
|
||
.timeline-marker.selected {
|
||
--marker-color: #f59e0b !important;
|
||
}
|
||
|
||
.timeline-marker.selected .timeline-marker-flag {
|
||
box-shadow: 0 0 0 1.5px var(--range-hover);
|
||
}
|
||
|
||
.timeline-marker.selected .timeline-marker-label {
|
||
font-weight: 700;
|
||
}
|
||
|
||
.scrub-handle {
|
||
position: absolute;
|
||
top: 0;
|
||
bottom: -1px;
|
||
transform: translateX(-50%);
|
||
pointer-events: none;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
/* Default: above the sticky name-spacer (z-index 30 in the scrub-bar
|
||
stacking context) so the handle isn't half-clipped when the playhead
|
||
is at t=0 and overlaps the name-spacer column. */
|
||
z-index: 41;
|
||
}
|
||
|
||
/* When the playhead has scrolled past the left edge of the ruler (i.e. the
|
||
handle centre sits behind the sticky name-spacer), drop below z:30 so
|
||
the name-spacer + toggle row covers the handle cleanly. Toggled from
|
||
JS via `scrubHandleObscured`. */
|
||
.scrub-handle.obscured {
|
||
z-index: 20;
|
||
}
|
||
|
||
.scrub-handle-head {
|
||
width: 14px;
|
||
height: 18px;
|
||
background: var(--accent);
|
||
/* Poke 3px into the scrub-bar's padding-top area so the head sticks
|
||
out above the ruler, with slightly rounded top corners (radius 2). */
|
||
margin-top: -3px;
|
||
clip-path: path('M 2 0 Q 0 0 0 2 L 0 10 L 7 18 L 14 10 L 14 2 Q 14 0 12 0 Z');
|
||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));
|
||
pointer-events: auto;
|
||
cursor: ew-resize;
|
||
transition: background 120ms var(--ease);
|
||
}
|
||
|
||
.scrub-handle-head:hover {
|
||
background: var(--range-hover);
|
||
}
|
||
|
||
.scrub-handle-line {
|
||
flex: 1;
|
||
width: 1px;
|
||
background: var(--accent);
|
||
}
|
||
|
||
.tracks {
|
||
display: flex;
|
||
flex-direction: column;
|
||
position: relative;
|
||
}
|
||
|
||
.box-select-rect {
|
||
position: absolute;
|
||
z-index: 50;
|
||
border: 1px solid var(--accent);
|
||
background: rgba(110, 168, 255, 0.12);
|
||
pointer-events: none;
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.kf.selected {
|
||
outline: 2px solid var(--accent);
|
||
outline-offset: 1px;
|
||
}
|
||
|
||
.kf.hold.selected {
|
||
outline: 2px solid var(--accent);
|
||
outline-offset: 2px;
|
||
}
|
||
|
||
.track {
|
||
display: flex;
|
||
align-items: center;
|
||
height: 30px;
|
||
border-bottom: 1px solid var(--border);
|
||
transition: background 140ms var(--ease);
|
||
position: relative;
|
||
}
|
||
|
||
.track.dragging {
|
||
opacity: 0.5;
|
||
}
|
||
|
||
.track.drop-before::before,
|
||
.track.drop-after::after {
|
||
content: '';
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
height: 2px;
|
||
background: var(--accent);
|
||
z-index: 5;
|
||
pointer-events: none;
|
||
box-shadow: 0 0 0 1px rgba(110, 168, 255, 0.35);
|
||
}
|
||
|
||
.track.drop-before::before {
|
||
top: -1px;
|
||
}
|
||
|
||
.track.drop-after::after {
|
||
bottom: -1px;
|
||
}
|
||
|
||
/* Inactive tracks fade as a whole row — same visual vocabulary as
|
||
the active track, just dimmer. Applying opacity to the row itself
|
||
keeps keyframes, segments, and the still-frame tail visually in
|
||
sync (previously we faded each child individually, which multiplied
|
||
against each child's own opacity and made diamonds read as ghostly
|
||
while their connecting segments looked nearly normal). */
|
||
.track.inactive {
|
||
opacity: 0.5;
|
||
transition: opacity 140ms var(--ease);
|
||
}
|
||
|
||
.track.inactive:hover {
|
||
opacity: 0.85;
|
||
}
|
||
|
||
.track.locked {
|
||
height: 15px;
|
||
background: color-mix(in srgb, #ff6b6b 8%, transparent);
|
||
}
|
||
|
||
.track.clip-track.locked {
|
||
height: 17px;
|
||
}
|
||
|
||
.track.sub-track.locked {
|
||
height: 13px;
|
||
}
|
||
|
||
.track.locked .track-name {
|
||
font-size: 10px;
|
||
background: color-mix(in srgb, #ff6b6b 8%, var(--panel));
|
||
}
|
||
|
||
.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: default;
|
||
/* Locked lane is fully inert: no hover, no click, no drag, nothing. */
|
||
pointer-events: none;
|
||
}
|
||
|
||
.track.locked .kf,
|
||
.track.locked .kf-seg,
|
||
.track.locked .clip-block {
|
||
opacity: 0.55;
|
||
}
|
||
|
||
.track.locked .drag-handle {
|
||
/* Keep the layout space so labels align with unlocked siblings — just hide
|
||
the grip visually. */
|
||
visibility: hidden;
|
||
}
|
||
|
||
/* Locked track-name is inert except for the lock toggle itself. */
|
||
.track.locked .track-name {
|
||
cursor: default;
|
||
pointer-events: none;
|
||
/* Keep the dimmed look but don't react to hover */
|
||
color: var(--text-dim);
|
||
}
|
||
|
||
.track.locked .track-name .lock-btn,
|
||
.track.locked .track-name .eye-btn {
|
||
/* Keep the eye toggle clickable on locked tracks — muting a layer
|
||
in the editor is orthogonal to locking it, and asking the user
|
||
to unlock first just to toggle visibility is annoying. */
|
||
pointer-events: auto;
|
||
}
|
||
|
||
/* Locked groups stay collapsible — re-enable pointer events on the name */
|
||
.track.group-track.locked .track-name {
|
||
pointer-events: auto;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.lock-btn {
|
||
margin-left: auto;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 0;
|
||
background: transparent;
|
||
border: none;
|
||
color: var(--text-dim);
|
||
cursor: pointer;
|
||
flex-shrink: 0;
|
||
opacity: 0;
|
||
transition: opacity 140ms var(--ease), color 140ms var(--ease);
|
||
}
|
||
|
||
/* Editor-only show/hide toggle. Sits to the left of the lock button.
|
||
Stays visible when the layer is hidden so the state is obvious; on
|
||
hover it fades in on every track so you can reach it without the
|
||
layer first being hidden. */
|
||
.eye-btn {
|
||
margin-left: auto;
|
||
margin-right: 2px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 0;
|
||
background: transparent;
|
||
border: none;
|
||
color: var(--text-dim);
|
||
cursor: pointer;
|
||
flex-shrink: 0;
|
||
opacity: 0;
|
||
transition: opacity 140ms var(--ease), color 140ms var(--ease);
|
||
}
|
||
|
||
.track-name:hover .eye-btn {
|
||
opacity: 0.7;
|
||
}
|
||
|
||
.eye-btn:hover {
|
||
color: var(--text);
|
||
opacity: 1;
|
||
}
|
||
|
||
.eye-btn.hidden {
|
||
opacity: 1;
|
||
color: var(--accent);
|
||
}
|
||
|
||
.track-name:hover .lock-btn {
|
||
opacity: 0.7;
|
||
}
|
||
|
||
.lock-btn:hover {
|
||
color: var(--text);
|
||
opacity: 1;
|
||
}
|
||
|
||
.lock-btn.locked {
|
||
opacity: 1;
|
||
color: var(--text);
|
||
}
|
||
|
||
.lock-btn.locked:hover {
|
||
color: var(--accent);
|
||
}
|
||
|
||
.lock-icon {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
line-height: 0;
|
||
}
|
||
|
||
.lock-icon.alt {
|
||
display: none;
|
||
}
|
||
|
||
.lock-btn:hover .lock-icon.rest {
|
||
display: none;
|
||
}
|
||
|
||
.lock-btn:hover .lock-icon.alt {
|
||
display: inline-flex;
|
||
}
|
||
|
||
.track-label {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
min-width: 0;
|
||
flex: 1 1 auto;
|
||
}
|
||
|
||
.track-name {
|
||
position: sticky;
|
||
left: 0;
|
||
z-index: 30;
|
||
width: var(--track-name-width);
|
||
padding: 0 12px;
|
||
color: var(--text-dim);
|
||
font-size: 12px;
|
||
border-right: 1px solid var(--border);
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
flex-shrink: 0;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
background: var(--panel);
|
||
transition: color 140ms var(--ease), background 140ms var(--ease);
|
||
}
|
||
|
||
.track-name:hover {
|
||
background: var(--panel-alt);
|
||
color: var(--text);
|
||
}
|
||
|
||
.track-name.selected {
|
||
color: var(--accent);
|
||
background:
|
||
linear-gradient(var(--accent-soft), var(--accent-soft)),
|
||
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;
|
||
color: var(--text-dim);
|
||
background: var(--panel-alt);
|
||
}
|
||
|
||
.track.sub-track .track-name:hover {
|
||
background: var(--panel-hover);
|
||
}
|
||
|
||
.track.sub-track {
|
||
height: 26px;
|
||
}
|
||
|
||
.track.sub-track .track-lane {
|
||
background: linear-gradient(
|
||
to bottom,
|
||
transparent 0%,
|
||
transparent 40%,
|
||
var(--panel-alt) 40%,
|
||
var(--panel-alt) 60%,
|
||
transparent 60%
|
||
);
|
||
background-size: 100% 2px;
|
||
background-repeat: no-repeat;
|
||
background-position: center;
|
||
}
|
||
|
||
.name-badge {
|
||
color: var(--accent);
|
||
margin-right: 4px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.name-badge.mask-negative {
|
||
color: #ff6b6b;
|
||
}
|
||
|
||
.drag-handle {
|
||
color: var(--text-dim);
|
||
margin-right: 4px;
|
||
flex-shrink: 0;
|
||
cursor: grab;
|
||
opacity: 0;
|
||
transition: opacity 140ms var(--ease);
|
||
}
|
||
|
||
.track-name:hover .drag-handle,
|
||
.track-name.selected .drag-handle {
|
||
opacity: 0.7;
|
||
}
|
||
|
||
.drag-handle:active {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
.sub-indent {
|
||
color: var(--text-dim);
|
||
margin-right: 4px;
|
||
font-size: 10px;
|
||
opacity: 0.6;
|
||
}
|
||
|
||
.track-lane {
|
||
position: relative;
|
||
height: 100%;
|
||
cursor: pointer;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.playhead {
|
||
position: absolute;
|
||
top: 0;
|
||
bottom: 0;
|
||
width: 1px;
|
||
background: var(--accent);
|
||
pointer-events: none;
|
||
transform: translateX(-0.5px);
|
||
/* Above segments/keyframes but below sticky label columns (z-index 30). */
|
||
z-index: 20;
|
||
}
|
||
|
||
.kf-seg {
|
||
position: absolute;
|
||
top: 50%;
|
||
height: 3px;
|
||
margin-top: -1.5px;
|
||
border-radius: 999px;
|
||
pointer-events: none;
|
||
background: var(--track-color, var(--accent));
|
||
opacity: 0.55;
|
||
}
|
||
|
||
.kf-seg.easing-linear {
|
||
background: var(--track-color, var(--accent));
|
||
}
|
||
|
||
.kf-seg.easing-easeIn {
|
||
background: linear-gradient(
|
||
90deg,
|
||
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;
|
||
}
|
||
|
||
.kf-seg.easing-easeOut {
|
||
background: linear-gradient(
|
||
90deg,
|
||
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;
|
||
}
|
||
|
||
.kf-seg.easing-easeInOut {
|
||
background: linear-gradient(
|
||
90deg,
|
||
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;
|
||
}
|
||
|
||
.kf-seg.held {
|
||
background: repeating-linear-gradient(
|
||
90deg,
|
||
var(--text-dim) 0,
|
||
var(--text-dim) 2px,
|
||
transparent 2px,
|
||
transparent 5px
|
||
);
|
||
opacity: 0.7;
|
||
}
|
||
|
||
/* "Tail" = on-stage indicator where the layer is visible but no
|
||
interpolation is happening (still-frame past the last keyframe, or
|
||
a single-keyframe layer that's always on stage). Solid line at low
|
||
opacity — reads as "layer idling on stage" rather than "thin line".
|
||
Overrides any preceding easing-* gradient background so the fill is
|
||
always a flat accent colour at the track's tint. */
|
||
.kf-seg.tail,
|
||
.kf-seg.tail.easing-linear,
|
||
.kf-seg.tail.easing-easeIn,
|
||
.kf-seg.tail.easing-easeOut,
|
||
.kf-seg.tail.easing-easeInOut {
|
||
background: var(--track-color, var(--accent));
|
||
opacity: 0.35;
|
||
}
|
||
|
||
.kf {
|
||
position: absolute;
|
||
top: 50%;
|
||
width: 11px;
|
||
height: 11px;
|
||
margin-left: -5.5px;
|
||
margin-top: -5.5px;
|
||
background: var(--track-color, var(--accent));
|
||
transform: rotate(45deg);
|
||
border-radius: 2px;
|
||
cursor: grab;
|
||
touch-action: none;
|
||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
|
||
transition: background 120ms var(--ease), transform 120ms var(--ease);
|
||
z-index: 2;
|
||
}
|
||
|
||
.kf:hover {
|
||
background: #fff;
|
||
transform: rotate(45deg) scale(1.15);
|
||
}
|
||
|
||
.kf:active {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
.kf.hold {
|
||
width: 15px;
|
||
height: 15px;
|
||
margin-left: -7.5px;
|
||
margin-top: -7.5px;
|
||
transform: none;
|
||
border-radius: 50%;
|
||
background: var(--panel);
|
||
border: 2px solid var(--track-color, var(--accent));
|
||
box-shadow: none;
|
||
}
|
||
|
||
/* Button-editor empty-slot placeholder. Outlined accent-coloured
|
||
circle that stays fully opaque (no fade) so it's obvious the slot
|
||
is interactive; on hover the fill solidifies into the accent so
|
||
the user sees "I'm about to drop a keyframe here". */
|
||
.kf-add {
|
||
position: absolute;
|
||
top: 50%;
|
||
width: 18px;
|
||
height: 18px;
|
||
margin-left: -9px;
|
||
margin-top: -9px;
|
||
padding: 0;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: var(--panel);
|
||
border: 1.5px solid var(--track-color, var(--accent));
|
||
border-radius: 50%;
|
||
color: var(--track-color, var(--accent));
|
||
cursor: pointer;
|
||
transition: background 140ms var(--ease), color 140ms var(--ease),
|
||
transform 140ms var(--ease);
|
||
z-index: 2;
|
||
}
|
||
|
||
.kf-add:hover {
|
||
background: var(--track-color, var(--accent));
|
||
color: #fff;
|
||
transform: scale(1.1);
|
||
}
|
||
|
||
.kf.hold:hover {
|
||
background: var(--panel);
|
||
border-color: #fff;
|
||
transform: scale(1.2);
|
||
}
|
||
|
||
.empty {
|
||
color: var(--text-dim);
|
||
font-style: italic;
|
||
padding: 12px;
|
||
margin: 0;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.track.clip-track {
|
||
height: 34px;
|
||
}
|
||
|
||
.clip-lane {
|
||
position: relative;
|
||
}
|
||
|
||
/* ---- Script track ---- */
|
||
|
||
.track.script-track {
|
||
height: 30px;
|
||
}
|
||
|
||
.script-lane {
|
||
position: relative;
|
||
}
|
||
|
||
.script-frame {
|
||
position: absolute;
|
||
top: 50%;
|
||
transform: translate(-50%, -50%);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 3px;
|
||
cursor: pointer;
|
||
touch-action: none;
|
||
user-select: none;
|
||
}
|
||
|
||
.script-frame-dot {
|
||
width: 9px;
|
||
height: 9px;
|
||
border-radius: 50%;
|
||
background: var(--track-color, #f59e0b);
|
||
box-shadow: 0 0 0 1.5px rgba(0, 0, 0, 0.35);
|
||
}
|
||
|
||
.script-frame-count {
|
||
font-size: 9px;
|
||
font-weight: 600;
|
||
color: var(--track-color, #f59e0b);
|
||
background: color-mix(in srgb, var(--track-color, #f59e0b) 18%, transparent);
|
||
border-radius: 999px;
|
||
padding: 0 5px;
|
||
line-height: 13px;
|
||
min-width: 13px;
|
||
text-align: center;
|
||
}
|
||
|
||
/* ---- Audio track ---- */
|
||
|
||
.track.audio-track {
|
||
height: 30px;
|
||
}
|
||
|
||
.audio-track.muted .track-name .track-label {
|
||
text-decoration: line-through;
|
||
opacity: 0.6;
|
||
}
|
||
|
||
.mute-indicator {
|
||
margin-left: auto;
|
||
background: transparent;
|
||
border: none;
|
||
padding: 2px 4px;
|
||
border-radius: var(--radius-sm);
|
||
cursor: pointer;
|
||
color: var(--text-dim);
|
||
display: inline-flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.mute-indicator:hover {
|
||
color: var(--text);
|
||
background: var(--panel-hover);
|
||
}
|
||
|
||
.audio-lane {
|
||
position: relative;
|
||
}
|
||
|
||
.audio-clip-block {
|
||
position: absolute;
|
||
top: 4px;
|
||
bottom: 4px;
|
||
background: linear-gradient(
|
||
to bottom,
|
||
color-mix(in srgb, var(--track-color, #0ea5e9) 35%, transparent) 0%,
|
||
color-mix(in srgb, var(--track-color, #0ea5e9) 15%, transparent) 100%
|
||
);
|
||
border: 1px solid color-mix(in srgb, var(--track-color, #0ea5e9) 60%, transparent);
|
||
border-radius: var(--radius-sm);
|
||
display: flex;
|
||
align-items: center;
|
||
overflow: hidden;
|
||
padding: 0 6px;
|
||
box-sizing: border-box;
|
||
cursor: grab;
|
||
touch-action: none;
|
||
user-select: none;
|
||
}
|
||
|
||
.audio-clip-wave {
|
||
position: absolute;
|
||
left: 1px;
|
||
right: 1px;
|
||
top: 2px;
|
||
pointer-events: none;
|
||
opacity: 0.85;
|
||
}
|
||
|
||
.audio-clip-block:active,
|
||
.audio-clip-block.moving {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
.audio-clip-block.moving {
|
||
border-color: var(--accent);
|
||
}
|
||
|
||
.track.audio-track.locked .audio-clip-block {
|
||
cursor: default;
|
||
}
|
||
|
||
.audio-clip-ghost,
|
||
.clip-instance-ghost {
|
||
position: absolute;
|
||
top: 4px;
|
||
bottom: 4px;
|
||
border: 1.5px dashed var(--accent);
|
||
border-radius: var(--radius-sm);
|
||
background: color-mix(in srgb, var(--accent) 15%, transparent);
|
||
pointer-events: none;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.audio-track.muted .audio-clip-block {
|
||
opacity: 0.45;
|
||
}
|
||
|
||
.audio-clip-label {
|
||
position: relative;
|
||
font-size: 10px;
|
||
color: var(--text);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
pointer-events: none;
|
||
/* Tiny shadow keeps the text legible over the waveform fill. */
|
||
text-shadow: 0 0 2px rgba(0, 0, 0, 0.4);
|
||
z-index: 1;
|
||
}
|
||
|
||
.clip-block {
|
||
position: absolute;
|
||
top: 4px;
|
||
bottom: 4px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 0 8px;
|
||
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(--track-color, var(--accent));
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
cursor: grab;
|
||
touch-action: none;
|
||
z-index: 2;
|
||
transition: background 140ms var(--ease), transform 140ms var(--ease);
|
||
}
|
||
|
||
.clip-block:hover {
|
||
background: color-mix(in srgb, var(--track-color, var(--accent)) 22%, transparent);
|
||
}
|
||
|
||
.clip-block:active {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
.clip-block-icon {
|
||
flex-shrink: 0;
|
||
opacity: 0.9;
|
||
}
|
||
|
||
.clip-block-name {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.clip-handle {
|
||
position: absolute;
|
||
top: 0;
|
||
bottom: 0;
|
||
width: 8px;
|
||
cursor: ew-resize;
|
||
z-index: 3;
|
||
touch-action: none;
|
||
background: linear-gradient(
|
||
to right,
|
||
rgba(255, 255, 255, 0.35),
|
||
rgba(255, 255, 255, 0)
|
||
);
|
||
opacity: 0;
|
||
transition: opacity 120ms var(--ease), background 120ms var(--ease);
|
||
}
|
||
|
||
.clip-handle.start {
|
||
left: 0;
|
||
border-top-left-radius: var(--radius-sm);
|
||
border-bottom-left-radius: var(--radius-sm);
|
||
}
|
||
|
||
.clip-handle.end {
|
||
right: 0;
|
||
background: linear-gradient(
|
||
to left,
|
||
rgba(255, 255, 255, 0.35),
|
||
rgba(255, 255, 255, 0)
|
||
);
|
||
border-top-right-radius: var(--radius-sm);
|
||
border-bottom-right-radius: var(--radius-sm);
|
||
}
|
||
|
||
.clip-handle::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: 20%;
|
||
bottom: 20%;
|
||
width: 2px;
|
||
border-radius: 2px;
|
||
background: var(--track-color, var(--accent));
|
||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3);
|
||
}
|
||
|
||
.clip-handle.start::after {
|
||
left: 2px;
|
||
}
|
||
|
||
.clip-handle.end::after {
|
||
right: 2px;
|
||
}
|
||
|
||
.clip-block:hover .clip-handle,
|
||
.audio-clip-block:hover .clip-handle {
|
||
opacity: 1;
|
||
}
|
||
|
||
.clip-block.trimmed-start .clip-handle.start,
|
||
.clip-block.trimmed-end .clip-handle.end,
|
||
.audio-clip-block.trimmed-start .clip-handle.start,
|
||
.audio-clip-block.trimmed-end .clip-handle.end {
|
||
opacity: 0.75;
|
||
}
|
||
|
||
.clip-block.trimmed-start .clip-handle.start::after,
|
||
.clip-block.trimmed-end .clip-handle.end::after,
|
||
.audio-clip-block.trimmed-start .clip-handle.start::after,
|
||
.audio-clip-block.trimmed-end .clip-handle.end::after {
|
||
background: #ffc857;
|
||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
|
||
}
|
||
|
||
.clip-block.trimming .clip-handle,
|
||
.audio-clip-block.trimming .clip-handle {
|
||
opacity: 1;
|
||
}
|
||
|
||
.clip-block.trimming,
|
||
.audio-clip-block.trimming {
|
||
box-shadow: 0 0 0 1px #ffc857;
|
||
}
|
||
|
||
.track.audio-track.locked .clip-handle {
|
||
display: none;
|
||
}
|
||
|
||
.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;
|
||
opacity: 1 !important;
|
||
}
|
||
|
||
.track.group-track {
|
||
height: 28px;
|
||
background: linear-gradient(
|
||
to bottom,
|
||
rgba(110, 168, 255, 0.05) 0%,
|
||
rgba(110, 168, 255, 0.02) 100%
|
||
);
|
||
font-weight: 500;
|
||
}
|
||
|
||
.track.group-track .track-name {
|
||
background: var(--panel-alt);
|
||
font-size: 12px;
|
||
color: var(--text);
|
||
gap: 4px;
|
||
padding-left: 7px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.track.group-track .track-name:hover {
|
||
background: var(--panel-hover);
|
||
}
|
||
|
||
.group-caret {
|
||
color: var(--text-dim);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.group-count {
|
||
margin-left: auto;
|
||
font-size: 10px;
|
||
color: var(--text-dim);
|
||
font-variant-numeric: tabular-nums;
|
||
opacity: 0.7;
|
||
}
|
||
|
||
.group-lane {
|
||
background: transparent;
|
||
pointer-events: auto;
|
||
position: relative;
|
||
}
|
||
|
||
.track.group-track.drop-into > .track-name,
|
||
.track.group-track .group-lane.drop-into {
|
||
background: var(--accent-soft);
|
||
box-shadow: inset 0 0 0 1px var(--accent);
|
||
}
|
||
|
||
.group-empty-hint {
|
||
position: sticky;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
font-size: 10px;
|
||
color: var(--text-dim);
|
||
opacity: 0.6;
|
||
font-style: italic;
|
||
}
|
||
|
||
.group-kf-dot {
|
||
position: absolute;
|
||
top: 50%;
|
||
width: 5px;
|
||
height: 5px;
|
||
margin-left: -2.5px;
|
||
margin-top: -2.5px;
|
||
border-radius: 50%;
|
||
background: var(--track-color, var(--accent));
|
||
opacity: 0.75;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.track.in-group .track-name {
|
||
padding-left: 38px;
|
||
}
|
||
|
||
.track.in-group.sub-track .track-name {
|
||
padding-left: 50px;
|
||
}
|
||
|
||
.clip-track-dropzone {
|
||
position: relative;
|
||
height: 26px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: transparent;
|
||
border: 1px dashed var(--border);
|
||
border-left: none;
|
||
border-right: none;
|
||
transition: background 140ms var(--ease), border-color 140ms var(--ease);
|
||
color: var(--text-dim);
|
||
font-size: 11px;
|
||
}
|
||
|
||
.clip-track-dropzone.hot {
|
||
background: var(--accent-soft);
|
||
border-color: var(--accent);
|
||
color: var(--accent);
|
||
}
|
||
|
||
.dropzone-hint {
|
||
position: absolute;
|
||
top: 50%;
|
||
/* `left` is driven from a computed so the hint stays in the centre of
|
||
the *visible* ruler area (i.e. centred in the user's viewport, right
|
||
of the sticky name-spacer) regardless of horizontal scroll. */
|
||
transform: translate(-50%, -50%);
|
||
pointer-events: none;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
|
||
.kf-menu {
|
||
position: fixed;
|
||
z-index: 1000;
|
||
min-width: 180px;
|
||
background: var(--panel);
|
||
border: 1px solid var(--border-strong);
|
||
border-radius: var(--radius);
|
||
box-shadow: var(--shadow-md);
|
||
padding: 5px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.kf-menu-section {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.kf-menu-header {
|
||
padding: 6px 10px 2px;
|
||
font-size: 10px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
color: var(--text-dim);
|
||
}
|
||
|
||
.kf-menu-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 7px 10px;
|
||
background: transparent;
|
||
border: none;
|
||
color: var(--text);
|
||
font: inherit;
|
||
font-size: 12px;
|
||
text-align: left;
|
||
cursor: pointer;
|
||
border-radius: var(--radius-sm);
|
||
width: 100%;
|
||
}
|
||
|
||
.kf-menu-item:hover:not(:disabled) {
|
||
background: var(--panel-alt);
|
||
}
|
||
|
||
.kf-menu-item:disabled {
|
||
opacity: 0.4;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.kf-menu-item.active {
|
||
color: var(--accent);
|
||
}
|
||
|
||
.kf-menu-item.danger {
|
||
color: var(--danger);
|
||
}
|
||
|
||
.kf-menu-item.danger:hover:not(:disabled) {
|
||
background: var(--danger-soft);
|
||
}
|
||
|
||
.kf-menu-icon {
|
||
width: 12px;
|
||
height: 12px;
|
||
flex-shrink: 0;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
/* Track-menu toggle items (e.g. "Separate Position and Rotation") show their
|
||
"active" checkmark on the far right so the left edge stays aligned with
|
||
items that only have an icon + label. */
|
||
.kf-menu-trailing-check {
|
||
margin-left: auto;
|
||
color: var(--accent);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.kf-menu-note {
|
||
padding: 2px 10px 4px;
|
||
font-size: 10px;
|
||
font-style: italic;
|
||
color: var(--text-dim);
|
||
}
|
||
|
||
.kf-menu-divider {
|
||
height: 1px;
|
||
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>
|