macro-clips slice 4d: button canvas runtime + pointer handling

- ButtonInstance gains optional x/y; new instances default to stage
  center and setButtonInstancePosition updates them
- useButtonRuntime composable holds per-instance state/disabled
  overrides (runtime-only, not persisted). getState layers the
  runtime disabled override on top of the persisted initial flag.
- store.actionRuntime's setButtonState/setButtonDisabled now drive
  useButtonRuntime instead of mutating the scene, so script-triggered
  state changes stay ephemeral
- CanvasStage renders each visible (playhead >= startTime) button
  instance inside a <v-group> at (bi.x, bi.y), drawing the current
  state's layer stack with draggable/listening disabled plus a
  transparent hit-rect sized to the clip's hitArea or the bbox of
  the first rect/circle layer. The group wires mouseenter/leave/
  down/up into the state machine and fires onClick via evalAction.
- exportPlayer runtime mirrors the same thing: getEffectiveButtonState,
  per-frame buttonHits array, canvas-level mousemove/down/up handlers
  do their own hit testing in scene space. RUNTIME_VERSION bumped to 5.
- tests cover useButtonRuntime defaults/overrides and the
  store.actionRuntime → runtime-state roundtrip via label resolution.

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 21:40:02 +02:00
1 parent 024883943b
commit f61f90d613
6 files changed
+412 -8

No files matched your search

