From f61f90d613969c570a18e44b670bde4e57f05467 Mon Sep 17 00:00:00 2001
From: th
Date: Mon, 20 Apr 2026 21:40:02 +0200
Subject: [PATCH] macro-clips slice 4d: button canvas runtime + pointer
handling
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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 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)
---
src/components/CanvasStage.vue | 178 +++++++++++++++++++++++++++-
src/composables/useButtonRuntime.ts | 48 ++++++++
src/devtools/tests.ts | 40 +++++++
src/lib/exportPlayer.ts | 124 ++++++++++++++++++-
src/stores/scene.ts | 27 ++++-
src/types/scene.ts | 3 +
6 files changed, 412 insertions(+), 8 deletions(-)
create mode 100644 src/composables/useButtonRuntime.ts
diff --git a/src/components/CanvasStage.vue b/src/components/CanvasStage.vue
index f1ff55f..f59a411 100644
--- a/src/components/CanvasStage.vue
+++ b/src/components/CanvasStage.vue
@@ -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(() => {
+ 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>(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()
for (const a of store.scene.assets) m.set(a.id, a)
@@ -2346,6 +2486,42 @@ onBeforeUnmount(() => {
/>
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/composables/useButtonRuntime.ts b/src/composables/useButtonRuntime.ts
new file mode 100644
index 0000000..c7f7cb3
--- /dev/null
+++ b/src/composables/useButtonRuntime.ts
@@ -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
+ // Runtime disabled flag — overrides (and can be overridden by) the
+ // instance's persisted `disabled` field. Undefined = use persisted.
+ disabled: Map
+}
+
+const runtime: RuntimeState = reactive({
+ state: new Map(),
+ disabled: new Map(),
+})
+
+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()
+ },
+ }
+}
diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts
index ddf8e8f..ce1ed6d 100644
--- a/src/devtools/tests.ts
+++ b/src/devtools/tests.ts
@@ -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()
diff --git a/src/lib/exportPlayer.ts b/src/lib/exportPlayer.ts
index 31d7a42..2a63d1e 100644
--- a/src/lib/exportPlayer.ts
+++ b/src/lib/exportPlayer.ts
@@ -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=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=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 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
diff --git a/src/types/scene.ts b/src/types/scene.ts
index b2609ba..9cef66a 100644
--- a/src/types/scene.ts
+++ b/src/types/scene.ts
@@ -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[]
|