macro-clips slice 4b: button instance dialog

- ButtonInstanceDialog.vue edits the three instance-scope fields:
  label (with uniqueness error surface), disabled flag, onClick
  actions via the shared ActionEditor
- commit waits on a successful label set so collisions show inline
  instead of silently dropping the rename
- Timeline wires click on a button-instance block to open the dialog

Creation/placement UX (sidebar entry + drag-to-track) and the per-state
layer editor are still deferred — this slice only makes existing
instances editable once something (devtools, later slice) has put them
on a clip track.

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 21:16:25 +02:00
1 parent ec8d25a351
commit 94ebbac238
3 files changed
+243

No files matched your search

+218
View File
@@ -0,0 +1,218 @@
<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
instanceId: string | null
}>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const store = useSceneStore()
const instance = computed(() => {
if (!props.instanceId) return null
return (
store.scene.buttonInstances?.find((bi) => bi.id === props.instanceId) ??
null
)
})
const buttonClip = computed(() => {
const bi = instance.value
if (!bi) return null
return store.scene.buttonClips?.find((c) => c.id === bi.buttonClipId) ?? null
})
const localLabel = ref('')
const localDisabled = ref(false)
const localActions = ref<Action[]>([])
const labelError = ref<string | null>(null)
watch(
[() => props.modelValue, () => props.instanceId],
([open, iid]) => {
if (!open || !iid) return
const bi = store.scene.buttonInstances?.find((x) => x.id === iid)
if (!bi) return
localLabel.value = bi.label ?? ''
localDisabled.value = !!bi.disabled
localActions.value = (bi.onClick ?? []).map((a) => ({ ...a }))
labelError.value = null
},
{ immediate: true },
)
function commit(): boolean {
if (!props.instanceId) return true
// Label uniqueness.
const label = localLabel.value.trim()
const ok = store.setButtonInstanceLabel(props.instanceId, label)
if (!ok) {
labelError.value = t('buttonInstanceDialog.labelCollision', { name: label })
return false
}
store.setButtonInstanceOnClick(props.instanceId, localActions.value)
store.setButtonInstanceDisabled(props.instanceId, localDisabled.value)
return true
}
function close() {
if (commit()) emit('update:modelValue', false)
}
function removeInstance() {
if (!props.instanceId) return
store.removeButtonInstance(props.instanceId)
emit('update:modelValue', false)
}
</script>
<template>
<Modal
:model-value="modelValue"
size="md"
:title="t('buttonInstanceDialog.title')"
@update:model-value="(v) => (v ? null : close())"
>
<div v-if="instance" class="body">
<div class="meta">
<span class="meta-label">{{ t('buttonInstanceDialog.clipName') }}</span>
<span class="meta-value">{{ buttonClip?.name ?? '?' }}</span>
</div>
<label class="field">
{{ t('buttonInstanceDialog.label') }}
<input
type="text"
:value="localLabel"
:placeholder="t('buttonInstanceDialog.labelPlaceholder')"
@input="localLabel = ($event.target as HTMLInputElement).value; labelError = null"
/>
<span v-if="labelError" class="err">{{ labelError }}</span>
</label>
<label class="field row">
<input
type="checkbox"
:checked="localDisabled"
@change="localDisabled = ($event.target as HTMLInputElement).checked"
/>
{{ t('buttonInstanceDialog.disabled') }}
</label>
<div class="field">
<span class="field-label">{{ t('buttonInstanceDialog.onClick') }}</span>
<ActionEditor
:actions="localActions"
@update:actions="localActions = $event"
/>
</div>
</div>
<template #footer>
<button class="btn danger" @click="removeInstance">
<PhTrash :size="12" />
{{ t('buttonInstanceDialog.remove') }}
</button>
<span class="spacer"></span>
<button class="btn primary" @click="close">
{{ t('buttonInstanceDialog.done') }}
</button>
</template>
</Modal>
</template>
<style scoped>
.body {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px 14px;
}
.meta {
display: flex;
gap: 8px;
align-items: baseline;
font-size: 12px;
}
.meta-label {
color: var(--text-dim);
}
.meta-value {
color: var(--text);
font-weight: 500;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 11px;
color: var(--text-dim);
}
.field.row {
flex-direction: row;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text);
}
.field-label {
font-size: 11px;
color: var(--text-dim);
}
.field input[type='text'] {
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;
}
.err {
color: var(--danger);
font-size: 11px;
}
.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>
+14
View File
@@ -31,6 +31,7 @@ import { sampleLayer } from '@/lib/interpolate'
import { snapToCandidates } from '@/lib/timelineSnap'
import { confirmDialog, promptDialog } from '@/components/ui'
import ScriptFrameDialog from './ScriptFrameDialog.vue'
import ButtonInstanceDialog from './ButtonInstanceDialog.vue'
import { t } from '@/i18n'
import { kfSelectionKey, useKfSelection, type KfSelectionKey } from '@/composables/useKfSelection'
import { showLocked } from '@/composables/useShowLocked'
@@ -1484,6 +1485,14 @@ const BUTTON_STATES = ['normal', 'hover', 'pressed', 'disabled'] as const
const scriptDialogOpen = ref(false)
const scriptDialogFrameId = ref<string | null>(null)
// Button instance dialog state
const buttonDialogOpen = ref(false)
const buttonDialogInstanceId = ref<string | null>(null)
function openButtonInstance(id: string) {
buttonDialogInstanceId.value = id
buttonDialogOpen.value = true
}
function openScriptFrame(frameId: string) {
scriptDialogFrameId.value = frameId
scriptDialogOpen.value = true
@@ -2274,6 +2283,7 @@ onBeforeUnmount(() => {
:class="{ disabled: bi.disabled }"
:style="buttonInstanceStyle(bi)"
:title="bi.label ?? buttonClipById.get(bi.buttonClipId)?.name ?? ''"
@click.stop="openButtonInstance(bi.id)"
>
<span
v-for="state in BUTTON_STATES"
@@ -2578,6 +2588,10 @@ onBeforeUnmount(() => {
v-model="scriptDialogOpen"
:frame-id="scriptDialogFrameId"
/>
<ButtonInstanceDialog
v-model="buttonDialogOpen"
:instance-id="buttonDialogInstanceId"
/>
</div>
</template>
+11
View File
@@ -396,6 +396,17 @@ export const en = {
remove: 'Delete frame',
done: 'Done',
},
buttonInstanceDialog: {
title: 'Button instance',
clipName: 'Button',
label: 'Label',
labelPlaceholder: 'e.g. playButton',
labelCollision: 'Label "{name}" is already used by another button instance.',
disabled: 'Start disabled',
onClick: 'On click',
remove: 'Delete instance',
done: 'Done',
},
} as const
export type Messages = typeof en