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

+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;