0.10.6: setButtonEnabled action — disable buttons from the timeline
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d3c5b8b640
commit
0bbef66192
8 files changed
+221
-2
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "animatr",
|
"name": "animatr",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.10.5",
|
"version": "0.10.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -23,6 +23,20 @@ const sceneOptions = computed(() =>
|
|||||||
store.project.scenes.map((s) => ({ id: s.id, name: s.name })),
|
store.project.scenes.map((s) => ({ id: s.id, name: s.name })),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const buttonLayerOptions = computed(() => {
|
||||||
|
const assetById = new Map(store.project.assets.map((a) => [a.id, a]))
|
||||||
|
const out: { id: string; label: string }[] = []
|
||||||
|
for (const page of store.project.scenes) {
|
||||||
|
for (const layer of page.layers) {
|
||||||
|
const asset = assetById.get(layer.assetId)
|
||||||
|
if (asset?.kind === 'button') {
|
||||||
|
out.push({ id: layer.id, label: `${page.name} / ${layer.name}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
const ACTION_TYPES: Action['type'][] = [
|
const ACTION_TYPES: Action['type'][] = [
|
||||||
'play',
|
'play',
|
||||||
'pause',
|
'pause',
|
||||||
@@ -31,6 +45,7 @@ const ACTION_TYPES: Action['type'][] = [
|
|||||||
'jumpToFrame',
|
'jumpToFrame',
|
||||||
'jumpToMarker',
|
'jumpToMarker',
|
||||||
'jumpToScene',
|
'jumpToScene',
|
||||||
|
'setButtonEnabled',
|
||||||
'script',
|
'script',
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -58,6 +73,14 @@ function makeDefault(type: Action['type']): Action {
|
|||||||
const target = others[0] ?? sceneOptions.value[0]
|
const target = others[0] ?? sceneOptions.value[0]
|
||||||
return { type: 'jumpToScene', sceneId: target?.id ?? '' }
|
return { type: 'jumpToScene', sceneId: target?.id ?? '' }
|
||||||
}
|
}
|
||||||
|
case 'setButtonEnabled':
|
||||||
|
// Default "disable" — matches the common use case of parking a
|
||||||
|
// button after it's been clicked.
|
||||||
|
return {
|
||||||
|
type: 'setButtonEnabled',
|
||||||
|
layerId: buttonLayerOptions.value[0]?.id ?? '',
|
||||||
|
enabled: false,
|
||||||
|
}
|
||||||
case 'script':
|
case 'script':
|
||||||
return { type: 'script', source: '' }
|
return { type: 'script', source: '' }
|
||||||
}
|
}
|
||||||
@@ -177,6 +200,26 @@ function onTypeChange(index: number, newType: Action['type']) {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="a.type === 'setButtonEnabled'" class="params">
|
||||||
|
<label>
|
||||||
|
{{ t('actions.param.buttonLayer') }}
|
||||||
|
<select
|
||||||
|
:value="a.layerId"
|
||||||
|
@change="setAt(i, { type: 'setButtonEnabled', layerId: ($event.target as HTMLSelectElement).value, enabled: a.enabled })"
|
||||||
|
>
|
||||||
|
<option v-if="!buttonLayerOptions.length" value="" disabled>{{ t('actions.noButtonLayers') }}</option>
|
||||||
|
<option v-for="b in buttonLayerOptions" :key="b.id" :value="b.id">{{ b.label }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="bool">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="a.enabled"
|
||||||
|
@change="setAt(i, { type: 'setButtonEnabled', layerId: a.layerId, enabled: ($event.target as HTMLInputElement).checked })"
|
||||||
|
/>
|
||||||
|
{{ t('actions.param.enabled') }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div v-else-if="a.type === 'script'" class="params">
|
<div v-else-if="a.type === 'script'" class="params">
|
||||||
<label class="block">
|
<label class="block">
|
||||||
{{ t('actions.param.source') }}
|
{{ t('actions.param.source') }}
|
||||||
|
|||||||
@@ -4233,6 +4233,103 @@ export function registerAllTests(): void {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
suite('ui — ActionEditor (setButtonEnabled)', () => {
|
||||||
|
test('setButtonEnabled appears in the type dropdown', async () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
void s
|
||||||
|
const handle = mountComponent(ActionEditor, {
|
||||||
|
actions: [{ type: 'pause' } as const],
|
||||||
|
})
|
||||||
|
await nextTick()
|
||||||
|
const select = handle.host.querySelector('.type-select') as HTMLSelectElement
|
||||||
|
const values = Array.from(select.options).map((o) => o.value)
|
||||||
|
expect(values.includes('setButtonEnabled')).toBe(true)
|
||||||
|
handle.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('switching a row to setButtonEnabled seeds it with the first button layer + enabled=false', async () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const btn = s.addButtonAsset('Play', 160, 60)
|
||||||
|
const layer = s.addLayer(btn.id)!
|
||||||
|
let latest: unknown = null
|
||||||
|
const handle = mountComponent(ActionEditor, {
|
||||||
|
actions: [{ type: 'pause' } as const],
|
||||||
|
'onUpdate:actions': (v: unknown) => {
|
||||||
|
latest = v
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await nextTick()
|
||||||
|
const select = handle.host.querySelector('.type-select') as HTMLSelectElement
|
||||||
|
select.value = 'setButtonEnabled'
|
||||||
|
select.dispatchEvent(new Event('change', { bubbles: true }))
|
||||||
|
await nextTick()
|
||||||
|
const next = (latest as unknown[])[0] as {
|
||||||
|
type: string
|
||||||
|
layerId?: string
|
||||||
|
enabled?: boolean
|
||||||
|
}
|
||||||
|
expect(next.type).toBe('setButtonEnabled')
|
||||||
|
expect(next.layerId).toBe(layer.id)
|
||||||
|
// Defaults to "disable" — the common use case.
|
||||||
|
expect(next.enabled).toBe(false)
|
||||||
|
handle.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('button dropdown lists one entry per button layer across scenes', async () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const btn1 = s.addButtonAsset('Play', 160, 60)
|
||||||
|
const layer1 = s.addLayer(btn1.id)!
|
||||||
|
s.addScene('Other')
|
||||||
|
const btn2 = s.addButtonAsset('Stop', 160, 60)
|
||||||
|
const layer2 = s.addLayer(btn2.id)!
|
||||||
|
let latest: unknown = null
|
||||||
|
const handle = mountComponent(ActionEditor, {
|
||||||
|
actions: [{ type: 'setButtonEnabled', layerId: layer1.id, enabled: false } as const],
|
||||||
|
'onUpdate:actions': (v: unknown) => {
|
||||||
|
latest = v
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await nextTick()
|
||||||
|
const selects = handle.host.querySelectorAll('select')
|
||||||
|
// action-type dropdown + button-layer dropdown
|
||||||
|
expect(selects.length).toBe(2)
|
||||||
|
const btnSelect = selects[1] as HTMLSelectElement
|
||||||
|
const ids = Array.from(btnSelect.options).map((o) => o.value)
|
||||||
|
expect(ids.sort()).toEqual([layer1.id, layer2.id].sort())
|
||||||
|
btnSelect.value = layer2.id
|
||||||
|
btnSelect.dispatchEvent(new Event('change', { bubbles: true }))
|
||||||
|
await nextTick()
|
||||||
|
const next = (latest as unknown[])[0] as { layerId: string; enabled: boolean }
|
||||||
|
expect(next.layerId).toBe(layer2.id)
|
||||||
|
expect(next.enabled).toBe(false)
|
||||||
|
handle.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('enabled checkbox round-trips true/false into the emitted action', async () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const btn = s.addButtonAsset('Play', 160, 60)
|
||||||
|
const layer = s.addLayer(btn.id)!
|
||||||
|
let latest: unknown = null
|
||||||
|
const handle = mountComponent(ActionEditor, {
|
||||||
|
actions: [{ type: 'setButtonEnabled', layerId: layer.id, enabled: false } as const],
|
||||||
|
'onUpdate:actions': (v: unknown) => {
|
||||||
|
latest = v
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await nextTick()
|
||||||
|
const cb = handle.host.querySelector('input[type="checkbox"]') as HTMLInputElement
|
||||||
|
expect(cb).toBeTruthy()
|
||||||
|
expect(cb.checked).toBe(false)
|
||||||
|
cb.checked = true
|
||||||
|
cb.dispatchEvent(new Event('change', { bubbles: true }))
|
||||||
|
await nextTick()
|
||||||
|
const next = (latest as unknown[])[0] as { layerId: string; enabled: boolean }
|
||||||
|
expect(next.layerId).toBe(layer.id)
|
||||||
|
expect(next.enabled).toBe(true)
|
||||||
|
handle.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
suite('ui — Timeline audio track', () => {
|
suite('ui — Timeline audio track', () => {
|
||||||
test('audio track renders as an .audio-track lane with one block per clip', async () => {
|
test('audio track renders as an .audio-track lane with one block per clip', async () => {
|
||||||
const s = makeFreshStore()
|
const s = makeFreshStore()
|
||||||
@@ -6348,6 +6445,29 @@ export function registerAllTests(): void {
|
|||||||
expect(rt.warnings[0].toLowerCase()).toContain('does not support')
|
expect(rt.warnings[0].toLowerCase()).toContain('does not support')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('setButtonEnabled dispatches setButtonEnabled on the runtime', () => {
|
||||||
|
const flips: { layerId: string; enabled: boolean }[] = []
|
||||||
|
const rt = makeMockRuntime({
|
||||||
|
setButtonEnabled(layerId: string, enabled: boolean) {
|
||||||
|
flips.push({ layerId, enabled })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
evalAction({ type: 'setButtonEnabled', layerId: 'L-1', enabled: false }, rt)
|
||||||
|
evalAction({ type: 'setButtonEnabled', layerId: 'L-1', enabled: true }, rt)
|
||||||
|
expect(flips).toEqual([
|
||||||
|
{ layerId: 'L-1', enabled: false },
|
||||||
|
{ layerId: 'L-1', enabled: true },
|
||||||
|
])
|
||||||
|
expect(rt.warnings.length).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('setButtonEnabled warns when the runtime omits the handler', () => {
|
||||||
|
const rt = makeMockRuntime() // no setButtonEnabled
|
||||||
|
evalAction({ type: 'setButtonEnabled', layerId: 'L-1', enabled: false }, rt)
|
||||||
|
expect(rt.warnings.length).toBe(1)
|
||||||
|
expect(rt.warnings[0].toLowerCase()).toContain('does not support')
|
||||||
|
})
|
||||||
|
|
||||||
test('script action parses and dispatches sub-actions (no warn on valid DSL)', () => {
|
test('script action parses and dispatches sub-actions (no warn on valid DSL)', () => {
|
||||||
const rt = makeMockRuntime()
|
const rt = makeMockRuntime()
|
||||||
evalAction({ type: 'script', source: 'play\npause' }, rt)
|
evalAction({ type: 'script', source: 'play\npause' }, rt)
|
||||||
@@ -6783,6 +6903,15 @@ export function registerAllTests(): void {
|
|||||||
expect(r).toContain('buttonLayerActiveAt')
|
expect(r).toContain('buttonLayerActiveAt')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('runtime engine evalAction handles setButtonEnabled via runtime.buttonDisabled', () => {
|
||||||
|
// Pin the engine source so the setButtonEnabled case both exists
|
||||||
|
// and writes into the runtime.buttonDisabled map — the same field
|
||||||
|
// the click-dispatcher reads to early-return on disabled buttons.
|
||||||
|
const r = generateRuntimeScript()
|
||||||
|
expect(r).toContain("case 'setButtonEnabled'")
|
||||||
|
expect(r).toContain('runtime.buttonDisabled[act.layerId]')
|
||||||
|
})
|
||||||
|
|
||||||
test('timeline segments skip the connector line before a still-frame kf in button mode', async () => {
|
test('timeline segments skip the connector line before a still-frame kf in button mode', async () => {
|
||||||
// In button-edit the state frames are discrete: the connector
|
// In button-edit the state frames are discrete: the connector
|
||||||
// line that spans between kfs on a regular timeline must NOT
|
// line that spans between kfs on a regular timeline must NOT
|
||||||
@@ -7135,6 +7264,22 @@ export function registerAllTests(): void {
|
|||||||
expect(rt.getEffectiveTime('btn-d', false)).toBe(BUTTON_STATE_TIMES.normal)
|
expect(rt.getEffectiveTime('btn-d', false)).toBe(BUTTON_STATE_TIMES.normal)
|
||||||
rt.clear('btn-d')
|
rt.clear('btn-d')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('store actionRuntime.setButtonEnabled toggles useButtonRuntime disabled state', async () => {
|
||||||
|
const mod = await import('@/composables/useButtonRuntime')
|
||||||
|
const btnRt = mod.useButtonRuntime()
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const btn = s.addButtonAsset('B', 100, 40)
|
||||||
|
const layer = s.addLayer(btn.id)!
|
||||||
|
btnRt.clear(layer.id)
|
||||||
|
const actionRt = s.actionRuntime()
|
||||||
|
expect(typeof actionRt.setButtonEnabled).toBe('function')
|
||||||
|
actionRt.setButtonEnabled!(layer.id, false)
|
||||||
|
expect(btnRt.getEffective(layer.id, false)).toBe('disabled')
|
||||||
|
actionRt.setButtonEnabled!(layer.id, true)
|
||||||
|
expect(btnRt.getEffective(layer.id, false)).toBe('normal')
|
||||||
|
btnRt.clear(layer.id)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
suite('button asset — thumbnail rendering', () => {
|
suite('button asset — thumbnail rendering', () => {
|
||||||
|
|||||||
@@ -494,6 +494,7 @@ export const en = {
|
|||||||
moveDown: 'Move down',
|
moveDown: 'Move down',
|
||||||
noMarkers: 'No markers defined',
|
noMarkers: 'No markers defined',
|
||||||
noScenes: 'No other scenes defined',
|
noScenes: 'No other scenes defined',
|
||||||
|
noButtonLayers: 'No button layers defined',
|
||||||
type: {
|
type: {
|
||||||
play: 'Play',
|
play: 'Play',
|
||||||
pause: 'Pause',
|
pause: 'Pause',
|
||||||
@@ -502,6 +503,7 @@ export const en = {
|
|||||||
jumpToFrame: 'Jump to frame',
|
jumpToFrame: 'Jump to frame',
|
||||||
jumpToMarker: 'Jump to marker',
|
jumpToMarker: 'Jump to marker',
|
||||||
jumpToScene: 'Jump to scene',
|
jumpToScene: 'Jump to scene',
|
||||||
|
setButtonEnabled: 'Enable / disable button',
|
||||||
script: 'Run script',
|
script: 'Run script',
|
||||||
},
|
},
|
||||||
param: {
|
param: {
|
||||||
@@ -509,6 +511,8 @@ export const en = {
|
|||||||
frame: 'Frame',
|
frame: 'Frame',
|
||||||
marker: 'Marker',
|
marker: 'Marker',
|
||||||
scene: 'Scene',
|
scene: 'Scene',
|
||||||
|
buttonLayer: 'Button',
|
||||||
|
enabled: 'Enabled',
|
||||||
source: 'Script source',
|
source: 'Script source',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ export interface ActionRuntime {
|
|||||||
* current playback state so a script-fired jump doesn't surprise-pause
|
* current playback state so a script-fired jump doesn't surprise-pause
|
||||||
* the timeline. */
|
* the timeline. */
|
||||||
switchScene?(sceneId: string): void
|
switchScene?(sceneId: string): void
|
||||||
|
/** Flip a button layer's runtime disabled flag. Optional: runtimes
|
||||||
|
* without button support ignore the action and the caller warns. */
|
||||||
|
setButtonEnabled?(layerId: string, enabled: boolean): void
|
||||||
warn(msg: string): void
|
warn(msg: string): void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +72,14 @@ export function evalAction(action: Action, rt: ActionRuntime): void {
|
|||||||
rt.switchScene(action.sceneId)
|
rt.switchScene(action.sceneId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
case 'setButtonEnabled': {
|
||||||
|
if (!rt.setButtonEnabled) {
|
||||||
|
rt.warn('setButtonEnabled: runtime does not support button state')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rt.setButtonEnabled(action.layerId, action.enabled)
|
||||||
|
return
|
||||||
|
}
|
||||||
case 'script': {
|
case 'script': {
|
||||||
const parsed = parseScript(action.source)
|
const parsed = parseScript(action.source)
|
||||||
if (parsed.errors.length) {
|
if (parsed.errors.length) {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ function isProject(p: ExportPayload): p is Project {
|
|||||||
return Array.isArray((p as Partial<Project>).scenes)
|
return Array.isArray((p as Partial<Project>).scenes)
|
||||||
}
|
}
|
||||||
|
|
||||||
const RUNTIME_VERSION = '25'
|
const RUNTIME_VERSION = '26'
|
||||||
|
|
||||||
const ENGINE_SOURCE = `(function(){
|
const ENGINE_SOURCE = `(function(){
|
||||||
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
|
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
|
||||||
@@ -568,6 +568,14 @@ const ENGINE_SOURCE = `(function(){
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
case 'setButtonEnabled': {
|
||||||
|
// Runtime override lives on runtime.buttonDisabled — keyed by
|
||||||
|
// scene-layer id. True = disabled, false = enabled. Clearing
|
||||||
|
// the entry would fall back to layer.buttonDisabled, which is
|
||||||
|
// the persisted initial flag, not what the script just asked for.
|
||||||
|
runtime.buttonDisabled[act.layerId] = !act.enabled;
|
||||||
|
return;
|
||||||
|
}
|
||||||
case 'script': {
|
case 'script': {
|
||||||
var parsed=parseScriptSource(act.source);
|
var parsed=parseScriptSource(act.source);
|
||||||
if(parsed.errors.length){
|
if(parsed.errors.length){
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
scriptFramesInRange,
|
scriptFramesInRange,
|
||||||
type ActionRuntime,
|
type ActionRuntime,
|
||||||
} from '@/lib/actions'
|
} from '@/lib/actions'
|
||||||
|
import { useButtonRuntime } from '@/composables/useButtonRuntime'
|
||||||
|
|
||||||
const uid = () => Math.random().toString(36).slice(2, 10)
|
const uid = () => Math.random().toString(36).slice(2, 10)
|
||||||
|
|
||||||
@@ -2639,6 +2640,9 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
// the current playhead — navigation vs. transition.
|
// the current playhead — navigation vs. transition.
|
||||||
self.switchScene(sceneId, { resetPlayhead: true })
|
self.switchScene(sceneId, { resetPlayhead: true })
|
||||||
},
|
},
|
||||||
|
setButtonEnabled(layerId: string, enabled: boolean) {
|
||||||
|
useButtonRuntime().setDisabled(layerId, !enabled)
|
||||||
|
},
|
||||||
warn(msg: string) {
|
warn(msg: string) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.warn('[ruwio]', msg)
|
console.warn('[ruwio]', msg)
|
||||||
|
|||||||
@@ -324,6 +324,10 @@ export type Action =
|
|||||||
* don't break existing actions. Playback state is preserved across
|
* don't break existing actions. Playback state is preserved across
|
||||||
* the jump — all pages share the same timeline. */
|
* the jump — all pages share the same timeline. */
|
||||||
| { type: 'jumpToScene'; sceneId: string }
|
| { type: 'jumpToScene'; sceneId: string }
|
||||||
|
/** Flip a button layer's runtime disabled flag. Layer id is the scene-
|
||||||
|
* layer that references a ButtonAsset; when enabled=false its onClick
|
||||||
|
* stops firing (the state machine never leaves `disabled`). */
|
||||||
|
| { type: 'setButtonEnabled'; layerId: string; enabled: boolean }
|
||||||
| { type: 'script'; source: string }
|
| { type: 'script'; source: string }
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in new issue
Block a user