610 lines
13 KiB
Vue
610 lines
13 KiB
Vue
<script setup lang="ts">
|
|
import { computed, ref, watch } from 'vue'
|
|
import {
|
|
PhCircle,
|
|
PhFilmStrip,
|
|
PhImage,
|
|
PhImageSquare,
|
|
PhListBullets,
|
|
PhPencilSimple,
|
|
PhPlus,
|
|
PhSquare,
|
|
PhTextT,
|
|
PhTrash,
|
|
PhUploadSimple,
|
|
} from '@phosphor-icons/vue'
|
|
import { useSceneStore } from '@/stores/scene'
|
|
import { isSvgFile, loadImage, parseSvgDimensions, readAsDataUrl } 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)
|
|
}
|
|
|
|
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 ?? [])
|
|
|
|
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 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) {
|
|
const dataUrl = await readAsDataUrl(file)
|
|
|
|
let width = 0
|
|
let height = 0
|
|
|
|
if (isSvgFile(file)) {
|
|
const text = await file.text()
|
|
const dims = parseSvgDimensions(text)
|
|
if (dims) {
|
|
width = dims.width
|
|
height = dims.height
|
|
}
|
|
}
|
|
|
|
if (!width || !height) {
|
|
const img = await loadImage(dataUrl)
|
|
width = img.naturalWidth || 300
|
|
height = img.naturalHeight || 300
|
|
}
|
|
|
|
const asset = store.addAsset({
|
|
kind: 'image',
|
|
name: file.name,
|
|
src: dataUrl,
|
|
width,
|
|
height,
|
|
})
|
|
store.addLayer(asset.id)
|
|
}
|
|
|
|
function triggerUpload() {
|
|
fileInput.value?.click()
|
|
}
|
|
|
|
const assetIconMap: Record<Asset['kind'], typeof PhSquare> = {
|
|
rect: PhSquare,
|
|
circle: PhCircle,
|
|
image: PhImage,
|
|
text: PhTextT,
|
|
}
|
|
|
|
const imageAssets = computed(() =>
|
|
store.scene.assets.filter((a): a is Extract<Asset, { kind: 'image' }> => a.kind === 'image'),
|
|
)
|
|
|
|
function onAssetDragStart(e: DragEvent, assetId: string) {
|
|
if (!e.dataTransfer) return
|
|
e.dataTransfer.effectAllowed = 'copy'
|
|
e.dataTransfer.setData('application/x-animatr-asset', assetId)
|
|
e.dataTransfer.setData('text/plain', assetId)
|
|
}
|
|
|
|
</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" @click="addSampleRect">
|
|
<PhSquare :size="14" />
|
|
{{ t('sidebar.shape.rect') }}
|
|
</button>
|
|
<button class="shape-btn" @click="addSampleCircle">
|
|
<PhCircle :size="14" />
|
|
{{ t('sidebar.shape.circle') }}
|
|
</button>
|
|
<button class="shape-btn" @click="addSampleText">
|
|
<PhTextT :size="14" />
|
|
{{ t('sidebar.shape.text') }}
|
|
</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.id)"
|
|
>
|
|
<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>
|
|
</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;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.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>
|