501 lines
12 KiB
Vue
501 lines
12 KiB
Vue
<script setup lang="ts">
|
|
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
|
import {
|
|
PhArrowClockwise,
|
|
PhArrowCounterClockwise,
|
|
PhArrowLeft,
|
|
PhExport,
|
|
PhFilmStrip,
|
|
PhFolderOpen,
|
|
PhFloppyDisk,
|
|
PhGearSix,
|
|
PhPause,
|
|
PhPlay,
|
|
PhSkipBack,
|
|
PhSparkle,
|
|
PhStop,
|
|
} from '@phosphor-icons/vue'
|
|
import AssetSidebar from './components/AssetSidebar.vue'
|
|
import CanvasStage from './components/CanvasStage.vue'
|
|
import DocumentSettings from './components/DocumentSettings.vue'
|
|
import ExportDialog from './components/ExportDialog.vue'
|
|
import FullscreenPreview from './components/FullscreenPreview.vue'
|
|
import PropertiesPanel from './components/PropertiesPanel.vue'
|
|
import Timeline from './components/Timeline.vue'
|
|
import WelcomeDialog from './components/WelcomeDialog.vue'
|
|
import TestOverlay from './devtools/TestOverlay.vue'
|
|
import { IconButton, Toast } from './components/ui'
|
|
import { useHistory } from './composables/useHistory'
|
|
import { usePlaybackLoop } from './composables/usePlaybackLoop'
|
|
import {
|
|
ProjectFileError,
|
|
downloadProject,
|
|
pickProjectFile,
|
|
readProjectFile,
|
|
} from './lib/projectFile'
|
|
import {
|
|
getLastOpenedId,
|
|
listRecentProjects,
|
|
saveProjectToStore,
|
|
setLastOpenedId,
|
|
type StoredProject,
|
|
} from './lib/projectStore'
|
|
import { useSceneStore } from './stores/scene'
|
|
|
|
const settingsOpen = ref(false)
|
|
const exportOpen = ref(false)
|
|
const testsOpen = ref(false)
|
|
const welcomeOpen = ref(false)
|
|
const fullscreenOpen = ref(false)
|
|
const loadError = ref<string | null>(null)
|
|
const savedToast = ref<string | null>(null)
|
|
const currentProjectId = ref<string | null>(null)
|
|
const currentProjectName = ref<string | null>(null)
|
|
|
|
usePlaybackLoop()
|
|
const store = useSceneStore()
|
|
const history = useHistory()
|
|
|
|
function rewind() {
|
|
store.setPlayhead(0)
|
|
}
|
|
|
|
function stop() {
|
|
store.pause()
|
|
}
|
|
|
|
async function saveProject() {
|
|
const scene = JSON.parse(JSON.stringify(store.scene))
|
|
let name = currentProjectName.value
|
|
if (!name) {
|
|
const suggested = `Projekt ${new Date().toLocaleDateString()}`
|
|
const entered = prompt('Projektname?', suggested)
|
|
if (entered === null) return
|
|
name = entered.trim() || suggested
|
|
}
|
|
const rec = await saveProjectToStore(scene, {
|
|
id: currentProjectId.value ?? undefined,
|
|
name,
|
|
})
|
|
currentProjectId.value = rec.id
|
|
currentProjectName.value = rec.name
|
|
setLastOpenedId(rec.id)
|
|
showSavedToast(`"${rec.name}" gespeichert`)
|
|
}
|
|
|
|
function exportProjectFile() {
|
|
const scene = JSON.parse(JSON.stringify(store.scene))
|
|
const base = currentProjectName.value ?? 'animatr'
|
|
const safe = base.replace(/[^a-z0-9äöüß_-]+/gi, '-').replace(/^-+|-+$/g, '') || 'animatr'
|
|
downloadProject(scene, `${safe}.json`)
|
|
}
|
|
|
|
async function importFromFile() {
|
|
const file = await pickProjectFile()
|
|
if (!file) return
|
|
const hasContent = store.scene.layers.length > 0 || store.scene.assets.length > 0
|
|
if (hasContent) {
|
|
const ok = confirm(
|
|
'Aktuelles Projekt wird durch die geladene Datei ersetzt. Ungespeicherte Änderungen gehen verloren. Fortfahren?',
|
|
)
|
|
if (!ok) return
|
|
}
|
|
try {
|
|
const scene = await readProjectFile(file)
|
|
store.loadScene(scene)
|
|
history.resetTo(scene)
|
|
currentProjectId.value = null
|
|
currentProjectName.value = file.name.replace(/\.[^.]+$/, '')
|
|
loadError.value = null
|
|
} catch (err) {
|
|
const msg =
|
|
err instanceof ProjectFileError ? err.message : (err as Error).message || String(err)
|
|
loadError.value = msg
|
|
setTimeout(() => {
|
|
loadError.value = null
|
|
}, 6000)
|
|
}
|
|
}
|
|
|
|
function openFromRecent(project: StoredProject) {
|
|
const cloned = JSON.parse(JSON.stringify(project.scene))
|
|
store.loadScene(cloned)
|
|
history.resetTo(cloned)
|
|
currentProjectId.value = project.id
|
|
currentProjectName.value = project.name
|
|
setLastOpenedId(project.id)
|
|
loadError.value = null
|
|
}
|
|
|
|
function showSavedToast(msg: string) {
|
|
savedToast.value = msg
|
|
setTimeout(() => {
|
|
if (savedToast.value === msg) savedToast.value = null
|
|
}, 2500)
|
|
}
|
|
|
|
async function bootWelcome() {
|
|
const recents = await listRecentProjects()
|
|
if (recents.length > 0) {
|
|
welcomeOpen.value = true
|
|
const lastId = getLastOpenedId()
|
|
if (lastId) {
|
|
const last = recents.find((p) => p.id === lastId)
|
|
if (last) {
|
|
currentProjectName.value = last.name
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function isEditable(el: EventTarget | null): boolean {
|
|
if (!(el instanceof HTMLElement)) return false
|
|
const tag = el.tagName
|
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true
|
|
if (el.isContentEditable) return true
|
|
return false
|
|
}
|
|
|
|
function onKeyDown(e: KeyboardEvent) {
|
|
if (e.ctrlKey && e.shiftKey && e.altKey && (e.code === 'KeyT' || e.key.toLowerCase() === 't')) {
|
|
e.preventDefault()
|
|
testsOpen.value = true
|
|
return
|
|
}
|
|
const mod = e.ctrlKey || e.metaKey
|
|
if (mod && !isEditable(e.target)) {
|
|
const key = e.key.toLowerCase()
|
|
if (key === 'z' && !e.shiftKey) {
|
|
e.preventDefault()
|
|
history.undo()
|
|
return
|
|
}
|
|
if ((key === 'z' && e.shiftKey) || key === 'y') {
|
|
e.preventDefault()
|
|
history.redo()
|
|
return
|
|
}
|
|
}
|
|
if (e.code !== 'Space') return
|
|
if (isEditable(e.target)) return
|
|
e.preventDefault()
|
|
if (e.shiftKey) {
|
|
store.pause()
|
|
fullscreenOpen.value = true
|
|
return
|
|
}
|
|
store.togglePlay()
|
|
}
|
|
|
|
onMounted(() => {
|
|
document.addEventListener('keydown', onKeyDown)
|
|
bootWelcome()
|
|
})
|
|
onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
|
|
</script>
|
|
|
|
<template>
|
|
<div class="app">
|
|
<header class="topbar">
|
|
<div class="brand-area">
|
|
<span class="brand">
|
|
<PhSparkle :size="16" weight="fill" class="brand-icon" />
|
|
Animatr
|
|
</span>
|
|
</div>
|
|
<div class="transport">
|
|
<IconButton
|
|
:icon="PhArrowCounterClockwise"
|
|
icon-weight="bold"
|
|
size="md"
|
|
:disabled="!history.canUndo.value"
|
|
title="Rückgängig (Ctrl+Z)"
|
|
@click="history.undo"
|
|
/>
|
|
<IconButton
|
|
:icon="PhArrowClockwise"
|
|
icon-weight="bold"
|
|
size="md"
|
|
:disabled="!history.canRedo.value"
|
|
title="Wiederholen (Ctrl+Shift+Z)"
|
|
@click="history.redo"
|
|
/>
|
|
<div class="tsep"></div>
|
|
<IconButton
|
|
:icon="PhSkipBack"
|
|
icon-weight="fill"
|
|
size="md"
|
|
title="Zum Anfang"
|
|
@click="rewind"
|
|
/>
|
|
<IconButton
|
|
:icon="store.playing ? PhPause : PhPlay"
|
|
icon-weight="fill"
|
|
size="md"
|
|
variant="primary"
|
|
:title="store.playing ? 'Pause (Space)' : 'Play (Space)'"
|
|
@click="store.togglePlay()"
|
|
/>
|
|
<IconButton
|
|
:icon="PhStop"
|
|
icon-weight="fill"
|
|
size="md"
|
|
title="Stop"
|
|
@click="stop"
|
|
/>
|
|
<span class="time">{{ store.playhead.toFixed(2) }}s / {{ store.scene.duration }}s</span>
|
|
</div>
|
|
<div class="topbar-right">
|
|
<span v-if="currentProjectName" class="project-name" :title="currentProjectName">
|
|
{{ currentProjectName }}
|
|
</span>
|
|
<button class="icon-btn" title="Zuletzt verwendete Projekte" @click="welcomeOpen = true">
|
|
<PhFolderOpen :size="16" />
|
|
</button>
|
|
<button class="icon-btn" title="Projekt speichern (in Browser-Speicher)" @click="saveProject">
|
|
<PhFloppyDisk :size="16" />
|
|
</button>
|
|
<button class="icon-btn" title="Document Settings" @click="settingsOpen = true">
|
|
<PhGearSix :size="16" />
|
|
</button>
|
|
<button class="export-btn" title="Exportieren" @click="exportOpen = true">
|
|
<PhExport :size="14" weight="bold" />
|
|
Export
|
|
</button>
|
|
</div>
|
|
</header>
|
|
<aside class="sidebar">
|
|
<div class="sidebar-scroll"><AssetSidebar /></div>
|
|
<div v-if="store.activeClipId" class="scope-footer">
|
|
<button class="scope-back" @click="store.exitClip">
|
|
<PhArrowLeft :size="12" weight="bold" />
|
|
Haupt-Timeline
|
|
</button>
|
|
<span class="scope-current" :title="store.activeClip?.name ?? ''">
|
|
<PhFilmStrip :size="12" />
|
|
{{ store.activeClip?.name ?? '' }}
|
|
</span>
|
|
</div>
|
|
</aside>
|
|
<main class="stage"><CanvasStage /></main>
|
|
<aside class="properties"><PropertiesPanel /></aside>
|
|
<footer class="timeline"><Timeline /></footer>
|
|
<DocumentSettings v-model="settingsOpen" />
|
|
<ExportDialog v-model="exportOpen" />
|
|
<TestOverlay v-model="testsOpen" />
|
|
<FullscreenPreview v-model="fullscreenOpen" />
|
|
<WelcomeDialog
|
|
v-model="welcomeOpen"
|
|
@open="openFromRecent"
|
|
@new="() => { currentProjectId = null; currentProjectName = null }"
|
|
@import="importFromFile"
|
|
@export="exportProjectFile"
|
|
/>
|
|
<Toast :visible="!!loadError" variant="error">
|
|
<strong>Projekt konnte nicht geladen werden.</strong>
|
|
<span>{{ loadError }}</span>
|
|
</Toast>
|
|
<Toast :visible="!!savedToast" variant="info" :message="savedToast ?? ''" />
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.app {
|
|
display: grid;
|
|
grid-template-columns: 260px 1fr 300px;
|
|
grid-template-rows: 48px 1fr 220px;
|
|
grid-template-areas:
|
|
'top top top'
|
|
'side stage props'
|
|
'time time time';
|
|
height: 100vh;
|
|
width: 100vw;
|
|
}
|
|
|
|
.topbar {
|
|
grid-area: top;
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 0 16px;
|
|
background: var(--panel);
|
|
border-bottom: 1px solid var(--border);
|
|
box-shadow: var(--shadow-sm);
|
|
}
|
|
|
|
.brand-area {
|
|
flex: 1 1 0;
|
|
display: flex;
|
|
gap: 10px;
|
|
align-items: center;
|
|
min-width: 0;
|
|
}
|
|
|
|
.brand {
|
|
font-weight: 600;
|
|
font-size: 14px;
|
|
letter-spacing: -0.01em;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
|
|
.brand-icon {
|
|
color: var(--accent);
|
|
}
|
|
|
|
.transport {
|
|
display: flex;
|
|
gap: 4px;
|
|
align-items: center;
|
|
}
|
|
|
|
.tsep {
|
|
width: 1px;
|
|
height: 18px;
|
|
background: var(--border);
|
|
margin: 0 4px;
|
|
}
|
|
|
|
.time {
|
|
margin-left: 10px;
|
|
font-variant-numeric: tabular-nums;
|
|
color: var(--text-dim);
|
|
font-size: 11px;
|
|
min-width: 92px;
|
|
text-align: center;
|
|
}
|
|
|
|
.topbar-right {
|
|
flex: 1 1 0;
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 6px;
|
|
}
|
|
|
|
.export-btn {
|
|
padding: 6px 12px;
|
|
border: 1px solid var(--accent);
|
|
color: var(--accent);
|
|
background: var(--accent-soft);
|
|
border-radius: var(--radius-sm);
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.export-btn:hover:not(:disabled) {
|
|
background: var(--accent-soft-strong);
|
|
}
|
|
|
|
.icon-btn {
|
|
width: 30px;
|
|
height: 30px;
|
|
padding: 0;
|
|
justify-content: center;
|
|
background: transparent;
|
|
border: 1px solid var(--border);
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
.icon-btn:hover:not(:disabled) {
|
|
background: var(--panel-alt);
|
|
color: var(--text);
|
|
}
|
|
|
|
.sidebar {
|
|
grid-area: side;
|
|
background: var(--panel);
|
|
border-right: 1px solid var(--border);
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.sidebar-scroll {
|
|
flex: 1 1 auto;
|
|
min-height: 0;
|
|
overflow: auto;
|
|
}
|
|
|
|
.scope-footer {
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 8px 10px;
|
|
border-top: 1px solid var(--border-strong);
|
|
background: var(--accent-soft);
|
|
color: var(--accent);
|
|
font-size: 11px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.scope-back {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
padding: 4px 8px;
|
|
background: var(--panel);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-sm);
|
|
color: var(--text-dim-strong);
|
|
font-size: 11px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.scope-back:hover {
|
|
background: var(--panel-alt);
|
|
color: var(--text);
|
|
border-color: var(--border-strong);
|
|
}
|
|
|
|
.scope-current {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
font-weight: 500;
|
|
overflow: hidden;
|
|
white-space: nowrap;
|
|
text-overflow: ellipsis;
|
|
min-width: 0;
|
|
}
|
|
|
|
.stage {
|
|
grid-area: stage;
|
|
background: var(--bg);
|
|
overflow: hidden;
|
|
min-width: 0;
|
|
min-height: 0;
|
|
}
|
|
|
|
.properties {
|
|
grid-area: props;
|
|
background: var(--panel);
|
|
border-left: 1px solid var(--border);
|
|
overflow: auto;
|
|
}
|
|
|
|
.timeline {
|
|
grid-area: time;
|
|
background: var(--panel);
|
|
border-top: 1px solid var(--border);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.project-name {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
max-width: 220px;
|
|
padding: 4px 10px;
|
|
font-size: 11px;
|
|
color: var(--text-dim-strong);
|
|
background: var(--panel-alt);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-sm);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
margin-right: 4px;
|
|
}
|
|
</style>
|