- Freeform drawing tool: new sidebar button enters a click-to-draw mode that appends points, shows a live ghost preview line, and exits via Enter or double-click (near the start point closes the path). Works end-to-end through all four render pipelines. - Polygon authoring gestures: double-click on a path inserts a vertex on the nearest edge; double-click on an open polygon's endpoint resumes the drawing session from that end, reversing point order when needed. - Stroke + closed-path properties: all shape types (rect, circle, text, polygon) expose stroke color, stroke width, and (for polygons) a closed toggle. Fill and stroke each support a "transparent" switch so shapes can render as outline-only or fill-only. - Mask preview no longer inherits the user's stroke — the dashed ghost keeps its own outline regardless of stroke-width changes. - Playhead no longer dims on inactive timeline tracks; only keyframes, segments and clip blocks fade. - Properties panel point-count control for polygons, plus per-keyframe points animation that correctly interpolates once a baseline exists. - Pivot drag hides the rotation handle until release. - Transformer attach retries on a short timer to avoid races where the Konva node hasn't mounted yet when selection changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2985 lines
81 KiB
Vue
2985 lines
81 KiB
Vue
<script setup lang="ts">
|
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
import {
|
|
PhArrowsSplit,
|
|
PhCaretDown,
|
|
PhCaretRight,
|
|
PhCheck,
|
|
PhCopy,
|
|
PhDotsSixVertical,
|
|
PhFolder,
|
|
PhFolderPlus,
|
|
PhLock,
|
|
PhLockOpen,
|
|
PhMagnet,
|
|
PhMaskHappy,
|
|
PhPauseCircle,
|
|
PhTrash,
|
|
} from '@phosphor-icons/vue'
|
|
import { useSceneStore } from '@/stores/scene'
|
|
import { sampleLayer } from '@/lib/interpolate'
|
|
import { snapToCandidates } from '@/lib/timelineSnap'
|
|
import { confirmDialog, promptDialog } from '@/components/ui'
|
|
import { t } from '@/i18n'
|
|
import { kfSelectionKey, useKfSelection, type KfSelectionKey } from '@/composables/useKfSelection'
|
|
import type {
|
|
AnimatableProps,
|
|
Clip,
|
|
ClipInstance,
|
|
Easing,
|
|
Keyframe,
|
|
Layer,
|
|
TrackGroup,
|
|
} 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
|
|
orderIndex: number
|
|
childCount: number
|
|
}
|
|
|
|
type TimelineTrack = TimelineLayerTrack | TimelineClipTrack | TimelineGroupTrack
|
|
|
|
const store = useSceneStore()
|
|
|
|
const duration = computed(() => store.activeDuration)
|
|
const inClipEdit = computed(() => store.activeClipId !== null)
|
|
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 TRACK_NAME_WIDTH_KEY = 'animatr:trackNameWidth'
|
|
const TRACK_NAME_WIDTH_DEFAULT = 140
|
|
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
|
|
|
|
const scrollEl = ref<HTMLDivElement | null>(null)
|
|
const viewportWidth = ref(0)
|
|
const pxPerSec = ref(0)
|
|
const autoFit = ref(true)
|
|
|
|
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(() => {
|
|
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 = ref<DisplayMode>('seconds')
|
|
const snapEnabled = ref(true)
|
|
|
|
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
|
|
}
|
|
|
|
function snapTime(t: number, ignoreKeyframeId?: string): number {
|
|
return snapToCandidates(t, allKeyframeTimes(ignoreKeyframeId), {
|
|
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
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
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 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))
|
|
}
|
|
|
|
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 || layer.locked) 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
|
|
const layer = store.activeLayers.find((l) => l.id === layerId)
|
|
if (layer?.locked) 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 tracks = computed<TimelineTrack[]>(() => {
|
|
const out: TimelineTrack[] = []
|
|
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 grp = groupById.value.get(id)
|
|
if (grp) {
|
|
out.push({
|
|
type: 'group',
|
|
groupId: grp.id,
|
|
name: grp.name,
|
|
collapsed: !!grp.collapsed,
|
|
color: grp.color ?? null,
|
|
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 out
|
|
})
|
|
|
|
function trackDndId(track: TimelineTrack): string {
|
|
if (track.type === 'layer') return track.layerId
|
|
if (track.type === 'clip') return track.clipTrackId
|
|
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') {
|
|
return track.groupChildIndex ?? null
|
|
}
|
|
return null
|
|
}
|
|
|
|
function trackParentGroupId(track: TimelineTrack): string | null {
|
|
if (track.type === 'layer' || track.type === 'clip') {
|
|
return track.parentGroupId ?? null
|
|
}
|
|
return null
|
|
}
|
|
|
|
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 (track.type !== 'group' && 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 (track.type !== 'group' && 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 (layer?.locked) 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)
|
|
const snappedPrimary = snapToCandidates(
|
|
rawNewTime,
|
|
allKeyframeTimesExcluding(ignoreIds),
|
|
{
|
|
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
|
|
}
|
|
}
|
|
|
|
interface KfSegment {
|
|
id: string
|
|
fromTime: number
|
|
toTime: number
|
|
easing: Easing
|
|
held: boolean
|
|
}
|
|
|
|
function segmentsOf(kfs: Keyframe[]): KfSegment[] {
|
|
if (kfs.length < 2) return []
|
|
const sorted = [...kfs].sort((a, b) => a.time - b.time)
|
|
const out: KfSegment[] = []
|
|
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,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
function segmentStyle(seg: KfSegment) {
|
|
return {
|
|
left: `${pxFromTime(seg.fromTime)}px`,
|
|
width: `${pxFromTime(seg.toTime - seg.fromTime)}px`,
|
|
}
|
|
}
|
|
|
|
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()
|
|
const layer = store.scene.layers.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.scene.layers.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'
|
|
trackId: string
|
|
x: number
|
|
y: number
|
|
separated: boolean
|
|
currentColor: string | null
|
|
locked: boolean
|
|
}
|
|
|
|
const TRACK_COLORS: Array<{ value: string | null; label: string }> = [
|
|
{ value: null, label: 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() {
|
|
store.addTrackGroup()
|
|
}
|
|
|
|
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',
|
|
)
|
|
) {
|
|
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
|
|
}
|
|
|
|
function onClipLaneDragOver(e: DragEvent, _clipTrackId: string) {
|
|
if (e.dataTransfer?.types.includes('application/x-animatr-clip')) {
|
|
e.preventDefault()
|
|
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
|
|
}
|
|
}
|
|
|
|
function onClipLaneDrop(e: DragEvent, clipTrackId: string) {
|
|
e.preventDefault()
|
|
const track = store.scene.clipTracks?.find((t) => t.id === clipTrackId)
|
|
if (track?.locked) return
|
|
const el = e.currentTarget as HTMLElement
|
|
const time = pixelTimeFromEvent(e, el)
|
|
const clipId = e.dataTransfer?.getData('application/x-animatr-clip')
|
|
if (clipId) {
|
|
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
|
|
const newStart = Math.max(0, m.initialStartTime + deltaSec)
|
|
store.moveClipInstance(m.instanceId, newStart)
|
|
}
|
|
|
|
function onClipBlockPointerUp(e: PointerEvent) {
|
|
const m = clipMove.value
|
|
if (!m || m.pointerId !== e.pointerId) return
|
|
const el = e.currentTarget as HTMLElement
|
|
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
|
|
const wasMoved = m.moved
|
|
clipMove.value = null
|
|
// suppress click/dblclick if we actually dragged
|
|
if (wasMoved) e.preventDefault()
|
|
}
|
|
|
|
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',
|
|
}
|
|
}
|
|
|
|
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 deltaSec = (e.clientX - t.startX) / pps
|
|
if (t.side === 'start') {
|
|
// shift block right = trim more from start + shift startTime
|
|
store.trimClipInstance(t.instanceId, {
|
|
trimStart: t.initialTrimStart + deltaSec,
|
|
startTime: t.initialStartTime + deltaSec,
|
|
})
|
|
} else {
|
|
// shift right edge right = less trimEnd (reveal more)
|
|
store.trimClipInstance(t.instanceId, {
|
|
trimEnd: t.initialTrimEnd - deltaSec,
|
|
})
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
async function onClipBlockContextMenu(e: MouseEvent, ci: ClipInstance) {
|
|
e.preventDefault()
|
|
const clip = clipById.value.get(ci.clipId)
|
|
const ok = await confirmDialog({
|
|
title: t('timeline.removeInstanceDialog.title'),
|
|
message: clip
|
|
? t('timeline.removeInstanceDialog.message', {
|
|
name: clip.name,
|
|
time: ci.startTime.toFixed(2),
|
|
})
|
|
: t('timeline.removeInstanceDialog.generic'),
|
|
confirmLabel: t('timeline.removeInstanceDialog.confirm'),
|
|
variant: 'danger',
|
|
})
|
|
if (!ok) return
|
|
store.removeClipInstance(ci.id)
|
|
}
|
|
|
|
const trackMenu = ref<TrackMenuState | null>(null)
|
|
const trackMenuRef = ref<HTMLDivElement | null>(null)
|
|
|
|
function onTrackContextMenu(e: MouseEvent, layerId: string) {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
const layer = store.activeLayers.find((l) => l.id === layerId)
|
|
if (!layer) return
|
|
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 toggleLocked() {
|
|
if (!trackMenu.value) return
|
|
store.setTrackLocked(trackMenu.value.trackId, !trackMenu.value.locked)
|
|
trackMenu.value = null
|
|
}
|
|
|
|
function applyTrackColor(color: string | null) {
|
|
if (!trackMenu.value) return
|
|
store.setTrackColor(trackMenu.value.trackId, color)
|
|
trackMenu.value = null
|
|
}
|
|
|
|
function trackColorStyle(color: string | null | undefined): Record<string, string> | undefined {
|
|
if (!color) return undefined
|
|
return { '--track-color': color }
|
|
}
|
|
|
|
function toggleSeparated() {
|
|
if (!trackMenu.value) return
|
|
store.setLayerSeparated(trackMenu.value.trackId, !trackMenu.value.separated)
|
|
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 {
|
|
store.removeTrackGroup(trackMenu.value.trackId)
|
|
}
|
|
trackMenu.value = null
|
|
}
|
|
|
|
async function renameFromMenu() {
|
|
if (!trackMenu.value) return
|
|
if (trackMenu.value.kind !== 'group') return
|
|
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)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
function onKeyDown(e: KeyboardEvent) {
|
|
if (e.key === 'Escape') {
|
|
menu.value = null
|
|
trackMenu.value = null
|
|
}
|
|
}
|
|
|
|
function onDocumentKeyDown(e: KeyboardEvent) {
|
|
if (e.key === 'Escape' && selectedKfs.value.length) {
|
|
selectedKfs.value = []
|
|
}
|
|
}
|
|
|
|
watch([menu, trackMenu], ([m, tm]) => {
|
|
if (m || tm) {
|
|
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)
|
|
})
|
|
|
|
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
|
|
|
|
onMounted(() => {
|
|
if (!scrollEl.value) return
|
|
ro = new ResizeObserver((entries) => {
|
|
const rect = entries[0].contentRect
|
|
viewportWidth.value = rect.width
|
|
})
|
|
ro.observe(scrollEl.value)
|
|
document.addEventListener('keydown', onDocumentKeyDown)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
ro?.disconnect()
|
|
ro = null
|
|
document.removeEventListener('pointerdown', onDocumentPointerDown, 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 class="scrub-bar">
|
|
<div class="name-spacer">
|
|
<div class="mode-toggle" :title="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 }"
|
|
:title="snapEnabled ? t('timeline.snapOn') : t('timeline.snapOff')"
|
|
@click="snapEnabled = !snapEnabled"
|
|
>
|
|
<PhMagnet :size="12" :weight="snapEnabled ? 'fill' : 'regular'" />
|
|
</button>
|
|
<button
|
|
v-if="!inClipEdit"
|
|
class="snap-btn"
|
|
:title="t('timeline.addGroupTooltip')"
|
|
@click="addGroup"
|
|
>
|
|
<PhFolderPlus :size="12" />
|
|
</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 class="scrub-handle" :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'}`
|
|
: `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: !!track.layer.locked,
|
|
'in-group': track.inGroup,
|
|
}"
|
|
:style="trackColorStyle(track.layer.color)"
|
|
@dragover="
|
|
track.isParent
|
|
? (track.inGroup && track.parentGroupId !== undefined && track.groupChildIndex !== undefined
|
|
? onGroupChildDragOver($event, track.parentGroupId, track.groupChildIndex)
|
|
: onTrackDragOver($event, track.orderIndex))
|
|
: null
|
|
"
|
|
@drop="
|
|
track.isParent
|
|
? (track.inGroup ? onGroupChildDrop($event) : onTrackDrop($event))
|
|
: null
|
|
"
|
|
@dragleave="onTrackDragLeave"
|
|
>
|
|
<span
|
|
class="track-name"
|
|
:class="{
|
|
selected: track.layerId === store.selectedLayerId && track.isParent,
|
|
parent: track.isParent,
|
|
sub: !track.isParent,
|
|
}"
|
|
:draggable="track.isParent"
|
|
@click="store.selectLayer(track.layerId)"
|
|
@contextmenu="track.isParent ? onTrackContextMenu($event, track.layerId) : null"
|
|
@dragstart="track.isParent ? onTrackDragStart($event, track.layerId) : null"
|
|
@dragend="onTrackDragEnd"
|
|
>
|
|
<PhDotsSixVertical v-if="track.isParent" :size="12" class="drag-handle" />
|
|
<PhLock
|
|
v-if="track.isParent && track.layer.locked"
|
|
:size="11"
|
|
class="lock-badge"
|
|
/>
|
|
<PhArrowsSplit
|
|
v-if="track.isParent && track.layer.separated"
|
|
:size="11"
|
|
class="name-badge"
|
|
/>
|
|
<PhMaskHappy
|
|
v-if="track.isParent && track.layer.isMask"
|
|
:size="11"
|
|
class="name-badge"
|
|
:class="{ 'mask-negative': track.layer.maskMode === 'negative' }"
|
|
/>
|
|
<span v-if="!track.isParent" class="sub-indent">↳</span>
|
|
{{ track.label }}
|
|
</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)"
|
|
:key="seg.id"
|
|
class="kf-seg"
|
|
:class="[seg.held ? 'held' : `easing-${seg.easing}`]"
|
|
: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"
|
|
:title="
|
|
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)"
|
|
/>
|
|
</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: !!clipTrackById.get(track.clipTrackId)?.locked,
|
|
'in-group': track.inGroup,
|
|
}"
|
|
:style="trackColorStyle(clipTrackById.get(track.clipTrackId)?.color)"
|
|
@dragover="
|
|
track.inGroup && track.parentGroupId !== undefined && track.groupChildIndex !== undefined
|
|
? onGroupChildDragOver($event, track.parentGroupId, track.groupChildIndex)
|
|
: onTrackDragOver($event, track.orderIndex)
|
|
"
|
|
@drop="track.inGroup ? onGroupChildDrop($event) : onTrackDrop($event)"
|
|
@dragleave="onTrackDragLeave"
|
|
>
|
|
<span
|
|
class="track-name clip-track-name"
|
|
draggable="true"
|
|
@contextmenu="onClipTrackContextMenu($event, track.clipTrackId)"
|
|
@dragstart="onTrackDragStart($event, track.clipTrackId)"
|
|
@dragend="onTrackDragEnd"
|
|
>
|
|
<PhDotsSixVertical :size="12" class="drag-handle" />
|
|
<PhLock
|
|
v-if="clipTrackById.get(track.clipTrackId)?.locked"
|
|
:size="11"
|
|
class="lock-badge"
|
|
/>
|
|
<PhFilmStrip :size="11" class="name-badge" />
|
|
{{ track.name }}
|
|
</span>
|
|
<div
|
|
class="track-lane clip-lane"
|
|
:style="{ width: laneWidth + 'px' }"
|
|
@dragover.stop="onClipLaneDragOver($event, track.clipTrackId)"
|
|
@drop.stop="onClipLaneDrop($event, track.clipTrackId)"
|
|
>
|
|
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
|
|
<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)"
|
|
:title="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>
|
|
|
|
<!-- Group header -->
|
|
<div
|
|
v-else
|
|
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,
|
|
}"
|
|
:style="trackColorStyle(track.color)"
|
|
@dragover="onTrackDragOver($event, track.orderIndex)"
|
|
@drop="onTrackDrop($event)"
|
|
@dragleave="onTrackDragLeave"
|
|
>
|
|
<span
|
|
class="track-name group-track-name"
|
|
draggable="true"
|
|
@click="toggleGroupCollapsed(track.groupId)"
|
|
@dblclick="renameGroupPrompt(track.groupId, track.name)"
|
|
@contextmenu="onGroupContextMenu($event, track.groupId)"
|
|
@dragstart="onTrackDragStart($event, track.groupId)"
|
|
@dragend="onTrackDragEnd"
|
|
>
|
|
<PhDotsSixVertical :size="12" class="drag-handle" />
|
|
<component
|
|
:is="track.collapsed ? PhCaretRight : PhCaretDown"
|
|
:size="11"
|
|
class="group-caret"
|
|
/>
|
|
<PhFolder :size="12" class="name-badge" />
|
|
{{ track.name }}
|
|
<span class="group-count">{{ t('timeline.groupChildCount', { n: track.childCount }) }}</span>
|
|
</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>
|
|
<p v-if="!tracks.length" class="empty">{{ t('timeline.noTracks') }}</p>
|
|
<div
|
|
v-if="!inClipEdit"
|
|
class="track clip-track-dropzone"
|
|
:class="{ hot: clipDragOverDropzone }"
|
|
@dragenter="onNewTrackDragEnter"
|
|
@dragover="onNewTrackDragOver"
|
|
@dragleave="onNewTrackDragLeave"
|
|
@drop="onNewTrackDrop"
|
|
>
|
|
<span class="dropzone-hint">{{ t('timeline.dropNewTrackHint') }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
class="name-col-resize"
|
|
:class="{ resizing: nameResizing }"
|
|
:style="{ left: trackNameWidth + 'px' }"
|
|
:title="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 && !menu.isHeld }"
|
|
:disabled="menu.isFirst || menu.isHeld"
|
|
@click="applyEasing(opt.value)"
|
|
>
|
|
<span class="check">
|
|
<PhCheck
|
|
v-if="menu.currentEasing === opt.value && !menu.isHeld"
|
|
:size="12"
|
|
weight="bold"
|
|
/>
|
|
</span>
|
|
<span class="kf-menu-icon-spacer"></span>
|
|
{{ opt.label }}
|
|
</button>
|
|
<div v-if="menu.isFirst" class="kf-menu-note">{{ t('timeline.firstKfHint') }}</div>
|
|
<div v-else-if="menu.isHeld" class="kf-menu-note">{{ t('timeline.holdDisabledHint') }}</div>
|
|
</div>
|
|
<div class="kf-menu-divider"></div>
|
|
<button
|
|
class="kf-menu-item"
|
|
:class="{ active: menu.isHeld }"
|
|
:disabled="menu.isLast"
|
|
:title="menu.isLast ? t('timeline.lastKfHint') : ''"
|
|
@click="toggleHold"
|
|
>
|
|
<span class="check">
|
|
<PhCheck v-if="menu.isHeld" :size="12" weight="bold" />
|
|
</span>
|
|
<PhPauseCircle :size="12" class="kf-menu-icon" />
|
|
{{ t('timeline.stillFrame') }}
|
|
</button>
|
|
<div class="kf-menu-divider"></div>
|
|
<button class="kf-menu-item" @click="duplicateFromMenu">
|
|
<span class="check"></span>
|
|
<PhCopy :size="12" class="kf-menu-icon" />
|
|
{{ t('timeline.duplicate') }}
|
|
</button>
|
|
<button class="kf-menu-item danger" @click="deleteFromMenu">
|
|
<span class="check"></span>
|
|
<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"
|
|
:title="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">
|
|
<span class="check"></span>
|
|
<PhArrowsSplit :size="12" class="kf-menu-icon" style="visibility: hidden" />
|
|
{{ t('timeline.renameGroup') }}
|
|
</button>
|
|
</template>
|
|
<template v-else>
|
|
<div class="kf-menu-divider"></div>
|
|
<button class="kf-menu-item" @click="toggleLocked">
|
|
<span class="check">
|
|
<PhCheck v-if="trackMenu.locked" :size="12" weight="bold" />
|
|
</span>
|
|
<component :is="trackMenu.locked ? PhLockOpen : PhLock" :size="12" class="kf-menu-icon" />
|
|
{{ trackMenu.locked ? t('timeline.unlock') : t('timeline.lock') }}
|
|
</button>
|
|
<template v-if="trackMenu.kind === 'layer'">
|
|
<button
|
|
class="kf-menu-item"
|
|
:disabled="trackMenu.locked"
|
|
@click="toggleSeparated"
|
|
>
|
|
<span class="check">
|
|
<PhCheck v-if="trackMenu.separated" :size="12" weight="bold" />
|
|
</span>
|
|
<PhArrowsSplit :size="12" class="kf-menu-icon" />
|
|
{{ t('timeline.separate') }}
|
|
</button>
|
|
</template>
|
|
</template>
|
|
<div class="kf-menu-divider"></div>
|
|
<button class="kf-menu-item danger" @click="deleteTrack">
|
|
<span class="check"></span>
|
|
<PhTrash :size="12" class="kf-menu-icon" />
|
|
{{
|
|
trackMenu.kind === 'layer'
|
|
? t('timeline.deleteLayer')
|
|
: trackMenu.kind === 'clip'
|
|
? t('timeline.deleteClipTrack')
|
|
: t('timeline.ungroup')
|
|
}}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.timeline {
|
|
--track-name-width: 140px;
|
|
position: relative;
|
|
height: 100%;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.name-col-resize {
|
|
position: absolute;
|
|
top: 0;
|
|
bottom: 0;
|
|
width: 8px;
|
|
transform: translateX(-4px);
|
|
cursor: ew-resize;
|
|
z-index: 15;
|
|
touch-action: none;
|
|
}
|
|
|
|
.name-col-resize::after {
|
|
content: '';
|
|
position: absolute;
|
|
top: 0;
|
|
bottom: 0;
|
|
left: 4px;
|
|
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;
|
|
z-index: 10;
|
|
display: flex;
|
|
height: 28px;
|
|
background: var(--panel);
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
.name-spacer {
|
|
position: sticky;
|
|
left: 0;
|
|
z-index: 2;
|
|
width: var(--track-name-width);
|
|
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);
|
|
}
|
|
|
|
.mode-toggle {
|
|
display: inline-flex;
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-sm);
|
|
overflow: hidden;
|
|
background: var(--bg);
|
|
}
|
|
|
|
.mode-btn {
|
|
padding: 3px 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;
|
|
}
|
|
|
|
.scrub-handle {
|
|
position: absolute;
|
|
top: 0;
|
|
bottom: -1px;
|
|
transform: translateX(-50%);
|
|
pointer-events: none;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
}
|
|
|
|
.scrub-handle-head {
|
|
width: 12px;
|
|
height: 14px;
|
|
background: var(--accent);
|
|
border-radius: 2px;
|
|
margin-top: 2px;
|
|
box-shadow: 0 0 0 1px var(--panel), 0 2px 6px rgba(0, 0, 0, 0.4);
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.track.inactive .track-lane > :not(.playhead) {
|
|
opacity: 0.4;
|
|
transition: opacity 140ms var(--ease);
|
|
}
|
|
|
|
.track.inactive:hover .track-lane > :not(.playhead) {
|
|
opacity: 0.75;
|
|
}
|
|
|
|
.track.locked {
|
|
height: 15px;
|
|
}
|
|
|
|
.track.clip-track.locked {
|
|
height: 17px;
|
|
}
|
|
|
|
.track.sub-track.locked {
|
|
height: 13px;
|
|
}
|
|
|
|
.track.locked .track-name {
|
|
font-size: 10px;
|
|
}
|
|
|
|
.track.locked .track-lane {
|
|
background-image: repeating-linear-gradient(
|
|
45deg,
|
|
rgba(255, 255, 255, 0.04) 0,
|
|
rgba(255, 255, 255, 0.04) 4px,
|
|
transparent 4px,
|
|
transparent 8px
|
|
);
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.track.locked .kf,
|
|
.track.locked .kf-seg,
|
|
.track.locked .clip-block {
|
|
opacity: 0.55;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.track.locked .drag-handle {
|
|
display: none;
|
|
}
|
|
|
|
.lock-badge {
|
|
color: var(--text-dim);
|
|
margin-right: 4px;
|
|
flex-shrink: 0;
|
|
opacity: 0.85;
|
|
}
|
|
|
|
/* Neutralize hover states on locked tracks */
|
|
.track.locked .track-name:hover {
|
|
background: var(--panel);
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
.track.locked .track-name.selected:hover {
|
|
background:
|
|
linear-gradient(var(--accent-soft), var(--accent-soft)),
|
|
var(--panel);
|
|
color: var(--accent);
|
|
}
|
|
|
|
.track.locked.sub-track .track-name:hover {
|
|
background: var(--panel-alt);
|
|
}
|
|
|
|
.track.locked.inactive:hover .track-lane {
|
|
opacity: 0.4;
|
|
}
|
|
|
|
.track.locked .clip-block:hover {
|
|
background: color-mix(in srgb, var(--track-color, var(--accent)) 14%, transparent);
|
|
transform: none;
|
|
}
|
|
|
|
.track.locked .clip-block:hover .clip-handle {
|
|
opacity: 0;
|
|
}
|
|
|
|
.track.locked .clip-block.trimmed-start:hover .clip-handle.start,
|
|
.track.locked .clip-block.trimmed-end:hover .clip-handle.end {
|
|
opacity: 0.55;
|
|
}
|
|
|
|
.track.locked .clip-handle:hover,
|
|
.track.locked .clip-handle:active {
|
|
background: transparent !important;
|
|
opacity: 0 !important;
|
|
}
|
|
|
|
.track.locked .kf:hover {
|
|
background: var(--track-color, var(--accent));
|
|
transform: rotate(45deg);
|
|
}
|
|
|
|
.track.locked .kf.hold:hover {
|
|
background: var(--panel);
|
|
border-color: var(--track-color, var(--accent));
|
|
transform: none;
|
|
}
|
|
|
|
.track-name {
|
|
position: sticky;
|
|
left: 0;
|
|
z-index: 5;
|
|
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);
|
|
z-index: 4;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.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;
|
|
background: linear-gradient(
|
|
to bottom,
|
|
transparent 0%,
|
|
rgba(110, 168, 255, 0.04) 50%,
|
|
transparent 100%
|
|
);
|
|
}
|
|
|
|
.clip-track .track-name {
|
|
background: var(--panel-alt);
|
|
font-size: 11px;
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
.clip-track-name {
|
|
gap: 6px;
|
|
}
|
|
|
|
.clip-lane {
|
|
position: relative;
|
|
}
|
|
|
|
.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 {
|
|
opacity: 1;
|
|
}
|
|
|
|
.clip-block.trimmed-start .clip-handle.start,
|
|
.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 {
|
|
background: #ffc857;
|
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
|
|
}
|
|
|
|
.clip-block.trimming .clip-handle {
|
|
opacity: 1;
|
|
}
|
|
|
|
.clip-block.trimming {
|
|
box-shadow: 0 0 0 1px #ffc857;
|
|
}
|
|
|
|
.clip-block.moving {
|
|
box-shadow: 0 0 0 1px var(--track-color, var(--accent)), 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
cursor: grabbing;
|
|
z-index: 4;
|
|
}
|
|
|
|
.clip-handle:hover,
|
|
.clip-handle:active {
|
|
background: rgba(255, 255, 255, 0.25) !important;
|
|
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;
|
|
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 {
|
|
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: sticky;
|
|
left: 50%;
|
|
transform: translateX(-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);
|
|
}
|
|
|
|
.check {
|
|
width: 14px;
|
|
height: 12px;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: var(--accent);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.kf-menu-icon,
|
|
.kf-menu-icon-spacer {
|
|
width: 12px;
|
|
height: 12px;
|
|
flex-shrink: 0;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.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>
|