This commit is contained in:
th committed 2026-04-19 20:31:53 +02:00
1 parent e4aa0dce86
commit cc6b11e61d
23 files changed
+2047 -524

No files matched your search

+78 -103
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import {
PhArrowClockwise,
PhArrowCounterClockwise,
PhExport,
PhFolderOpen,
PhFloppyDisk,
@@ -15,10 +17,13 @@ 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,
@@ -39,6 +44,7 @@ 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)
@@ -46,6 +52,7 @@ const currentProjectName = ref<string | null>(null)
usePlaybackLoop()
const store = useSceneStore()
const history = useHistory()
function rewind() {
store.setPlayhead(0)
@@ -94,6 +101,7 @@ async function importFromFile() {
try {
const scene = await readProjectFile(file)
store.loadScene(scene)
history.resetTo(scene)
currentProjectId.value = null
currentProjectName.value = file.name.replace(/\.[^.]+$/, '')
loadError.value = null
@@ -108,7 +116,9 @@ async function importFromFile() {
}
function openFromRecent(project: StoredProject) {
store.loadScene(JSON.parse(JSON.stringify(project.scene)))
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)
@@ -150,9 +160,28 @@ function onKeyDown(e: KeyboardEvent) {
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()
}
@@ -171,23 +200,47 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
<PhSparkle :size="16" weight="fill" class="brand-icon" />
Animatr
</span>
<span class="hint">PoC</span>
</div>
<div class="transport">
<button class="tbtn" title="Zum Anfang" @click="rewind">
<PhSkipBack :size="14" weight="fill" />
</button>
<button
class="tbtn primary"
<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()"
>
<PhPause v-if="store.playing" :size="14" weight="fill" />
<PhPlay v-else :size="14" weight="fill" />
</button>
<button class="tbtn" title="Stop" @click="stop">
<PhStop :size="14" weight="fill" />
</button>
/>
<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">
@@ -216,6 +269,7 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
<DocumentSettings v-model="settingsOpen" />
<ExportDialog v-model="exportOpen" />
<TestOverlay v-model="testsOpen" />
<FullscreenPreview v-model="fullscreenOpen" />
<WelcomeDialog
v-model="welcomeOpen"
@open="openFromRecent"
@@ -223,15 +277,11 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
@import="importFromFile"
@export="exportProjectFile"
/>
<Transition name="fade">
<div v-if="loadError" class="load-error" role="alert">
<strong>Projekt konnte nicht geladen werden.</strong>
<span>{{ loadError }}</span>
</div>
</Transition>
<Transition name="fade">
<div v-if="savedToast" class="saved-toast" role="status">{{ savedToast }}</div>
</Transition>
<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>
@@ -279,49 +329,17 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
color: var(--accent);
}
.hint {
color: var(--text-dim);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 2px 6px;
border: 1px solid var(--border);
border-radius: 10px;
}
.transport {
display: flex;
gap: 4px;
align-items: center;
}
.tbtn {
width: 32px;
height: 30px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid var(--border);
color: var(--text-dim-strong);
border-radius: var(--radius-sm);
}
.tbtn:hover:not(:disabled) {
background: var(--panel-alt);
color: var(--text);
}
.tbtn.primary {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent);
}
.tbtn.primary:hover:not(:disabled) {
background: var(--accent-soft-strong);
color: var(--accent);
.tsep {
width: 1px;
height: 18px;
background: var(--border);
margin: 0 4px;
}
.time {
@@ -398,40 +416,6 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
overflow: hidden;
}
.load-error {
position: fixed;
bottom: 240px;
left: 50%;
transform: translateX(-50%);
z-index: 1500;
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 14px;
background: var(--danger-soft);
color: var(--danger);
border: 1px solid var(--danger);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
font-size: 12px;
max-width: 520px;
}
.saved-toast {
position: fixed;
bottom: 240px;
left: 50%;
transform: translateX(-50%);
z-index: 1500;
padding: 8px 14px;
background: var(--panel);
border: 1px solid var(--border-strong);
color: var(--text);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
font-size: 12px;
}
.project-name {
display: inline-flex;
align-items: center;
@@ -447,13 +431,4 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
text-overflow: ellipsis;
margin-right: 4px;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 140ms var(--ease);
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>