feat: 5-frame button assets + timeline/sidebar polish
Button assets
- Re-introduce button-asset type with 4 content states (normal/hover/
pressed/disabled) stored as keyframes at BUTTON_STATE_TIMES, plus a
separate hit-area editor mode.
- useButtonRuntime composable for per-instance state/disabled flags on
the main stage.
- Sidebar library lists button assets with live 80×80 thumbnails;
renderButtonThumbnail now accepts an images map so image-kind layers
inside a button render instead of producing a fully-transparent PNG.
- Compact (no-thumbnail) mode aligns button rows to the library-row
height.
Scope footer
- Button-edit mode now uses the same sidebar scope-footer pattern as
clip-edit (back button to "Main timeline" + active scope chip);
removed the inline exit button from the timeline scrub-bar.
Timeline
- Track-name column default bumped to 260px to match the assets sidebar
above it (storage key bumped to :v2 so stale values reset).
- scrub-bar z-index raised above sticky track-name columns so rows
scroll under it instead of over.
- scrub-handle flips z-index based on horizontal scroll: above
name-spacer when the playhead is in view (no half-clip at t=0),
below when the playhead has scrolled off the left edge (so the
toggle row cleanly covers it).
- name-spacer extends ±3/1px to fully cover the obscured handle.
- scrub-handle head pokes 3px above the ruler with rounded top corners
via clip-path path().
Track context menu
- Added missing i18n keys (renameScriptTrack / deleteScriptTrack /
renameScriptTrackDialog.{title,confirm}).
- Rename items show a visible pencil icon; empty .check slot removed
so items line up flush-left; toggle items place their active ✓ on
the right (kf-menu-trailing-check).
Dev HUD
- Test progress bar flips to --danger as soon as the first failure is
recorded, instead of only at the end of the run.
Regression tests
- renderButtonThumbnail: null on empty button, renders shape layers,
accepts custom images map.
- i18n: renameScriptTrack / deleteScriptTrack / dialog keys resolve.
- scrub-handle-obscured logic (visible at t=0, hidden when playhead is
scrolled off-screen, still visible when playhead is far to the
right of the scroll offset).
- Updated button state-scrubber expectations to match
BUTTON_STATE_TIMES (normal=1, hover=2, pressed=3, disabled=4) and
that hit-mode parks at normal=1 rather than 0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
31604052ca
commit
c3764201b8
16 files changed
+2560
-349
No files matched your search
+483
-21
@@ -40,6 +40,7 @@ import type {
|
||||
Clip,
|
||||
ClipInstance,
|
||||
Easing,
|
||||
HitShape,
|
||||
Keyframe,
|
||||
Layer,
|
||||
TrackGroup,
|
||||
@@ -97,7 +98,9 @@ type TimelineTrack =
|
||||
|
||||
const store = useSceneStore()
|
||||
|
||||
const duration = computed(() => store.activeDuration)
|
||||
const duration = computed(() =>
|
||||
store.activeButtonAssetId ? 5 : store.activeDuration,
|
||||
)
|
||||
const inClipEdit = computed(() => store.activeClipId !== null)
|
||||
const clipInstancesByTrack = computed<Record<string, ClipInstance[]>>(() => {
|
||||
const out: Record<string, ClipInstance[]> = {}
|
||||
@@ -133,8 +136,11 @@ const scriptFramesByTrack = computed<Record<string, typeof store.scene.scriptFra
|
||||
}
|
||||
return out
|
||||
})
|
||||
const TRACK_NAME_WIDTH_KEY = 'animatr:trackNameWidth'
|
||||
const TRACK_NAME_WIDTH_DEFAULT = 140
|
||||
// Bump the storage key so clients with an old (narrower) stored value pick
|
||||
// up the new default that matches the assets-sidebar column width (260px),
|
||||
// which keeps the timeline track-name column visually aligned with it.
|
||||
const TRACK_NAME_WIDTH_KEY = 'animatr:trackNameWidth:v2'
|
||||
const TRACK_NAME_WIDTH_DEFAULT = 260
|
||||
const TRACK_NAME_WIDTH_MIN = 80
|
||||
const TRACK_NAME_WIDTH_MAX = 400
|
||||
|
||||
@@ -198,8 +204,16 @@ function onNameResizeEnd(e: PointerEvent) {
|
||||
const MIN_PX_PER_SEC = 2
|
||||
const MAX_PX_PER_SEC = 2000
|
||||
|
||||
// Fixed 100px per state-time unit in button-edit mode. Hoisted here so the
|
||||
// effectivePxPerSec computed below can reference it.
|
||||
const BUTTON_STATE_WIDTH = 100
|
||||
|
||||
const scrollEl = ref<HTMLDivElement | null>(null)
|
||||
const viewportWidth = ref(0)
|
||||
// Tracks horizontal scroll offset of the timeline so we can hide the
|
||||
// scrub-handle behind the sticky name-spacer when the playhead has
|
||||
// scrolled off the left edge of the visible ruler area.
|
||||
const scrollLeft = ref(0)
|
||||
const pxPerSec = ref(0)
|
||||
const autoFit = ref(true)
|
||||
|
||||
@@ -214,6 +228,9 @@ const fitPxPerSec = computed(() => {
|
||||
})
|
||||
|
||||
const effectivePxPerSec = computed(() => {
|
||||
// Button-edit uses a fixed 100px per state unit so the kf dots on tracks
|
||||
// line up with the state labels on the scrubber above.
|
||||
if (store.activeButtonAssetId) return BUTTON_STATE_WIDTH
|
||||
if (autoFit.value || pxPerSec.value <= 0) return fitPxPerSec.value
|
||||
return pxPerSec.value
|
||||
})
|
||||
@@ -284,6 +301,15 @@ function pxFromTime(t: number) {
|
||||
return t * effectivePxPerSec.value
|
||||
}
|
||||
|
||||
// True when the playhead has scrolled off the left side of the visible ruler
|
||||
// area (so the handle's centre sits behind the sticky name-spacer column).
|
||||
// Used to drop the handle's z-index below the name-spacer so it doesn't
|
||||
// render over the toggle-row buttons. When in view, the handle sits above
|
||||
// the name-spacer so its left half isn't clipped at t=0.
|
||||
const scrubHandleObscured = computed(
|
||||
() => scrollLeft.value > pxFromTime(store.playhead),
|
||||
)
|
||||
|
||||
function groupKeyframeTimes(groupId: string): number[] {
|
||||
const group = groupById.value.get(groupId)
|
||||
if (!group) return []
|
||||
@@ -628,6 +654,19 @@ function isTrackEffectivelyLocked(track: TimelineTrack): boolean {
|
||||
|
||||
const tracks = computed<TimelineTrack[]>(() => {
|
||||
const out: TimelineTrack[] = []
|
||||
// Button-asset edit: show only the active state's layers. 'hit' mode
|
||||
// has no layer stack — timeline is empty (user edits hit shapes on
|
||||
// the stage).
|
||||
if (store.activeButtonAssetId) {
|
||||
// Hit mode is a separate stage — it owns the timeline, shows hit shapes
|
||||
// as tracks (rendered below, outside the main track v-for), and layers
|
||||
// from the 4 content states do not appear here.
|
||||
if (store.activeButtonEditMode === 'hit') return out
|
||||
store.activeLayers.forEach((layer, orderIndex) => {
|
||||
emitLayerTrack(layer, orderIndex, false, out)
|
||||
})
|
||||
return out
|
||||
}
|
||||
if (store.activeClipId) {
|
||||
store.activeLayers.forEach((layer, orderIndex) => {
|
||||
emitLayerTrack(layer, orderIndex, false, out)
|
||||
@@ -936,6 +975,63 @@ function onKeyframePointerUp(e: PointerEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Script frame dragging ------------------------------------------------
|
||||
|
||||
interface SfDrag {
|
||||
frameId: string
|
||||
lane: HTMLElement
|
||||
pointerId: number
|
||||
startX: number
|
||||
moved: boolean
|
||||
initialTime: number
|
||||
}
|
||||
|
||||
const sfDrag = ref<SfDrag | null>(null)
|
||||
|
||||
function onScriptFramePointerDown(e: PointerEvent, frameId: string) {
|
||||
if (e.button !== 0) return
|
||||
const el = e.currentTarget as HTMLElement
|
||||
const lane = el.parentElement
|
||||
if (!lane) return
|
||||
const frame = store.scene.scriptFrames?.find((f) => f.id === frameId)
|
||||
if (!frame) return
|
||||
e.stopPropagation()
|
||||
el.setPointerCapture(e.pointerId)
|
||||
sfDrag.value = {
|
||||
frameId,
|
||||
lane,
|
||||
pointerId: e.pointerId,
|
||||
startX: e.clientX,
|
||||
moved: false,
|
||||
initialTime: frame.time,
|
||||
}
|
||||
}
|
||||
|
||||
function onScriptFramePointerMove(e: PointerEvent) {
|
||||
const d = sfDrag.value
|
||||
if (!d || d.pointerId !== e.pointerId) return
|
||||
if (!d.moved && Math.abs(e.clientX - d.startX) < 3) return
|
||||
d.moved = true
|
||||
const rect = d.lane.getBoundingClientRect()
|
||||
if (rect.width <= 0) return
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
const newTime = snapTime(ratio * duration.value)
|
||||
store.setScriptFrameTime(d.frameId, newTime)
|
||||
}
|
||||
|
||||
function onScriptFramePointerUp(e: PointerEvent) {
|
||||
const d = sfDrag.value
|
||||
if (!d || d.pointerId !== e.pointerId) return
|
||||
const el = e.currentTarget as HTMLElement
|
||||
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
|
||||
if (!d.moved) {
|
||||
openScriptFrame(d.frameId)
|
||||
}
|
||||
sfDrag.value = null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface KfSegment {
|
||||
id: string
|
||||
fromTime: number
|
||||
@@ -999,7 +1095,7 @@ function onKeyframeContextMenu(
|
||||
) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const layer = store.scene.layers.find((l) => l.id === layerId)
|
||||
const layer = store.activeLayers.find((l) => l.id === layerId)
|
||||
const kf = kfs.find((k) => k.id === keyframeId)
|
||||
if (!layer || !kf) return
|
||||
const sorted = [...kfs].sort((a, b) => a.time - b.time)
|
||||
@@ -1039,7 +1135,7 @@ 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 layer = store.activeLayers.find((l) => l.id === menu.value!.layerId)
|
||||
const all = layer
|
||||
? [
|
||||
...layer.keyframes,
|
||||
@@ -1471,6 +1567,90 @@ function clipBlockStyle(ci: ClipInstance) {
|
||||
}
|
||||
}
|
||||
|
||||
// -------- Button asset edit bar --------------------------------------
|
||||
|
||||
const BUTTON_EDIT_STATES = ['normal', 'hover', 'pressed', 'disabled'] as const
|
||||
type ButtonContentState = (typeof BUTTON_EDIT_STATES)[number]
|
||||
|
||||
function stateLabel(st: ButtonContentState): string {
|
||||
return st[0].toUpperCase() + st.slice(1)
|
||||
}
|
||||
|
||||
const activeButtonAssetName = computed(() => {
|
||||
const id = store.activeButtonAssetId
|
||||
if (!id) return ''
|
||||
const asset = store.scene.assets.find((a) => a.id === id)
|
||||
return asset && asset.kind === 'button' ? asset.name : ''
|
||||
})
|
||||
|
||||
// Visual "state timeline" — each state sits at a fixed 100px increment so
|
||||
// the track-lane kf dots (at pxFromTime(t) = t * 100) line up with the
|
||||
// state label directly above. Hit has no kf semantics; it sits one slot
|
||||
// past 'disabled' as a navigation shortcut only.
|
||||
function stateTickLeft(idx: number): number {
|
||||
return (idx + 1) * BUTTON_STATE_WIDTH
|
||||
}
|
||||
const BUTTON_STATE_TOTAL_WIDTH = BUTTON_STATE_WIDTH * (BUTTON_EDIT_STATES.length + 1)
|
||||
|
||||
const currentStateLeft = computed(() => {
|
||||
const st = store.activeButtonState
|
||||
if (!st) return 0
|
||||
const idx = BUTTON_EDIT_STATES.indexOf(st as ButtonContentState)
|
||||
return idx >= 0 ? stateTickLeft(idx) : 0
|
||||
})
|
||||
|
||||
function updateStateFromPointer(e: PointerEvent) {
|
||||
const el = e.currentTarget as HTMLElement | null
|
||||
if (!el) return
|
||||
const r = el.getBoundingClientRect()
|
||||
const x = e.clientX - r.left
|
||||
const idx = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
BUTTON_EDIT_STATES.length - 1,
|
||||
Math.round((x - BUTTON_STATE_WIDTH) / BUTTON_STATE_WIDTH),
|
||||
),
|
||||
)
|
||||
store.setActiveButtonState(BUTTON_EDIT_STATES[idx])
|
||||
}
|
||||
|
||||
const activeButtonHitShapes = computed(() => {
|
||||
const id = store.activeButtonAssetId
|
||||
if (!id) return []
|
||||
const asset = store.scene.assets.find((a) => a.id === id)
|
||||
return asset && asset.kind === 'button' ? asset.hitArea : []
|
||||
})
|
||||
|
||||
function hitShapeLabel(shape: HitShape, idx: number): string {
|
||||
const n = idx + 1
|
||||
if (shape.kind === 'rect') return `Rect ${n} · ${Math.round(shape.width)}×${Math.round(shape.height)}`
|
||||
if (shape.kind === 'circle') return `Circle ${n} · r=${Math.round(shape.radius)}`
|
||||
return `Polygon ${n} · ${shape.points.length} pts`
|
||||
}
|
||||
|
||||
function onDeleteHitShape(idx: number) {
|
||||
const id = store.activeButtonAssetId
|
||||
if (!id) return
|
||||
store.removeButtonHitShape(id, idx)
|
||||
}
|
||||
|
||||
function onStateScrubPointerDown(e: PointerEvent) {
|
||||
const el = e.currentTarget as HTMLElement
|
||||
el.setPointerCapture(e.pointerId)
|
||||
updateStateFromPointer(e)
|
||||
}
|
||||
|
||||
function onStateScrubPointerMove(e: PointerEvent) {
|
||||
const el = e.currentTarget as HTMLElement
|
||||
if (!el.hasPointerCapture(e.pointerId)) return
|
||||
updateStateFromPointer(e)
|
||||
}
|
||||
|
||||
function onStateScrubPointerUp(e: PointerEvent) {
|
||||
const el = e.currentTarget as HTMLElement
|
||||
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
|
||||
}
|
||||
|
||||
// Script frame dialog state
|
||||
const scriptDialogOpen = ref(false)
|
||||
const scriptDialogFrameId = ref<string | null>(null)
|
||||
@@ -1851,6 +2031,10 @@ function onWheel(e: WheelEvent) {
|
||||
|
||||
let ro: ResizeObserver | null = null
|
||||
|
||||
function onScrollCapture() {
|
||||
if (scrollEl.value) scrollLeft.value = scrollEl.value.scrollLeft
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!scrollEl.value) return
|
||||
ro = new ResizeObserver((entries) => {
|
||||
@@ -1858,12 +2042,16 @@ onMounted(() => {
|
||||
viewportWidth.value = rect.width
|
||||
})
|
||||
ro.observe(scrollEl.value)
|
||||
scrollEl.value.addEventListener('scroll', onScrollCapture, { passive: true })
|
||||
// Seed with current value.
|
||||
scrollLeft.value = scrollEl.value.scrollLeft
|
||||
document.addEventListener('keydown', onDocumentKeyDown)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
ro?.disconnect()
|
||||
ro = null
|
||||
if (scrollEl.value) scrollEl.value.removeEventListener('scroll', onScrollCapture)
|
||||
document.removeEventListener('pointerdown', onDocumentPointerDown, true)
|
||||
document.removeEventListener('pointerdown', onOutsideMarkerPointerDown, true)
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
@@ -1875,7 +2063,58 @@ onBeforeUnmount(() => {
|
||||
<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 v-if="store.activeButtonAssetId" class="scrub-bar button-scrub-bar">
|
||||
<div class="name-spacer">
|
||||
<div class="button-mode-toggle">
|
||||
<button
|
||||
class="mode-btn"
|
||||
:class="{ active: store.activeButtonEditMode === 'states' }"
|
||||
:title="t('timeline.buttonModeStates')"
|
||||
@click="store.setButtonEditMode('states')"
|
||||
>{{ t('timeline.buttonModeStatesShort') }}</button>
|
||||
<button
|
||||
class="mode-btn hit"
|
||||
:class="{ active: store.activeButtonEditMode === 'hit' }"
|
||||
:title="t('timeline.buttonModeHit')"
|
||||
@click="store.setButtonEditMode('hit')"
|
||||
>{{ t('timeline.buttonModeHitShort') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="store.activeButtonEditMode === 'states'"
|
||||
class="scrub-ruler button-state-ruler"
|
||||
:style="{ width: BUTTON_STATE_TOTAL_WIDTH + 'px' }"
|
||||
@pointerdown="onStateScrubPointerDown"
|
||||
@pointermove="onStateScrubPointerMove"
|
||||
@pointerup="onStateScrubPointerUp"
|
||||
@pointercancel="onStateScrubPointerUp"
|
||||
>
|
||||
<span
|
||||
v-for="(st, idx) in BUTTON_EDIT_STATES"
|
||||
:key="st"
|
||||
class="tick button-state-tick"
|
||||
:class="{
|
||||
active: store.activeButtonState === st,
|
||||
}"
|
||||
:style="{ left: stateTickLeft(idx) + 'px' }"
|
||||
>
|
||||
<span class="tick-line"></span>
|
||||
<span class="tick-label">{{ stateLabel(st) }}</span>
|
||||
</span>
|
||||
<span
|
||||
class="scrub-handle"
|
||||
:style="{ left: currentStateLeft + 'px' }"
|
||||
>
|
||||
<span class="scrub-handle-head"></span>
|
||||
<span class="scrub-handle-line"></span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="hit-edit-header">
|
||||
<span class="hit-edit-title">{{ t('timeline.hitAreaHeader') }}</span>
|
||||
<span class="hit-edit-hint">{{ t('timeline.hitAreaHint') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="scrub-bar">
|
||||
<div class="name-spacer">
|
||||
<div class="mode-toggle" :title="t('timeline.toggleTimeUnit')">
|
||||
<button
|
||||
@@ -2001,7 +2240,11 @@ onBeforeUnmount(() => {
|
||||
<span class="timeline-marker-flag"></span>
|
||||
<span class="timeline-marker-label">{{ marker.name }}</span>
|
||||
</span>
|
||||
<span class="scrub-handle" :style="{ left: pxFromTime(store.playhead) + 'px' }">
|
||||
<span
|
||||
class="scrub-handle"
|
||||
:class="{ obscured: scrubHandleObscured }"
|
||||
:style="{ left: pxFromTime(store.playhead) + 'px' }"
|
||||
>
|
||||
<span class="scrub-handle-head"></span>
|
||||
<span class="scrub-handle-line"></span>
|
||||
</span>
|
||||
@@ -2344,7 +2587,10 @@ onBeforeUnmount(() => {
|
||||
class="script-frame"
|
||||
:style="{ left: pxFromTime(frame.time) + 'px' }"
|
||||
:title="frame.label ?? t('timeline.scriptFrameTooltip', { n: frame.actions.length })"
|
||||
@click.stop="openScriptFrame(frame.id)"
|
||||
@pointerdown="onScriptFramePointerDown($event, frame.id)"
|
||||
@pointermove="onScriptFramePointerMove"
|
||||
@pointerup="onScriptFramePointerUp"
|
||||
@pointercancel="onScriptFramePointerUp"
|
||||
>
|
||||
<span class="script-frame-dot"></span>
|
||||
<span v-if="frame.actions.length > 1" class="script-frame-count">{{ frame.actions.length }}</span>
|
||||
@@ -2431,9 +2677,40 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p v-if="!tracks.length" class="empty">{{ t('timeline.noTracks') }}</p>
|
||||
<template v-if="store.activeButtonEditMode === 'hit'">
|
||||
<div
|
||||
v-for="(shape, idx) in activeButtonHitShapes"
|
||||
:key="`hit-track-${idx}`"
|
||||
class="track hit-track"
|
||||
>
|
||||
<div class="name-col">
|
||||
<span class="hit-track-kind">
|
||||
<PhSquare v-if="shape.kind === 'rect'" :size="11" />
|
||||
<PhCircle v-else-if="shape.kind === 'circle'" :size="11" />
|
||||
<PhPolygon v-else :size="11" />
|
||||
</span>
|
||||
<span class="hit-track-label">{{ hitShapeLabel(shape, idx) }}</span>
|
||||
<button
|
||||
class="hit-track-delete"
|
||||
:title="t('timeline.hitTrackDelete')"
|
||||
@pointerdown.stop
|
||||
@click.stop="onDeleteHitShape(idx)"
|
||||
>×</button>
|
||||
</div>
|
||||
<div class="lane hit-lane"></div>
|
||||
</div>
|
||||
<p v-if="!activeButtonHitShapes.length" class="empty">
|
||||
{{ t('timeline.hitAreaEmpty') }}
|
||||
</p>
|
||||
</template>
|
||||
<p
|
||||
v-else-if="!tracks.length"
|
||||
class="empty"
|
||||
>
|
||||
{{ store.activeButtonAssetId ? t('timeline.emptyButtonState') : t('timeline.noTracks') }}
|
||||
</p>
|
||||
<div
|
||||
v-if="!inClipEdit"
|
||||
v-if="!inClipEdit && !store.activeButtonAssetId"
|
||||
class="track clip-track-dropzone"
|
||||
:class="{ hot: clipDragOverDropzone }"
|
||||
@dragenter="onNewTrackDragEnter"
|
||||
@@ -2542,16 +2819,14 @@ onBeforeUnmount(() => {
|
||||
<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" />
|
||||
<PhPencilSimpleLine :size="12" class="kf-menu-icon" />
|
||||
{{ t('timeline.renameGroup') }}
|
||||
</button>
|
||||
</template>
|
||||
<template v-else-if="trackMenu.kind === 'script'">
|
||||
<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" />
|
||||
<PhPencilSimpleLine :size="12" class="kf-menu-icon" />
|
||||
{{ t('timeline.renameScriptTrack') }}
|
||||
</button>
|
||||
</template>
|
||||
@@ -2563,17 +2838,19 @@ onBeforeUnmount(() => {
|
||||
: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') }}
|
||||
<PhCheck
|
||||
v-if="trackMenu.separated"
|
||||
:size="12"
|
||||
weight="bold"
|
||||
class="kf-menu-trailing-check"
|
||||
/>
|
||||
</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'
|
||||
@@ -2599,6 +2876,155 @@ onBeforeUnmount(() => {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.button-scrub-bar {
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--panel));
|
||||
}
|
||||
|
||||
.button-scrub-bar .button-name-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.button-state-ruler {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button-state-tick .tick-line {
|
||||
height: 8px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.button-state-tick.active .tick-line {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.button-state-tick .tick-label {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: capitalize;
|
||||
padding-top: 4px;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.button-state-tick.active .tick-label {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.button-mode-toggle {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.button-mode-toggle .mode-btn {
|
||||
padding: 3px 8px;
|
||||
min-width: 48px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: var(--text-dim);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button-mode-toggle .mode-btn:hover {
|
||||
background: var(--panel-alt);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.button-mode-toggle .mode-btn.active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.button-mode-toggle .mode-btn.hit.active {
|
||||
background: color-mix(in srgb, #f59e0b 24%, transparent);
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.button-mode-toggle .mode-btn + .mode-btn {
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.hit-edit-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 12px;
|
||||
color: var(--text);
|
||||
font-size: 11px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.hit-edit-title {
|
||||
font-weight: 600;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.hit-edit-hint {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.hit-track .name-col {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.hit-track-kind {
|
||||
color: #f59e0b;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hit-track-label {
|
||||
font-size: 11px;
|
||||
color: var(--text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hit-track-delete {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg);
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.hit-track-delete:hover {
|
||||
background: var(--panel-alt);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.hit-lane {
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent 0 8px,
|
||||
color-mix(in srgb, #f59e0b 8%, transparent) 8px 16px
|
||||
);
|
||||
}
|
||||
|
||||
.name-col-resize {
|
||||
@@ -2653,9 +3079,14 @@ onBeforeUnmount(() => {
|
||||
.scrub-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
/* Above sticky track-name columns (z-index 30) so that scrolling rows
|
||||
pass *under* the scrub-bar + name-spacer instead of sliding over it. */
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
/* 28px ruler content + 3px padding-top reserved for the scrub handle
|
||||
head to poke above the ruler. Total box height: 31px. */
|
||||
height: 28px;
|
||||
padding-top: 3px;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
@@ -2665,6 +3096,13 @@ onBeforeUnmount(() => {
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
width: var(--track-name-width);
|
||||
/* Extend 3px up (into the scrub-bar's padding-top) and 1px down (over
|
||||
the scrub-bar's border so the handle line's `bottom: -1px` overshoot
|
||||
can't peek through underneath). Together this makes the toggle row
|
||||
fully cover the obscured handle. */
|
||||
height: calc(100% + 4px);
|
||||
margin-top: -3px;
|
||||
margin-bottom: -1px;
|
||||
background: var(--panel);
|
||||
border-right: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
@@ -2936,6 +3374,17 @@ onBeforeUnmount(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
/* Default: above the sticky name-spacer (z-index 30 in the scrub-bar
|
||||
stacking context) so the handle isn't half-clipped when the playhead
|
||||
is at t=0 and overlaps the name-spacer column. */
|
||||
z-index: 41;
|
||||
}
|
||||
|
||||
/* When the playhead has scrolled past the left edge of the ruler (i.e. the
|
||||
handle centre sits behind the sticky name-spacer), drop below z:30 so
|
||||
the name-spacer + toggle row covers the handle cleanly. Toggled from
|
||||
JS via `scrubHandleObscured`. */
|
||||
.scrub-handle.obscured {
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
@@ -2943,8 +3392,10 @@ onBeforeUnmount(() => {
|
||||
width: 14px;
|
||||
height: 18px;
|
||||
background: var(--accent);
|
||||
margin-top: 2px;
|
||||
clip-path: polygon(0 0, 100% 0, 100% 55%, 50% 100%, 0 55%);
|
||||
/* Poke 3px into the scrub-bar's padding-top area so the head sticks
|
||||
out above the ruler, with slightly rounded top corners (radius 2). */
|
||||
margin-top: -3px;
|
||||
clip-path: path('M 2 0 Q 0 0 0 2 L 0 10 L 7 18 L 14 10 L 14 2 Q 14 0 12 0 Z');
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));
|
||||
pointer-events: auto;
|
||||
cursor: ew-resize;
|
||||
@@ -3435,6 +3886,8 @@ onBeforeUnmount(() => {
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.script-frame-dot {
|
||||
@@ -3775,6 +4228,15 @@ onBeforeUnmount(() => {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Track-menu toggle items (e.g. "Separate Position and Rotation") show their
|
||||
"active" checkmark on the far right so the left edge stays aligned with
|
||||
items that only have an icon + label. */
|
||||
.kf-menu-trailing-check {
|
||||
margin-left: auto;
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.kf-menu-note {
|
||||
padding: 2px 10px 4px;
|
||||
font-size: 10px;
|
||||
|
||||
Reference in new issue
Block a user