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
+189
-6
@@ -4,10 +4,13 @@ import type {
|
||||
AnimatableProps,
|
||||
Asset,
|
||||
BackgroundVideo,
|
||||
ButtonAsset,
|
||||
ButtonState,
|
||||
Clip,
|
||||
ClipInstance,
|
||||
ClipTrack,
|
||||
Easing,
|
||||
HitShape,
|
||||
Keyframe,
|
||||
Layer,
|
||||
Marker,
|
||||
@@ -17,7 +20,7 @@ import type {
|
||||
ScriptTrack,
|
||||
TrackGroup,
|
||||
} from '@/types/scene'
|
||||
import { DEFAULT_BASE_PROPS } from '@/types/scene'
|
||||
import { BUTTON_STATE_TIMES, DEFAULT_BASE_PROPS } from '@/types/scene'
|
||||
import { sampleLayer } from '@/lib/interpolate'
|
||||
import {
|
||||
buildMarkerMap,
|
||||
@@ -32,12 +35,24 @@ type AssetInit = Asset extends infer A ? (A extends Asset ? Omit<A, 'id'> : neve
|
||||
|
||||
const KEYFRAME_EPSILON = 1e-3
|
||||
|
||||
/** The button editor has two orthogonal sub-modes: state editing (4 content
|
||||
* states as kf-time slots) vs hit-area editing (hit shapes only, with normal
|
||||
* state as onion-skin). Switching modes keeps each mode's last selection. */
|
||||
export type ButtonEditMode = 'states' | 'hit'
|
||||
|
||||
interface State {
|
||||
scene: Scene
|
||||
selectedLayerId: string | null
|
||||
playhead: number
|
||||
playing: boolean
|
||||
activeClipId: string | null
|
||||
/** When set, the Stage + Timeline are inside a Button-asset edit session. */
|
||||
activeButtonAssetId: string | null
|
||||
/** 'states' drives the state scrubber; 'hit' swaps to hit-shape editing. */
|
||||
activeButtonEditMode: ButtonEditMode | null
|
||||
/** Which of the 4 content states is currently active. Only meaningful while
|
||||
* `activeButtonEditMode === 'states'`; preserved across mode toggles. */
|
||||
activeButtonState: ButtonState | null
|
||||
drawingMode: 'freeform' | null
|
||||
drawingAssetId: string | null
|
||||
drawingLayerId: string | null
|
||||
@@ -117,6 +132,23 @@ function hasKeys(obj: Partial<AnimatableProps>): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan every container a layer can live in — scene-level, clip layers,
|
||||
* button-asset state stacks — and return the layer with the matching id.
|
||||
* Used by mutation helpers that don't know the caller's edit scope.
|
||||
*/
|
||||
function findLayerAnywhere(scene: Scene, layerId: string): Layer | null {
|
||||
for (const l of scene.layers) if (l.id === layerId) return l
|
||||
for (const clip of scene.clips ?? []) {
|
||||
for (const l of clip.layers) if (l.id === layerId) return l
|
||||
}
|
||||
for (const asset of scene.assets) {
|
||||
if (asset.kind !== 'button') continue
|
||||
for (const l of asset.layers) if (l.id === layerId) return l
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeScene(scene: Scene): Scene {
|
||||
scene.clips ||= []
|
||||
scene.clipTracks ||= []
|
||||
@@ -131,6 +163,7 @@ function normalizeScene(scene: Scene): Scene {
|
||||
for (const l of scene.layers) known.add(l.id)
|
||||
for (const t of scene.clipTracks) known.add(t.id)
|
||||
for (const g of scene.groups) known.add(g.id)
|
||||
for (const st of scene.scriptTracks) known.add(st.id)
|
||||
|
||||
// Cleanup: group childIds should only reference existing layers/clipTracks
|
||||
const layerAndClipIds = new Set<string>()
|
||||
@@ -165,6 +198,9 @@ function normalizeScene(scene: Scene): Scene {
|
||||
for (const g of scene.groups) {
|
||||
if (!scene.trackOrder.includes(g.id)) scene.trackOrder.push(g.id)
|
||||
}
|
||||
for (const st of scene.scriptTracks) {
|
||||
if (!scene.trackOrder.includes(st.id)) scene.trackOrder.push(st.id)
|
||||
}
|
||||
return scene
|
||||
}
|
||||
|
||||
@@ -187,6 +223,9 @@ export const useSceneStore = defineStore('scene', {
|
||||
playhead: 0,
|
||||
playing: false,
|
||||
activeClipId: null,
|
||||
activeButtonAssetId: null,
|
||||
activeButtonEditMode: null,
|
||||
activeButtonState: null,
|
||||
drawingMode: null,
|
||||
drawingAssetId: null,
|
||||
drawingLayerId: null,
|
||||
@@ -197,7 +236,19 @@ export const useSceneStore = defineStore('scene', {
|
||||
const clips = state.scene.clips ?? []
|
||||
return clips.find((c) => c.id === state.activeClipId) ?? null
|
||||
},
|
||||
activeButtonAsset(state): ButtonAsset | null {
|
||||
if (!state.activeButtonAssetId) return null
|
||||
const asset = state.scene.assets.find((a) => a.id === state.activeButtonAssetId)
|
||||
return asset && asset.kind === 'button' ? asset : null
|
||||
},
|
||||
activeLayers(): Layer[] {
|
||||
const bAsset = this.activeButtonAsset
|
||||
if (bAsset) {
|
||||
// Hit mode shares the same layer list — the canvas switches to
|
||||
// hit-shape editing but tracks stay on the button's layers so the
|
||||
// user can still see/select them.
|
||||
return bAsset.layers
|
||||
}
|
||||
const clip = this.activeClip
|
||||
return clip ? clip.layers : this.scene.layers
|
||||
},
|
||||
@@ -257,14 +308,24 @@ export const useSceneStore = defineStore('scene', {
|
||||
this.scene.assets.push(created)
|
||||
return created
|
||||
},
|
||||
addLayer(assetId: string, name?: string): Layer {
|
||||
addLayer(assetId: string, name?: string): Layer | null {
|
||||
// Hit-area edit has no content-layer container — new layers from
|
||||
// the sidebar/drop path would silently leak into the shared states
|
||||
// list. Reject here so hit-mode truly owns its own (hit-shape) track
|
||||
// list and nothing bleeds across.
|
||||
if (this.activeButtonEditMode === 'hit') return null
|
||||
const asset = this.scene.assets.find((a) => a.id === assetId)
|
||||
// Default position = center of the active canvas. In button-asset
|
||||
// edit that's the button's smaller internal canvas, so the new
|
||||
// layer lands inside the visible (and unclipped) area.
|
||||
const canvas = this.activeButtonAsset?.canvas ?? this.scene.canvas
|
||||
const base: AnimatableProps = {
|
||||
...DEFAULT_BASE_PROPS,
|
||||
x: this.scene.canvas.width / 2,
|
||||
y: this.scene.canvas.height / 2,
|
||||
x: canvas.width / 2,
|
||||
y: canvas.height / 2,
|
||||
}
|
||||
if (asset && 'fill' in asset) base.fill = asset.fill
|
||||
const initialTime = this.activeButtonAssetId ? BUTTON_STATE_TIMES.normal : 0
|
||||
const layer: Layer = {
|
||||
id: uid(),
|
||||
name: name ?? asset?.name ?? 'Layer',
|
||||
@@ -274,7 +335,7 @@ export const useSceneStore = defineStore('scene', {
|
||||
keyframes: [
|
||||
{
|
||||
id: uid(),
|
||||
time: 0,
|
||||
time: initialTime,
|
||||
props: { ...base },
|
||||
easing: 'linear',
|
||||
},
|
||||
@@ -287,6 +348,10 @@ export const useSceneStore = defineStore('scene', {
|
||||
} else if (asset.kind === 'circle' || asset.kind === 'text' || asset.kind === 'polygon') {
|
||||
layer.offsetX = 0
|
||||
layer.offsetY = 0
|
||||
} else if (asset.kind === 'button') {
|
||||
// Place the button centered on (x, y) using Konva's offset mechanism.
|
||||
layer.offsetX = asset.canvas.width / 2
|
||||
layer.offsetY = asset.canvas.height / 2
|
||||
}
|
||||
if (asset.kind === 'polygon') {
|
||||
layer.keyframes[0].props = {
|
||||
@@ -296,7 +361,9 @@ export const useSceneStore = defineStore('scene', {
|
||||
}
|
||||
}
|
||||
this.activeLayers.unshift(layer)
|
||||
if (!this.activeClipId) {
|
||||
// Only top-level scene layers appear in trackOrder. Clip-edit and
|
||||
// button-asset-edit layers live inside their own containers.
|
||||
if (!this.activeClipId && !this.activeButtonAssetId) {
|
||||
const order = (this.scene.trackOrder ||= [])
|
||||
if (!order.includes(layer.id)) order.unshift(layer.id)
|
||||
}
|
||||
@@ -1136,6 +1203,116 @@ export const useSceneStore = defineStore('scene', {
|
||||
this.scene.scriptFrames?.sort((a, b) => a.time - b.time)
|
||||
},
|
||||
|
||||
// -------- Button assets --------------------------------------------
|
||||
|
||||
addButtonAsset(name?: string, width = 200, height = 80): ButtonAsset {
|
||||
const asset: ButtonAsset = {
|
||||
id: uid(),
|
||||
kind: 'button',
|
||||
name: name?.trim() || 'Button',
|
||||
canvas: {
|
||||
width: Math.max(1, Math.round(width)),
|
||||
height: Math.max(1, Math.round(height)),
|
||||
},
|
||||
layers: [],
|
||||
hitArea: [],
|
||||
}
|
||||
this.scene.assets.push(asset)
|
||||
return asset
|
||||
},
|
||||
setButtonAssetCanvas(assetId: string, width: number, height: number) {
|
||||
const asset = this.scene.assets.find((a) => a.id === assetId)
|
||||
if (!asset || asset.kind !== 'button') return
|
||||
asset.canvas = {
|
||||
width: Math.max(1, Math.round(width)),
|
||||
height: Math.max(1, Math.round(height)),
|
||||
}
|
||||
},
|
||||
enterButtonAssetEdit(assetId: string) {
|
||||
const asset = this.scene.assets.find((a) => a.id === assetId)
|
||||
if (!asset || asset.kind !== 'button') return
|
||||
this.activeButtonAssetId = assetId
|
||||
this.activeButtonEditMode = 'states'
|
||||
this.activeButtonState = 'normal'
|
||||
this.activeClipId = null
|
||||
this.selectedLayerId = null
|
||||
this.playhead = BUTTON_STATE_TIMES.normal
|
||||
this.playing = false
|
||||
},
|
||||
setButtonEditMode(mode: ButtonEditMode) {
|
||||
if (!this.activeButtonAssetId) return
|
||||
this.activeButtonEditMode = mode
|
||||
this.selectedLayerId = null
|
||||
if (mode === 'states') {
|
||||
// Landing back in states-mode: keep the last content state (or
|
||||
// default to normal) and sync the playhead to its kf-time.
|
||||
const st = this.activeButtonState ?? 'normal'
|
||||
this.activeButtonState = st
|
||||
this.playhead = BUTTON_STATE_TIMES[st]
|
||||
} else {
|
||||
// Hit mode onion-skins normal, so park the playhead at normal's
|
||||
// time. No mutations can happen here — layers are inert.
|
||||
this.playhead = BUTTON_STATE_TIMES.normal
|
||||
}
|
||||
},
|
||||
setActiveButtonState(state: ButtonState) {
|
||||
if (!this.activeButtonAssetId) return
|
||||
// Scrubbing a state implies the user is in states-mode.
|
||||
this.activeButtonEditMode = 'states'
|
||||
this.activeButtonState = state
|
||||
// States ARE keyframes — scrubbing moves the playhead to that
|
||||
// state's fixed time so existing kf mutation paths target it.
|
||||
this.playhead = BUTTON_STATE_TIMES[state]
|
||||
this.selectedLayerId = null
|
||||
},
|
||||
exitButtonAssetEdit() {
|
||||
this.activeButtonAssetId = null
|
||||
this.activeButtonEditMode = null
|
||||
this.activeButtonState = null
|
||||
this.selectedLayerId = null
|
||||
this.playhead = 0
|
||||
this.playing = false
|
||||
},
|
||||
addButtonHitShape(assetId: string, shape: HitShape): HitShape | null {
|
||||
const asset = this.scene.assets.find((a) => a.id === assetId)
|
||||
if (!asset || asset.kind !== 'button') return null
|
||||
asset.hitArea.push(shape)
|
||||
return shape
|
||||
},
|
||||
removeButtonHitShape(assetId: string, index: number) {
|
||||
const asset = this.scene.assets.find((a) => a.id === assetId)
|
||||
if (!asset || asset.kind !== 'button') return
|
||||
if (index < 0 || index >= asset.hitArea.length) return
|
||||
asset.hitArea.splice(index, 1)
|
||||
},
|
||||
updateButtonHitShape(
|
||||
assetId: string,
|
||||
index: number,
|
||||
patch: Partial<HitShape>,
|
||||
) {
|
||||
const asset = this.scene.assets.find((a) => a.id === assetId)
|
||||
if (!asset || asset.kind !== 'button') return
|
||||
const current = asset.hitArea[index]
|
||||
if (!current) return
|
||||
// Preserve kind — patch can only override numeric fields.
|
||||
asset.hitArea[index] = { ...current, ...patch } as HitShape
|
||||
},
|
||||
|
||||
// -------- Button layer per-instance (on click / disabled) ----------
|
||||
|
||||
setLayerOnClick(layerId: string, actions: Action[]) {
|
||||
const layer = findLayerAnywhere(this.scene, layerId)
|
||||
if (!layer) return
|
||||
if (actions.length) layer.onClick = actions.map((a) => ({ ...a }))
|
||||
else delete layer.onClick
|
||||
},
|
||||
setLayerButtonDisabled(layerId: string, disabled: boolean) {
|
||||
const layer = findLayerAnywhere(this.scene, layerId)
|
||||
if (!layer) return
|
||||
if (disabled) layer.buttonDisabled = true
|
||||
else delete layer.buttonDisabled
|
||||
},
|
||||
|
||||
setLayerSeparated(layerId: string, separated: boolean) {
|
||||
const layer = this.activeLayers.find((l) => l.id === layerId)
|
||||
if (!layer) return
|
||||
@@ -1208,6 +1385,9 @@ export const useSceneStore = defineStore('scene', {
|
||||
this.playhead = 0
|
||||
this.playing = false
|
||||
this.activeClipId = null
|
||||
this.activeButtonAssetId = null
|
||||
this.activeButtonEditMode = null
|
||||
this.activeButtonState = null
|
||||
},
|
||||
addClip(name?: string): Clip {
|
||||
const clip: Clip = {
|
||||
@@ -1370,6 +1550,9 @@ export const useSceneStore = defineStore('scene', {
|
||||
const clip = this.scene.clips?.find((c) => c.id === id)
|
||||
if (!clip) return
|
||||
this.activeClipId = id
|
||||
this.activeButtonAssetId = null
|
||||
this.activeButtonEditMode = null
|
||||
this.activeButtonState = null
|
||||
this.selectedLayerId = null
|
||||
this.playhead = 0
|
||||
this.playing = false
|
||||
|
||||
Reference in new issue
Block a user