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:
1 parent
92d58937e3
commit
b9d1ca16f7
4 files changed
+635
No files matched your search
@@ -0,0 +1,392 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
PhArrowDown,
|
||||
PhArrowUp,
|
||||
PhPlus,
|
||||
PhTrash,
|
||||
} from '@phosphor-icons/vue'
|
||||
import { useSceneStore } from '@/stores/scene'
|
||||
import { t } from '@/i18n'
|
||||
import type { Action, ButtonState } from '@/types/scene'
|
||||
|
||||
const props = defineProps<{ actions: Action[] }>()
|
||||
const emit = defineEmits<{ 'update:actions': [value: Action[]] }>()
|
||||
|
||||
const store = useSceneStore()
|
||||
|
||||
const markerNames = computed(() =>
|
||||
(store.scene.markers ?? []).map((m) => m.name),
|
||||
)
|
||||
|
||||
interface ButtonInstanceOption {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const buttonInstanceOptions = computed<ButtonInstanceOption[]>(() =>
|
||||
(store.scene.buttonInstances ?? [])
|
||||
.filter((bi) => !!bi.label)
|
||||
.map((bi) => ({ id: bi.id, label: bi.label! })),
|
||||
)
|
||||
|
||||
const BUTTON_STATES: ButtonState[] = ['normal', 'hover', 'pressed', 'disabled']
|
||||
|
||||
const ACTION_TYPES: Action['type'][] = [
|
||||
'play',
|
||||
'pause',
|
||||
'rewindToLoop',
|
||||
'jumpTo',
|
||||
'jumpToFrame',
|
||||
'jumpToMarker',
|
||||
'setButtonState',
|
||||
'setButtonDisabled',
|
||||
'script',
|
||||
]
|
||||
|
||||
function actionTypeLabel(type: Action['type']): string {
|
||||
return t(`actions.type.${type}`)
|
||||
}
|
||||
|
||||
function makeDefault(type: Action['type']): Action {
|
||||
switch (type) {
|
||||
case 'play':
|
||||
case 'pause':
|
||||
case 'rewindToLoop':
|
||||
return { type }
|
||||
case 'jumpTo':
|
||||
return { type: 'jumpTo', time: 0 }
|
||||
case 'jumpToFrame':
|
||||
return { type: 'jumpToFrame', frame: 0 }
|
||||
case 'jumpToMarker':
|
||||
return { type: 'jumpToMarker', name: markerNames.value[0] ?? '' }
|
||||
case 'setButtonState':
|
||||
return {
|
||||
type: 'setButtonState',
|
||||
instanceId: buttonInstanceOptions.value[0]?.id ?? '',
|
||||
state: 'normal',
|
||||
}
|
||||
case 'setButtonDisabled':
|
||||
return {
|
||||
type: 'setButtonDisabled',
|
||||
instanceId: buttonInstanceOptions.value[0]?.id ?? '',
|
||||
disabled: true,
|
||||
}
|
||||
case 'script':
|
||||
return { type: 'script', source: '' }
|
||||
}
|
||||
}
|
||||
|
||||
function setAt(index: number, next: Action) {
|
||||
const copy = [...props.actions]
|
||||
copy[index] = next
|
||||
emit('update:actions', copy)
|
||||
}
|
||||
|
||||
function addAction() {
|
||||
emit('update:actions', [...props.actions, makeDefault('pause')])
|
||||
}
|
||||
|
||||
function removeAt(index: number) {
|
||||
const copy = [...props.actions]
|
||||
copy.splice(index, 1)
|
||||
emit('update:actions', copy)
|
||||
}
|
||||
|
||||
function moveUp(index: number) {
|
||||
if (index === 0) return
|
||||
const copy = [...props.actions]
|
||||
const tmp = copy[index - 1]
|
||||
copy[index - 1] = copy[index]
|
||||
copy[index] = tmp
|
||||
emit('update:actions', copy)
|
||||
}
|
||||
|
||||
function moveDown(index: number) {
|
||||
if (index === props.actions.length - 1) return
|
||||
const copy = [...props.actions]
|
||||
const tmp = copy[index + 1]
|
||||
copy[index + 1] = copy[index]
|
||||
copy[index] = tmp
|
||||
emit('update:actions', copy)
|
||||
}
|
||||
|
||||
function onTypeChange(index: number, newType: Action['type']) {
|
||||
if (props.actions[index].type === newType) return
|
||||
setAt(index, makeDefault(newType))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="action-editor">
|
||||
<div v-if="!actions.length" class="empty">{{ t('actions.empty') }}</div>
|
||||
<ol v-else class="list">
|
||||
<li v-for="(a, i) in actions" :key="i" class="row">
|
||||
<div class="head">
|
||||
<span class="idx">{{ i + 1 }}</span>
|
||||
<select
|
||||
class="type-select"
|
||||
:value="a.type"
|
||||
@change="onTypeChange(i, ($event.target as HTMLSelectElement).value as Action['type'])"
|
||||
>
|
||||
<option v-for="type in ACTION_TYPES" :key="type" :value="type">
|
||||
{{ actionTypeLabel(type) }}
|
||||
</option>
|
||||
</select>
|
||||
<button class="icon-btn" :title="t('actions.moveUp')" :disabled="i === 0" @click="moveUp(i)">
|
||||
<PhArrowUp :size="13" />
|
||||
</button>
|
||||
<button class="icon-btn" :title="t('actions.moveDown')" :disabled="i === actions.length - 1" @click="moveDown(i)">
|
||||
<PhArrowDown :size="13" />
|
||||
</button>
|
||||
<button class="icon-btn danger" :title="t('actions.remove')" @click="removeAt(i)">
|
||||
<PhTrash :size="13" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="a.type === 'jumpTo'" class="params">
|
||||
<label>
|
||||
{{ t('actions.param.time') }}
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
:value="a.time"
|
||||
@input="setAt(i, { type: 'jumpTo', time: Number(($event.target as HTMLInputElement).value) })"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div v-else-if="a.type === 'jumpToFrame'" class="params">
|
||||
<label>
|
||||
{{ t('actions.param.frame') }}
|
||||
<input
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
:value="a.frame"
|
||||
@input="setAt(i, { type: 'jumpToFrame', frame: Math.round(Number(($event.target as HTMLInputElement).value)) })"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div v-else-if="a.type === 'jumpToMarker'" class="params">
|
||||
<label>
|
||||
{{ t('actions.param.marker') }}
|
||||
<select
|
||||
:value="a.name"
|
||||
@change="setAt(i, { type: 'jumpToMarker', name: ($event.target as HTMLSelectElement).value })"
|
||||
>
|
||||
<option v-if="!markerNames.length" value="" disabled>{{ t('actions.noMarkers') }}</option>
|
||||
<option v-for="name in markerNames" :key="name" :value="name">{{ name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div v-else-if="a.type === 'setButtonState'" class="params">
|
||||
<label>
|
||||
{{ t('actions.param.button') }}
|
||||
<select
|
||||
:value="a.instanceId"
|
||||
@change="setAt(i, { type: 'setButtonState', instanceId: ($event.target as HTMLSelectElement).value, state: a.state })"
|
||||
>
|
||||
<option v-if="!buttonInstanceOptions.length" value="" disabled>{{ t('actions.noButtons') }}</option>
|
||||
<option v-for="b in buttonInstanceOptions" :key="b.id" :value="b.id">{{ b.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{{ t('actions.param.state') }}
|
||||
<select
|
||||
:value="a.state"
|
||||
@change="setAt(i, { type: 'setButtonState', instanceId: a.instanceId, state: ($event.target as HTMLSelectElement).value as ButtonState })"
|
||||
>
|
||||
<option v-for="s in BUTTON_STATES" :key="s" :value="s">{{ s }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div v-else-if="a.type === 'setButtonDisabled'" class="params">
|
||||
<label>
|
||||
{{ t('actions.param.button') }}
|
||||
<select
|
||||
:value="a.instanceId"
|
||||
@change="setAt(i, { type: 'setButtonDisabled', instanceId: ($event.target as HTMLSelectElement).value, disabled: a.disabled })"
|
||||
>
|
||||
<option v-if="!buttonInstanceOptions.length" value="" disabled>{{ t('actions.noButtons') }}</option>
|
||||
<option v-for="b in buttonInstanceOptions" :key="b.id" :value="b.id">{{ b.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="bool">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="a.disabled"
|
||||
@change="setAt(i, { type: 'setButtonDisabled', instanceId: a.instanceId, disabled: ($event.target as HTMLInputElement).checked })"
|
||||
/>
|
||||
{{ t('actions.param.disabled') }}
|
||||
</label>
|
||||
</div>
|
||||
<div v-else-if="a.type === 'script'" class="params">
|
||||
<label class="block">
|
||||
{{ t('actions.param.source') }}
|
||||
<textarea
|
||||
rows="3"
|
||||
:value="a.source"
|
||||
@input="setAt(i, { type: 'script', source: ($event.target as HTMLTextAreaElement).value })"
|
||||
></textarea>
|
||||
</label>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<button class="add-btn" @click="addAction">
|
||||
<PhPlus :size="12" weight="bold" />
|
||||
{{ t('actions.add') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.action-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--panel-alt);
|
||||
padding: 6px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.idx {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
min-width: 14px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.type-select {
|
||||
flex: 1;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 3px 6px;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon-btn:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
background: var(--panel-hover);
|
||||
}
|
||||
|
||||
.icon-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.icon-btn.danger:hover {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.params {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.params label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.params label.bool {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.params label.block {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.params input[type='number'],
|
||||
.params select,
|
||||
.params textarea {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 3px 6px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.params textarea {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
resize: vertical;
|
||||
min-height: 48px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
background: var(--panel);
|
||||
border: 1px dashed var(--border-strong);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
align-self: flex-start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.add-btn:hover {
|
||||
background: var(--panel-hover);
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -30,6 +30,7 @@ import { useSceneStore } from '@/stores/scene'
|
||||
import { sampleLayer } from '@/lib/interpolate'
|
||||
import { snapToCandidates } from '@/lib/timelineSnap'
|
||||
import { confirmDialog, promptDialog } from '@/components/ui'
|
||||
import ScriptFrameDialog from './ScriptFrameDialog.vue'
|
||||
import { t } from '@/i18n'
|
||||
import { kfSelectionKey, useKfSelection, type KfSelectionKey } from '@/composables/useKfSelection'
|
||||
import { showLocked } from '@/composables/useShowLocked'
|
||||
@@ -1478,6 +1479,27 @@ function clipBlockStyle(ci: ClipInstance) {
|
||||
}
|
||||
|
||||
const BUTTON_STATES = ['normal', 'hover', 'pressed', 'disabled'] as const
|
||||
|
||||
// Script frame dialog state
|
||||
const scriptDialogOpen = ref(false)
|
||||
const scriptDialogFrameId = ref<string | null>(null)
|
||||
|
||||
function openScriptFrame(frameId: string) {
|
||||
scriptDialogFrameId.value = frameId
|
||||
scriptDialogOpen.value = true
|
||||
}
|
||||
|
||||
function onScriptLaneClick(e: MouseEvent, trackId: string) {
|
||||
// Only handle clicks on the bare lane (not on an existing frame).
|
||||
if ((e.target as HTMLElement).closest('.script-frame')) return
|
||||
const el = e.currentTarget as HTMLElement
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.width <= 0) return
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
const time = snapTime(ratio * duration.value)
|
||||
const frame = store.addScriptFrame(trackId, time)
|
||||
if (frame) openScriptFrame(frame.id)
|
||||
}
|
||||
const BUTTON_CELL_SEC = 0.5 // visual width of each of the 4 state cells
|
||||
function buttonInstanceStyle(bi: ButtonInstance) {
|
||||
// Four cells, each with a fixed per-cell visual width. Kept as a fixed
|
||||
@@ -2311,6 +2333,7 @@ onBeforeUnmount(() => {
|
||||
<div
|
||||
class="track-lane script-lane"
|
||||
:style="{ width: laneWidth + 'px' }"
|
||||
@click="track.locked ? null : onScriptLaneClick($event, track.scriptTrackId)"
|
||||
>
|
||||
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
|
||||
<span
|
||||
@@ -2319,6 +2342,7 @@ onBeforeUnmount(() => {
|
||||
class="script-frame"
|
||||
:style="{ left: pxFromTime(frame.time) + 'px' }"
|
||||
:title="frame.label ?? t('timeline.scriptFrameTooltip', { n: frame.actions.length })"
|
||||
@click.stop="openScriptFrame(frame.id)"
|
||||
>
|
||||
<span class="script-frame-dot"></span>
|
||||
<span v-if="frame.actions.length > 1" class="script-frame-count">{{ frame.actions.length }}</span>
|
||||
@@ -2550,6 +2574,10 @@ onBeforeUnmount(() => {
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
<ScriptFrameDialog
|
||||
v-model="scriptDialogOpen"
|
||||
:frame-id="scriptDialogFrameId"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in new issue
Block a user