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:
1 parent
ec8d25a351
commit
94ebbac238
3 files changed
+243
No files matched your search
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user