macro-clips slice 7: button state transitions
- ButtonClip transitions are now editable in ButtonInstanceDialog (from/to/duration/easing) and persisted on the clip via the new store.setButtonClipTransitions action; transitions are shared by every instance of the button (clip-scoped, not instance-scoped). - lerpProps exported from interpolate.ts so callers outside the sampler can blend two poses. - useButtonRuntime tracks an active transition per instance plus a reactive transitionTick that pokes Vue computeds each frame. A self-driving RAF loop runs only while some transition is active (idles when none), so animations work paused or playing. - applyButtonStateChange(id, to, transitions, persistedDisabled) is the one entry point for state changes — it looks up a matching from→to transition and starts it, or flips state instantly. Pointer handlers in CanvasStage and store.actionRuntime.setButtonState both go through it. - CanvasStage blends the two state stacks via sampleStateLayers + blendStates: layers matched by name interpolate their sampled props with lerpProps; unmatched layers fade in/out. Renderer uses the blended layer list in place of the pure state stack during transit. - Export runtime mirrors the same flow: applyButtonChange starts transitions, sampleButtonTransition drives per-frame progress, and drawButtonStatesBlended composites via drawLayer(layer, 0, override). drawLayer gained an optional propsOverride param for this. RUNTIME_VERSION bumped to 6. - Tests cover applyButtonStateChange matching logic and sampleTransition's time → u mapping plus done flag. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
f61f90d613
commit
68ea8576b6
8 files changed
+515
-32
No files matched your search
@@ -1,11 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { PhTrash } from '@phosphor-icons/vue'
|
||||
import { PhPlus, PhTrash } from '@phosphor-icons/vue'
|
||||
import { useSceneStore } from '@/stores/scene'
|
||||
import { t } from '@/i18n'
|
||||
import { Modal } from '@/components/ui'
|
||||
import ActionEditor from './ActionEditor.vue'
|
||||
import type { Action } from '@/types/scene'
|
||||
import type { Action, ButtonState, Easing, StateTransition } from '@/types/scene'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -32,8 +32,12 @@ const buttonClip = computed(() => {
|
||||
const localLabel = ref('')
|
||||
const localDisabled = ref(false)
|
||||
const localActions = ref<Action[]>([])
|
||||
const localTransitions = ref<StateTransition[]>([])
|
||||
const labelError = ref<string | null>(null)
|
||||
|
||||
const BUTTON_STATES: ButtonState[] = ['normal', 'hover', 'pressed', 'disabled']
|
||||
const EASINGS: Easing[] = ['linear', 'easeIn', 'easeOut', 'easeInOut']
|
||||
|
||||
watch(
|
||||
[() => props.modelValue, () => props.instanceId],
|
||||
([open, iid]) => {
|
||||
@@ -43,6 +47,8 @@ watch(
|
||||
localLabel.value = bi.label ?? ''
|
||||
localDisabled.value = !!bi.disabled
|
||||
localActions.value = (bi.onClick ?? []).map((a) => ({ ...a }))
|
||||
const clip = store.scene.buttonClips?.find((c) => c.id === bi.buttonClipId)
|
||||
localTransitions.value = (clip?.transitions ?? []).map((t) => ({ ...t }))
|
||||
labelError.value = null
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -59,9 +65,30 @@ function commit(): boolean {
|
||||
}
|
||||
store.setButtonInstanceOnClick(props.instanceId, localActions.value)
|
||||
store.setButtonInstanceDisabled(props.instanceId, localDisabled.value)
|
||||
const bi = store.scene.buttonInstances?.find((x) => x.id === props.instanceId)
|
||||
if (bi) store.setButtonClipTransitions(bi.buttonClipId, localTransitions.value)
|
||||
return true
|
||||
}
|
||||
|
||||
function addTransition() {
|
||||
localTransitions.value = [
|
||||
...localTransitions.value,
|
||||
{ from: 'normal', to: 'hover', duration: 0.15, easing: 'easeOut' },
|
||||
]
|
||||
}
|
||||
|
||||
function removeTransition(index: number) {
|
||||
const next = [...localTransitions.value]
|
||||
next.splice(index, 1)
|
||||
localTransitions.value = next
|
||||
}
|
||||
|
||||
function updateTransition(index: number, patch: Partial<StateTransition>) {
|
||||
const next = [...localTransitions.value]
|
||||
next[index] = { ...next[index], ...patch }
|
||||
localTransitions.value = next
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (commit()) emit('update:modelValue', false)
|
||||
}
|
||||
@@ -110,6 +137,50 @@ function removeInstance() {
|
||||
@update:actions="localActions = $event"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-label">
|
||||
{{ t('buttonInstanceDialog.transitions') }}
|
||||
<small class="field-hint">{{ t('buttonInstanceDialog.transitionsHint') }}</small>
|
||||
</span>
|
||||
<ol v-if="localTransitions.length" class="trans-list">
|
||||
<li v-for="(tr, i) in localTransitions" :key="i" class="trans-row">
|
||||
<select
|
||||
:value="tr.from"
|
||||
@change="updateTransition(i, { from: ($event.target as HTMLSelectElement).value as ButtonState })"
|
||||
>
|
||||
<option v-for="s in BUTTON_STATES" :key="s" :value="s">{{ s }}</option>
|
||||
</select>
|
||||
<span class="arrow">→</span>
|
||||
<select
|
||||
:value="tr.to"
|
||||
@change="updateTransition(i, { to: ($event.target as HTMLSelectElement).value as ButtonState })"
|
||||
>
|
||||
<option v-for="s in BUTTON_STATES" :key="s" :value="s">{{ s }}</option>
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
:value="tr.duration"
|
||||
@input="updateTransition(i, { duration: Number(($event.target as HTMLInputElement).value) })"
|
||||
class="dur"
|
||||
/>
|
||||
<select
|
||||
:value="tr.easing"
|
||||
@change="updateTransition(i, { easing: ($event.target as HTMLSelectElement).value as Easing })"
|
||||
>
|
||||
<option v-for="e in EASINGS" :key="e" :value="e">{{ e }}</option>
|
||||
</select>
|
||||
<button class="icon-btn danger" :title="t('actions.remove')" @click="removeTransition(i)">
|
||||
<PhTrash :size="13" />
|
||||
</button>
|
||||
</li>
|
||||
</ol>
|
||||
<button class="add-btn" @click="addTransition">
|
||||
<PhPlus :size="12" weight="bold" />
|
||||
{{ t('buttonInstanceDialog.addTransition') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<button class="btn danger" @click="removeInstance">
|
||||
@@ -215,4 +286,83 @@ function removeInstance() {
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
margin-left: 6px;
|
||||
color: var(--text-dim);
|
||||
font-weight: normal;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.trans-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 6px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.trans-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.trans-row select,
|
||||
.trans-row input.dur {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 3px 5px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.trans-row input.dur {
|
||||
width: 56px;
|
||||
}
|
||||
|
||||
.trans-row .arrow {
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon-btn.danger:hover {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 9px;
|
||||
background: var(--panel);
|
||||
border: 1px dashed var(--border-strong);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 11px;
|
||||
align-self: flex-start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.add-btn:hover {
|
||||
background: var(--panel-hover);
|
||||
}
|
||||
</style>
|
||||
@@ -18,8 +18,12 @@ import type {
|
||||
Layer,
|
||||
Point,
|
||||
} from '@/types/scene'
|
||||
import { useButtonRuntime } from '@/composables/useButtonRuntime'
|
||||
import {
|
||||
applyButtonStateChange,
|
||||
useButtonRuntime,
|
||||
} from '@/composables/useButtonRuntime'
|
||||
import { evalAction } from '@/lib/actions'
|
||||
import { lerpProps } from '@/lib/interpolate'
|
||||
import { t } from '@/i18n'
|
||||
import { useKfSelection } from '@/composables/useKfSelection'
|
||||
import { showLocked } from '@/composables/useShowLocked'
|
||||
@@ -208,23 +212,86 @@ interface ButtonRenderItem {
|
||||
hit: { width: number; height: number; offsetX: number; offsetY: number }
|
||||
}
|
||||
|
||||
function sampleStateLayers(
|
||||
clip: ButtonClip,
|
||||
state: ButtonState,
|
||||
): ButtonRenderItem['layers'] {
|
||||
const layers = clip.states[state] ?? []
|
||||
const out: ButtonRenderItem['layers'] = []
|
||||
for (const layer of layers) {
|
||||
const asset = store.scene.assets.find((a) => a.id === layer.assetId)
|
||||
if (!asset) continue
|
||||
out.push({ layer, asset, props: sampleLayer(layer, 0) })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Blend the "from" state's layers into the "to" state's layers by layer
|
||||
* name. Matched pairs lerp their props; unmatched from-layers fade out,
|
||||
* unmatched to-layers fade in.
|
||||
*/
|
||||
function blendStates(
|
||||
clip: ButtonClip,
|
||||
from: ButtonState,
|
||||
to: ButtonState,
|
||||
u: number,
|
||||
): ButtonRenderItem['layers'] {
|
||||
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 seen = new Set<string>()
|
||||
const out: ButtonRenderItem['layers'] = []
|
||||
// Preserve to-state order primarily, with unmatched from-layers pinned
|
||||
// underneath so the new state visually rises into place.
|
||||
for (const l of fromLayers) {
|
||||
if (toByName.has(l.name)) continue
|
||||
const asset = store.scene.assets.find((a) => a.id === 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 = store.scene.assets.find((a) => a.id === l.assetId)
|
||||
if (!asset) continue
|
||||
seen.add(l.name)
|
||||
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 } })
|
||||
}
|
||||
}
|
||||
void seen
|
||||
return out
|
||||
}
|
||||
|
||||
const activeButtonInstances = computed<ButtonRenderItem[]>(() => {
|
||||
// Force re-evaluation on every transition RAF tick.
|
||||
void buttonRt.transitionTick.value
|
||||
const out: ButtonRenderItem[] = []
|
||||
const instances = store.scene.buttonInstances ?? []
|
||||
const clips = store.scene.buttonClips ?? []
|
||||
const clipById = new Map(clips.map((c) => [c.id, c]))
|
||||
const now = performance.now()
|
||||
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) })
|
||||
const tr = buttonRt.sampleTransition(bi.id, now)
|
||||
let rendered: ButtonRenderItem['layers']
|
||||
if (tr && !tr.done && !disabled) {
|
||||
rendered = blendStates(clip, tr.from, tr.to, tr.u)
|
||||
} else {
|
||||
rendered = sampleStateLayers(clip, state)
|
||||
}
|
||||
// Hit area: explicit hitArea on the clip, or fall back to the bbox of
|
||||
// the first rect-like asset in the normal state.
|
||||
@@ -293,25 +360,30 @@ function buttonChildConfig<T extends Record<string, unknown>>(cfg: T): T {
|
||||
return { ...cfg, draggable: false, listening: false }
|
||||
}
|
||||
|
||||
function transitionsFor(bi: ButtonInstance) {
|
||||
return store.scene.buttonClips?.find((c) => c.id === bi.buttonClipId)?.transitions
|
||||
}
|
||||
|
||||
function onButtonMouseEnter(bi: ButtonInstance) {
|
||||
const cur = buttonRt.getState(bi.id, bi.disabled)
|
||||
if (cur === 'normal') buttonRt.setState(bi.id, 'hover')
|
||||
if (cur === 'normal') applyButtonStateChange(bi.id, 'hover', transitionsFor(bi), bi.disabled)
|
||||
}
|
||||
|
||||
function onButtonMouseLeave(bi: ButtonInstance) {
|
||||
const cur = buttonRt.getState(bi.id, bi.disabled)
|
||||
if (cur === 'hover' || cur === 'pressed') buttonRt.setState(bi.id, 'normal')
|
||||
if (cur === 'hover' || cur === 'pressed')
|
||||
applyButtonStateChange(bi.id, 'normal', transitionsFor(bi), bi.disabled)
|
||||
}
|
||||
|
||||
function onButtonMouseDown(bi: ButtonInstance) {
|
||||
const cur = buttonRt.getState(bi.id, bi.disabled)
|
||||
if (cur === 'hover') buttonRt.setState(bi.id, 'pressed')
|
||||
if (cur === 'hover') applyButtonStateChange(bi.id, 'pressed', transitionsFor(bi), bi.disabled)
|
||||
}
|
||||
|
||||
function onButtonMouseUp(bi: ButtonInstance) {
|
||||
const cur = buttonRt.getState(bi.id, bi.disabled)
|
||||
if (cur !== 'pressed') return
|
||||
buttonRt.setState(bi.id, 'hover')
|
||||
applyButtonStateChange(bi.id, 'hover', transitionsFor(bi), bi.disabled)
|
||||
const actions = bi.onClick
|
||||
if (actions && actions.length) runButtonActions(actions)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user