revert macro-clips button feature (keep markers + scripts)

The button authoring ended up too far from the intended model after
three correction passes. Pulling it out entirely so we can rebuild
from a clean spec. Markers, script tracks, script frames, the Action
editor, the Script DSL and the runtime for all of the above stay —
they work.

Removed:
- types: ButtonState, StateTransition, ButtonHitArea, ButtonClip,
  ButtonInstance, setButtonState/setButtonDisabled Action variants,
  Scene.buttonClips/buttonInstances
- store: activeButtonClip* getters, all add/remove/setButton* actions,
  seedDefaultButtonClip, enterButtonEdit/setActiveButtonState/
  exitButtonEdit, button-label resolution in actionRuntime
- useButtonRuntime composable, buttonBlend helper,
  ButtonInstanceDialog, sidebar Buttons section, Timeline button
  rendering + pointer handlers, CanvasStage button rendering +
  pointer state machine, exportPlayer button runtime + hit-testing
- DSL commands state/enable/disable and their parse errors
- 35 button-related tests + helpers; stale testing doc; stale design
  doc

Re-applied three uncommitted fixes that were unrelated to the button
work:
- clicking a marker no longer jumps the playhead (only drag / dblclick
  act on it)
- script tracks get a right-click context menu (rename + delete,
  color in the existing palette)
- onTracksPointerDown excludes .script-lane / .script-frame so
  clicks on empty lanes or on frame dots reach the frame-open path
  instead of being swallowed by box-select pointer capture

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 23:37:49 +02:00
1 parent 616973b0fa
commit 31604052ca
16 files changed
+84 -2809

No files matched your search

