revert macro-clips button feature (keep markers + scripts)

The button authoring ended up too far from the intended model after
three correction passes. Pulling it out entirely so we can rebuild
from a clean spec. Markers, script tracks, script frames, the Action
editor, the Script DSL and the runtime for all of the above stay —
they work.

Removed:
- types: ButtonState, StateTransition, ButtonHitArea, ButtonClip,
  ButtonInstance, setButtonState/setButtonDisabled Action variants,
  Scene.buttonClips/buttonInstances
- store: activeButtonClip* getters, all add/remove/setButton* actions,
  seedDefaultButtonClip, enterButtonEdit/setActiveButtonState/
  exitButtonEdit, button-label resolution in actionRuntime
- useButtonRuntime composable, buttonBlend helper,
  ButtonInstanceDialog, sidebar Buttons section, Timeline button
  rendering + pointer handlers, CanvasStage button rendering +
  pointer state machine, exportPlayer button runtime + hit-testing
- DSL commands state/enable/disable and their parse errors
- 35 button-related tests + helpers; stale testing doc; stale design
  doc

Re-applied three uncommitted fixes that were unrelated to the button
work:
- clicking a marker no longer jumps the playhead (only drag / dblclick
  act on it)
- script tracks get a right-click context menu (rename + delete,
  color in the existing palette)
- onTracksPointerDown excludes .script-lane / .script-frame so
  clicks on empty lanes or on frame dots reach the frame-open path
  instead of being swallowed by box-select pointer capture

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-20 23:37:49 +02:00
1 parent 616973b0fa
commit 31604052ca
16 files changed
+84 -2809

No files matched your search

