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" />