+1 -69
View File
@@ -8,7 +8,7 @@ import {
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { t } from '@/i18n'
import type { Action, ButtonState } from '@/types/scene'
import type { Action } from '@/types/scene'
const props = defineProps<{ actions: Action[] }>()
const emit = defineEmits<{ 'update:actions': [value: Action[]] }>()
@@ -19,19 +19,6 @@ 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',
@@ -39,8 +26,6 @@ const ACTION_TYPES: Action['type'][] = [
'jumpTo',
'jumpToFrame',
'jumpToMarker',
'setButtonState',
'setButtonDisabled',
'script',
]
@@ -60,18 +45,6 @@ function makeDefault(type: Action['type']): Action {
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: '' }
}
@@ -179,47 +152,6 @@ function onTypeChange(index: number, newType: Action['type']) {
</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') }}
-86
View File
@@ -2,7 +2,6 @@
import { computed, ref, watch } from 'vue'
import {
PhCircle,
PhCursorClick,
PhFilmStrip,
PhImage,
PhImageSquare,
@@ -108,7 +107,6 @@ async function addSampleText() {
}
const clips = computed(() => store.scene.clips ?? [])
const buttonClips = computed(() => store.scene.buttonClips ?? [])
async function addClip() {
const input = await promptDialog({
@@ -149,45 +147,6 @@ function onClipDragStart(e: DragEvent, clipId: string) {
e.dataTransfer.setData('text/plain', clipId)
}
async function addButtonClip() {
const input = await promptDialog({
title: t('sidebar.newButtonDialog.title'),
message: t('sidebar.newButtonDialog.message'),
defaultValue: t('sidebar.newButtonDialog.default'),
confirmLabel: t('sidebar.newButtonDialog.confirm'),
})
if (input === null) return
store.seedDefaultButtonClip(input || t('sidebar.newButtonDialog.default'))
}
async function renameButtonClipPrompt(id: string, current: string) {
const input = await promptDialog({
title: t('sidebar.renameButtonDialog.title'),
defaultValue: current,
confirmLabel: t('sidebar.renameButtonDialog.confirm'),
})
if (input === null) return
store.setButtonClipName(id, input)
}
async function deleteButtonClip(id: string) {
const ok = await confirmDialog({
title: t('sidebar.deleteButtonDialog.title'),
message: t('sidebar.deleteButtonDialog.message'),
confirmLabel: t('sidebar.deleteButtonDialog.confirm'),
variant: 'danger',
})
if (!ok) return
store.removeButtonClip(id)
}
function onButtonClipDragStart(e: DragEvent, id: string) {
if (!e.dataTransfer) return
e.dataTransfer.effectAllowed = 'copy'
e.dataTransfer.setData('application/x-animatr-button-clip', id)
e.dataTransfer.setData('text/plain', id)
}
async function handleFileSelected(event: Event) {
uploadError.value = null
const input = event.target as HTMLInputElement
@@ -377,51 +336,6 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
</li>
<li v-if="!clips.length" class="empty">{{ t('sidebar.noClips') }}</li>
</ul>
<div class="lib-header clips-header">
<h4>{{ t('sidebar.buttons') }}</h4>
<button
class="thumb-toggle"
:title="t('sidebar.createButtonTooltip')"
@click="addButtonClip"
>
<PhPlus :size="12" weight="bold" />
</button>
</div>
<ul class="clips">
<li
v-for="bc in buttonClips"
:key="bc.id"
class="clip button-clip"
draggable="true"
@dragstart="onButtonClipDragStart($event, bc.id)"
>
<div class="clip-icon">
<PhCursorClick :size="16" />
</div>
<div class="clip-meta">
<span class="clip-name" :title="bc.name">{{ bc.name }}</span>
<span class="clip-sub">{{ t('sidebar.buttonSubtitle') }}</span>
</div>
<div class="clip-actions">
<button
class="clip-btn"
:title="t('sidebar.renameTooltip')"
@click.stop="renameButtonClipPrompt(bc.id, bc.name)"
>
<PhPencilSimple :size="11" />
</button>
<button
class="clip-btn danger"
:title="t('sidebar.deleteTooltip')"
@click.stop="deleteButtonClip(bc.id)"
>
<PhTrash :size="11" />
</button>
</div>
</li>
<li v-if="!buttonClips.length" class="empty">{{ t('sidebar.noButtons') }}</li>
</ul>
</div>
</template>
-368
View File
@@ -1,368 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { PhPlus, 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, ButtonState, Easing, StateTransition } 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 localTransitions = ref<StateTransition[]>([])
const labelError = ref<string | null>(null)
const BUTTON_STATES: ButtonState[] = ['normal', 'hover', 'pressed', 'disabled']
const EASINGS: Easing[] = ['linear', 'easeIn', 'easeOut', 'easeInOut']
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 }))
const clip = store.scene.buttonClips?.find((c) => c.id === bi.buttonClipId)
localTransitions.value = (clip?.transitions ?? []).map((t) => ({ ...t }))
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)
const bi = store.scene.buttonInstances?.find((x) => x.id === props.instanceId)
if (bi) store.setButtonClipTransitions(bi.buttonClipId, localTransitions.value)
return true
}
function addTransition() {
localTransitions.value = [
...localTransitions.value,
{ from: 'normal', to: 'hover', duration: 0.15, easing: 'easeOut' },
]
}
function removeTransition(index: number) {
const next = [...localTransitions.value]
next.splice(index, 1)
localTransitions.value = next
}
function updateTransition(index: number, patch: Partial<StateTransition>) {
const next = [...localTransitions.value]
next[index] = { ...next[index], ...patch }
localTransitions.value = next
}
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 class="field">
<span class="field-label">
{{ t('buttonInstanceDialog.transitions') }}
<small class="field-hint">{{ t('buttonInstanceDialog.transitionsHint') }}</small>
</span>
<ol v-if="localTransitions.length" class="trans-list">
<li v-for="(tr, i) in localTransitions" :key="i" class="trans-row">
<select
:value="tr.from"
@change="updateTransition(i, { from: ($event.target as HTMLSelectElement).value as ButtonState })"
>
<option v-for="s in BUTTON_STATES" :key="s" :value="s">{{ s }}</option>
</select>
<span class="arrow">→</span>
<select
:value="tr.to"
@change="updateTransition(i, { to: ($event.target as HTMLSelectElement).value as ButtonState })"
>
<option v-for="s in BUTTON_STATES" :key="s" :value="s">{{ s }}</option>
</select>
<input
type="number"
step="0.01"
min="0"
:value="tr.duration"
@input="updateTransition(i, { duration: Number(($event.target as HTMLInputElement).value) })"
class="dur"
/>
<select
:value="tr.easing"
@change="updateTransition(i, { easing: ($event.target as HTMLSelectElement).value as Easing })"
>
<option v-for="e in EASINGS" :key="e" :value="e">{{ e }}</option>
</select>
<button class="icon-btn danger" :title="t('actions.remove')" @click="removeTransition(i)">
<PhTrash :size="13" />
</button>
</li>
</ol>
<button class="add-btn" @click="addTransition">
<PhPlus :size="12" weight="bold" />
{{ t('buttonInstanceDialog.addTransition') }}
</button>
</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;
}
.field-hint {
margin-left: 6px;
color: var(--text-dim);
font-weight: normal;
font-size: 10px;
}
.trans-list {
list-style: none;
padding: 0;
margin: 0 0 6px 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.trans-row {
display: flex;
align-items: center;
gap: 4px;
}
.trans-row select,
.trans-row input.dur {
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
padding: 3px 5px;
border-radius: var(--radius-sm);
font-size: 11px;
font-family: inherit;
}
.trans-row input.dur {
width: 56px;
}
.trans-row .arrow {
color: var(--text-dim);
font-size: 11px;
}
.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.danger:hover {
background: var(--danger-soft);
color: var(--danger);
}
.add-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 9px;
background: var(--panel);
border: 1px dashed var(--border-strong);
color: var(--text);
border-radius: var(--radius-sm);
font-size: 11px;
align-self: flex-start;
cursor: pointer;
}
.add-btn:hover {
background: var(--panel-hover);
}
</style>
-207
View File
@@ -8,22 +8,12 @@ import { importImageFile, loadImage } from '@/lib/fileLoader'
import { useClipartStore } from '@/stores/clipart'
import { drawSceneFrame, loadSceneImages } from '@/lib/render'
import type {
Action,
AnimatableProps,
Asset,
ButtonClip,
ButtonInstance,
ButtonState,
Keyframe,
Layer,
Point,
} from '@/types/scene'
import {
applyButtonStateChange,
useButtonRuntime,
} from '@/composables/useButtonRuntime'
import { evalAction } from '@/lib/actions'
import { blendButtonStates } from '@/lib/buttonBlend'
import { t } from '@/i18n'
import { useKfSelection } from '@/composables/useKfSelection'
import { showLocked } from '@/composables/useShowLocked'
@@ -197,167 +187,6 @@ const canvasBorderConfig = computed(() => ({
const zoomPercent = computed(() => Math.round(zoom.value * 100))
const buttonRt = useButtonRuntime()
interface ButtonRenderItem {
bi: ButtonInstance
clip: ButtonClip
state: ButtonState
disabled: boolean
layers: Array<{
layer: Layer
asset: Asset
props: AnimatableProps
}>
hit: { width: number; height: number; offsetX: number; offsetY: number }
}
function sampleStateLayers(
clip: ButtonClip,
state: ButtonState,
): ButtonRenderItem['layers'] {
const layers = clip.states[state] ?? []
const out: ButtonRenderItem['layers'] = []
for (const layer of layers) {
const asset = store.scene.assets.find((a) => a.id === layer.assetId)
if (!asset) continue
out.push({ layer, asset, props: sampleLayer(layer, 0) })
}
return out
}
function blendStates(
clip: ButtonClip,
from: ButtonState,
to: ButtonState,
u: number,
): ButtonRenderItem['layers'] {
return blendButtonStates(clip, from, to, u, (id) =>
store.scene.assets.find((a) => a.id === id),
)
}
const activeButtonInstances = computed<ButtonRenderItem[]>(() => {
// Force re-evaluation on every transition RAF tick.
void buttonRt.transitionTick.value
const out: ButtonRenderItem[] = []
const instances = store.scene.buttonInstances ?? []
const clips = store.scene.buttonClips ?? []
const clipById = new Map(clips.map((c) => [c.id, c]))
const now = performance.now()
for (const bi of instances) {
if (store.playhead < bi.startTime) continue
const clip = clipById.get(bi.buttonClipId)
if (!clip) continue
const disabled = buttonRt.isDisabled(bi.id, bi.disabled)
const state = buttonRt.getState(bi.id, bi.disabled)
const tr = buttonRt.sampleTransition(bi.id, now)
let rendered: ButtonRenderItem['layers']
if (tr && !tr.done && !disabled) {
rendered = blendStates(clip, tr.from, tr.to, tr.u)
} else {
rendered = sampleStateLayers(clip, state)
}
// Hit area: explicit hitArea on the clip, or fall back to the bbox of
// the first rect-like asset in the normal state.
let hit = clip.hitArea
? {
width: clip.hitArea.width,
height: clip.hitArea.height,
offsetX: clip.hitArea.offsetX ?? clip.hitArea.width / 2,
offsetY: clip.hitArea.offsetY ?? clip.hitArea.height / 2,
}
: null
if (!hit) {
for (const l of clip.states.normal) {
const a = store.scene.assets.find((x) => x.id === l.assetId)
if (!a) continue
if (a.kind === 'rect' || a.kind === 'image') {
hit = {
width: a.width,
height: a.height,
offsetX: l.offsetX ?? a.width / 2,
offsetY: l.offsetY ?? a.height / 2,
}
break
}
if (a.kind === 'circle') {
hit = {
width: a.radius * 2,
height: a.radius * 2,
offsetX: (l.offsetX ?? 0) + a.radius,
offsetY: (l.offsetY ?? 0) + a.radius,
}
break
}
}
}
if (!hit) hit = { width: 120, height: 40, offsetX: 60, offsetY: 20 }
out.push({ bi, clip, state, disabled, layers: rendered, hit })
}
return out
})
function buttonGroupConfig(bi: ButtonInstance) {
return {
id: `btn:${bi.id}`,
x: bi.x ?? 0,
y: bi.y ?? 0,
listening: true,
}
}
function buttonHitRectConfig(item: ButtonRenderItem) {
return {
x: 0,
y: 0,
width: item.hit.width,
height: item.hit.height,
offsetX: item.hit.offsetX,
offsetY: item.hit.offsetY,
fill: 'rgba(0,0,0,0.001)', // nearly transparent but hittable
listening: !item.disabled,
name: 'button-hit',
}
}
function buttonChildConfig<T extends Record<string, unknown>>(cfg: T): T {
return { ...cfg, draggable: false, listening: false }
}
function transitionsFor(bi: ButtonInstance) {
return store.scene.buttonClips?.find((c) => c.id === bi.buttonClipId)?.transitions
}
function onButtonMouseEnter(bi: ButtonInstance) {
const cur = buttonRt.getState(bi.id, bi.disabled)
if (cur === 'normal') applyButtonStateChange(bi.id, 'hover', transitionsFor(bi), bi.disabled)
}
function onButtonMouseLeave(bi: ButtonInstance) {
const cur = buttonRt.getState(bi.id, bi.disabled)
if (cur === 'hover' || cur === 'pressed')
applyButtonStateChange(bi.id, 'normal', transitionsFor(bi), bi.disabled)
}
function onButtonMouseDown(bi: ButtonInstance) {
const cur = buttonRt.getState(bi.id, bi.disabled)
if (cur === 'hover') applyButtonStateChange(bi.id, 'pressed', transitionsFor(bi), bi.disabled)
}
function onButtonMouseUp(bi: ButtonInstance) {
const cur = buttonRt.getState(bi.id, bi.disabled)
if (cur !== 'pressed') return
applyButtonStateChange(bi.id, 'hover', transitionsFor(bi), bi.disabled)
const actions = bi.onClick
if (actions && actions.length) runButtonActions(actions)
}
function runButtonActions(actions: Action[]) {
const rt = store.actionRuntime()
for (const a of actions) evalAction(a, rt)
}
const assetMap = computed(() => {
const m = new Map<string, Asset>()
for (const a of store.scene.assets) m.set(a.id, a)
@@ -2523,42 +2352,6 @@ onBeforeUnmount(() => {
/>
</template>
</template>
<template v-for="item in activeButtonInstances" :key="`bi-${item.bi.id}`">
<v-group
:config="buttonGroupConfig(item.bi)"
@mouseenter="onButtonMouseEnter(item.bi)"
@mouseleave="onButtonMouseLeave(item.bi)"
@mousedown="onButtonMouseDown(item.bi)"
@mouseup="onButtonMouseUp(item.bi)"
>
<v-rect :config="buttonHitRectConfig(item)" />
<template
v-for="(entry, bli) in item.layers"
:key="`${item.bi.id}-${entry.layer.id}-${bli}`"
>
<v-rect
v-if="entry.asset.kind === 'rect'"
:config="buttonChildConfig(rectConfig(entry.layer, entry.asset, entry.props))"
/>
<v-circle
v-else-if="entry.asset.kind === 'circle'"
:config="buttonChildConfig(circleConfig(entry.layer, entry.asset, entry.props))"
/>
<v-text
v-else-if="entry.asset.kind === 'text'"
:config="buttonChildConfig(textConfig(entry.layer, entry.asset, entry.props))"
/>
<v-line
v-else-if="entry.asset.kind === 'polygon'"
:config="buttonChildConfig(polygonConfig(entry.layer, entry.asset, entry.props))"
/>
<v-image
v-else-if="entry.asset.kind === 'image' && imageConfig(entry.layer, entry.asset, entry.props)"
:config="buttonChildConfig(imageConfig(entry.layer, entry.asset, entry.props)!)"
/>
</template>
</v-group>
</template>
</v-group>
<v-rect :config="canvasBorderConfig" />
<v-line v-if="rotationArmConfig" :config="rotationArmConfig" />
+69 -158
View File
@@ -31,14 +31,12 @@ 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'
import type {
AnimatableProps,
Asset,
ButtonInstance,
Clip,
ClipInstance,
Easing,
@@ -135,21 +133,6 @@ const scriptFramesByTrack = computed<Record<string, typeof store.scene.scriptFra
}
return out
})
const buttonClipById = computed(() => {
const m = new Map<string, NonNullable<typeof store.scene.buttonClips>[number]>()
for (const c of store.scene.buttonClips ?? []) m.set(c.id, c)
return m
})
const buttonInstancesByTrack = computed<
Record<string, NonNullable<typeof store.scene.buttonInstances>>
>(() => {
const out: Record<string, NonNullable<typeof store.scene.buttonInstances>> = {}
for (const bi of store.scene.buttonInstances ?? []) {
;(out[bi.clipTrackId] ||= []).push(bi)
}
return out
})
const TRACK_NAME_WIDTH_KEY = 'animatr:trackNameWidth'
const TRACK_NAME_WIDTH_DEFAULT = 140
const TRACK_NAME_WIDTH_MIN = 80
@@ -460,14 +443,8 @@ function onMarkerPointerMove(e: PointerEvent, markerId: string) {
function onMarkerPointerUp(e: PointerEvent, markerId: string) {
const el = e.currentTarget as HTMLElement
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
const drag = markerDrag.value
markerDrag.value = null
if (!drag || drag.id !== markerId) return
// Click (no movement) → jump playhead to the marker.
if (!drag.moved) {
const m = store.scene.markers?.find((x) => x.id === markerId)
if (m) store.setPlayhead(m.time)
}
void markerId
}
async function renameMarkerPrompt(markerId: string) {
@@ -1077,7 +1054,7 @@ function duplicateFromMenu() {
}
interface TrackMenuState {
kind: 'layer' | 'clip' | 'group'
kind: 'layer' | 'clip' | 'group' | 'script'
trackId: string
x: number
y: number
@@ -1277,7 +1254,7 @@ function onTracksPointerDown(e: PointerEvent) {
if (!target) return
if (
target.closest(
'.kf, .clip-block, .clip-handle, .track-name, .drag-handle, .add-clip-btn, .playhead',
'.kf, .clip-block, .clip-handle, .track-name, .drag-handle, .add-clip-btn, .playhead, .script-lane, .script-frame',
)
) {
return
@@ -1364,11 +1341,7 @@ function pixelTimeFromEvent(e: DragEvent | PointerEvent, el: HTMLElement): numbe
}
function onClipLaneDragOver(e: DragEvent, _clipTrackId: string) {
const types = e.dataTransfer?.types
if (
types?.includes('application/x-animatr-clip') ||
types?.includes('application/x-animatr-button-clip')
) {
if (e.dataTransfer?.types.includes('application/x-animatr-clip')) {
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
}
@@ -1381,14 +1354,7 @@ function onClipLaneDrop(e: DragEvent, clipTrackId: string) {
const el = e.currentTarget as HTMLElement
const time = pixelTimeFromEvent(e, el)
const clipId = e.dataTransfer?.getData('application/x-animatr-clip')
if (clipId) {
store.addClipInstance(clipId, time, clipTrackId)
return
}
const buttonClipId = e.dataTransfer?.getData('application/x-animatr-button-clip')
if (buttonClipId) {
store.addButtonInstance(buttonClipId, clipTrackId, time)
}
if (clipId) store.addClipInstance(clipId, time, clipTrackId)
}
interface ClipMoveState {
@@ -1447,24 +1413,16 @@ function onClipBlockPointerUp(e: PointerEvent) {
const clipDragOverDropzone = ref(false)
function isClipDragLike(e: DragEvent): boolean {
const types = e.dataTransfer?.types
return !!(
types?.includes('application/x-animatr-clip') ||
types?.includes('application/x-animatr-button-clip')
)
}
function onNewTrackDragEnter(e: DragEvent) {
if (!isClipDragLike(e)) return
if (!e.dataTransfer?.types.includes('application/x-animatr-clip')) return
e.preventDefault()
clipDragOverDropzone.value = true
}
function onNewTrackDragOver(e: DragEvent) {
if (!isClipDragLike(e)) return
if (!e.dataTransfer?.types.includes('application/x-animatr-clip')) return
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
e.dataTransfer.dropEffect = 'copy'
clipDragOverDropzone.value = true
}
@@ -1475,14 +1433,12 @@ function onNewTrackDragLeave() {
function onNewTrackDrop(e: DragEvent) {
clipDragOverDropzone.value = false
const clipId = e.dataTransfer?.getData('application/x-animatr-clip')
const buttonClipId = e.dataTransfer?.getData('application/x-animatr-button-clip')
if (!clipId && !buttonClipId) return
if (!clipId) return
e.preventDefault()
const el = e.currentTarget as HTMLElement
const time = pixelTimeFromLane(e, el)
const track = store.addClipTrack()
if (clipId) store.addClipInstance(clipId, time, track.id)
else if (buttonClipId) store.addButtonInstance(buttonClipId, track.id, time)
store.addClipInstance(clipId, time, track.id)
}
function pixelTimeFromLane(e: DragEvent, el: HTMLElement): number {
@@ -1515,20 +1471,10 @@ 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)
// 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
@@ -1545,16 +1491,6 @@ function onScriptLaneClick(e: MouseEvent, trackId: string) {
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
// (time-like) span so the block scales with timeline zoom.
const totalSec = BUTTON_CELL_SEC * 4
return {
left: pxFromTime(bi.startTime) + 'px',
width: Math.max(80, pxFromTime(totalSec)) + 'px',
}
}
function trimTooltip(ci: ClipInstance): string {
const name = clipById.value.get(ci.clipId)?.name ?? '?'
@@ -1699,6 +1635,22 @@ function onClipTrackContextMenu(e: MouseEvent, clipTrackId: string) {
}
}
function onScriptTrackContextMenu(e: MouseEvent, scriptTrackId: string) {
e.preventDefault()
e.stopPropagation()
const track = store.scene.scriptTracks?.find((t) => t.id === scriptTrackId)
if (!track) return
trackMenu.value = {
kind: 'script',
trackId: scriptTrackId,
x: e.clientX,
y: e.clientY,
separated: false,
currentColor: track.color ?? null,
locked: !!track.locked,
}
}
function applyTrackColor(color: string | null) {
if (!trackMenu.value) return
store.setTrackColor(trackMenu.value.trackId, color)
@@ -1722,6 +1674,8 @@ function deleteTrack() {
store.removeLayer(trackMenu.value.trackId)
} else if (trackMenu.value.kind === 'clip') {
store.removeClipTrack(trackMenu.value.trackId)
} else if (trackMenu.value.kind === 'script') {
store.removeScriptTrack(trackMenu.value.trackId)
} else {
store.removeTrackGroup(trackMenu.value.trackId)
}
@@ -1730,19 +1684,35 @@ function deleteTrack() {
async function renameFromMenu() {
if (!trackMenu.value) return
if (trackMenu.value.kind !== 'group') return
const group = groupById.value.get(trackMenu.value.trackId)
if (!group) return
const id = trackMenu.value.trackId
const current = group.name
trackMenu.value = null
const next = await promptDialog({
title: t('timeline.renameGroupDialog.title'),
defaultValue: current,
confirmLabel: t('timeline.renameGroupDialog.confirm'),
})
if (next === null) return
store.renameGroup(id, next)
if (trackMenu.value.kind === 'group') {
const group = groupById.value.get(trackMenu.value.trackId)
if (!group) return
const id = trackMenu.value.trackId
const current = group.name
trackMenu.value = null
const next = await promptDialog({
title: t('timeline.renameGroupDialog.title'),
defaultValue: current,
confirmLabel: t('timeline.renameGroupDialog.confirm'),
})
if (next === null) return
store.renameGroup(id, next)
return
}
if (trackMenu.value.kind === 'script') {
const track = scriptTrackById.value.get(trackMenu.value.trackId)
if (!track) return
const id = trackMenu.value.trackId
const current = track.name ?? t('timeline.scriptTrackName')
trackMenu.value = null
const next = await promptDialog({
title: t('timeline.renameScriptTrackDialog.title'),
defaultValue: current,
confirmLabel: t('timeline.renameScriptTrackDialog.confirm'),
})
if (next === null) return
store.setScriptTrackName(id, next)
}
}
function onDocumentPointerDown(e: PointerEvent) {
@@ -2313,22 +2283,6 @@ onBeforeUnmount(() => {
@click.stop
/>
</span>
<span
v-for="bi in buttonInstancesByTrack[track.clipTrackId] ?? []"
:key="`bi-${bi.id}`"
class="button-instance"
: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"
:key="state"
class="button-instance-cell"
:data-state="state"
>{{ state[0].toUpperCase() }}</span>
</span>
</div>
</div>
@@ -2351,6 +2305,7 @@ onBeforeUnmount(() => {
<span
class="track-name script-track-name"
:draggable="!track.locked"
@contextmenu="!track.locked ? onScriptTrackContextMenu($event, track.scriptTrackId) : null"
@dragstart="!track.locked ? onTrackDragStart($event, track.scriptTrackId) : null"
@dragend="onTrackDragEnd"
>
@@ -2592,6 +2547,14 @@ onBeforeUnmount(() => {
{{ t('timeline.renameGroup') }}
</button>
</template>
<template v-else-if="trackMenu.kind === 'script'">
<div class="kf-menu-divider"></div>
<button class="kf-menu-item" @click="renameFromMenu">
<span class="check"></span>
<PhArrowsSplit :size="12" class="kf-menu-icon" style="visibility: hidden" />
{{ t('timeline.renameScriptTrack') }}
</button>
</template>
<template v-else>
<template v-if="trackMenu.kind === 'layer'">
<div class="kf-menu-divider"></div>
@@ -2617,7 +2580,9 @@ onBeforeUnmount(() => {
? t('timeline.deleteLayer')
: trackMenu.kind === 'clip'
? t('timeline.deleteClipTrack')
: t('timeline.ungroup')
: trackMenu.kind === 'script'
? t('timeline.deleteScriptTrack')
: t('timeline.ungroup')
}}
</button>
</div>
@@ -2625,10 +2590,6 @@ onBeforeUnmount(() => {
v-model="scriptDialogOpen"
:frame-id="scriptDialogFrameId"
/>
<ButtonInstanceDialog
v-model="buttonDialogOpen"
:instance-id="buttonDialogInstanceId"
/>
</div>
</template>
@@ -3496,56 +3457,6 @@ onBeforeUnmount(() => {
text-align: center;
}
/* ---- Button instance (4 cells) ---- */
.button-instance {
position: absolute;
top: 4px;
bottom: 4px;
display: inline-flex;
border: 1px solid var(--track-color, var(--accent));
border-radius: var(--radius-sm);
overflow: hidden;
background: color-mix(in srgb, var(--track-color, var(--accent)) 10%, transparent);
color: var(--track-color, var(--accent));
font-size: 10px;
font-weight: 600;
}
.button-instance.disabled {
opacity: 0.5;
}
.button-instance-cell {
flex: 1 1 0;
display: flex;
align-items: center;
justify-content: center;
border-right: 1px solid color-mix(in srgb, var(--track-color, var(--accent)) 40%, transparent);
background: color-mix(in srgb, var(--track-color, var(--accent)) 8%, transparent);
letter-spacing: 0.04em;
}
.button-instance-cell:last-child {
border-right: none;
}
.button-instance-cell[data-state='hover'] {
background: color-mix(in srgb, var(--track-color, var(--accent)) 14%, transparent);
}
.button-instance-cell[data-state='pressed'] {
background: color-mix(in srgb, var(--track-color, var(--accent)) 22%, transparent);
}
.button-instance-cell[data-state='disabled'] {
background: repeating-linear-gradient(
45deg,
color-mix(in srgb, var(--track-color, var(--accent)) 8%, transparent) 0 4px,
transparent 4px 8px
);
}
.clip-block {
position: absolute;
top: 4px;