bak
This commit is contained in:
1 parent
43933f93d1
commit
03b1147a93
10 files changed
+804
-80
No files matched your search
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import type Konva from 'konva'
|
||||
import { PhCornersIn, PhCrop } from '@phosphor-icons/vue'
|
||||
import { PhCornersIn, PhCrop, PhMaskHappy, PhPencilSimple } from '@phosphor-icons/vue'
|
||||
import { useSceneStore } from '@/stores/scene'
|
||||
import { sampleLayer } from '@/lib/interpolate'
|
||||
import { loadImage } from '@/lib/fileLoader'
|
||||
import { drawSceneFrame, loadSceneImages } from '@/lib/render'
|
||||
import type { AnimatableProps, Asset, Layer } from '@/types/scene'
|
||||
import { t } from '@/i18n'
|
||||
import { useKfSelection } from '@/composables/useKfSelection'
|
||||
@@ -22,6 +23,9 @@ const zoom = ref(1)
|
||||
const viewX = ref(0)
|
||||
const viewY = ref(0)
|
||||
const cropObjects = ref(false)
|
||||
const maskPreview = ref(false)
|
||||
const previewCanvasEl = ref<HTMLCanvasElement | null>(null)
|
||||
const previewImages = ref<Record<string, HTMLImageElement>>({})
|
||||
let didInitialFit = false
|
||||
|
||||
const ZOOM_MIN = 0.05
|
||||
@@ -251,6 +255,15 @@ function shadowCfg(props: AnimatableProps): Record<string, unknown> {
|
||||
}
|
||||
|
||||
function baseNodeConfig(layer: Layer, props: AnimatableProps) {
|
||||
const maskGhost = layer.isMask
|
||||
? {
|
||||
opacity: props.opacity * 0.45,
|
||||
stroke: layer.maskMode === 'negative' ? '#ff6b6b' : '#6ea8ff',
|
||||
strokeWidth: 1.5,
|
||||
dash: [6, 4],
|
||||
strokeScaleEnabled: false,
|
||||
}
|
||||
: {}
|
||||
return {
|
||||
id: layer.id,
|
||||
x: props.x,
|
||||
@@ -262,6 +275,7 @@ function baseNodeConfig(layer: Layer, props: AnimatableProps) {
|
||||
visible: layer.visible,
|
||||
draggable: true,
|
||||
...shadowCfg(props),
|
||||
...maskGhost,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -825,6 +839,62 @@ function onContainerDrop(e: DragEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
function resizePreviewCanvas() {
|
||||
const canvas = previewCanvasEl.value
|
||||
if (!canvas) return
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const cw = containerSize.value.width
|
||||
const ch = containerSize.value.height
|
||||
canvas.width = Math.max(1, Math.round(cw * dpr))
|
||||
canvas.height = Math.max(1, Math.round(ch * dpr))
|
||||
canvas.style.width = `${cw}px`
|
||||
canvas.style.height = `${ch}px`
|
||||
}
|
||||
|
||||
function drawPreview() {
|
||||
const canvas = previewCanvasEl.value
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const cw = containerSize.value.width
|
||||
const ch = containerSize.value.height
|
||||
if (!cw || !ch) return
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
ctx.clearRect(0, 0, cw, ch)
|
||||
ctx.save()
|
||||
ctx.translate(viewX.value, viewY.value)
|
||||
ctx.scale(zoom.value, zoom.value)
|
||||
drawSceneFrame(ctx, store.scene, store.playhead, previewImages.value)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
watch(
|
||||
[
|
||||
() => store.playhead,
|
||||
() => store.scene,
|
||||
() => containerSize.value,
|
||||
zoom,
|
||||
viewX,
|
||||
viewY,
|
||||
],
|
||||
async () => {
|
||||
if (!maskPreview.value) return
|
||||
await nextTick()
|
||||
resizePreviewCanvas()
|
||||
drawPreview()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(maskPreview, async (on) => {
|
||||
if (!on) return
|
||||
previewImages.value = await loadSceneImages(store.scene)
|
||||
await nextTick()
|
||||
resizePreviewCanvas()
|
||||
drawPreview()
|
||||
})
|
||||
|
||||
function zoomBy(factor: number) {
|
||||
const cw = containerSize.value.width
|
||||
const ch = containerSize.value.height
|
||||
@@ -966,6 +1036,11 @@ onBeforeUnmount(() => {
|
||||
<v-transformer ref="transformerRef" :config="transformerConfig" />
|
||||
</v-layer>
|
||||
</v-stage>
|
||||
<canvas
|
||||
v-show="maskPreview"
|
||||
ref="previewCanvasEl"
|
||||
class="mask-preview-canvas"
|
||||
></canvas>
|
||||
|
||||
<div class="zoom-controls" @pointerdown.stop>
|
||||
<button class="zc-btn" :title="t('canvas.zoomOut')" @click="zoomBy(1 / 1.25)">−</button>
|
||||
@@ -987,6 +1062,15 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<PhCrop :size="13" />
|
||||
</button>
|
||||
<button
|
||||
class="zc-btn"
|
||||
:class="{ active: maskPreview }"
|
||||
:title="maskPreview ? t('canvas.maskPreviewOff') : t('canvas.maskPreviewOn')"
|
||||
@click="maskPreview = !maskPreview"
|
||||
>
|
||||
<PhMaskHappy v-if="!maskPreview" :size="13" />
|
||||
<PhPencilSimple v-else :size="13" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="dims">{{ store.scene.canvas.width }} × {{ store.scene.canvas.height }}</div>
|
||||
@@ -1001,6 +1085,13 @@ onBeforeUnmount(() => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mask-preview-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.dims {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
|
||||
@@ -39,6 +39,17 @@ const blurAsset = computed(() => {
|
||||
return a && (a.kind === 'rect' || a.kind === 'circle') ? a : null
|
||||
})
|
||||
|
||||
const canBeMask = computed(() => {
|
||||
const a = selectedAsset.value
|
||||
return !!a && (a.kind === 'rect' || a.kind === 'circle' || a.kind === 'text')
|
||||
})
|
||||
|
||||
const maskInGroup = computed(() => {
|
||||
if (!selected.value) return false
|
||||
const id = selected.value.id
|
||||
return (store.scene.groups ?? []).some((g) => g.childIds.includes(id))
|
||||
})
|
||||
|
||||
const blurValue = computed<number>({
|
||||
get: () => {
|
||||
const a = blurAsset.value
|
||||
@@ -255,6 +266,43 @@ function removeKeyframeHere() {
|
||||
<RangeSlider v-model="opacity" :min="0" :max="1" :step="0.01" :format="formatPercent" />
|
||||
</label>
|
||||
|
||||
<section v-if="canBeMask" class="mask-group">
|
||||
<h4>{{ t('properties.mask') }}</h4>
|
||||
<label class="mask-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="!!selected.isMask"
|
||||
@change="store.setLayerMask(selected.id, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<span>{{ t('properties.maskUse') }}</span>
|
||||
</label>
|
||||
<div v-if="selected.isMask" class="mask-mode-row">
|
||||
<label class="mask-radio">
|
||||
<input
|
||||
type="radio"
|
||||
:checked="(selected.maskMode ?? 'positive') === 'positive'"
|
||||
@change="store.setLayerMaskMode(selected.id, 'positive')"
|
||||
/>
|
||||
<span>{{ t('properties.maskPositive') }}</span>
|
||||
</label>
|
||||
<label class="mask-radio">
|
||||
<input
|
||||
type="radio"
|
||||
:checked="selected.maskMode === 'negative'"
|
||||
@change="store.setLayerMaskMode(selected.id, 'negative')"
|
||||
/>
|
||||
<span>{{ t('properties.maskNegative') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="selected.isMask && maskInGroup" class="status-active">
|
||||
{{ t('properties.maskActive') }}
|
||||
</p>
|
||||
<p v-if="selected.isMask && !maskInGroup" class="status-warn">
|
||||
{{ t('properties.maskNeedsGroup') }}
|
||||
</p>
|
||||
<p v-if="selected.isMask" class="hint">{{ t('properties.maskHint') }}</p>
|
||||
</section>
|
||||
|
||||
<button class="danger-btn" @click="store.removeLayer(selected.id)">
|
||||
<PhTrash :size="13" />
|
||||
{{ t('properties.deleteLayer') }}
|
||||
@@ -332,6 +380,65 @@ input {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mask-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.mask-group h4 {
|
||||
margin: 0;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-dim);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mask-toggle,
|
||||
.mask-radio {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
cursor: pointer;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.mask-mode-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mask-toggle input,
|
||||
.mask-radio input {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.status-active,
|
||||
.status-warn {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.status-active {
|
||||
color: #6ea8ff;
|
||||
background: color-mix(in srgb, #6ea8ff 14%, transparent);
|
||||
}
|
||||
|
||||
.status-warn {
|
||||
color: #ffb84d;
|
||||
background: color-mix(in srgb, #ffb84d 16%, transparent);
|
||||
}
|
||||
|
||||
.kf-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
+180
-20
@@ -12,6 +12,7 @@ import {
|
||||
PhLock,
|
||||
PhLockOpen,
|
||||
PhMagnet,
|
||||
PhMaskHappy,
|
||||
PhPauseCircle,
|
||||
PhTrash,
|
||||
} from '@phosphor-icons/vue'
|
||||
@@ -41,6 +42,8 @@ interface TimelineLayerTrack {
|
||||
isParent: boolean
|
||||
orderIndex: number
|
||||
inGroup: boolean
|
||||
parentGroupId?: string
|
||||
groupChildIndex?: number
|
||||
}
|
||||
|
||||
interface TimelineClipTrack {
|
||||
@@ -49,6 +52,8 @@ interface TimelineClipTrack {
|
||||
name: string
|
||||
orderIndex: number
|
||||
inGroup: boolean
|
||||
parentGroupId?: string
|
||||
groupChildIndex?: number
|
||||
}
|
||||
|
||||
interface TimelineGroupTrack {
|
||||
@@ -240,6 +245,30 @@ 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)}`
|
||||
@@ -340,13 +369,21 @@ function onKeyframeDblClick(e: MouseEvent, layerId: string, keyframeId: string)
|
||||
store.removeKeyframe(layerId, keyframeId)
|
||||
}
|
||||
|
||||
function emitLayerTrack(layer: Layer, orderIndex: number, inGroup: boolean, out: TimelineTrack[]) {
|
||||
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({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex, inGroup })
|
||||
out.push({ ...base, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true })
|
||||
} else {
|
||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex, inGroup })
|
||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.positionKeyframes ?? [], kind: 'position', label: t('timeline.position'), isParent: false, orderIndex, inGroup })
|
||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.rotationKeyframes ?? [], kind: 'rotation', label: t('timeline.rotation'), isParent: false, orderIndex, inGroup })
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,17 +421,25 @@ const tracks = computed<TimelineTrack[]>(() => {
|
||||
childCount: grp.childIds.length,
|
||||
})
|
||||
if (!grp.collapsed) {
|
||||
for (const cid of grp.childIds) {
|
||||
grp.childIds.forEach((cid, childIndex) => {
|
||||
const cl = layerById.get(cid)
|
||||
if (cl) {
|
||||
emitLayerTrack(cl, -1, true, out)
|
||||
continue
|
||||
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 })
|
||||
out.push({
|
||||
type: 'clip',
|
||||
clipTrackId: cct.id,
|
||||
name: cct.name ?? t('timeline.clipTrackName'),
|
||||
orderIndex: -1,
|
||||
inGroup: true,
|
||||
parentGroupId: grp.id,
|
||||
groupChildIndex: childIndex,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -412,16 +457,44 @@ function isTrackHead(track: TimelineTrack): boolean {
|
||||
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
|
||||
@@ -727,6 +800,7 @@ const TRACK_COLORS: Array<{ value: string | null; label: string }> = [
|
||||
|
||||
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
|
||||
@@ -748,6 +822,45 @@ function onTrackDragOver(e: DragEvent, orderIndex: number) {
|
||||
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() {
|
||||
@@ -773,6 +886,7 @@ function onTrackDragEnd() {
|
||||
dragTrackId.value = null
|
||||
dropIndex.value = null
|
||||
dropGroupId.value = null
|
||||
dropWithinGroupId.value = null
|
||||
}
|
||||
|
||||
const dropGroupId = ref<string | null>(null)
|
||||
@@ -1470,16 +1584,26 @@ onBeforeUnmount(() => {
|
||||
:class="{
|
||||
'sub-track': !track.isParent,
|
||||
inactive: store.selectedLayerId !== null && track.layerId !== store.selectedLayerId,
|
||||
'drop-before': !track.inGroup && isDropBefore(track),
|
||||
'drop-after': !track.inGroup && isDropAfter(track, tracks[idx + 1]),
|
||||
'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 ? onTrackDragOver($event, track.orderIndex) : null"
|
||||
@drop="track.isParent && !track.inGroup ? onTrackDrop($event) : null"
|
||||
@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
|
||||
@@ -1506,6 +1630,12 @@ onBeforeUnmount(() => {
|
||||
: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>
|
||||
@@ -1556,16 +1686,20 @@ onBeforeUnmount(() => {
|
||||
v-else-if="track.type === 'clip'"
|
||||
class="track clip-track"
|
||||
:class="{
|
||||
'drop-before': !track.inGroup && isDropBefore(track),
|
||||
'drop-after': !track.inGroup && isDropAfter(track, tracks[idx + 1]),
|
||||
'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 ? onTrackDragOver($event, track.orderIndex) : null"
|
||||
@drop="!track.inGroup ? onTrackDrop($event) : null"
|
||||
@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
|
||||
@@ -1683,9 +1817,18 @@ onBeforeUnmount(() => {
|
||||
@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>
|
||||
@@ -2289,6 +2432,10 @@ onBeforeUnmount(() => {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.name-badge.mask-negative {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
color: var(--text-dim);
|
||||
margin-right: 4px;
|
||||
@@ -2649,12 +2796,25 @@ onBeforeUnmount(() => {
|
||||
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: 22px;
|
||||
padding-left: 38px;
|
||||
}
|
||||
|
||||
.track.in-group.sub-track .track-name {
|
||||
padding-left: 34px;
|
||||
padding-left: 50px;
|
||||
}
|
||||
|
||||
.clip-track-dropzone {
|
||||
|
||||
Reference in new issue
Block a user