- 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>
179 lines
4.0 KiB
Vue
179 lines
4.0 KiB
Vue
<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>
|