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) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-20 21:31:41 +02:00
1 parent fb58cd18f7
commit 024883943b
5 files changed
+193 -6

No files matched your search

+86
View File
@@ -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<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>
+25 -6
View File
@@ -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 {