+1 -13
View File
@@ -1,4 +1,4 @@
import type { Action, ButtonState, Marker } from '@/types/scene'
import type { Action, Marker } from '@/types/scene'
import { parseScript } from './scriptDsl'
export interface ActionRuntime {
@@ -10,12 +10,6 @@ export interface ActionRuntime {
setPlayhead(t: number): void
play(): void
pause(): void
/**
* `instanceIdOrLabel` may be a real ButtonInstance id OR a user-facing
* label — the runtime resolves via the label map internally.
*/
setButtonState(instanceIdOrLabel: string, state: ButtonState): void
setButtonDisabled(instanceIdOrLabel: string, disabled: boolean): void
warn(msg: string): void
}
@@ -55,12 +49,6 @@ export function evalAction(action: Action, rt: ActionRuntime): void {
rt.setPlayhead(clamp(t, 0, rt.duration))
return
}
case 'setButtonState':
rt.setButtonState(action.instanceId, action.state)
return
case 'setButtonDisabled':
rt.setButtonDisabled(action.instanceId, action.disabled)
return
case 'script': {
const parsed = parseScript(action.source)
if (parsed.errors.length) {
-55
View File
@@ -1,55 +0,0 @@
import type { AnimatableProps, Asset, ButtonClip, ButtonState, Layer } from '@/types/scene'
import { lerpProps, sampleLayer } from '@/lib/interpolate'
export interface BlendedLayerRender {
layer: Layer
asset: Asset
props: AnimatableProps
}
/**
* Blend the "from" state's layers into the "to" state's layers by layer
* name. Matched pairs lerp their sampled (t=0) props; unmatched
* from-layers fade out (opacity × (1 − u)), unmatched to-layers fade in
* (opacity × u). Used by the canvas renderer and the export runtime
* while a button state transition is in progress.
*
* `u` is expected to be post-easing progress ∈ [0, 1].
*/
export function blendButtonStates(
clip: ButtonClip,
from: ButtonState,
to: ButtonState,
u: number,
assetBy: (id: string) => Asset | undefined,
): BlendedLayerRender[] {
const fromLayers = clip.states[from] ?? []
const toLayers = clip.states[to] ?? []
const fromByName = new Map<string, Layer>()
const toByName = new Map<string, Layer>()
for (const l of fromLayers) fromByName.set(l.name, l)
for (const l of toLayers) toByName.set(l.name, l)
const out: BlendedLayerRender[] = []
// Unmatched from-layers render first (behind to-layers) and fade out.
for (const l of fromLayers) {
if (toByName.has(l.name)) continue
const asset = assetBy(l.assetId)
if (!asset) continue
const base = sampleLayer(l, 0)
out.push({ layer: l, asset, props: { ...base, opacity: base.opacity * (1 - u) } })
}
for (const l of toLayers) {
const asset = assetBy(l.assetId)
if (!asset) continue
const fromLayer = fromByName.get(l.name)
if (fromLayer) {
const a = sampleLayer(fromLayer, 0)
const b = sampleLayer(l, 0)
out.push({ layer: l, asset, props: lerpProps(a, b, u) })
} else {
const base = sampleLayer(l, 0)
out.push({ layer: l, asset, props: { ...base, opacity: base.opacity * u } })
}
}
return out
}
+2 -254
View File
@@ -1,6 +1,6 @@
import type { Scene } from '@/types/scene'
const RUNTIME_VERSION = '6'
const RUNTIME_VERSION = '7'
const ENGINE_SOURCE = `(function(){
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
@@ -197,174 +197,9 @@ const ENGINE_SOURCE = `(function(){
jumpTo: null,
forcePause: false,
forcePlay: false,
buttonStates: {},
buttonDisabled: {},
buttonLabels: {},
warn: function(m){ if(window.console)window.console.warn('[animatr]',m); }
};
(scene.markers||[]).forEach(function(mk){ runtime.markers[mk.name]=mk.time; });
(scene.buttonInstances||[]).forEach(function(bi){
if(bi.label) runtime.buttonLabels[bi.label]=bi.id;
});
function resolveButtonRef(ref){
// Try as real instance id first, then as label.
var bis=scene.buttonInstances||[];
for(var bri=0;bri<bis.length;bri++) if(bis[bri].id===ref) return ref;
return runtime.buttonLabels[ref]||null;
}
var buttonHits=[]; // refreshed per draw()
function getEffectiveButtonState(bi){
var ovD=runtime.buttonDisabled[bi.id];
var isDis=ovD===undefined?!!bi.disabled:ovD;
if(isDis) return 'disabled';
return runtime.buttonStates[bi.id]||'normal';
}
function setButtonState(id, st){
if(st==='normal') delete runtime.buttonStates[id];
else runtime.buttonStates[id]=st;
}
var buttonTransitions={}; // id -> { from, to, startedAt, duration, easing }
function startButtonTransition(id, from, to, duration, easing){
buttonTransitions[id]={from:from, to:to, startedAt:performance.now(), duration:duration, easing:easing};
}
function easingProgressRt(u, e){
if(u<=0) return 0; if(u>=1) return 1;
if(e==='easeIn') return u*u;
if(e==='easeOut') return 1-(1-u)*(1-u);
if(e==='easeInOut') return u<0.5?2*u*u:1-Math.pow(-2*u+2,2)/2;
return u;
}
function sampleButtonTransition(id){
var tr=buttonTransitions[id]; if(!tr) return null;
var dur=Math.max(0.0001, tr.duration);
var raw=(performance.now()-tr.startedAt)/1000/dur;
if(raw>=1){ delete buttonTransitions[id]; return { u:1, from:tr.from, to:tr.to, done:true }; }
return { u:easingProgressRt(raw, tr.easing), from:tr.from, to:tr.to, done:false };
}
function applyButtonChange(bi, clip, to){
var from=getEffectiveButtonState(bi);
if(from===to) return;
var trs=clip.transitions||[];
for(var ti=0; ti<trs.length; ti++){
if(trs[ti].from===from && trs[ti].to===to && trs[ti].duration>0){
startButtonTransition(bi.id, from, to, trs[ti].duration, trs[ti].easing);
break;
}
}
setButtonState(bi.id, to);
}
function lerpScalar(a,b,t){return a+(b-a)*t;}
function lerpRuntimeProps(a,b,t){
var out={
x:lerpScalar(a.x,b.x,t),
y:lerpScalar(a.y,b.y,t),
scaleX:lerpScalar(a.scaleX,b.scaleX,t),
scaleY:lerpScalar(a.scaleY,b.scaleY,t),
rotation:lerpScalar(a.rotation,b.rotation,t),
opacity:lerpScalar(a.opacity,b.opacity,t)
};
if(a.fill&&b.fill) out.fill=lerpColor(a.fill,b.fill,t);
else if(b.fill) out.fill=b.fill;
else if(a.fill) out.fill=a.fill;
return out;
}
function drawButtonStatesBlended(clip, from, to, u){
var fromLs=(clip.states&&clip.states[from])||[];
var toLs=(clip.states&&clip.states[to])||[];
var toByName={};
for(var ii=0; ii<toLs.length; ii++) toByName[toLs[ii].name]=toLs[ii];
var fromByName={};
for(var ij=0; ij<fromLs.length; ij++) fromByName[fromLs[ij].name]=fromLs[ij];
// Unmatched from-layers fade out.
for(var fi=0; fi<fromLs.length; fi++){
var fl=fromLs[fi];
if(toByName[fl.name]) continue;
var flp=sample(fl,0);
drawLayer(fl, 0, { opacity: flp.opacity*(1-u) });
}
for(var ti2=0; ti2<toLs.length; ti2++){
var tl=toLs[ti2];
var fm=fromByName[tl.name];
if(fm){
var ap=sample(fm,0);
var bp=sample(tl,0);
drawLayer(tl, 0, lerpRuntimeProps(ap,bp,u));
} else {
var tlp=sample(tl,0);
drawLayer(tl, 0, { opacity: tlp.opacity*u });
}
}
}
function pointerToSceneXY(e){
var rc=canvas.getBoundingClientRect();
var lw=scene.canvas.width, lh=scene.canvas.height;
var cw=size.w, ch=size.h;
if(!cw||!ch||!rc.width||!rc.height) return null;
var sc=Math.min(cw/lw, ch/lh);
var ox=(cw-lw*sc)/2, oy=(ch-lh*sc)/2;
var cx=(e.clientX-rc.left)/rc.width*cw;
var cy=(e.clientY-rc.top)/rc.height*ch;
return { x:(cx-ox)/sc, y:(cy-oy)/sc };
}
function hitButtonAt(p){
for(var i=buttonHits.length-1; i>=0; i--){
var h=buttonHits[i];
var lx=p.x-(h.bi.x||0), ly=p.y-(h.bi.y||0);
if(lx>=h.hit.left && lx<=h.hit.right && ly>=h.hit.top && ly<=h.hit.bottom) return h;
}
return null;
}
function findClipForInstance(bi){
var cs=scene.buttonClips||[];
for(var i=0;i<cs.length;i++) if(cs[i].id===bi.buttonClipId) return cs[i];
return null;
}
function onStageMove(e){
var p=pointerToSceneXY(e);
if(!p) return;
var hit=hitButtonAt(p);
for(var i=0;i<buttonHits.length;i++){
var bh=buttonHits[i];
if(getEffectiveButtonState(bh.bi)==='disabled') continue;
var clip=findClipForInstance(bh.bi);
if(!clip) continue;
if(hit && hit.bi.id===bh.bi.id){
if(getEffectiveButtonState(bh.bi)==='normal') applyButtonChange(bh.bi, clip, 'hover');
} else {
var cur=getEffectiveButtonState(bh.bi);
if(cur==='hover'||cur==='pressed') applyButtonChange(bh.bi, clip, 'normal');
}
}
}
function onStageDown(e){
var p=pointerToSceneXY(e);
if(!p) return;
var hit=hitButtonAt(p);
if(!hit) return;
if(getEffectiveButtonState(hit.bi)==='disabled') return;
var clip=findClipForInstance(hit.bi);
if(clip) applyButtonChange(hit.bi, clip, 'pressed');
}
function onStageUp(e){
var p=pointerToSceneXY(e);
if(!p) return;
for(var i=0;i<buttonHits.length;i++){
var bh=buttonHits[i];
if(getEffectiveButtonState(bh.bi)!=='pressed') continue;
var lx=p.x-(bh.bi.x||0), ly=p.y-(bh.bi.y||0);
var inside=lx>=bh.hit.left&&lx<=bh.hit.right&&ly>=bh.hit.top&&ly<=bh.hit.bottom;
var clip=findClipForInstance(bh.bi);
if(clip) applyButtonChange(bh.bi, clip, inside?'hover':'normal');
if(inside){
var acts=bh.bi.onClick||[];
for(var ai=0; ai<acts.length; ai++) evalAction(acts[ai]);
}
}
}
canvas.addEventListener('mousemove', onStageMove);
canvas.addEventListener('mousedown', onStageDown);
canvas.addEventListener('mouseup', onStageUp);
var BUTTON_STATES=['normal','hover','pressed','disabled'];
function parseScriptSource(source){
var outActs=[];
var outErrs=[];
@@ -399,18 +234,6 @@ const ENGINE_SOURCE = `(function(){
else parsed={type:'jumpTo',time:tm};
}
}
} else if(cmd==='state'){
if(toks.length<3) err='state: expected btn:<label> <state>';
else if(toks[1].indexOf('btn:')!==0) err='state: expected btn:<label>';
else {
var st=toks[2];
if(BUTTON_STATES.indexOf(st)<0) err='state: unknown state '+st;
else parsed={type:'setButtonState',instanceId:toks[1].slice(4),state:st};
}
} else if(cmd==='enable'||cmd==='disable'){
if(toks.length<2) err=cmd+': missing btn:<label>';
else if(toks[1].indexOf('btn:')!==0) err=cmd+': expected btn:<label>';
else parsed={type:'setButtonDisabled',instanceId:toks[1].slice(4),disabled:cmd==='disable'};
} else {
err='unknown command "'+cmd+'"';
}
@@ -432,26 +255,6 @@ const ENGINE_SOURCE = `(function(){
runtime.jumpTo=Math.max(0,Math.min(runtime.duration,mt));
return;
}
case 'setButtonState': {
var sid=resolveButtonRef(act.instanceId);
if(!sid){ runtime.warn('Unknown button ref: '+act.instanceId); return; }
var sBi=null, sClip=null;
var sBis=scene.buttonInstances||[];
for(var sbI=0; sbI<sBis.length; sbI++) if(sBis[sbI].id===sid){ sBi=sBis[sbI]; break; }
if(sBi){
var sClips=scene.buttonClips||[];
for(var scI=0; scI<sClips.length; scI++) if(sClips[scI].id===sBi.buttonClipId){ sClip=sClips[scI]; break; }
}
if(sBi && sClip) applyButtonChange(sBi, sClip, act.state);
else setButtonState(sid, act.state);
return;
}
case 'setButtonDisabled': {
var did=resolveButtonRef(act.instanceId);
if(!did){ runtime.warn('Unknown button ref: '+act.instanceId); return; }
runtime.buttonDisabled[did]=act.disabled;
return;
}
case 'script': {
var parsed=parseScriptSource(act.source);
if(parsed.errors.length){
@@ -573,11 +376,10 @@ const ENGINE_SOURCE = `(function(){
}
ctxm.restore();
}
function drawLayer(layer,time,propsOverride){
function drawLayer(layer,time){
var asset=assetMap[layer.assetId];
if(!asset||!layer.visible)return;
var p=sample(layer,time);
if(propsOverride){ for(var poKey in propsOverride) p[poKey]=propsOverride[poKey]; }
ctx.save();
ctx.globalAlpha=ctx.globalAlpha*p.opacity;
ctx.translate(p.x,p.y);
@@ -807,60 +609,6 @@ const ENGINE_SOURCE = `(function(){
ctx.drawImage(topOff,0,0);
ctx.restore();
}
// Draw button instances on top of everything else, and record their
// scene-space hit rectangles for pointer handling.
buttonHits.length=0;
var bInsts=scene.buttonInstances||[];
var bClips=scene.buttonClips||[];
var bClipById={};
for(var bcI=0;bcI<bClips.length;bcI++) bClipById[bClips[bcI].id]=bClips[bcI];
for(var biI=0;biI<bInsts.length;biI++){
var bi=bInsts[biI];
if(time<bi.startTime) continue;
var bclp=bClipById[bi.buttonClipId];
if(!bclp) continue;
var bst=getEffectiveButtonState(bi);
var btr=sampleButtonTransition(bi.id);
var bx=bi.x||0, by=bi.y||0;
ctx.save();
ctx.translate(bx, by);
if(btr && !btr.done && bst!=='disabled'){
drawButtonStatesBlended(bclp, btr.from, btr.to, btr.u);
} else {
var bStack=(bclp.states&&bclp.states[bst])||[];
for(var blI=0; blI<bStack.length; blI++) drawLayer(bStack[blI], 0);
}
ctx.restore();
// Hit rect: explicit hitArea on clip, else bbox of first rect/circle
// layer in the normal state.
var hit=null;
if(bclp.hitArea){
var ox2=bclp.hitArea.offsetX==null?(bclp.hitArea.width/2):bclp.hitArea.offsetX;
var oy2=bclp.hitArea.offsetY==null?(bclp.hitArea.height/2):bclp.hitArea.offsetY;
hit={left:-ox2, top:-oy2, right:bclp.hitArea.width-ox2, bottom:bclp.hitArea.height-oy2};
}
if(!hit){
var normalLs=(bclp.states&&bclp.states.normal)||[];
for(var nli=0; nli<normalLs.length; nli++){
var nl=normalLs[nli];
var na=assetMap[nl.assetId];
if(!na) continue;
if(na.kind==='rect'||na.kind==='image'){
var rox=nl.offsetX==null?na.width/2:nl.offsetX;
var roy=nl.offsetY==null?na.height/2:nl.offsetY;
hit={left:-rox, top:-roy, right:na.width-rox, bottom:na.height-roy};
break;
}
if(na.kind==='circle'){
var cox=nl.offsetX||0, coy=nl.offsetY||0;
hit={left:cox-na.radius, top:coy-na.radius, right:cox+na.radius, bottom:coy+na.radius};
break;
}
}
}
if(!hit) hit={left:-60, top:-20, right:60, bottom:20};
buttonHits.push({bi:bi, hit:hit});
}
ctx.restore();
}
loadImages(scene.assets).then(function(loaded){
+1 -50
View File
@@ -1,4 +1,4 @@
import type { Action, ButtonState } from '@/types/scene'
import type { Action } from '@/types/scene'
export interface ParseError {
line: number
@@ -10,13 +10,6 @@ export interface ParseResult {
errors: ParseError[]
}
const BUTTON_STATES = new Set<ButtonState>([
'normal',
'hover',
'pressed',
'disabled',
])
/**
* Parse a small line-based DSL into Action[]. Comments start with `//`
* and each non-empty line is one command. Known commands:
@@ -25,12 +18,6 @@ const BUTTON_STATES = new Set<ButtonState>([
* jump <seconds> (e.g. `jump 2.5`)
* jump #<frame> (e.g. `jump #135`)
* jump @<marker> (e.g. `jump @intro`)
* state btn:<label> <state> (state ∈ normal|hover|pressed|disabled)
* enable btn:<label>
* disable btn:<label>
*
* Button labels are left as-is in `instanceId`. The runtime resolves
* labels to real instance IDs at eval time.
*/
export function parseScript(source: string): ParseResult {
const actions: Action[] = []
@@ -64,12 +51,6 @@ function parseLine(line: string): { action?: Action; error?: string } {
return ok({ type: 'rewindToLoop' })
case 'jump':
return parseJump(tokens)
case 'state':
return parseState(tokens)
case 'enable':
return parseEnable(tokens, false)
case 'disable':
return parseEnable(tokens, true)
default:
return { error: `unknown command "${cmd}"` }
}
@@ -96,33 +77,3 @@ function parseJump(tokens: string[]): { action?: Action; error?: string } {
if (!Number.isFinite(time)) return { error: `jump: invalid time "${arg}"` }
return ok({ type: 'jumpTo', time })
}
function parseButtonRef(token: string): string | null {
if (!token.startsWith('btn:')) return null
const label = token.slice(4)
return label || null
}
function parseState(tokens: string[]): { action?: Action; error?: string } {
if (tokens.length < 3) return { error: 'state: expected `btn:<label> <state>`' }
const label = parseButtonRef(tokens[1])
if (!label) return { error: `state: expected button ref like btn:<label>, got "${tokens[1]}"` }
const state = tokens[2] as ButtonState
if (!BUTTON_STATES.has(state)) {
return { error: `state: unknown state "${state}"` }
}
return ok({ type: 'setButtonState', instanceId: label, state })
}
function parseEnable(
tokens: string[],
disabled: boolean,
): { action?: Action; error?: string } {
const verb = disabled ? 'disable' : 'enable'
if (tokens.length < 2) return { error: `${verb}: missing button ref` }
const label = parseButtonRef(tokens[1])
if (!label) {
return { error: `${verb}: expected button ref like btn:<label>` }
}
return ok({ type: 'setButtonDisabled', instanceId: label, disabled })
}