From 92d58937e3f0be325873e655e2a8bfff5b26609f Mon Sep 17 00:00:00 2001
From: th
Date: Mon, 20 Apr 2026 20:49:59 +0200
Subject: [PATCH] macro-clips slice 3: timeline rendering for scripts and
button instances
- TimelineScriptTrack added to the track union; tracks computed emits one
per ScriptTrack in trackOrder, with the show-locked filter honoring its
locked flag
- script track row: drag handle + PhCode name badge + lock button; lane
renders each ScriptFrame as an orange dot with optional action-count
badge
- button instances render inside clip-track lanes as 4 cells labeled
N/H/P/D, colored per state, with a striped pattern on disabled and a
fixed visual width per cell
- new scrub-bar button to add a script track (PhCode icon)
- type narrowing tightened so inGroup checks skip script/group tracks
explicitly rather than relying on the negation
Co-Authored-By: Claude Opus 4.7 (1M context)
---
src/components/Timeline.vue | 272 +++++++++++++++++++++++++++++++++++-
src/i18n/messages.ts | 3 +
2 files changed, 270 insertions(+), 5 deletions(-)
diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue
index a096ac2..6fcb262 100644
--- a/src/components/Timeline.vue
+++ b/src/components/Timeline.vue
@@ -5,6 +5,7 @@ import {
PhCaretDown,
PhCaretRight,
PhCheck,
+ PhCode,
PhCopy,
PhDotsSixVertical,
PhFolder,
@@ -35,6 +36,7 @@ import { showLocked } from '@/composables/useShowLocked'
import type {
AnimatableProps,
Asset,
+ ButtonInstance,
Clip,
ClipInstance,
Easing,
@@ -78,7 +80,20 @@ interface TimelineGroupTrack {
childCount: number
}
-type TimelineTrack = TimelineLayerTrack | TimelineClipTrack | TimelineGroupTrack
+interface TimelineScriptTrack {
+ type: 'script'
+ scriptTrackId: string
+ name: string
+ orderIndex: number
+ locked: boolean
+ color: string | null
+}
+
+type TimelineTrack =
+ | TimelineLayerTrack
+ | TimelineClipTrack
+ | TimelineGroupTrack
+ | TimelineScriptTrack
const store = useSceneStore()
@@ -106,6 +121,32 @@ const groupById = computed(() => {
for (const g of store.scene.groups ?? []) m.set(g.id, g)
return m
})
+const scriptTrackById = computed(() => {
+ const m = new Map[number]>()
+ for (const t of store.scene.scriptTracks ?? []) m.set(t.id, t)
+ return m
+})
+const scriptFramesByTrack = computed>(() => {
+ const out: Record> = {}
+ for (const f of store.scene.scriptFrames ?? []) {
+ ;(out[f.trackId] ||= []).push(f)
+ }
+ return out
+})
+const buttonClipById = computed(() => {
+ const m = new Map[number]>()
+ for (const c of store.scene.buttonClips ?? []) m.set(c.id, c)
+ return m
+})
+const buttonInstancesByTrack = computed<
+ Record>
+>(() => {
+ const out: Record> = {}
+ for (const bi of store.scene.buttonInstances ?? []) {
+ ;(out[bi.clipTrackId] ||= []).push(bi)
+ }
+ return out
+})
const TRACK_NAME_WIDTH_KEY = 'animatr:trackNameWidth'
const TRACK_NAME_WIDTH_DEFAULT = 140
@@ -611,6 +652,18 @@ const tracks = computed(() => {
out.push({ type: 'clip', clipTrackId: ct.id, name: ct.name ?? t('timeline.clipTrackName'), orderIndex, inGroup: false })
return
}
+ const st = scriptTrackById.value.get(id)
+ if (st) {
+ out.push({
+ type: 'script',
+ scriptTrackId: st.id,
+ name: st.name ?? t('timeline.scriptTrackName'),
+ orderIndex,
+ locked: !!st.locked,
+ color: st.color ?? null,
+ })
+ return
+ }
const grp = groupById.value.get(id)
if (grp) {
out.push({
@@ -666,6 +719,9 @@ const tracks = computed(() => {
if (lockedGroupChildIds.has(tr.clipTrackId)) return false
return true
}
+ if (tr.type === 'script') {
+ return !tr.locked
+ }
// group: hide when locked
return !lockedGroupIds.has(tr.groupId)
})
@@ -674,6 +730,7 @@ const tracks = computed(() => {
function trackDndId(track: TimelineTrack): string {
if (track.type === 'layer') return track.layerId
if (track.type === 'clip') return track.clipTrackId
+ if (track.type === 'script') return track.scriptTrackId
return track.groupId
}
@@ -704,7 +761,7 @@ function isDropBefore(track: TimelineTrack): boolean {
if (trackParentGroupId(track) !== dropWithinGroupId.value) return false
return dropIndex.value === trackChildIndex(track)
}
- if (track.type !== 'group' && track.inGroup) return false
+ if ((track.type === 'layer' || track.type === 'clip') && track.inGroup) return false
return dropIndex.value === track.orderIndex
}
@@ -719,7 +776,7 @@ function isDropAfter(track: TimelineTrack, nextTrack: TimelineTrack | undefined)
if (nextTrack && trackChildIndex(nextTrack) === ci) return false
return true
}
- if (track.type !== 'group' && track.inGroup) return false
+ if ((track.type === 'layer' || track.type === 'clip') && track.inGroup) return false
if (dropIndex.value !== track.orderIndex + 1) return false
if (nextTrack && nextTrack.orderIndex === track.orderIndex) return false
return true
@@ -1420,6 +1477,18 @@ function clipBlockStyle(ci: ClipInstance) {
}
}
+const BUTTON_STATES = ['normal', 'hover', 'pressed', 'disabled'] as const
+const BUTTON_CELL_SEC = 0.5 // visual width of each of the 4 state cells
+function buttonInstanceStyle(bi: ButtonInstance) {
+ // Four cells, each with a fixed per-cell visual width. Kept as a fixed
+ // (time-like) span so the block scales with timeline zoom.
+ const totalSec = BUTTON_CELL_SEC * 4
+ return {
+ left: pxFromTime(bi.startTime) + 'px',
+ width: Math.max(80, pxFromTime(totalSec)) + 'px',
+ }
+}
+
function trimTooltip(ci: ClipInstance): string {
const name = clipById.value.get(ci.clipId)?.name ?? '?'
const dur = visibleDurationOf(ci).toFixed(2)
@@ -1809,6 +1878,14 @@ onBeforeUnmount(() => {
>
+
|