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

+178
View File
@@ -0,0 +1,178 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { 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'
const props = defineProps<{
modelValue: boolean
frameId: string | null
}>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const store = useSceneStore()
const frame = computed(() => {
if (!props.frameId) return null
return store.scene.scriptFrames?.find((f) => f.id === props.frameId) ?? null
})
// Local working copy so edits don't commit until the user closes.
const localTime = ref(0)
const localActions = ref<Action[]>([])
const localLabel = ref('')
watch(
[() => props.modelValue, () => props.frameId],
([open, fid]) => {
if (!open || !fid) return
const f = store.scene.scriptFrames?.find((x) => x.id === fid)
if (!f) return
localTime.value = f.time
localActions.value = f.actions.map((a) => ({ ...a }))
localLabel.value = f.label ?? ''
},
{ immediate: true },
)
function commit() {
if (!props.frameId) return
const f = frame.value
if (!f) return
if (Math.abs(f.time - localTime.value) > 1e-4) {
store.setScriptFrameTime(f.id, localTime.value)
}
store.setScriptFrameActions(f.id, localActions.value)
const nextLabel = localLabel.value.trim()
if (nextLabel && nextLabel !== f.label) f.label = nextLabel
else if (!nextLabel && f.label) delete f.label
}
function close() {
commit()
emit('update:modelValue', false)
}
function removeFrame() {
if (!props.frameId) return
store.removeScriptFrame(props.frameId)
emit('update:modelValue', false)
}
</script>
<template>
<Modal
:model-value="modelValue"
size="md"
:title="t('scriptFrameDialog.title')"
@update:model-value="(v) => (v ? null : close())"
>
<div v-if="frame" class="body">
<div class="row">
<label>
{{ t('scriptFrameDialog.time') }}
<input
type="number"
step="0.01"
min="0"
:value="localTime"
@input="localTime = Number(($event.target as HTMLInputElement).value)"
/>
</label>
<label class="grow">
{{ t('scriptFrameDialog.label') }}
<input
type="text"
:value="localLabel"
:placeholder="t('scriptFrameDialog.labelPlaceholder')"
@input="localLabel = ($event.target as HTMLInputElement).value"
/>
</label>
</div>
<ActionEditor
:actions="localActions"
@update:actions="localActions = $event"
/>
</div>
<template #footer>
<button class="btn danger" @click="removeFrame">
<PhTrash :size="12" />
{{ t('scriptFrameDialog.remove') }}
</button>
<span class="spacer"></span>
<button class="btn primary" @click="close">{{ t('scriptFrameDialog.done') }}</button>
</template>
</Modal>
</template>
<style scoped>
.body {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px 14px;
}
.row {
display: flex;
gap: 10px;
align-items: flex-end;
}
.row label {
display: flex;
flex-direction: column;
gap: 3px;
font-size: 11px;
color: var(--text-dim);
}
.row label.grow {
flex: 1;
}
.row input {
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
padding: 4px 7px;
border-radius: var(--radius-sm);
font-size: 12px;
font-family: inherit;
}
.btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 10px;
background: var(--panel);
border: 1px solid var(--border);
color: var(--text);
border-radius: var(--radius-sm);
font-size: 12px;
}
.btn:hover {
background: var(--panel-hover);
}
.btn.primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.btn.danger:hover {
background: var(--danger-soft);
color: var(--danger);
border-color: var(--danger);
}
.spacer {
flex: 1;
}
</style>