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') }}
  • + +
    +

    {{ t('sidebar.buttons') }}

    + +
    +
      +
    • +
      + +
      +
      + {{ bc.name }} + {{ t('sidebar.buttonSubtitle') }} +
      +
      + + +
      +
    • +
    • {{ t('sidebar.noButtons') }}
    • +
    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)