bak
This commit is contained in:
1 parent
6b6157d786
commit
3ef5fb5a2d
19 files changed
+1244
-94
No files matched your search
+149
-50
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user