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:
1 parent
fb58cd18f7
commit
024883943b
5 files changed
+193
-6
No files matched your search
@@ -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>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<Asset, { kind: 'rect' }>
|
||||
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)
|
||||
|
||||
Reference in new issue
Block a user