This commit is contained in:
th committed 2026-04-20 00:07:08 +02:00
1 parent 6b6157d786
commit 3ef5fb5a2d
19 files changed
+1244 -94

No files matched your search

+149 -50
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
PhArrowsSplit,
PhCheck,
@@ -16,6 +16,7 @@ import { sampleLayer } from '@/lib/interpolate'
import { snapToCandidates } from '@/lib/timelineSnap'
import { confirmDialog } from '@/components/ui'
import { t } from '@/i18n'
import { kfSelectionKey, useKfSelection, type KfSelectionKey } from '@/composables/useKfSelection'
import type {
AnimatableProps,
Clip,
@@ -318,23 +319,9 @@ function isDropAfter(track: TimelineTrack, nextTrack: TimelineTrack | undefined)
return true
}
interface SelectionKey {
layerId: string
kind: 'main' | 'position' | 'rotation'
keyframeId: string
}
const selectedKfs = ref<SelectionKey[]>([])
function selectionKey(k: SelectionKey): string {
return `${k.layerId}:${k.kind}:${k.keyframeId}`
}
const selectedKfSet = computed(() => {
const s = new Set<string>()
for (const k of selectedKfs.value) s.add(selectionKey(k))
return s
})
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}`)
@@ -687,6 +674,7 @@ interface BoxSelectState {
currentClientY: number
tracksEl: HTMLElement
moved: boolean
additive: boolean
}
const boxSelect = ref<BoxSelectState | null>(null)
@@ -728,6 +716,7 @@ function onTracksPointerDown(e: PointerEvent) {
currentClientY: e.clientY,
tracksEl,
moved: false,
additive: e.shiftKey,
}
}
@@ -749,8 +738,8 @@ function onTracksPointerUp(e: PointerEvent) {
if (b.tracksEl.hasPointerCapture(e.pointerId)) b.tracksEl.releasePointerCapture(e.pointerId)
if (b.moved) {
commitBoxSelection(b)
} else {
// bare click on empty space → clear selection
} else if (!b.additive) {
// bare click on empty space without Shift → clear selection
clearKfSelection()
}
boxSelect.value = null
@@ -763,7 +752,7 @@ function commitBoxSelection(b: BoxSelectState) {
const bottom = Math.max(b.originClientY, b.currentClientY)
const nodes = b.tracksEl.querySelectorAll<HTMLElement>('.kf[data-kf-id]')
const next: SelectionKey[] = []
const hits: SelectionKey[] = []
for (const node of Array.from(nodes)) {
const r = node.getBoundingClientRect()
const cx = r.left + r.width / 2
@@ -773,9 +762,22 @@ function commitBoxSelection(b: BoxSelectState) {
const kind = node.dataset.kind as 'main' | 'position' | 'rotation' | undefined
const keyframeId = node.dataset.kfId
if (!layerId || !kind || !keyframeId) continue
next.push({ layerId, kind, keyframeId })
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
}
selectedKfs.value = next
}
function pixelTimeFromEvent(e: DragEvent | PointerEvent, el: HTMLElement): number {
@@ -858,8 +860,46 @@ function onClipBlockPointerUp(e: PointerEvent) {
if (wasMoved) e.preventDefault()
}
function addClipTrack() {
store.addClipTrack()
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 = 140
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 {
@@ -1076,6 +1116,36 @@ watch([menu, trackMenu], ([m, tm]) => {
}
})
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) {
@@ -1308,6 +1378,7 @@ onBeforeUnmount(() => {
@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"
@@ -1357,11 +1428,16 @@ onBeforeUnmount(() => {
</div>
</template>
<p v-if="!tracks.length" class="empty">{{ t('timeline.noTracks') }}</p>
<div v-if="!inClipEdit" class="track clip-track add-clip-track">
<button class="add-clip-btn" :title="t('timeline.addClipTrackTooltip')" @click="addClipTrack">
<PhPlus :size="11" weight="bold" />
<span>{{ t('timeline.addClipTrack') }}</span>
</button>
<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>
@@ -1391,6 +1467,7 @@ onBeforeUnmount(() => {
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>
@@ -1407,16 +1484,18 @@ onBeforeUnmount(() => {
<span class="check">
<PhCheck v-if="menu.isHeld" :size="12" weight="bold" />
</span>
<PhPauseCircle :size="12" />
<PhPauseCircle :size="12" class="kf-menu-icon" />
{{ t('timeline.stillFrame') }}
</button>
<div class="kf-menu-divider"></div>
<button class="kf-menu-item" @click="duplicateFromMenu">
<PhCopy :size="12" />
<span class="check"></span>
<PhCopy :size="12" class="kf-menu-icon" />
{{ t('timeline.duplicate') }}
</button>
<button class="kf-menu-item danger" @click="deleteFromMenu">
<PhTrash :size="12" />
<span class="check"></span>
<PhTrash :size="12" class="kf-menu-icon" />
{{ t('timeline.delete') }}
</button>
</div>
@@ -1428,7 +1507,6 @@ onBeforeUnmount(() => {
:style="{ left: trackMenu.x + 'px', top: trackMenu.y + 'px' }"
>
<div class="kf-menu-section">
<div class="kf-menu-header">{{ t('timeline.color') }}</div>
<div class="color-tiles">
<button
v-for="c in TRACK_COLORS"
@@ -1452,11 +1530,10 @@ onBeforeUnmount(() => {
<span class="check">
<PhCheck v-if="trackMenu.locked" :size="12" weight="bold" />
</span>
<component :is="trackMenu.locked ? PhLockOpen : PhLock" :size="12" />
<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'">
<div class="kf-menu-divider"></div>
<button
class="kf-menu-item"
:disabled="trackMenu.locked"
@@ -1465,12 +1542,14 @@ onBeforeUnmount(() => {
<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>
<div class="kf-menu-divider"></div>
<button class="kf-menu-item danger" @click="deleteTrack">
<PhTrash :size="12" />
<span class="check"></span>
<PhTrash :size="12" class="kf-menu-icon" />
{{ trackMenu.kind === 'layer' ? t('timeline.deleteLayer') : t('timeline.deleteClipTrack') }}
</button>
</div>
@@ -1822,7 +1901,7 @@ onBeforeUnmount(() => {
.track-name {
position: sticky;
left: 0;
z-index: 3;
z-index: 5;
width: var(--track-name-width);
padding: 0 12px;
color: var(--text-dim);
@@ -1931,6 +2010,7 @@ onBeforeUnmount(() => {
background: var(--accent);
pointer-events: none;
transform: translateX(-0.5px);
z-index: 4;
}
.kf-seg {
@@ -2193,28 +2273,34 @@ onBeforeUnmount(() => {
opacity: 1 !important;
}
.add-clip-track {
.clip-track-dropzone {
height: 26px;
background: transparent;
border-bottom: 1px dashed var(--border);
}
.add-clip-btn {
display: inline-flex;
display: flex;
align-items: center;
gap: 6px;
height: 100%;
padding: 0 14px;
justify-content: center;
background: transparent;
border: none;
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;
}
.add-clip-btn:hover {
.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;
@@ -2280,9 +2366,22 @@ onBeforeUnmount(() => {
.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 {