From 024883943b5895cb2f0d03721731cf98d1e0f123 Mon Sep 17 00:00:00 2001
From: th
Date: Mon, 20 Apr 2026 21:31:41 +0200
Subject: [PATCH] macro-clips slice 4c: button creation in sidebar + drag to
clip tracks
- store.seedDefaultButtonClip(name) adds a button clip with a single
shared rect asset and four pre-populated state stacks in blue shades
(normal/hover/pressed/disabled). Layer name 'Face' is consistent so
transitions can later pair layers by name.
- AssetSidebar gets a Buttons section below Clips: create via prompt,
rename, delete, drag-to-track. Drag uses a new dataTransfer type
'application/x-animatr-button-clip' to keep it distinct from
animation-clip drops.
- Timeline clip-lane drop accepts both types and dispatches
addClipInstance / addButtonInstance accordingly; the new-track
drop zone handles both too, so dragging a button to an empty area
creates a fresh clip track + instance in one step.
- i18n for the sidebar strings and three dialogs.
- test covers seedDefaultButtonClip: four populated states, shared
asset, consistent layer name.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
src/components/AssetSidebar.vue | 86 +++++++++++++++++++++++++++++++++
src/components/Timeline.vue | 31 +++++++++---
src/devtools/tests.ts | 19 ++++++++
src/i18n/messages.ts | 19 ++++++++
src/stores/scene.ts | 44 +++++++++++++++++
5 files changed, 193 insertions(+), 6 deletions(-)
diff --git a/src/components/AssetSidebar.vue b/src/components/AssetSidebar.vue
index 3749bf7..581b814 100644
--- a/src/components/AssetSidebar.vue
+++ b/src/components/AssetSidebar.vue
@@ -2,6 +2,7 @@
import { computed, ref, watch } from 'vue'
import {
PhCircle,
+ PhCursorClick,
PhFilmStrip,
PhImage,
PhImageSquare,
@@ -107,6 +108,7 @@ async function addSampleText() {
}
const clips = computed(() => store.scene.clips ?? [])
+const buttonClips = computed(() => store.scene.buttonClips ?? [])
async function addClip() {
const input = await promptDialog({
@@ -147,6 +149,45 @@ 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
@@ -336,6 +377,51 @@ function onAssetDragStart(e: DragEvent, asset: Extract
{{ t('sidebar.noClips') }}
+
+
+
diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue
index 23b53a4..e7a50b6 100644
--- a/src/components/Timeline.vue
+++ b/src/components/Timeline.vue
@@ -1347,7 +1347,11 @@ function pixelTimeFromEvent(e: DragEvent | PointerEvent, el: HTMLElement): numbe
}
function onClipLaneDragOver(e: DragEvent, _clipTrackId: string) {
- if (e.dataTransfer?.types.includes('application/x-animatr-clip')) {
+ const types = e.dataTransfer?.types
+ if (
+ types?.includes('application/x-animatr-clip') ||
+ types?.includes('application/x-animatr-button-clip')
+ ) {
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
}
@@ -1362,6 +1366,11 @@ function onClipLaneDrop(e: DragEvent, clipTrackId: string) {
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)
}
}
@@ -1421,16 +1430,24 @@ 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 (!e.dataTransfer?.types.includes('application/x-animatr-clip')) return
+ if (!isClipDragLike(e)) return
e.preventDefault()
clipDragOverDropzone.value = true
}
function onNewTrackDragOver(e: DragEvent) {
- if (!e.dataTransfer?.types.includes('application/x-animatr-clip')) return
+ if (!isClipDragLike(e)) return
e.preventDefault()
- e.dataTransfer.dropEffect = 'copy'
+ if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
clipDragOverDropzone.value = true
}
@@ -1441,12 +1458,14 @@ function onNewTrackDragLeave() {
function onNewTrackDrop(e: DragEvent) {
clipDragOverDropzone.value = false
const clipId = e.dataTransfer?.getData('application/x-animatr-clip')
- if (!clipId) return
+ const buttonClipId = e.dataTransfer?.getData('application/x-animatr-button-clip')
+ if (!clipId && !buttonClipId) return
e.preventDefault()
const el = e.currentTarget as HTMLElement
const time = pixelTimeFromLane(e, el)
const track = store.addClipTrack()
- store.addClipInstance(clipId, time, track.id)
+ if (clipId) store.addClipInstance(clipId, time, track.id)
+ else if (buttonClipId) store.addButtonInstance(buttonClipId, track.id, time)
}
function pixelTimeFromLane(e: DragEvent, el: HTMLElement): number {
diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts
index e73b4bc..ddf8e8f 100644
--- a/src/devtools/tests.ts
+++ b/src/devtools/tests.ts
@@ -3189,6 +3189,25 @@ export function registerAllTests(): void {
expect(getB?.label).toBe('mute')
})
+ test('seedDefaultButtonClip produces four populated state stacks', () => {
+ const s = makeFreshStore()
+ const bc = s.seedDefaultButtonClip('Play')
+ expect(bc.states.normal).toHaveLength(1)
+ expect(bc.states.hover).toHaveLength(1)
+ expect(bc.states.pressed).toHaveLength(1)
+ expect(bc.states.disabled).toHaveLength(1)
+ // Each state has its own face layer, all pointing at the same asset.
+ const assetIds = new Set(
+ [...bc.states.normal, ...bc.states.hover, ...bc.states.pressed, ...bc.states.disabled]
+ .map((l) => l.assetId),
+ )
+ expect(assetIds.size).toBe(1)
+ // Layer names consistent so transitions can match across states.
+ for (const state of ['normal', 'hover', 'pressed', 'disabled'] as const) {
+ expect(bc.states[state][0].name).toBe('Face')
+ }
+ })
+
test('setButtonInstanceOnClick + setButtonInstanceDisabled', () => {
const s = makeFreshStore()
const bc = s.addButtonClip('B')
diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts
index 64deedc..3f48f17 100644
--- a/src/i18n/messages.ts
+++ b/src/i18n/messages.ts
@@ -95,6 +95,25 @@ export const en = {
message: 'The clip and all placed instances will be removed.',
confirm: 'Delete',
},
+ buttons: 'Buttons',
+ buttonSubtitle: 'Button · 4 states',
+ noButtons: 'No buttons yet',
+ createButtonTooltip: 'Create new button',
+ newButtonDialog: {
+ title: 'New button',
+ message: 'Button name:',
+ default: 'Button',
+ confirm: 'Create',
+ },
+ renameButtonDialog: {
+ title: 'Rename button',
+ confirm: 'Apply',
+ },
+ deleteButtonDialog: {
+ title: 'Delete button?',
+ message: 'The button and all placed instances will be removed.',
+ confirm: 'Delete',
+ },
},
properties: {
diff --git a/src/stores/scene.ts b/src/stores/scene.ts
index e3065d8..40d90c9 100644
--- a/src/stores/scene.ts
+++ b/src/stores/scene.ts
@@ -1145,6 +1145,50 @@ export const useSceneStore = defineStore('scene', {
;(this.scene.buttonClips ||= []).push(clip)
return clip
},
+ /**
+ * Create a ButtonClip pre-populated with a visible rectangle for each
+ * of the four states (different shades per state). The single shared
+ * asset is added to the scene. Intended for the "+ Button" sidebar
+ * flow — advanced users can still edit or replace the layers later.
+ */
+ seedDefaultButtonClip(name?: string): ButtonClip {
+ const title = name?.trim() || 'Button'
+ const clip = this.addButtonClip(title)
+ const asset = this.addAsset({
+ kind: 'rect',
+ name: `${title} face`,
+ width: 120,
+ height: 40,
+ fill: '#3b82f6',
+ }) as Extract
+ const makeFace = (fill: string, layerName: string): Layer => ({
+ id: uid(),
+ name: layerName,
+ assetId: asset.id,
+ visible: true,
+ base: { ...DEFAULT_BASE_PROPS, fill },
+ keyframes: [
+ {
+ id: uid(),
+ time: 0,
+ props: { ...DEFAULT_BASE_PROPS, fill },
+ easing: 'linear',
+ },
+ ],
+ offsetX: asset.width / 2,
+ offsetY: asset.height / 2,
+ })
+ const target = this.scene.buttonClips?.find((c) => c.id === clip.id)
+ if (target) {
+ target.states = {
+ normal: [makeFace('#3b82f6', 'Face')],
+ hover: [makeFace('#60a5fa', 'Face')],
+ pressed: [makeFace('#2563eb', 'Face')],
+ disabled: [makeFace('#9ca3af', 'Face')],
+ }
+ }
+ return target ?? clip
+ },
removeButtonClip(id: string) {
if (this.scene.buttonClips) {
this.scene.buttonClips = this.scene.buttonClips.filter((c) => c.id !== id)
|