Files
RUWIO/src/components/AssetSidebar.vue
T
thandClaude Opus 4.7 024883943b 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>
2026-04-20 21:31:41 +02:00

741 lines
18 KiB
Vue

<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import {
PhCircle,
PhCursorClick,
PhFilmStrip,
PhImage,
PhImageSquare,
PhListBullets,
PhPencilSimple,
PhPencilSimpleLine,
PhPlus,
PhPolygon,
PhSquare,
PhTextT,
PhTrash,
PhUploadSimple,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { importImageFile } from '@/lib/fileLoader'
import type { Asset } from '@/types/scene'
import { confirmDialog, promptDialog } from '@/components/ui'
import { t } from '@/i18n'
const store = useSceneStore()
const fileInput = ref<HTMLInputElement | null>(null)
const uploadError = ref<string | null>(null)
const STORAGE_KEY = 'animatr.assetThumbs'
const storedThumbs = typeof localStorage !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null
const showThumbs = ref(storedThumbs !== 'false')
watch(showThumbs, (v) => {
try {
localStorage.setItem(STORAGE_KEY, String(v))
} catch {
/* ignore */
}
})
function addSampleRect() {
const asset = store.addAsset({
kind: 'rect',
name: 'Rect',
width: 160,
height: 100,
fill: '#6ea8ff',
})
store.addLayer(asset.id)
}
function addSampleCircle() {
const asset = store.addAsset({
kind: 'circle',
name: 'Circle',
radius: 60,
fill: '#ff7a59',
})
store.addLayer(asset.id)
}
function startFreeformDrawing() {
store.startFreeformDrawing()
}
async function addSamplePolygon() {
const input = await promptDialog({
title: t('sidebar.newPolygonDialog.title'),
message: t('sidebar.newPolygonDialog.message'),
defaultValue: '6',
confirmLabel: t('sidebar.newPolygonDialog.confirm'),
})
if (input === null) return
const parsed = parseInt(input.trim(), 10)
const sides = Number.isFinite(parsed) ? Math.max(3, Math.min(24, parsed)) : 6
const radius = 60
const points: { x: number; y: number }[] = []
for (let i = 0; i < sides; i++) {
const a = (i / sides) * Math.PI * 2 - Math.PI / 2
points.push({ x: Math.cos(a) * radius, y: Math.sin(a) * radius })
}
const asset = store.addAsset({
kind: 'polygon',
name: `Polygon ${sides}`,
points,
fill: '#9b7ede',
})
store.addLayer(asset.id)
}
async function addSampleText() {
const input = await promptDialog({
title: t('sidebar.newTextDialog.title'),
message: t('sidebar.newTextDialog.message'),
defaultValue: t('sidebar.newTextDialog.default'),
confirmLabel: t('sidebar.newTextDialog.confirm'),
})
if (input === null) return
const text = input.trim() || t('sidebar.newTextDialog.fallback')
const asset = store.addAsset({
kind: 'text',
name: text.length > 24 ? text.slice(0, 24) + '…' : text,
text,
fontSize: 48,
fill: '#f5d76e',
})
store.addLayer(asset.id)
}
const clips = computed(() => store.scene.clips ?? [])
const buttonClips = computed(() => store.scene.buttonClips ?? [])
async function addClip() {
const input = await promptDialog({
title: t('sidebar.newClipDialog.title'),
message: t('sidebar.newClipDialog.message'),
defaultValue: t('sidebar.newClipDialog.default'),
confirmLabel: t('sidebar.newClipDialog.confirm'),
})
if (input === null) return
store.addClip(input)
}
async function renameClipPrompt(clipId: string, current: string) {
const input = await promptDialog({
title: t('sidebar.renameClipDialog.title'),
defaultValue: current,
confirmLabel: t('sidebar.renameClipDialog.confirm'),
})
if (input === null) return
store.renameClip(clipId, input)
}
async function deleteClip(clipId: string) {
const ok = await confirmDialog({
title: t('sidebar.deleteClipDialog.title'),
message: t('sidebar.deleteClipDialog.message'),
confirmLabel: t('sidebar.deleteClipDialog.confirm'),
variant: 'danger',
})
if (!ok) return
store.removeClip(clipId)
}
function onClipDragStart(e: DragEvent, clipId: string) {
if (!e.dataTransfer) return
e.dataTransfer.effectAllowed = 'copy'
e.dataTransfer.setData('application/x-animatr-clip', clipId)
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
const files = Array.from(input.files ?? [])
try {
for (const file of files) await importFile(file)
} catch (err) {
uploadError.value = err instanceof Error ? err.message : String(err)
} finally {
if (fileInput.value) fileInput.value.value = ''
}
}
async function importFile(file: File) {
await importImageFile(store, file)
}
function triggerUpload() {
fileInput.value?.click()
}
const assetIconMap: Record<Asset['kind'], typeof PhSquare> = {
rect: PhSquare,
circle: PhCircle,
image: PhImage,
text: PhTextT,
polygon: PhPolygon,
}
const imageAssets = computed(() =>
store.scene.assets.filter((a): a is Extract<Asset, { kind: 'image' }> => a.kind === 'image'),
)
function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>) {
if (!e.dataTransfer) return
e.dataTransfer.effectAllowed = 'copy'
e.dataTransfer.setData('application/x-animatr-asset', asset.id)
e.dataTransfer.setData('text/plain', asset.id)
// Use the actual graphic as drag ghost instead of the library entry.
const target = e.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
const thumbImg = target.querySelector('img') as HTMLImageElement | null
if (thumbImg && thumbImg.complete && thumbImg.naturalWidth > 0) {
const r = thumbImg.getBoundingClientRect()
const ox = Math.max(0, e.clientX - r.left)
const oy = Math.max(0, e.clientY - r.top)
e.dataTransfer.setDragImage(thumbImg, ox, oy)
return
}
// Fallback — create an offscreen image of the asset for use as drag ghost.
const ghost = document.createElement('img')
ghost.src = asset.src
ghost.style.cssText =
'position:fixed;top:-10000px;left:-10000px;width:72px;height:auto;pointer-events:none;'
document.body.appendChild(ghost)
const gx = Math.max(0, e.clientX - rect.left)
const gy = Math.max(0, e.clientY - rect.top)
e.dataTransfer.setDragImage(ghost, gx, gy)
setTimeout(() => {
ghost.remove()
}, 0)
}
</script>
<template>
<div class="panel">
<h3>Assets</h3>
<button class="upload-btn" @click="triggerUpload">
<PhUploadSimple :size="16" />
<span>{{ t('sidebar.addFile') }}</span>
</button>
<div class="actions">
<button class="shape-btn" :title="t('sidebar.shape.rect')" @click="addSampleRect">
<PhSquare :size="16" />
</button>
<button class="shape-btn" :title="t('sidebar.shape.circle')" @click="addSampleCircle">
<PhCircle :size="16" />
</button>
<button class="shape-btn" :title="t('sidebar.shape.text')" @click="addSampleText">
<PhTextT :size="16" />
</button>
<button class="shape-btn" :title="t('sidebar.shape.polygon')" @click="addSamplePolygon">
<PhPolygon :size="16" />
</button>
<button
class="shape-btn"
:class="{ active: store.drawingMode === 'freeform' }"
:title="store.drawingMode === 'freeform' ? t('sidebar.shape.freeformActive') : t('sidebar.shape.freeform')"
@click="startFreeformDrawing"
>
<PhPencilSimpleLine :size="16" />
</button>
</div>
<input
ref="fileInput"
type="file"
accept="image/svg+xml,image/png,image/jpeg,image/webp,image/gif"
multiple
style="display: none"
@change="handleFileSelected"
/>
<p v-if="uploadError" class="error">{{ uploadError }}</p>
<div class="lib-header">
<h4>{{ t('sidebar.library') }}</h4>
<button
class="thumb-toggle"
:title="showThumbs ? t('sidebar.thumbnailsHide') : t('sidebar.thumbnailsShow')"
@click="showThumbs = !showThumbs"
>
<PhImageSquare v-if="showThumbs" :size="13" />
<PhListBullets v-else :size="13" />
</button>
</div>
<ul class="assets" :class="{ compact: !showThumbs }">
<li
v-for="asset in imageAssets"
:key="asset.id"
draggable="true"
@dragstart="onAssetDragStart($event, asset)"
>
<template v-if="showThumbs">
<div class="thumb">
<img :src="asset.src" :alt="asset.name" draggable="false" />
</div>
</template>
<template v-else>
<component :is="assetIconMap[asset.kind]" :size="14" class="asset-icon" />
</template>
<div class="meta">
<span class="name" :title="asset.name">{{ asset.name }}</span>
<span v-if="showThumbs" class="sub">{{ t('sidebar.imageLabel') }}</span>
</div>
<button
class="add-layer"
:title="t('sidebar.addLayerTooltip')"
@click="store.addLayer(asset.id)"
>
<PhPlus :size="12" weight="bold" />
</button>
</li>
<li v-if="!imageAssets.length" class="empty">{{ t('sidebar.noImages') }}</li>
</ul>
<div class="lib-header clips-header">
<h4>{{ t('sidebar.clips') }}</h4>
<button class="thumb-toggle" :title="t('sidebar.createClipTooltip')" @click="addClip">
<PhPlus :size="12" weight="bold" />
</button>
</div>
<ul class="clips">
<li
v-for="clip in clips"
:key="clip.id"
class="clip"
:class="{ editing: store.activeClipId === clip.id }"
draggable="true"
@dragstart="onClipDragStart($event, clip.id)"
@dblclick="store.enterClip(clip.id)"
>
<div class="clip-icon">
<PhFilmStrip :size="16" />
</div>
<div class="clip-meta">
<span class="clip-name" :title="clip.name">{{ clip.name }}</span>
<span class="clip-sub">
{{ t('sidebar.clipLayerCount', { n: clip.layers.length }) }} · {{ clip.duration.toFixed(1) }}s
</span>
</div>
<div class="clip-actions">
<button
class="clip-btn"
:title="t('sidebar.renameTooltip')"
@click.stop="renameClipPrompt(clip.id, clip.name)"
>
<PhPencilSimple :size="11" />
</button>
<button class="clip-btn danger" :title="t('sidebar.deleteTooltip')" @click.stop="deleteClip(clip.id)">
<PhTrash :size="11" />
</button>
</div>
</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>
<style scoped>
.panel {
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
h3 {
margin: 0 0 2px;
font-size: 13px;
font-weight: 600;
}
h4 {
margin: 0;
font-size: 10px;
text-transform: uppercase;
color: var(--text-dim);
letter-spacing: 0.08em;
font-weight: 500;
}
.upload-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 12px;
background: var(--accent-soft);
border: 1px dashed var(--accent);
color: var(--accent);
border-radius: var(--radius);
font-size: 12px;
font-weight: 500;
}
.upload-btn:hover:not(:disabled) {
background: var(--accent-soft-strong);
}
.actions {
display: flex;
gap: 6px;
}
.shape-btn {
flex: 1;
justify-content: center;
align-items: center;
padding: 6px 0;
font-size: 12px;
}
.shape-btn.active {
background: var(--accent-soft);
color: var(--accent);
border-color: var(--accent);
}
.error {
color: var(--danger);
font-size: 11px;
margin: 2px 0 0;
padding: 6px 8px;
background: var(--danger-soft);
border-radius: var(--radius-sm);
}
.lib-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 6px;
}
.thumb-toggle {
width: 22px;
height: 22px;
padding: 0;
justify-content: center;
background: transparent;
border: 1px solid var(--border);
color: var(--text-dim);
border-radius: var(--radius-sm);
}
.thumb-toggle:hover:not(:disabled) {
background: var(--panel-alt);
color: var(--text);
}
.assets {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.assets li {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
border-radius: var(--radius-sm);
background: var(--panel-alt);
font-size: 12px;
transition: background 140ms var(--ease);
}
.assets li:hover {
background: var(--panel-hover);
}
.assets li.unused {
opacity: 0.9;
}
.assets.compact li {
gap: 8px;
padding: 6px 8px;
}
.thumb {
width: 40px;
height: 40px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
}
.thumb img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
pointer-events: none;
}
.shape-preview {
display: block;
}
.text-preview {
font-weight: 600;
font-size: 18px;
line-height: 1;
letter-spacing: -0.02em;
}
.asset-icon {
color: var(--text-dim);
flex-shrink: 0;
}
.meta {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sub {
font-size: 10px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.add-layer {
width: 22px;
height: 22px;
padding: 0;
justify-content: center;
background: transparent;
border: 1px solid var(--border);
color: var(--text-dim);
flex-shrink: 0;
}
.add-layer:hover:not(:disabled) {
color: var(--accent);
border-color: var(--accent);
}
.empty {
background: transparent !important;
color: var(--text-dim);
font-style: italic;
justify-content: center;
}
.clips-header {
margin-top: 14px;
}
.clips {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.clip {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: var(--panel-alt);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: grab;
transition: background 140ms var(--ease), border-color 140ms var(--ease);
}
.clip:hover {
background: var(--panel-hover);
border-color: var(--border-strong);
}
.clip:active {
cursor: grabbing;
}
.clip.editing {
border-color: var(--accent);
background: var(--accent-soft);
}
.clip-icon {
width: 26px;
height: 26px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--accent);
background: var(--bg);
border-radius: var(--radius-sm);
}
.clip-meta {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.clip-name {
font-size: 12px;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.clip-sub {
font-size: 10px;
color: var(--text-dim);
}
.clip-actions {
display: flex;
gap: 2px;
opacity: 0;
transition: opacity 140ms var(--ease);
}
.clip:hover .clip-actions,
.clip.editing .clip-actions {
opacity: 1;
}
.clip-btn {
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
background: transparent;
border: 1px solid transparent;
color: var(--text-dim);
border-radius: 4px;
}
.clip-btn:hover {
background: var(--panel);
color: var(--text);
}
.clip-btn.danger:hover {
background: var(--danger-soft);
color: var(--danger);
border-color: var(--danger);
}
</style>