macro-clips slice 4a: script frame dialog + reusable action editor

- ActionEditor.vue — reusable list editor for Action[]: add/remove/reorder,
  per-type parameter forms (jump to time/frame/marker, set button
  state/disabled, script source). Marker and button dropdowns pull from
  the store's current scene.
- ScriptFrameDialog.vue — modal wrapping ActionEditor plus time and label
  inputs; commits on close, supports deletion.
- Timeline wires the dialog: clicking a script frame opens it for edit,
  clicking the empty script lane creates a new frame at that time and
  opens the editor.
- i18n keys for all action types, parameters, and dialog labels.

Button-authoring UI (definitions + instance placement + properties panel)
is its own slice (4b) — this commit ships script authoring only.

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 20:54:24 +02:00
1 parent 92d58937e3
commit b9d1ca16f7
4 files changed
+635

No files matched your search

+392
View File
@@ -0,0 +1,392 @@
<script setup lang="ts">
import { computed } from 'vue'
import {
PhArrowDown,
PhArrowUp,
PhPlus,
PhTrash,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { t } from '@/i18n'
import type { Action, ButtonState } from '@/types/scene'
const props = defineProps<{ actions: Action[] }>()
const emit = defineEmits<{ 'update:actions': [value: Action[]] }>()
const store = useSceneStore()
const markerNames = computed(() =>
(store.scene.markers ?? []).map((m) => m.name),
)
interface ButtonInstanceOption {
id: string
label: string
}
const buttonInstanceOptions = computed<ButtonInstanceOption[]>(() =>
(store.scene.buttonInstances ?? [])
.filter((bi) => !!bi.label)
.map((bi) => ({ id: bi.id, label: bi.label! })),
)
const BUTTON_STATES: ButtonState[] = ['normal', 'hover', 'pressed', 'disabled']
const ACTION_TYPES: Action['type'][] = [
'play',
'pause',
'rewindToLoop',
'jumpTo',
'jumpToFrame',
'jumpToMarker',
'setButtonState',
'setButtonDisabled',
'script',
]
function actionTypeLabel(type: Action['type']): string {
return t(`actions.type.${type}`)
}
function makeDefault(type: Action['type']): Action {
switch (type) {
case 'play':
case 'pause':
case 'rewindToLoop':
return { type }
case 'jumpTo':
return { type: 'jumpTo', time: 0 }
case 'jumpToFrame':
return { type: 'jumpToFrame', frame: 0 }
case 'jumpToMarker':
return { type: 'jumpToMarker', name: markerNames.value[0] ?? '' }
case 'setButtonState':
return {
type: 'setButtonState',
instanceId: buttonInstanceOptions.value[0]?.id ?? '',
state: 'normal',
}
case 'setButtonDisabled':
return {
type: 'setButtonDisabled',
instanceId: buttonInstanceOptions.value[0]?.id ?? '',
disabled: true,
}
case 'script':
return { type: 'script', source: '' }
}
}
function setAt(index: number, next: Action) {
const copy = [...props.actions]
copy[index] = next
emit('update:actions', copy)
}
function addAction() {
emit('update:actions', [...props.actions, makeDefault('pause')])
}
function removeAt(index: number) {
const copy = [...props.actions]
copy.splice(index, 1)
emit('update:actions', copy)
}
function moveUp(index: number) {
if (index === 0) return
const copy = [...props.actions]
const tmp = copy[index - 1]
copy[index - 1] = copy[index]
copy[index] = tmp
emit('update:actions', copy)
}
function moveDown(index: number) {
if (index === props.actions.length - 1) return
const copy = [...props.actions]
const tmp = copy[index + 1]
copy[index + 1] = copy[index]
copy[index] = tmp
emit('update:actions', copy)
}
function onTypeChange(index: number, newType: Action['type']) {
if (props.actions[index].type === newType) return
setAt(index, makeDefault(newType))
}
</script>
<template>
<div class="action-editor">
<div v-if="!actions.length" class="empty">{{ t('actions.empty') }}</div>
<ol v-else class="list">
<li v-for="(a, i) in actions" :key="i" class="row">
<div class="head">
<span class="idx">{{ i + 1 }}</span>
<select
class="type-select"
:value="a.type"
@change="onTypeChange(i, ($event.target as HTMLSelectElement).value as Action['type'])"
>
<option v-for="type in ACTION_TYPES" :key="type" :value="type">
{{ actionTypeLabel(type) }}
</option>
</select>
<button class="icon-btn" :title="t('actions.moveUp')" :disabled="i === 0" @click="moveUp(i)">
<PhArrowUp :size="13" />
</button>
<button class="icon-btn" :title="t('actions.moveDown')" :disabled="i === actions.length - 1" @click="moveDown(i)">
<PhArrowDown :size="13" />
</button>
<button class="icon-btn danger" :title="t('actions.remove')" @click="removeAt(i)">
<PhTrash :size="13" />
</button>
</div>
<div v-if="a.type === 'jumpTo'" class="params">
<label>
{{ t('actions.param.time') }}
<input
type="number"
step="0.01"
min="0"
:value="a.time"
@input="setAt(i, { type: 'jumpTo', time: Number(($event.target as HTMLInputElement).value) })"
/>
</label>
</div>
<div v-else-if="a.type === 'jumpToFrame'" class="params">
<label>
{{ t('actions.param.frame') }}
<input
type="number"
step="1"
min="0"
:value="a.frame"
@input="setAt(i, { type: 'jumpToFrame', frame: Math.round(Number(($event.target as HTMLInputElement).value)) })"
/>
</label>
</div>
<div v-else-if="a.type === 'jumpToMarker'" class="params">
<label>
{{ t('actions.param.marker') }}
<select
:value="a.name"
@change="setAt(i, { type: 'jumpToMarker', name: ($event.target as HTMLSelectElement).value })"
>
<option v-if="!markerNames.length" value="" disabled>{{ t('actions.noMarkers') }}</option>
<option v-for="name in markerNames" :key="name" :value="name">{{ name }}</option>
</select>
</label>
</div>
<div v-else-if="a.type === 'setButtonState'" class="params">
<label>
{{ t('actions.param.button') }}
<select
:value="a.instanceId"
@change="setAt(i, { type: 'setButtonState', instanceId: ($event.target as HTMLSelectElement).value, state: a.state })"
>
<option v-if="!buttonInstanceOptions.length" value="" disabled>{{ t('actions.noButtons') }}</option>
<option v-for="b in buttonInstanceOptions" :key="b.id" :value="b.id">{{ b.label }}</option>
</select>
</label>
<label>
{{ t('actions.param.state') }}
<select
:value="a.state"
@change="setAt(i, { type: 'setButtonState', instanceId: a.instanceId, state: ($event.target as HTMLSelectElement).value as ButtonState })"
>
<option v-for="s in BUTTON_STATES" :key="s" :value="s">{{ s }}</option>
</select>
</label>
</div>
<div v-else-if="a.type === 'setButtonDisabled'" class="params">
<label>
{{ t('actions.param.button') }}
<select
:value="a.instanceId"
@change="setAt(i, { type: 'setButtonDisabled', instanceId: ($event.target as HTMLSelectElement).value, disabled: a.disabled })"
>
<option v-if="!buttonInstanceOptions.length" value="" disabled>{{ t('actions.noButtons') }}</option>
<option v-for="b in buttonInstanceOptions" :key="b.id" :value="b.id">{{ b.label }}</option>
</select>
</label>
<label class="bool">
<input
type="checkbox"
:checked="a.disabled"
@change="setAt(i, { type: 'setButtonDisabled', instanceId: a.instanceId, disabled: ($event.target as HTMLInputElement).checked })"
/>
{{ t('actions.param.disabled') }}
</label>
</div>
<div v-else-if="a.type === 'script'" class="params">
<label class="block">
{{ t('actions.param.source') }}
<textarea
rows="3"
:value="a.source"
@input="setAt(i, { type: 'script', source: ($event.target as HTMLTextAreaElement).value })"
></textarea>
</label>
</div>
</li>
</ol>
<button class="add-btn" @click="addAction">
<PhPlus :size="12" weight="bold" />
{{ t('actions.add') }}
</button>
</div>
</template>
<style scoped>
.action-editor {
display: flex;
flex-direction: column;
gap: 8px;
}
.empty {
padding: 10px;
text-align: center;
color: var(--text-dim);
font-style: italic;
font-size: 12px;
}
.list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.row {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--panel-alt);
padding: 6px 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.head {
display: flex;
align-items: center;
gap: 6px;
}
.idx {
font-size: 10px;
color: var(--text-dim);
min-width: 14px;
font-variant-numeric: tabular-nums;
}
.type-select {
flex: 1;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
font-size: 12px;
border-radius: var(--radius-sm);
padding: 3px 6px;
}
.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:hover:not(:disabled) {
color: var(--text);
background: var(--panel-hover);
}
.icon-btn:disabled {
opacity: 0.4;
cursor: default;
}
.icon-btn.danger:hover {
background: var(--danger-soft);
color: var(--danger);
}
.params {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.params label {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 10px;
color: var(--text-dim);
}
.params label.bool {
flex-direction: row;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--text);
}
.params label.block {
flex: 1 1 100%;
}
.params input[type='number'],
.params select,
.params textarea {
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
font-size: 12px;
border-radius: var(--radius-sm);
padding: 3px 6px;
font-family: inherit;
}
.params textarea {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
resize: vertical;
min-height: 48px;
width: 100%;
box-sizing: border-box;
}
.add-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: var(--panel);
border: 1px dashed var(--border-strong);
color: var(--text);
border-radius: var(--radius-sm);
font-size: 12px;
align-self: flex-start;
cursor: pointer;
}
.add-btn:hover {
background: var(--panel-hover);
}
</style>