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 { snapToCandidates } from '@/lib/timelineSnap'
|
||||||
import { confirmDialog, promptDialog } from '@/components/ui'
|
import { confirmDialog, promptDialog } from '@/components/ui'
|
||||||
import ScriptFrameDialog from './ScriptFrameDialog.vue'
|
import ScriptFrameDialog from './ScriptFrameDialog.vue'
|
||||||
|
import ButtonInstanceDialog from './ButtonInstanceDialog.vue'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
import { kfSelectionKey, useKfSelection, type KfSelectionKey } from '@/composables/useKfSelection'
|
import { kfSelectionKey, useKfSelection, type KfSelectionKey } from '@/composables/useKfSelection'
|
||||||
import { showLocked } from '@/composables/useShowLocked'
|
import { showLocked } from '@/composables/useShowLocked'
|
||||||
@@ -1484,6 +1485,14 @@ const BUTTON_STATES = ['normal', 'hover', 'pressed', 'disabled'] as const
|
|||||||
const scriptDialogOpen = ref(false)
|
const scriptDialogOpen = ref(false)
|
||||||
const scriptDialogFrameId = ref<string | null>(null)
|
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) {
|
function openScriptFrame(frameId: string) {
|
||||||
scriptDialogFrameId.value = frameId
|
scriptDialogFrameId.value = frameId
|
||||||
scriptDialogOpen.value = true
|
scriptDialogOpen.value = true
|
||||||
@@ -2274,6 +2283,7 @@ onBeforeUnmount(() => {
|
|||||||
:class="{ disabled: bi.disabled }"
|
:class="{ disabled: bi.disabled }"
|
||||||
:style="buttonInstanceStyle(bi)"
|
:style="buttonInstanceStyle(bi)"
|
||||||
:title="bi.label ?? buttonClipById.get(bi.buttonClipId)?.name ?? ''"
|
:title="bi.label ?? buttonClipById.get(bi.buttonClipId)?.name ?? ''"
|
||||||
|
@click.stop="openButtonInstance(bi.id)"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
v-for="state in BUTTON_STATES"
|
v-for="state in BUTTON_STATES"
|
||||||
@@ -2578,6 +2588,10 @@ onBeforeUnmount(() => {
|
|||||||
v-model="scriptDialogOpen"
|
v-model="scriptDialogOpen"
|
||||||
:frame-id="scriptDialogFrameId"
|
:frame-id="scriptDialogFrameId"
|
||||||
/>
|
/>
|
||||||
|
<ButtonInstanceDialog
|
||||||
|
v-model="buttonDialogOpen"
|
||||||
|
:instance-id="buttonDialogInstanceId"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -396,6 +396,17 @@ export const en = {
|
|||||||
remove: 'Delete frame',
|
remove: 'Delete frame',
|
||||||
done: 'Done',
|
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
|
} as const
|
||||||
|
|
||||||
export type Messages = typeof en
|
export type Messages = typeof en
|
||||||
Reference in new issue
Block a user