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:
1 parent
616973b0fa
commit
31604052ca
16 files changed
+84
-2809
No files matched your search
+69
-158
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user