+177 -1
View File
@@ -7,7 +7,19 @@ import { sampleLayer } from '@/lib/interpolate'
import { importImageFile, loadImage } from '@/lib/fileLoader'
import { useClipartStore } from '@/stores/clipart'
import { drawSceneFrame, loadSceneImages } from '@/lib/render'
import type { AnimatableProps, Asset, Keyframe, Layer, Point } from '@/types/scene'
import type {
Action,
AnimatableProps,
Asset,
ButtonClip,
ButtonInstance,
ButtonState,
Keyframe,
Layer,
Point,
} from '@/types/scene'
import { useButtonRuntime } from '@/composables/useButtonRuntime'
import { evalAction } from '@/lib/actions'
import { t } from '@/i18n'
import { useKfSelection } from '@/composables/useKfSelection'
import { showLocked } from '@/composables/useShowLocked'
@@ -181,6 +193,134 @@ const canvasBorderConfig = computed(() => ({
const zoomPercent = computed(() => Math.round(zoom.value * 100))
const buttonRt = useButtonRuntime()
interface ButtonRenderItem {
bi: ButtonInstance
clip: ButtonClip
state: ButtonState
disabled: boolean
layers: Array<{
layer: Layer
asset: Asset
props: AnimatableProps
}>
hit: { width: number; height: number; offsetX: number; offsetY: number }
}
const activeButtonInstances = computed<ButtonRenderItem[]>(() => {
const out: ButtonRenderItem[] = []
const instances = store.scene.buttonInstances ?? []
const clips = store.scene.buttonClips ?? []
const clipById = new Map(clips.map((c) => [c.id, c]))
for (const bi of instances) {
if (store.playhead < bi.startTime) continue
const clip = clipById.get(bi.buttonClipId)
if (!clip) continue
const disabled = buttonRt.isDisabled(bi.id, bi.disabled)
const state = buttonRt.getState(bi.id, bi.disabled)
const layers = clip.states[state] ?? []
const rendered: ButtonRenderItem['layers'] = []
for (const layer of layers) {
const asset = store.scene.assets.find((a) => a.id === layer.assetId)
if (!asset) continue
rendered.push({ layer, asset, props: sampleLayer(layer, 0) })
}
// Hit area: explicit hitArea on the clip, or fall back to the bbox of
// the first rect-like asset in the normal state.
let hit = clip.hitArea
? {
width: clip.hitArea.width,
height: clip.hitArea.height,
offsetX: clip.hitArea.offsetX ?? clip.hitArea.width / 2,
offsetY: clip.hitArea.offsetY ?? clip.hitArea.height / 2,
}
: null
if (!hit) {
for (const l of clip.states.normal) {
const a = store.scene.assets.find((x) => x.id === l.assetId)
if (!a) continue
if (a.kind === 'rect' || a.kind === 'image') {
hit = {
width: a.width,
height: a.height,
offsetX: l.offsetX ?? a.width / 2,
offsetY: l.offsetY ?? a.height / 2,
}
break
}
if (a.kind === 'circle') {
hit = {
width: a.radius * 2,
height: a.radius * 2,
offsetX: (l.offsetX ?? 0) + a.radius,
offsetY: (l.offsetY ?? 0) + a.radius,
}
break
}
}
}
if (!hit) hit = { width: 120, height: 40, offsetX: 60, offsetY: 20 }
out.push({ bi, clip, state, disabled, layers: rendered, hit })
}
return out
})
function buttonGroupConfig(bi: ButtonInstance) {
return {
id: `btn:${bi.id}`,
x: bi.x ?? 0,
y: bi.y ?? 0,
listening: true,
}
}
function buttonHitRectConfig(item: ButtonRenderItem) {
return {
x: 0,
y: 0,
width: item.hit.width,
height: item.hit.height,
offsetX: item.hit.offsetX,
offsetY: item.hit.offsetY,
fill: 'rgba(0,0,0,0.001)', // nearly transparent but hittable
listening: !item.disabled,
name: 'button-hit',
}
}
function buttonChildConfig<T extends Record<string, unknown>>(cfg: T): T {
return { ...cfg, draggable: false, listening: false }
}
function onButtonMouseEnter(bi: ButtonInstance) {
const cur = buttonRt.getState(bi.id, bi.disabled)
if (cur === 'normal') buttonRt.setState(bi.id, 'hover')
}
function onButtonMouseLeave(bi: ButtonInstance) {
const cur = buttonRt.getState(bi.id, bi.disabled)
if (cur === 'hover' || cur === 'pressed') buttonRt.setState(bi.id, 'normal')
}
function onButtonMouseDown(bi: ButtonInstance) {
const cur = buttonRt.getState(bi.id, bi.disabled)
if (cur === 'hover') buttonRt.setState(bi.id, 'pressed')
}
function onButtonMouseUp(bi: ButtonInstance) {
const cur = buttonRt.getState(bi.id, bi.disabled)
if (cur !== 'pressed') return
buttonRt.setState(bi.id, 'hover')
const actions = bi.onClick
if (actions && actions.length) runButtonActions(actions)
}
function runButtonActions(actions: Action[]) {
const rt = store.actionRuntime()
for (const a of actions) evalAction(a, rt)
}
const assetMap = computed(() => {
const m = new Map<string, Asset>()
for (const a of store.scene.assets) m.set(a.id, a)
@@ -2346,6 +2486,42 @@ onBeforeUnmount(() => {
/>
</template>
</template>
<template v-for="item in activeButtonInstances" :key="`bi-${item.bi.id}`">
<v-group
:config="buttonGroupConfig(item.bi)"
@mouseenter="onButtonMouseEnter(item.bi)"
@mouseleave="onButtonMouseLeave(item.bi)"
@mousedown="onButtonMouseDown(item.bi)"
@mouseup="onButtonMouseUp(item.bi)"
>
<v-rect :config="buttonHitRectConfig(item)" />
<template
v-for="(entry, bli) in item.layers"
:key="`${item.bi.id}-${entry.layer.id}-${bli}`"
>
<v-rect
v-if="entry.asset.kind === 'rect'"
:config="buttonChildConfig(rectConfig(entry.layer, entry.asset, entry.props))"
/>
<v-circle
v-else-if="entry.asset.kind === 'circle'"
:config="buttonChildConfig(circleConfig(entry.layer, entry.asset, entry.props))"
/>
<v-text
v-else-if="entry.asset.kind === 'text'"
:config="buttonChildConfig(textConfig(entry.layer, entry.asset, entry.props))"
/>
<v-line
v-else-if="entry.asset.kind === 'polygon'"
:config="buttonChildConfig(polygonConfig(entry.layer, entry.asset, entry.props))"
/>
<v-image
v-else-if="entry.asset.kind === 'image' && imageConfig(entry.layer, entry.asset, entry.props)"
:config="buttonChildConfig(imageConfig(entry.layer, entry.asset, entry.props)!)"
/>
</template>
</v-group>
</template>
</v-group>
<v-rect :config="canvasBorderConfig" />
<v-line v-if="rotationArmConfig" :config="rotationArmConfig" />
+48
View File
@@ -0,0 +1,48 @@
import { reactive } from 'vue'
import type { ButtonState } from '@/types/scene'
// Runtime-only per-instance state. Not persisted; resets on page reload.
interface RuntimeState {
// Which state the button is currently showing. Undefined = 'normal'.
state: Map<string, ButtonState>
// Runtime disabled flag — overrides (and can be overridden by) the
// instance's persisted `disabled` field. Undefined = use persisted.
disabled: Map<string, boolean>
}
const runtime: RuntimeState = reactive({
state: new Map<string, ButtonState>(),
disabled: new Map<string, boolean>(),
})
export function useButtonRuntime() {
return {
state: runtime.state,
disabled: runtime.disabled,
getState(id: string, persistedDisabled?: boolean): ButtonState {
const override = runtime.disabled.get(id)
const effectiveDisabled =
override !== undefined ? override : !!persistedDisabled
if (effectiveDisabled) return 'disabled'
return runtime.state.get(id) ?? 'normal'
},
isDisabled(id: string, persistedDisabled?: boolean): boolean {
const override = runtime.disabled.get(id)
return override !== undefined ? override : !!persistedDisabled
},
setState(id: string, next: ButtonState) {
if (next === 'normal') runtime.state.delete(id)
else runtime.state.set(id, next)
},
setDisabled(id: string, next: boolean) {
runtime.disabled.set(id, next)
},
clearDisabled(id: string) {
runtime.disabled.delete(id)
},
reset() {
runtime.state.clear()
runtime.disabled.clear()
},
}
}
+40
View File
@@ -1,5 +1,6 @@
import { createApp, h, nextTick, type Component, type Slot } from 'vue'
import { createBlankScene, useSceneStore } from '@/stores/scene'
import { useButtonRuntime } from '@/composables/useButtonRuntime'
import {
buildMarkerMap,
evalAction,
@@ -3521,6 +3522,45 @@ export function registerAllTests(): void {
})
})
suite('button runtime — state machine + action wiring', () => {
test('default state is normal; set/clear round-trip works', () => {
const rt = useButtonRuntime()
rt.reset()
expect(rt.getState('b1')).toBe('normal')
rt.setState('b1', 'pressed')
expect(rt.getState('b1')).toBe('pressed')
rt.setState('b1', 'normal') // normal uses delete internally
expect(rt.getState('b1')).toBe('normal')
})
test('persisted disabled surfaces as state=disabled until runtime overrides', () => {
const rt = useButtonRuntime()
rt.reset()
expect(rt.getState('b1', true)).toBe('disabled')
rt.setDisabled('b1', false) // runtime enables
expect(rt.getState('b1', true)).toBe('normal')
rt.setDisabled('b1', true)
expect(rt.getState('b1', false)).toBe('disabled')
})
test('store.actionRuntime resolves labels and drives useButtonRuntime', () => {
const s = makeFreshStore()
const rt = useButtonRuntime()
rt.reset()
const bc = s.addButtonClip('B')
const ct = s.addClipTrack()
const inst = s.addButtonInstance(bc.id, ct.id, 0)!
s.setButtonInstanceLabel(inst.id, 'play')
const ar = s.actionRuntime()
ar.setButtonState('play', 'pressed')
expect(rt.getState(inst.id)).toBe('pressed')
ar.setButtonDisabled('play', true)
expect(rt.getState(inst.id)).toBe('disabled')
ar.setButtonDisabled('play', false)
expect(rt.getState(inst.id, inst.disabled)).toBe('pressed')
})
})
suite('scene store — macro model normalization', () => {
test('loadScene initializes new arrays when missing', () => {
const s = makeFreshStore()
+123 -1
View File
@@ -1,6 +1,6 @@
import type { Scene } from '@/types/scene'
const RUNTIME_VERSION = '4'
const RUNTIME_VERSION = '5'
const ENGINE_SOURCE = `(function(){
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
@@ -212,6 +212,79 @@ const ENGINE_SOURCE = `(function(){
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;
}
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 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];
var isDis=getEffectiveButtonState(bh.bi)==='disabled';
if(isDis) continue;
if(hit && hit.bi.id===bh.bi.id){
var cur=getEffectiveButtonState(bh.bi);
if(cur==='normal') setButtonState(bh.bi.id,'hover');
} else {
var cur2=getEffectiveButtonState(bh.bi);
if(cur2==='hover'||cur2==='pressed') setButtonState(bh.bi.id,'normal');
}
}
}
function onStageDown(e){
var p=pointerToSceneXY(e);
if(!p) return;
var hit=hitButtonAt(p);
if(!hit) return;
if(getEffectiveButtonState(hit.bi)==='disabled') return;
setButtonState(hit.bi.id,'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;
setButtonState(bh.bi.id, 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=[];
@@ -646,6 +719,55 @@ 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 bStack=(bclp.states&&bclp.states[bst])||[];
var bx=bi.x||0, by=bi.y||0;
ctx.save();
ctx.translate(bx, by);
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){
+21 -6
View File
@@ -27,6 +27,7 @@ import {
scriptFramesInRange,
type ActionRuntime,
} from '@/lib/actions'
import { useButtonRuntime } from '@/composables/useButtonRuntime'
const uid = () => Math.random().toString(36).slice(2, 10)
@@ -1215,10 +1216,18 @@ export const useSceneStore = defineStore('scene', {
buttonClipId,
clipTrackId,
startTime: Math.max(0, Math.min(this.scene.duration, startTime)),
x: this.scene.canvas.width / 2,
y: this.scene.canvas.height / 2,
}
;(this.scene.buttonInstances ||= []).push(inst)
return inst
},
setButtonInstancePosition(id: string, x: number, y: number) {
const target = this.scene.buttonInstances?.find((bi) => bi.id === id)
if (!target) return
target.x = x
target.y = y
},
removeButtonInstance(id: string) {
if (!this.scene.buttonInstances) return
this.scene.buttonInstances = this.scene.buttonInstances.filter((bi) => bi.id !== id)
@@ -1545,6 +1554,7 @@ export const useSceneStore = defineStore('scene', {
if (byId.has(ref)) return ref
return byLabel.get(ref) ?? null
}
const btnRt = useButtonRuntime()
return {
fps: self.scene.fps,
playbackIn: self.playbackIn,
@@ -1560,18 +1570,23 @@ export const useSceneStore = defineStore('scene', {
pause() {
self.pause()
},
setButtonState() {
// No-op in editor for now — runtime-state management lands in
// the export-runtime slice together with pointer handling.
},
setButtonDisabled(ref: string, disabled: boolean) {
setButtonState(ref, state) {
const id = resolve(ref)
if (!id) {
// eslint-disable-next-line no-console
console.warn('[animatr] unknown button ref:', ref)
return
}
self.setButtonInstanceDisabled(id, disabled)
btnRt.setState(id, state)
},
setButtonDisabled(ref, disabled) {
const id = resolve(ref)
if (!id) {
// eslint-disable-next-line no-console
console.warn('[animatr] unknown button ref:', ref)
return
}
btnRt.setDisabled(id, disabled)
},
warn(msg: string) {
// eslint-disable-next-line no-console
+3
View File
@@ -198,6 +198,9 @@ export interface ButtonInstance {
buttonClipId: string
clipTrackId: string
startTime: number
/** Stage-space position of the button's origin. */
x?: number
y?: number
/** Unique, user-facing handle used in dropdowns and script DSL. */
label?: string
onClick?: Action[]