This commit is contained in:
th committed 2026-04-19 22:32:06 +02:00
1 parent fcefd258eb
commit 6b6157d786
23 files changed
+1140 -237

No files matched your search

+30 -20
View File
@@ -24,7 +24,8 @@ 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 { confirmDialog, DialogsHost, IconButton, promptDialog, Toast } from './components/ui'
import { t } from './i18n'
import { useHistory } from './composables/useHistory'
import { usePlaybackLoop } from './composables/usePlaybackLoop'
import {
@@ -140,8 +141,13 @@ 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)
const suggested = t('app.saveDialog.defaultName', { date: new Date().toLocaleDateString() })
const entered = await promptDialog({
title: t('app.saveDialog.title'),
message: t('app.saveDialog.message'),
defaultValue: suggested,
confirmLabel: t('app.saveDialog.confirm'),
})
if (entered === null) return
name = entered.trim() || suggested
}
@@ -152,7 +158,7 @@ async function saveProject() {
currentProjectId.value = rec.id
currentProjectName.value = rec.name
setLastOpenedId(rec.id)
showSavedToast(`"${rec.name}" gespeichert`)
showSavedToast(t('app.savedToast', { name: rec.name }))
}
function exportProjectFile() {
@@ -167,9 +173,12 @@ async function importFromFile() {
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?',
)
const ok = await confirmDialog({
title: t('app.replaceDialog.title'),
message: t('app.replaceDialog.message'),
confirmLabel: t('app.replaceDialog.confirm'),
variant: 'danger',
})
if (!ok) return
}
try {
@@ -285,7 +294,7 @@ onBeforeUnmount(() => {
icon-weight="bold"
size="md"
:disabled="!history.canUndo.value"
title="Rückgängig (Ctrl+Z)"
:title="t('app.undo')"
@click="history.undo"
/>
<IconButton
@@ -293,7 +302,7 @@ onBeforeUnmount(() => {
icon-weight="bold"
size="md"
:disabled="!history.canRedo.value"
title="Wiederholen (Ctrl+Shift+Z)"
:title="t('app.redo')"
@click="history.redo"
/>
<div class="tsep"></div>
@@ -301,7 +310,7 @@ onBeforeUnmount(() => {
:icon="PhSkipBack"
icon-weight="fill"
size="md"
title="Zum Anfang"
:title="t('app.jumpToStart')"
@click="rewind"
/>
<IconButton
@@ -309,14 +318,14 @@ onBeforeUnmount(() => {
icon-weight="fill"
size="md"
variant="primary"
:title="store.playing ? 'Pause (Space)' : 'Play (Space)'"
:title="store.playing ? t('app.pause') : t('app.play')"
@click="store.togglePlay()"
/>
<IconButton
:icon="PhStop"
icon-weight="fill"
size="md"
title="Stop"
:title="t('app.stop')"
@click="stop"
/>
<span class="time">{{ store.playhead.toFixed(2) }}s / {{ store.scene.duration }}s</span>
@@ -325,18 +334,18 @@ onBeforeUnmount(() => {
<span v-if="currentProjectName" class="project-name" :title="currentProjectName">
{{ currentProjectName }}
</span>
<button class="icon-btn" title="Zuletzt verwendete Projekte" @click="welcomeOpen = true">
<button class="icon-btn" :title="t('app.recentProjects')" @click="welcomeOpen = true">
<PhFolderOpen :size="16" />
</button>
<button class="icon-btn" title="Projekt speichern (in Browser-Speicher)" @click="saveProject">
<button class="icon-btn" :title="t('app.saveProject')" @click="saveProject">
<PhFloppyDisk :size="16" />
</button>
<button class="icon-btn" title="Document Settings" @click="settingsOpen = true">
<button class="icon-btn" :title="t('app.documentSettings')" @click="settingsOpen = true">
<PhGearSix :size="16" />
</button>
<button class="export-btn" title="Exportieren" @click="exportOpen = true">
<button class="export-btn" :title="t('app.export')" @click="exportOpen = true">
<PhExport :size="14" weight="bold" />
Export
{{ t('app.export') }}
</button>
</div>
</header>
@@ -345,7 +354,7 @@ onBeforeUnmount(() => {
<div v-if="store.activeClipId" class="scope-footer">
<button class="scope-back" @click="store.exitClip">
<PhArrowLeft :size="12" weight="bold" />
Haupt-Timeline
{{ t('app.mainTimeline') }}
</button>
<span class="scope-current" :title="store.activeClip?.name ?? ''">
<PhFilmStrip :size="12" />
@@ -359,7 +368,7 @@ onBeforeUnmount(() => {
<div
class="tl-resize"
:class="{ resizing }"
title="Timeline-Höhe ziehen"
:title="t('app.dragResize')"
@pointerdown="onTimelineResizeStart"
@pointermove="onTimelineResizeMove"
@pointerup="onTimelineResizeEnd"
@@ -371,6 +380,7 @@ onBeforeUnmount(() => {
<ExportDialog v-model="exportOpen" />
<TestOverlay v-model="testsOpen" />
<FullscreenPreview v-model="fullscreenOpen" />
<DialogsHost />
<WelcomeDialog
v-model="welcomeOpen"
@open="openFromRecent"
@@ -379,7 +389,7 @@ onBeforeUnmount(() => {
@export="exportProjectFile"
/>
<Toast :visible="!!loadError" variant="error">
<strong>Projekt konnte nicht geladen werden.</strong>
<strong>{{ t('app.loadFailed') }}</strong>
<span>{{ loadError }}</span>
</Toast>
<Toast :visible="!!savedToast" variant="info" :message="savedToast ?? ''" />
+63 -74
View File
@@ -16,6 +16,8 @@ import {
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()
@@ -54,10 +56,15 @@ function addSampleCircle() {
store.addLayer(asset.id)
}
function addSampleText() {
const input = prompt('Text?', 'Hallo')
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() || 'Text'
const text = input.trim() || t('sidebar.newTextDialog.fallback')
const asset = store.addAsset({
kind: 'text',
name: text.length > 24 ? text.slice(0, 24) + '…' : text,
@@ -70,20 +77,35 @@ function addSampleText() {
const clips = computed(() => store.scene.clips ?? [])
function addClip() {
const input = prompt('Name des Clips?', 'Clip')
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)
}
function renameClipPrompt(clipId: string, current: string) {
const input = prompt('Neuer Name?', current)
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)
}
function deleteClip(clipId: string) {
if (!confirm('Clip und alle Instanzen wirklich löschen?')) return
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)
}
@@ -149,34 +171,17 @@ const assetIconMap: Record<Asset['kind'], typeof PhSquare> = {
text: PhTextT,
}
const assetsWithUsage = computed(() =>
store.scene.assets.map((a) => ({
asset: a,
inUse: store.scene.layers.some((l) => l.assetId === a.id),
})),
const imageAssets = computed(() =>
store.scene.assets.filter((a): a is Extract<Asset, { kind: 'image' }> => a.kind === 'image'),
)
const THUMB_SIZE = 40
function rectPreviewStyle(asset: Extract<Asset, { kind: 'rect' }>) {
const scale = Math.min(THUMB_SIZE / asset.width, THUMB_SIZE / asset.height) * 0.9
return {
width: `${Math.max(6, asset.width * scale)}px`,
height: `${Math.max(6, asset.height * scale)}px`,
background: asset.fill,
borderRadius: '2px',
} as const
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)
}
function circlePreviewStyle(asset: Extract<Asset, { kind: 'circle' }>) {
const d = Math.min(THUMB_SIZE, asset.radius * 2) * 0.9
return {
width: `${d}px`,
height: `${d}px`,
background: asset.fill,
borderRadius: '50%',
} as const
}
</script>
<template>
@@ -185,21 +190,21 @@ function circlePreviewStyle(asset: Extract<Asset, { kind: 'circle' }>) {
<button class="upload-btn" @click="triggerUpload">
<PhUploadSimple :size="16" />
<span>SVG / Bild hinzufügen</span>
<span>{{ t('sidebar.addFile') }}</span>
</button>
<div class="actions">
<button class="shape-btn" @click="addSampleRect">
<PhSquare :size="14" />
Rect
{{ t('sidebar.shape.rect') }}
</button>
<button class="shape-btn" @click="addSampleCircle">
<PhCircle :size="14" />
Circle
{{ t('sidebar.shape.circle') }}
</button>
<button class="shape-btn" @click="addSampleText">
<PhTextT :size="14" />
Text
{{ t('sidebar.shape.text') }}
</button>
</div>
@@ -214,10 +219,10 @@ function circlePreviewStyle(asset: Extract<Asset, { kind: 'circle' }>) {
<p v-if="uploadError" class="error">{{ uploadError }}</p>
<div class="lib-header">
<h4>Bibliothek</h4>
<h4>{{ t('sidebar.library') }}</h4>
<button
class="thumb-toggle"
:title="showThumbs ? 'Thumbnails ausblenden' : 'Thumbnails anzeigen'"
:title="showThumbs ? t('sidebar.thumbnailsHide') : t('sidebar.thumbnailsShow')"
@click="showThumbs = !showThumbs"
>
<PhImageSquare v-if="showThumbs" :size="13" />
@@ -225,54 +230,38 @@ function circlePreviewStyle(asset: Extract<Asset, { kind: 'circle' }>) {
</button>
</div>
<ul class="assets" :class="{ compact: !showThumbs }">
<li v-for="item in assetsWithUsage" :key="item.asset.id" :class="{ unused: !item.inUse }">
<li
v-for="asset in imageAssets"
:key="asset.id"
draggable="true"
@dragstart="onAssetDragStart($event, asset.id)"
>
<template v-if="showThumbs">
<div class="thumb">
<img
v-if="item.asset.kind === 'image'"
:src="item.asset.src"
:alt="item.asset.name"
draggable="false"
/>
<div
v-else-if="item.asset.kind === 'rect'"
class="shape-preview"
:style="rectPreviewStyle(item.asset)"
/>
<div
v-else-if="item.asset.kind === 'circle'"
class="shape-preview"
:style="circlePreviewStyle(item.asset)"
/>
<span
v-else-if="item.asset.kind === 'text'"
class="text-preview"
:style="{ color: item.asset.fill }"
>Aa</span>
<img :src="asset.src" :alt="asset.name" draggable="false" />
</div>
</template>
<template v-else>
<component :is="assetIconMap[item.asset.kind]" :size="14" class="asset-icon" />
<component :is="assetIconMap[asset.kind]" :size="14" class="asset-icon" />
</template>
<div class="meta">
<span class="name" :title="item.asset.name">{{ item.asset.name }}</span>
<span v-if="showThumbs" class="sub">{{ item.asset.kind }}</span>
<span class="name" :title="asset.name">{{ asset.name }}</span>
<span v-if="showThumbs" class="sub">{{ t('sidebar.imageLabel') }}</span>
</div>
<button
v-if="!item.inUse"
class="add-layer"
title="Als Layer einfügen"
@click="store.addLayer(item.asset.id)"
:title="t('sidebar.addLayerTooltip')"
@click="store.addLayer(asset.id)"
>
<PhPlus :size="12" weight="bold" />
</button>
</li>
<li v-if="!store.scene.assets.length" class="empty">keine Assets</li>
<li v-if="!imageAssets.length" class="empty">{{ t('sidebar.noImages') }}</li>
</ul>
<div class="lib-header clips-header">
<h4>Clips</h4>
<button class="thumb-toggle" title="Neuen Clip anlegen" @click="addClip">
<h4>{{ t('sidebar.clips') }}</h4>
<button class="thumb-toggle" :title="t('sidebar.createClipTooltip')" @click="addClip">
<PhPlus :size="12" weight="bold" />
</button>
</div>
@@ -292,23 +281,23 @@ function circlePreviewStyle(asset: Extract<Asset, { kind: 'circle' }>) {
<div class="clip-meta">
<span class="clip-name" :title="clip.name">{{ clip.name }}</span>
<span class="clip-sub">
{{ clip.layers.length }} Layer · {{ clip.duration.toFixed(1) }}s
{{ t('sidebar.clipLayerCount', { n: clip.layers.length }) }} · {{ clip.duration.toFixed(1) }}s
</span>
</div>
<div class="clip-actions">
<button
class="clip-btn"
title="Umbenennen"
:title="t('sidebar.renameTooltip')"
@click.stop="renameClipPrompt(clip.id, clip.name)"
>
<PhPencilSimple :size="11" />
</button>
<button class="clip-btn danger" title="Löschen" @click.stop="deleteClip(clip.id)">
<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">keine Clips</li>
<li v-if="!clips.length" class="empty">{{ t('sidebar.noClips') }}</li>
</ul>
</div>
</template>
+34 -6
View File
@@ -6,6 +6,7 @@ import { useSceneStore } from '@/stores/scene'
import { sampleLayer } from '@/lib/interpolate'
import { loadImage } from '@/lib/fileLoader'
import type { AnimatableProps, Asset, Layer } from '@/types/scene'
import { t } from '@/i18n'
const store = useSceneStore()
@@ -606,6 +607,29 @@ function onContainerPointerUp(e: PointerEvent) {
if (container.value) container.value.style.cursor = ''
}
function onContainerDragOver(e: DragEvent) {
if (!e.dataTransfer) return
if (!e.dataTransfer.types.includes('application/x-animatr-asset')) return
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
}
function onContainerDrop(e: DragEvent) {
const assetId = e.dataTransfer?.getData('application/x-animatr-asset')
if (!assetId) return
e.preventDefault()
const rect = container.value?.getBoundingClientRect()
if (!rect) return
const sx = e.clientX - rect.left
const sy = e.clientY - rect.top
const worldX = (sx - viewX.value) / zoom.value
const worldY = (sy - viewY.value) / zoom.value
const layer = store.addLayer(assetId)
if (layer) {
store.upsertKeyframe(layer.id, 0, { x: worldX, y: worldY })
}
}
function zoomBy(factor: number) {
const cw = containerSize.value.width
const ch = containerSize.value.height
@@ -638,6 +662,8 @@ onMounted(() => {
container.value.addEventListener('pointermove', onContainerPointerMove)
container.value.addEventListener('pointerup', onContainerPointerUp)
container.value.addEventListener('pointercancel', onContainerPointerUp)
container.value.addEventListener('dragover', onContainerDragOver)
container.value.addEventListener('drop', onContainerDrop)
})
onBeforeUnmount(() => {
@@ -649,6 +675,8 @@ onBeforeUnmount(() => {
container.value?.removeEventListener('pointermove', onContainerPointerMove)
container.value?.removeEventListener('pointerup', onContainerPointerUp)
container.value?.removeEventListener('pointercancel', onContainerPointerUp)
container.value?.removeEventListener('dragover', onContainerDragOver)
container.value?.removeEventListener('drop', onContainerDrop)
})
</script>
@@ -736,21 +764,21 @@ onBeforeUnmount(() => {
</v-stage>
<div class="zoom-controls" @pointerdown.stop>
<button class="zc-btn" title="Zoom out" @click="zoomBy(1 / 1.25)">−</button>
<button class="zc-label" title="Fit to view" @click="fitToContainer">
<button class="zc-btn" :title="t('canvas.zoomOut')" @click="zoomBy(1 / 1.25)">−</button>
<button class="zc-label" :title="t('canvas.fitToView')" @click="fitToContainer">
{{ zoomPercent }}%
</button>
<button class="zc-btn" title="Zoom in" @click="zoomBy(1.25)">+</button>
<button class="zc-btn" :title="t('canvas.zoomIn')" @click="zoomBy(1.25)">+</button>
<div class="zc-sep"></div>
<button class="zc-btn wide" title="Zoom 1:1" @click="zoomToOne">1:1</button>
<button class="zc-btn" title="An Viewport anpassen" @click="fitToContainer">
<button class="zc-btn wide" :title="t('canvas.zoomOneToOne')" @click="zoomToOne">1:1</button>
<button class="zc-btn" :title="t('canvas.fitToView')" @click="fitToContainer">
<PhCornersIn :size="13" />
</button>
<div class="zc-sep"></div>
<button
class="zc-btn crop-btn"
:class="{ active: cropObjects }"
:title="cropObjects ? 'Alles zeigen' : 'Nur Bühnenausschnitt zeigen (crop)'"
:title="cropObjects ? t('canvas.cropOff') : t('canvas.cropOn')"
@click="cropObjects = !cropObjects"
>
<PhCrop :size="13" />
+18 -17
View File
@@ -5,6 +5,7 @@ import { useSceneStore } from '@/stores/scene'
import { useTheme } from '@/composables/useTheme'
import type { Theme } from '@/composables/useTheme'
import { Modal } from '@/components/ui'
import { t } from '@/i18n'
defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
@@ -15,9 +16,9 @@ const { theme, setTheme } = useTheme()
type TabId = 'document' | 'timeline' | 'theme'
const tabs: { id: TabId; label: string; icon: typeof PhFrameCorners }[] = [
{ id: 'document', label: 'Document', icon: PhFrameCorners },
{ id: 'timeline', label: 'Timeline', icon: PhClock },
{ id: 'theme', label: 'UI Theme', icon: PhPalette },
{ id: 'document', label: t('settings.tab.document'), icon: PhFrameCorners },
{ id: 'timeline', label: t('settings.tab.timeline'), icon: PhClock },
{ id: 'theme', label: t('settings.tab.theme'), icon: PhPalette },
]
const activeTab = ref<TabId>('document')
@@ -71,7 +72,7 @@ function applyTheme(t: Theme) {
<template>
<Modal
:model-value="modelValue"
title="Settings"
:title="t('settings.title')"
size="lg"
@update:model-value="emit('update:modelValue', $event)"
>
@@ -91,14 +92,14 @@ function applyTheme(t: Theme) {
<div class="content">
<section v-if="activeTab === 'document'">
<h3>Canvas</h3>
<h3>{{ t('settings.canvas') }}</h3>
<div class="row">
<label>
Width
{{ t('settings.width') }}
<input type="number" min="1" step="1" v-model.number="width" />
</label>
<label>
Height
{{ t('settings.height') }}
<input type="number" min="1" step="1" v-model.number="height" />
</label>
</div>
@@ -114,7 +115,7 @@ function applyTheme(t: Theme) {
</button>
</div>
<label class="stacked">
Background
{{ t('settings.background') }}
<div class="color-row">
<input
type="color"
@@ -128,24 +129,24 @@ function applyTheme(t: Theme) {
</section>
<section v-else-if="activeTab === 'timeline'">
<h3>Timeline</h3>
<h3>{{ t('settings.tab.timeline') }}</h3>
<div class="row">
<label>
Duration (s)
{{ t('settings.duration') }}
<input type="number" min="0.1" step="0.1" v-model.number="duration" />
</label>
<label>
FPS
{{ t('settings.fps') }}
<input type="number" min="1" step="1" v-model.number="fps" />
</label>
</div>
<p class="hint">
Die Timeline-Länge setzt die Ruler-Skala. Playback stoppt am letzten gesetzten Keyframe.
{{ t('settings.timelineHint') }}
</p>
</section>
<section v-else-if="activeTab === 'theme'">
<h3>UI Theme</h3>
<h3>{{ t('settings.tab.theme') }}</h3>
<div class="theme-grid">
<button
class="theme-card"
@@ -158,7 +159,7 @@ function applyTheme(t: Theme) {
</div>
<div class="theme-label">
<PhMoon :size="14" />
<span>Dark</span>
<span>{{ t('settings.themeDark') }}</span>
</div>
</button>
<button
@@ -172,16 +173,16 @@ function applyTheme(t: Theme) {
</div>
<div class="theme-label">
<PhSun :size="14" />
<span>Light</span>
<span>{{ t('settings.themeLight') }}</span>
</div>
</button>
</div>
<p class="hint">Gespeichert im Browser, beim nächsten Öffnen wiederverwendet.</p>
<p class="hint">{{ t('settings.themeHint') }}</p>
</section>
</div>
</div>
<template #footer>
<button class="primary" @click="close">Fertig</button>
<button class="primary" @click="close">{{ t('settings.done') }}</button>
</template>
</Modal>
</template>
+17 -16
View File
@@ -12,6 +12,7 @@ import { downloadExampleHtml, downloadRuntime, downloadSceneExport } from '@/lib
import { downloadSceneAsVideo, pickMimeType } from '@/lib/videoExport'
import { downloadFromServer } from '@/lib/serverExport'
import { Modal } from '@/components/ui'
import { t } from '@/i18n'
defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
@@ -53,7 +54,7 @@ async function exportWebm() {
progress.value = {
progress: 0,
controller,
label: 'Rendering WebM (Browser, Realtime)…',
label: t('export.labelWebm'),
error: null,
}
store.pause()
@@ -81,7 +82,7 @@ async function exportMp4Server() {
progress.value = {
progress: 0,
controller,
label: 'Rendering MP4 (Server)…',
label: t('export.labelMp4'),
error: null,
}
store.pause()
@@ -122,7 +123,7 @@ function onUpdate(v: boolean) {
<template>
<Modal
:model-value="modelValue"
title="Export"
:title="t('export.title')"
size="md"
:dismissable="!isLocked"
:close-on-backdrop="!isLocked"
@@ -135,9 +136,9 @@ function onUpdate(v: boolean) {
<div class="opt-text">
<div class="opt-title">
animatr-runtime.js
<span class="badge">1× pro Seite</span>
<span class="badge">{{ t('export.runtimeBadge') }}</span>
</div>
<div class="opt-desc">Engine — einmal einbinden, beliebig viele Szenen teilen sich die Runtime</div>
<div class="opt-desc">{{ t('export.runtimeDesc') }}</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
@@ -147,9 +148,9 @@ function onUpdate(v: boolean) {
<div class="opt-text">
<div class="opt-title">
animation.js
<span class="badge">Szene</span>
<span class="badge">{{ t('export.sceneBadge') }}</span>
</div>
<div class="opt-desc">Nur Szenen-Daten — benötigt geladene Runtime</div>
<div class="opt-desc">{{ t('export.sceneDesc') }}</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
@@ -157,8 +158,8 @@ function onUpdate(v: boolean) {
<button class="opt" @click="exportHtml">
<div class="opt-icon"><PhFileHtml :size="20" /></div>
<div class="opt-text">
<div class="opt-title">Beispiel HTML</div>
<div class="opt-desc">Standalone HTML — Browser öffnen und fertig</div>
<div class="opt-title">{{ t('export.exampleTitle') }}</div>
<div class="opt-desc">{{ t('export.exampleDesc') }}</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
@@ -168,9 +169,9 @@ function onUpdate(v: boolean) {
<div class="opt-text">
<div class="opt-title">
MP4 Video
<span class="badge accent">Server · Frame-exakt</span>
<span class="badge accent">{{ t('export.mp4Badge') }}</span>
</div>
<div class="opt-desc">H.264, beliebige Auflösung — benötigt Render-Server</div>
<div class="opt-desc">{{ t('export.mp4Desc') }}</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
@@ -180,10 +181,10 @@ function onUpdate(v: boolean) {
<div class="opt-text">
<div class="opt-title">
WebM Video
<span class="badge">Browser · Realtime</span>
<span class="badge">{{ t('export.webmBadge') }}</span>
</div>
<div class="opt-desc">
{{ videoSupported ? 'Ohne Server — Aufnahme in Echtzeit' : 'Browser unterstützt MediaRecorder nicht' }}
{{ videoSupported ? t('export.webmDesc') : t('export.webmNotSupported') }}
</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
@@ -200,15 +201,15 @@ function onUpdate(v: boolean) {
<div class="r-bar-fill" :style="{ width: progress.progress * 100 + '%' }"></div>
</div>
<div class="r-actions">
<button class="secondary" @click="cancel">Abbrechen</button>
<button class="secondary" @click="cancel">{{ t('export.cancel') }}</button>
</div>
</div>
<div v-else class="error-view">
<div class="error-title">Export fehlgeschlagen</div>
<div class="error-title">{{ t('export.failed') }}</div>
<div class="error-msg">{{ progress.error }}</div>
<div class="error-actions">
<button class="primary" @click="dismissError">Zurück</button>
<button class="primary" @click="dismissError">{{ t('export.back') }}</button>
</div>
</div>
</Modal>
+4 -3
View File
@@ -3,6 +3,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { PhPause, PhPlay, PhSkipBack, PhX } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { drawSceneFrame, loadSceneImages } from '@/lib/render'
import { t } from '@/i18n'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
@@ -291,16 +292,16 @@ const timeLabel = computed(() => {
<div class="fs-progress-handle" :style="{ left: progressPct + '%' }"></div>
</div>
<div class="fs-toolbar">
<button class="fs-btn" :title="playing ? 'Pause' : 'Play'" @click="toggle">
<button class="fs-btn" :title="playing ? t('fullscreen.pause') : t('fullscreen.play')" @click="toggle">
<PhPause v-if="playing" :size="15" weight="fill" />
<PhPlay v-else :size="15" weight="fill" />
</button>
<button class="fs-btn" title="Zum Anfang" @click="rewind">
<button class="fs-btn" :title="t('fullscreen.jumpToStart')" @click="rewind">
<PhSkipBack :size="15" weight="fill" />
</button>
<span class="fs-time">{{ timeLabel }}</span>
<div class="fs-spacer"></div>
<button class="fs-btn close" title="Schließen (Esc)" @click="close">
<button class="fs-btn close" :title="t('fullscreen.close')" @click="close">
<PhX :size="15" weight="bold" />
</button>
</div>
+5 -4
View File
@@ -5,6 +5,7 @@ import { useSceneStore } from '@/stores/scene'
import { sampleLayer } from '@/lib/interpolate'
import type { AnimatableProps } from '@/types/scene'
import RangeSlider from './RangeSlider.vue'
import { t } from '@/i18n'
const formatPercent = (v: number) => `${Math.round(v * 100)}%`
@@ -86,10 +87,10 @@ function removeKeyframeHere() {
<div class="kf-row" :class="{ on: hasKeyframeHere }">
<PhDiamond :size="11" :weight="hasKeyframeHere ? 'fill' : 'regular'" class="kf-dot" />
<span class="kf-label">
{{ hasKeyframeHere ? 'Keyframe' : 'Interpoliert' }}
{{ hasKeyframeHere ? t('properties.keyframeLabel') : t('properties.interpolated') }}
<span class="kf-time">@ {{ store.playhead.toFixed(2) }}s</span>
</span>
<button v-if="hasKeyframeHere" class="kf-btn" title="Keyframe hier löschen" @click="removeKeyframeHere">
<button v-if="hasKeyframeHere" class="kf-btn" :title="t('properties.removeKeyframe')" @click="removeKeyframeHere">
<PhX :size="12" weight="bold" />
</button>
</div>
@@ -121,10 +122,10 @@ function removeKeyframeHere() {
<button class="danger-btn" @click="store.removeLayer(selected.id)">
<PhTrash :size="13" />
Layer löschen
{{ t('properties.deleteLayer') }}
</button>
</template>
<p v-else class="hint">Kein Layer ausgewählt.</p>
<p v-else class="hint">{{ t('properties.noLayer') }}</p>
</div>
</template>
+373 -49
View File
@@ -14,6 +14,8 @@ import {
import { useSceneStore } from '@/stores/scene'
import { sampleLayer } from '@/lib/interpolate'
import { snapToCandidates } from '@/lib/timelineSnap'
import { confirmDialog } from '@/components/ui'
import { t } from '@/i18n'
import type {
AnimatableProps,
Clip,
@@ -263,8 +265,8 @@ const tracks = computed<TimelineTrack[]>(() => {
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex })
} else {
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex })
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.positionKeyframes ?? [], kind: 'position', label: 'Position', isParent: false, orderIndex })
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.rotationKeyframes ?? [], kind: 'rotation', label: 'Rotation', isParent: false, orderIndex })
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.positionKeyframes ?? [], kind: 'position', label: t('timeline.position'), isParent: false, orderIndex })
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.rotationKeyframes ?? [], kind: 'rotation', label: t('timeline.rotation'), isParent: false, orderIndex })
}
})
return out
@@ -279,14 +281,14 @@ const tracks = computed<TimelineTrack[]>(() => {
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex })
} else {
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex })
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.positionKeyframes ?? [], kind: 'position', label: 'Position', isParent: false, orderIndex })
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.rotationKeyframes ?? [], kind: 'rotation', label: 'Rotation', isParent: false, orderIndex })
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.positionKeyframes ?? [], kind: 'position', label: t('timeline.position'), isParent: false, orderIndex })
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.rotationKeyframes ?? [], kind: 'rotation', label: t('timeline.rotation'), isParent: false, orderIndex })
}
return
}
const ct = clipTrackById.value.get(id)
if (ct) {
out.push({ type: 'clip', clipTrackId: ct.id, name: ct.name ?? 'Clip-Spur', orderIndex })
out.push({ type: 'clip', clipTrackId: ct.id, name: ct.name ?? t('timeline.clipTrackName'), orderIndex })
}
})
return out
@@ -316,33 +318,110 @@ function isDropAfter(track: TimelineTrack, nextTrack: TimelineTrack | undefined)
return true
}
interface SelectionKey {
layerId: string
kind: 'main' | 'position' | 'rotation'
keyframeId: string
}
const selectedKfs = ref<SelectionKey[]>([])
function selectionKey(k: SelectionKey): string {
return `${k.layerId}:${k.kind}:${k.keyframeId}`
}
const selectedKfSet = computed(() => {
const s = new Set<string>()
for (const k of selectedKfs.value) s.add(selectionKey(k))
return s
})
function isKfSelected(layerId: string, kind: 'main' | 'position' | 'rotation', keyframeId: string): boolean {
return selectedKfSet.value.has(`${layerId}:${kind}:${keyframeId}`)
}
function clearKfSelection() {
selectedKfs.value = []
}
interface SelectionSnapshotEntry {
layerId: string
keyframeId: string
kind: 'main' | 'position' | 'rotation'
initialTime: number
}
function snapshotSelection(): SelectionSnapshotEntry[] {
const out: SelectionSnapshotEntry[] = []
for (const s of selectedKfs.value) {
const layer = store.activeLayers.find((l) => l.id === s.layerId)
if (!layer) continue
const arr =
s.kind === 'position'
? layer.positionKeyframes
: s.kind === 'rotation'
? layer.rotationKeyframes
: layer.keyframes
const kf = arr?.find((k) => k.id === s.keyframeId)
if (kf) out.push({ ...s, initialTime: kf.time })
}
return out
}
interface KfDrag {
layerId: string
keyframeId: string
kind: 'main' | 'position' | 'rotation'
lane: HTMLElement
pointerId: number
startX: number
moved: boolean
initialTime: number
snapshot: SelectionSnapshotEntry[]
}
const kfDrag = ref<KfDrag | null>(null)
function onKeyframePointerDown(e: PointerEvent, layerId: string, keyframeId: string) {
function onKeyframePointerDown(
e: PointerEvent,
layerId: string,
keyframeId: string,
kind: 'main' | 'position' | 'rotation',
) {
if (e.button !== 0) return
const layer = store.activeLayers.find((l) => l.id === layerId)
if (layer?.locked) return
const kfEl = e.currentTarget as HTMLElement
const lane = kfEl.parentElement
if (!lane) return
// If this keyframe isn't in the current selection, replace selection with just this one.
const key = `${layerId}:${kind}:${keyframeId}`
if (!selectedKfSet.value.has(key)) {
selectedKfs.value = [{ layerId, kind, keyframeId }]
}
const arr =
kind === 'position'
? layer?.positionKeyframes
: kind === 'rotation'
? layer?.rotationKeyframes
: layer?.keyframes
const srcKf = arr?.find((k) => k.id === keyframeId)
const initialTime = srcKf?.time ?? 0
e.stopPropagation()
kfEl.setPointerCapture(e.pointerId)
kfDrag.value = {
layerId,
keyframeId,
kind,
lane,
pointerId: e.pointerId,
startX: e.clientX,
moved: false,
initialTime,
snapshot: snapshotSelection(),
}
store.selectLayer(layerId)
}
@@ -355,9 +434,50 @@ function onKeyframePointerMove(e: PointerEvent) {
const rect = d.lane.getBoundingClientRect()
if (rect.width <= 0) return
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const newTime = snapTime(ratio * duration.value, d.keyframeId)
store.setKeyframeTime(d.layerId, d.keyframeId, newTime)
store.setPlayhead(newTime)
const rawNewTime = ratio * duration.value
// For snapping, ignore all currently selected kfs so we don't snap to ourselves
const ignoreIds = new Set<string>()
for (const s of d.snapshot) ignoreIds.add(s.keyframeId)
const snappedPrimary = snapToCandidates(
rawNewTime,
allKeyframeTimesExcluding(ignoreIds),
{
enabled: snapEnabled.value,
pxPerSec: effectivePxPerSec.value,
pixelThreshold: SNAP_PX,
duration: duration.value,
},
)
// Group delta clamped so nothing leaves [0, duration]
let delta = snappedPrimary - d.initialTime
let minDelta = -Infinity
let maxDelta = Infinity
for (const s of d.snapshot) {
if (-s.initialTime > minDelta) minDelta = -s.initialTime
if (duration.value - s.initialTime < maxDelta) maxDelta = duration.value - s.initialTime
}
delta = Math.max(minDelta, Math.min(maxDelta, delta))
if (d.snapshot.length <= 1) {
store.setKeyframeTime(d.layerId, d.keyframeId, d.initialTime + delta)
} else {
for (const s of d.snapshot) {
store.setKeyframeTime(s.layerId, s.keyframeId, s.initialTime + delta)
}
}
store.setPlayhead(d.initialTime + delta)
}
function allKeyframeTimesExcluding(ignoreIds: Set<string>): number[] {
const out: number[] = []
for (const layer of store.activeLayers) {
for (const k of layer.keyframes) if (!ignoreIds.has(k.id)) out.push(k.time)
if (layer.positionKeyframes)
for (const k of layer.positionKeyframes) if (!ignoreIds.has(k.id)) out.push(k.time)
if (layer.rotationKeyframes)
for (const k of layer.rotationKeyframes) if (!ignoreIds.has(k.id)) out.push(k.time)
}
return out
}
function onKeyframePointerUp(e: PointerEvent) {
@@ -404,10 +524,10 @@ function segmentStyle(seg: KfSegment) {
}
const EASING_OPTIONS: { value: Easing; label: string }[] = [
{ value: 'linear', label: 'Linear' },
{ value: 'easeIn', label: 'Ease In' },
{ value: 'easeOut', label: 'Ease Out' },
{ value: 'easeInOut', label: 'Ease In-Out' },
{ value: 'linear', label: t('timeline.easingLinear') },
{ value: 'easeIn', label: t('timeline.easingEaseIn') },
{ value: 'easeOut', label: t('timeline.easingEaseOut') },
{ value: 'easeInOut', label: t('timeline.easingEaseInOut') },
]
interface KfMenuState {
@@ -498,16 +618,16 @@ interface TrackMenuState {
}
const TRACK_COLORS: Array<{ value: string | null; label: string }> = [
{ value: null, label: 'Keine' },
{ value: '#ff6b6b', label: 'Rot' },
{ value: '#ffa94d', label: 'Orange' },
{ value: '#ffd43b', label: 'Gelb' },
{ value: '#51cf66', label: 'Grün' },
{ value: '#4dc4ff', label: 'Türkis' },
{ value: '#6ea8ff', label: 'Blau' },
{ value: '#b197fc', label: 'Lila' },
{ value: '#ff8cc6', label: 'Pink' },
{ value: '#adb5bd', label: 'Grau' },
{ value: null, label: t('timeline.colors.none') },
{ value: '#ff6b6b', label: t('timeline.colors.red') },
{ value: '#ffa94d', label: t('timeline.colors.orange') },
{ value: '#ffd43b', label: t('timeline.colors.yellow') },
{ value: '#51cf66', label: t('timeline.colors.green') },
{ value: '#4dc4ff', label: t('timeline.colors.cyan') },
{ value: '#6ea8ff', label: t('timeline.colors.blue') },
{ value: '#b197fc', label: t('timeline.colors.purple') },
{ value: '#ff8cc6', label: t('timeline.colors.pink') },
{ value: '#adb5bd', label: t('timeline.colors.gray') },
]
const dragTrackId = ref<string | null>(null)
@@ -559,6 +679,105 @@ function onTrackDragEnd() {
dropIndex.value = null
}
interface BoxSelectState {
pointerId: number
originClientX: number
originClientY: number
currentClientX: number
currentClientY: number
tracksEl: HTMLElement
moved: boolean
}
const boxSelect = ref<BoxSelectState | null>(null)
const boxRectStyle = computed(() => {
const b = boxSelect.value
if (!b || !b.moved) return null
const tRect = b.tracksEl.getBoundingClientRect()
const left = Math.min(b.originClientX, b.currentClientX) - tRect.left
const top = Math.min(b.originClientY, b.currentClientY) - tRect.top
const width = Math.abs(b.currentClientX - b.originClientX)
const height = Math.abs(b.currentClientY - b.originClientY)
return {
left: left + 'px',
top: top + 'px',
width: width + 'px',
height: height + 'px',
}
})
function onTracksPointerDown(e: PointerEvent) {
if (e.button !== 0) return
const target = e.target as HTMLElement | null
if (!target) return
if (
target.closest(
'.kf, .clip-block, .clip-handle, .track-name, .drag-handle, .add-clip-btn, .playhead',
)
) {
return
}
const tracksEl = e.currentTarget as HTMLElement
tracksEl.setPointerCapture(e.pointerId)
boxSelect.value = {
pointerId: e.pointerId,
originClientX: e.clientX,
originClientY: e.clientY,
currentClientX: e.clientX,
currentClientY: e.clientY,
tracksEl,
moved: false,
}
}
function onTracksPointerMove(e: PointerEvent) {
const b = boxSelect.value
if (!b || b.pointerId !== e.pointerId) return
b.currentClientX = e.clientX
b.currentClientY = e.clientY
if (!b.moved) {
const dx = b.currentClientX - b.originClientX
const dy = b.currentClientY - b.originClientY
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) b.moved = true
}
}
function onTracksPointerUp(e: PointerEvent) {
const b = boxSelect.value
if (!b || b.pointerId !== e.pointerId) return
if (b.tracksEl.hasPointerCapture(e.pointerId)) b.tracksEl.releasePointerCapture(e.pointerId)
if (b.moved) {
commitBoxSelection(b)
} else {
// bare click on empty space → clear selection
clearKfSelection()
}
boxSelect.value = null
}
function commitBoxSelection(b: BoxSelectState) {
const left = Math.min(b.originClientX, b.currentClientX)
const right = Math.max(b.originClientX, b.currentClientX)
const top = Math.min(b.originClientY, b.currentClientY)
const bottom = Math.max(b.originClientY, b.currentClientY)
const nodes = b.tracksEl.querySelectorAll<HTMLElement>('.kf[data-kf-id]')
const next: SelectionKey[] = []
for (const node of Array.from(nodes)) {
const r = node.getBoundingClientRect()
const cx = r.left + r.width / 2
const cy = r.top + r.height / 2
if (cx < left || cx > right || cy < top || cy > bottom) continue
const layerId = node.dataset.layerId
const kind = node.dataset.kind as 'main' | 'position' | 'rotation' | undefined
const keyframeId = node.dataset.kfId
if (!layerId || !kind || !keyframeId) continue
next.push({ layerId, kind, keyframeId })
}
selectedKfs.value = next
}
function pixelTimeFromEvent(e: DragEvent | PointerEvent, el: HTMLElement): number {
const rect = el.getBoundingClientRect()
if (rect.width <= 0) return 0
@@ -665,10 +884,10 @@ function trimTooltip(ci: ClipInstance): string {
const name = clipById.value.get(ci.clipId)?.name ?? '?'
const dur = visibleDurationOf(ci).toFixed(2)
const trims: string[] = []
if ((ci.trimStart ?? 0) > 0) trims.push(`Start -${(ci.trimStart ?? 0).toFixed(2)}s`)
if ((ci.trimEnd ?? 0) > 0) trims.push(`Ende -${(ci.trimEnd ?? 0).toFixed(2)}s`)
const suffix = trims.length ? ` · ${trims.join(' · ')}` : ''
return `${name} · ${dur}s${suffix} · Kanten ziehen zum Verkürzen, Doppelklick zum Öffnen`
if ((ci.trimStart ?? 0) > 0) trims.push(t('timeline.trimStart', { value: (ci.trimStart ?? 0).toFixed(2) }))
if ((ci.trimEnd ?? 0) > 0) trims.push(t('timeline.trimEnd', { value: (ci.trimEnd ?? 0).toFixed(2) }))
const trimsStr = trims.length ? ` · ${trims.join(' · ')}` : ''
return t('timeline.trimTooltip', { name, duration: dur, trims: trimsStr })
}
interface ClipTrimState {
@@ -734,9 +953,21 @@ function onClipBlockDblClick(ci: ClipInstance) {
store.enterClip(ci.clipId)
}
function onClipBlockContextMenu(e: MouseEvent, ci: ClipInstance) {
async function onClipBlockContextMenu(e: MouseEvent, ci: ClipInstance) {
e.preventDefault()
if (!confirm('Clip-Instanz entfernen?')) return
const clip = clipById.value.get(ci.clipId)
const ok = await confirmDialog({
title: t('timeline.removeInstanceDialog.title'),
message: clip
? t('timeline.removeInstanceDialog.message', {
name: clip.name,
time: ci.startTime.toFixed(2),
})
: t('timeline.removeInstanceDialog.generic'),
confirmLabel: t('timeline.removeInstanceDialog.confirm'),
variant: 'danger',
})
if (!ok) return
store.removeClipInstance(ci.id)
}
@@ -829,6 +1060,12 @@ function onKeyDown(e: KeyboardEvent) {
}
}
function onDocumentKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape' && selectedKfs.value.length) {
selectedKfs.value = []
}
}
watch([menu, trackMenu], ([m, tm]) => {
if (m || tm) {
document.addEventListener('pointerdown', onDocumentPointerDown, true)
@@ -874,6 +1111,7 @@ onMounted(() => {
viewportWidth.value = rect.width
})
ro.observe(scrollEl.value)
document.addEventListener('keydown', onDocumentKeyDown)
})
onBeforeUnmount(() => {
@@ -881,6 +1119,7 @@ onBeforeUnmount(() => {
ro = null
document.removeEventListener('pointerdown', onDocumentPointerDown, true)
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('keydown', onDocumentKeyDown)
})
</script>
@@ -890,7 +1129,7 @@ onBeforeUnmount(() => {
<div class="tl-content" :style="{ width: totalWidth + 'px' }">
<div class="scrub-bar">
<div class="name-spacer">
<div class="mode-toggle" title="Zeit-Einheit umschalten">
<div class="mode-toggle" :title="t('timeline.toggleTimeUnit')">
<button
class="mode-btn"
:class="{ active: displayMode === 'seconds' }"
@@ -905,7 +1144,7 @@ onBeforeUnmount(() => {
<button
class="snap-btn"
:class="{ active: snapEnabled }"
:title="snapEnabled ? 'Snapping aktiv (Playhead & Keyframes rasten aneinander ein)' : 'Snapping aus'"
:title="snapEnabled ? t('timeline.snapOn') : t('timeline.snapOff')"
@click="snapEnabled = !snapEnabled"
>
<PhMagnet :size="12" :weight="snapEnabled ? 'fill' : 'regular'" />
@@ -935,7 +1174,14 @@ onBeforeUnmount(() => {
</span>
</div>
</div>
<div class="tracks">
<div
class="tracks"
@pointerdown="onTracksPointerDown"
@pointermove="onTracksPointerMove"
@pointerup="onTracksPointerUp"
@pointercancel="onTracksPointerUp"
>
<div v-if="boxRectStyle" class="box-select-rect" :style="boxRectStyle"></div>
<template v-for="(track, idx) in tracks" :key="track.type === 'layer' ? `l:${track.layerId}:${track.kind}` : `c:${track.clipTrackId}`">
<!-- Layer track -->
<div
@@ -1000,14 +1246,20 @@ onBeforeUnmount(() => {
v-for="kf in track.kfs"
:key="kf.id"
class="kf"
:class="{ hold: kf.hold }"
:class="{
hold: kf.hold,
selected: isKfSelected(track.layerId, track.kind, kf.id),
}"
:style="{ left: pxFromTime(kf.time) + 'px' }"
:data-layer-id="track.layerId"
:data-kind="track.kind"
:data-kf-id="kf.id"
:title="
kf.hold
? `Still Frame ${formatKfTime(kf.time)} — hält bis zum nächsten Keyframe`
: `Keyframe ${formatKfTime(kf.time)} — ziehen zum Verschieben, Rechtsklick für Optionen`
? t('timeline.stillFrameTooltip', { time: formatKfTime(kf.time) })
: t('timeline.keyframeTooltip', { time: formatKfTime(kf.time) })
"
@pointerdown="onKeyframePointerDown($event, track.layerId, kf.id)"
@pointerdown="onKeyframePointerDown($event, track.layerId, kf.id, track.kind)"
@pointermove="onKeyframePointerMove"
@pointerup="onKeyframePointerUp"
@pointercancel="onKeyframePointerUp"
@@ -1104,11 +1356,11 @@ onBeforeUnmount(() => {
</div>
</div>
</template>
<p v-if="!tracks.length" class="empty">Noch keine Tracks.</p>
<p v-if="!tracks.length" class="empty">{{ t('timeline.noTracks') }}</p>
<div v-if="!inClipEdit" class="track clip-track add-clip-track">
<button class="add-clip-btn" title="Weitere Clip-Spur" @click="addClipTrack">
<button class="add-clip-btn" :title="t('timeline.addClipTrackTooltip')" @click="addClipTrack">
<PhPlus :size="11" weight="bold" />
<span>Clip-Spur hinzufügen</span>
<span>{{ t('timeline.addClipTrack') }}</span>
</button>
</div>
</div>
@@ -1141,31 +1393,31 @@ onBeforeUnmount(() => {
</span>
{{ opt.label }}
</button>
<div v-if="menu.isFirst" class="kf-menu-note">Erster Keyframe — Easing ohne Effekt</div>
<div v-else-if="menu.isHeld" class="kf-menu-note">Still Frame — Easing deaktiviert</div>
<div v-if="menu.isFirst" class="kf-menu-note">{{ t('timeline.firstKfHint') }}</div>
<div v-else-if="menu.isHeld" class="kf-menu-note">{{ t('timeline.holdDisabledHint') }}</div>
</div>
<div class="kf-menu-divider"></div>
<button
class="kf-menu-item"
:class="{ active: menu.isHeld }"
:disabled="menu.isLast"
:title="menu.isLast ? 'Letzter Keyframe — Still Frame ohne Effekt' : ''"
:title="menu.isLast ? t('timeline.lastKfHint') : ''"
@click="toggleHold"
>
<span class="check">
<PhCheck v-if="menu.isHeld" :size="12" weight="bold" />
</span>
<PhPauseCircle :size="12" />
Still Frame
{{ t('timeline.stillFrame') }}
</button>
<div class="kf-menu-divider"></div>
<button class="kf-menu-item" @click="duplicateFromMenu">
<PhCopy :size="12" />
Duplizieren
{{ t('timeline.duplicate') }}
</button>
<button class="kf-menu-item danger" @click="deleteFromMenu">
<PhTrash :size="12" />
Löschen
{{ t('timeline.delete') }}
</button>
</div>
@@ -1176,7 +1428,7 @@ onBeforeUnmount(() => {
:style="{ left: trackMenu.x + 'px', top: trackMenu.y + 'px' }"
>
<div class="kf-menu-section">
<div class="kf-menu-header">Farbe</div>
<div class="kf-menu-header">{{ t('timeline.color') }}</div>
<div class="color-tiles">
<button
v-for="c in TRACK_COLORS"
@@ -1201,7 +1453,7 @@ onBeforeUnmount(() => {
<PhCheck v-if="trackMenu.locked" :size="12" weight="bold" />
</span>
<component :is="trackMenu.locked ? PhLockOpen : PhLock" :size="12" />
{{ trackMenu.locked ? 'Entsperren' : 'Sperren' }}
{{ trackMenu.locked ? t('timeline.unlock') : t('timeline.lock') }}
</button>
<template v-if="trackMenu.kind === 'layer'">
<div class="kf-menu-divider"></div>
@@ -1213,13 +1465,13 @@ onBeforeUnmount(() => {
<span class="check">
<PhCheck v-if="trackMenu.separated" :size="12" weight="bold" />
</span>
Separate Position and Rotation
{{ t('timeline.separate') }}
</button>
</template>
<div class="kf-menu-divider"></div>
<button class="kf-menu-item danger" @click="deleteTrack">
<PhTrash :size="12" />
{{ trackMenu.kind === 'layer' ? 'Layer löschen' : 'Clip-Spur löschen' }}
{{ trackMenu.kind === 'layer' ? t('timeline.deleteLayer') : t('timeline.deleteClipTrack') }}
</button>
</div>
</div>
@@ -1405,6 +1657,26 @@ onBeforeUnmount(() => {
.tracks {
display: flex;
flex-direction: column;
position: relative;
}
.box-select-rect {
position: absolute;
z-index: 50;
border: 1px solid var(--accent);
background: rgba(110, 168, 255, 0.12);
pointer-events: none;
border-radius: 2px;
}
.kf.selected {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.kf.hold.selected {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.track {
@@ -1495,6 +1767,58 @@ onBeforeUnmount(() => {
opacity: 0.85;
}
/* Neutralize hover states on locked tracks */
.track.locked .track-name:hover {
background: var(--panel);
color: var(--text-dim);
}
.track.locked .track-name.selected:hover {
background:
linear-gradient(var(--accent-soft), var(--accent-soft)),
var(--panel);
color: var(--accent);
}
.track.locked.sub-track .track-name:hover {
background: var(--panel-alt);
}
.track.locked.inactive:hover .track-lane {
opacity: 0.4;
}
.track.locked .clip-block:hover {
background: color-mix(in srgb, var(--track-color, var(--accent)) 14%, transparent);
transform: none;
}
.track.locked .clip-block:hover .clip-handle {
opacity: 0;
}
.track.locked .clip-block.trimmed-start:hover .clip-handle.start,
.track.locked .clip-block.trimmed-end:hover .clip-handle.end {
opacity: 0.55;
}
.track.locked .clip-handle:hover,
.track.locked .clip-handle:active {
background: transparent !important;
opacity: 0 !important;
}
.track.locked .kf:hover {
background: var(--track-color, var(--accent));
transform: rotate(45deg);
}
.track.locked .kf.hold:hover {
background: var(--panel);
border-color: var(--track-color, var(--accent));
transform: none;
}
.track-name {
position: sticky;
left: 0;
+21 -12
View File
@@ -12,7 +12,8 @@ import {
listRecentProjects,
type StoredProject,
} from '@/lib/projectStore'
import { Modal } from '@/components/ui'
import { confirmDialog, Modal } from '@/components/ui'
import { t } from '@/i18n'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{
@@ -43,7 +44,9 @@ function formatDate(ts: number): string {
d.getMonth() === now.getMonth() &&
d.getDate() === now.getDate()
if (sameDay) {
return `heute ${d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`
return t('welcome.today', {
time: d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
})
}
return d.toLocaleString([], {
day: '2-digit',
@@ -65,7 +68,13 @@ function openProject(p: StoredProject) {
async function removeProject(e: MouseEvent, p: StoredProject) {
e.stopPropagation()
if (!confirm(`"${p.name}" wirklich löschen?`)) return
const ok = await confirmDialog({
title: t('welcome.deleteDialog.title'),
message: t('welcome.deleteDialog.message', { name: p.name }),
confirmLabel: t('welcome.deleteDialog.confirm'),
variant: 'danger',
})
if (!ok) return
await deleteStoredProject(p.id)
await reload()
}
@@ -108,14 +117,14 @@ watch(
>
<template #header>
<div class="hdr-text">
<h2>Willkommen bei Animatr</h2>
<p class="sub">Zuletzt bearbeitete Projekte</p>
<h2>{{ t('welcome.heading') }}</h2>
<p class="sub">{{ t('welcome.subheading') }}</p>
</div>
</template>
<div class="body">
<div v-if="loading" class="empty">Laden…</div>
<div v-if="loading" class="empty">{{ t('welcome.loading') }}</div>
<div v-else-if="!recents.length" class="empty">
Keine gespeicherten Projekte vorhanden.
{{ t('welcome.empty') }}
</div>
<ul v-else class="recents">
<li
@@ -131,12 +140,12 @@ watch(
<div class="meta">
<div class="name">{{ p.name }}</div>
<div class="sub-info">
{{ layerCount(p) }} Layer · {{ formatDate(p.savedAt) }}
{{ t('welcome.layerCount', { n: layerCount(p) }) }} · {{ formatDate(p.savedAt) }}
</div>
</div>
<button
class="del"
title="Projekt löschen"
:title="t('welcome.deleteTooltip')"
@click="removeProject($event, p)"
>
<PhTrash :size="13" />
@@ -147,15 +156,15 @@ watch(
<template #footer>
<button class="ftr-btn" @click="startFresh">
<PhPlus :size="13" weight="bold" />
Neues Projekt
{{ t('welcome.newProject') }}
</button>
<button class="ftr-btn" @click="importFile">
<PhFileArrowUp :size="13" />
Datei importieren…
{{ t('welcome.importFile') }}
</button>
<button class="ftr-btn" @click="exportFile">
<PhDownloadSimple :size="13" />
Aktuelles exportieren
{{ t('welcome.exportCurrent') }}
</button>
</template>
</Modal>
+176
View File
@@ -0,0 +1,176 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import Modal from './Modal.vue'
import {
cancelDialog,
dialogState,
resolveDialog,
type DialogState,
} from './dialogs'
import { t } from '@/i18n'
const current = computed<DialogState | null>(() => dialogState.value)
const open = computed({
get: () => current.value !== null,
set: (v: boolean) => {
if (!v) cancelDialog()
},
})
const inputValue = ref('')
const inputEl = ref<HTMLInputElement | null>(null)
watch(
() => current.value,
async (s) => {
if (s?.kind === 'prompt') {
inputValue.value = s.opts.defaultValue ?? ''
await nextTick()
inputEl.value?.focus()
inputEl.value?.select()
}
},
{ immediate: true },
)
function onConfirm() {
const s = current.value
if (!s) return
if (s.kind === 'confirm') resolveDialog(true)
else resolveDialog(inputValue.value)
}
function onCancel() {
cancelDialog()
}
function onSubmit(e: Event) {
e.preventDefault()
onConfirm()
}
function title(): string {
const s = current.value
if (!s) return ''
return s.opts.title ?? (s.kind === 'confirm' ? t('dialog.confirm') : t('dialog.input'))
}
function confirmLabel(): string {
return current.value?.opts.confirmLabel ?? t('dialog.ok')
}
function cancelLabel(): string {
return current.value?.opts.cancelLabel ?? t('dialog.cancel')
}
function isDanger(): boolean {
const s = current.value
return s?.kind === 'confirm' && s.opts.variant === 'danger'
}
</script>
<template>
<Modal
:model-value="open"
:title="title()"
size="sm"
@update:model-value="open = $event"
>
<div v-if="current?.kind === 'confirm'" class="body confirm-body">
<p class="msg">{{ current.opts.message }}</p>
</div>
<form
v-else-if="current?.kind === 'prompt'"
class="body prompt-body"
@submit="onSubmit"
>
<p v-if="current.opts.message" class="msg">{{ current.opts.message }}</p>
<input
ref="inputEl"
v-model="inputValue"
type="text"
class="prompt-input"
:placeholder="current.opts.placeholder ?? ''"
@keydown.enter.prevent="onConfirm"
@keydown.esc.prevent="onCancel"
/>
</form>
<template #footer>
<button class="btn secondary" @click="onCancel">{{ cancelLabel() }}</button>
<button
class="btn primary"
:class="{ danger: isDanger() }"
@click="onConfirm"
>
{{ confirmLabel() }}
</button>
</template>
</Modal>
</template>
<style scoped>
.body {
padding: 18px 20px 14px;
}
.msg {
margin: 0 0 12px;
font-size: 13px;
color: var(--text);
line-height: 1.45;
white-space: pre-wrap;
}
.prompt-body .msg {
margin-bottom: 8px;
}
.prompt-input {
width: 100%;
padding: 8px 10px;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
border-radius: var(--radius-sm);
font: inherit;
font-size: 13px;
}
.prompt-input:focus {
outline: none;
border-color: var(--accent);
}
.btn {
padding: 6px 14px;
border-radius: var(--radius-sm);
font: inherit;
font-size: 12px;
font-weight: 500;
border: 1px solid var(--border);
background: var(--panel-alt);
color: var(--text);
cursor: pointer;
}
.btn:hover {
background: var(--panel-hover);
border-color: var(--border-strong);
}
.btn.primary {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent);
}
.btn.primary:hover {
background: var(--accent-soft-strong);
}
.btn.primary.danger {
background: var(--danger-soft);
border-color: var(--danger);
color: var(--danger);
}
</style>
+2 -1
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted } from 'vue'
import { PhX } from '@phosphor-icons/vue'
import { t } from '@/i18n'
type Size = 'sm' | 'md' | 'lg'
@@ -59,7 +60,7 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
v-if="dismissable"
class="close"
:disabled="!dismissable"
title="Schließen"
:title="t('dialog.close')"
@click="close"
>
<PhX :size="14" weight="bold" />
+71
View File
@@ -0,0 +1,71 @@
import { shallowRef } from 'vue'
export interface ConfirmOptions {
title?: string
message: string
confirmLabel?: string
cancelLabel?: string
variant?: 'default' | 'danger'
}
export interface PromptOptions {
title?: string
message?: string
defaultValue?: string
placeholder?: string
confirmLabel?: string
cancelLabel?: string
}
interface ConfirmState {
kind: 'confirm'
opts: ConfirmOptions
resolve: (v: boolean) => void
}
interface PromptState {
kind: 'prompt'
opts: PromptOptions
resolve: (v: string | null) => void
}
export type DialogState = ConfirmState | PromptState
export const dialogState = shallowRef<DialogState | null>(null)
export function confirmDialog(opts: ConfirmOptions): Promise<boolean> {
// Cancel any pending dialog with a negative result
if (dialogState.value) {
if (dialogState.value.kind === 'confirm') dialogState.value.resolve(false)
else dialogState.value.resolve(null)
}
return new Promise<boolean>((resolve) => {
dialogState.value = { kind: 'confirm', opts, resolve }
})
}
export function promptDialog(opts: PromptOptions): Promise<string | null> {
if (dialogState.value) {
if (dialogState.value.kind === 'confirm') dialogState.value.resolve(false)
else dialogState.value.resolve(null)
}
return new Promise<string | null>((resolve) => {
dialogState.value = { kind: 'prompt', opts, resolve }
})
}
export function resolveDialog(value: boolean | string | null): void {
const s = dialogState.value
if (!s) return
if (s.kind === 'confirm') s.resolve(typeof value === 'boolean' ? value : false)
else s.resolve(typeof value === 'string' ? value : null)
dialogState.value = null
}
export function cancelDialog(): void {
const s = dialogState.value
if (!s) return
if (s.kind === 'confirm') s.resolve(false)
else s.resolve(null)
dialogState.value = null
}
+3
View File
@@ -1,3 +1,6 @@
export { default as DialogsHost } from './DialogsHost.vue'
export { default as IconButton } from './IconButton.vue'
export { default as Modal } from './Modal.vue'
export { default as Toast } from './Toast.vue'
export { confirmDialog, promptDialog } from './dialogs'
export type { ConfirmOptions, PromptOptions } from './dialogs'
+12 -11
View File
@@ -2,6 +2,7 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { PhArrowClockwise, PhCheck, PhClipboardText, PhWarning, PhX } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { t } from '@/i18n'
import { runAll, type TestResult } from './testRunner'
import { registerAllTests } from './tests'
@@ -190,7 +191,7 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
<Transition name="hud-pop">
<div v-if="modelValue && running" class="hud" role="status" aria-live="polite">
<div class="hud-top">
<span class="hud-tag">Tests laufen</span>
<span class="hud-tag">{{ t('tests.running') }}</span>
<span class="hud-counts">
<span class="hud-count">{{ done }}/{{ total }}</span>
<span class="hud-chip ok" v-if="passedSoFar">{{ passedSoFar }} ✓</span>
@@ -213,7 +214,7 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
<div class="panel" role="dialog" aria-modal="true">
<header class="hdr">
<div class="hdr-title">
<span>Test Runner</span>
<span>{{ t('tests.title') }}</span>
<span v-if="results.length" class="hdr-status">
<span class="ok-badge" :class="{ bad: failed > 0 }">
{{ passed }}/{{ results.length }}
@@ -225,16 +226,16 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
<button
class="tbtn"
:disabled="!results.length"
:title="clipboardStatus === 'copied' ? 'Report kopiert' : 'Report in Zwischenablage kopieren'"
:title="clipboardStatus === 'copied' ? t('tests.copiedTooltip') : t('tests.copyTooltip')"
:class="{ copied: clipboardStatus === 'copied', failed: clipboardStatus === 'failed' }"
@click="copyReport"
>
<PhClipboardText :size="14" />
</button>
<button class="tbtn" title="Erneut ausführen" @click="run">
<button class="tbtn" :title="t('tests.runAgain')" @click="run">
<PhArrowClockwise :size="14" />
</button>
<button class="tbtn" title="Schließen" @click="close">
<button class="tbtn" :title="t('tests.close')" @click="close">
<PhX :size="14" weight="bold" />
</button>
</div>
@@ -242,16 +243,16 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
<div v-if="clipboardStatus !== 'idle'" class="clip-banner" :class="clipboardStatus">
<span v-if="clipboardStatus === 'copied'">
Fehlerreport wurde in die Zwischenablage kopiert.
{{ t('tests.copiedBanner') }}
</span>
<span v-else>
Kopieren fehlgeschlagen — Report manuell über das Clipboard-Icon auslösen.
{{ t('tests.copyFailedBanner') }}
</span>
</div>
<div class="body">
<div v-if="!results.length" class="empty">
Keine Ergebnisse — „Erneut ausführen“ klicken oder <kbd>⌃⇧⌥T</kbd> drücken.
{{ t('tests.emptyState', { shortcut: '⌃⇧⌥T' }) }}
</div>
<div v-for="g in grouped" :key="g.suite" class="suite">
<div class="suite-hdr" :class="{ bad: !g.ok }">
@@ -274,9 +275,9 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
</div>
<footer class="ftr">
<span class="ftr-item">{{ results.length }} Tests</span>
<span class="ftr-item ok" v-if="passed">{{ passed }} grün</span>
<span class="ftr-item bad" v-if="failed">{{ failed }} rot</span>
<span class="ftr-item">{{ t('tests.testsCountLabel', { n: results.length }) }}</span>
<span class="ftr-item ok" v-if="passed">{{ t('tests.passedLabel', { n: passed }) }}</span>
<span class="ftr-item bad" v-if="failed">{{ t('tests.failedLabel', { n: failed }) }}</span>
</footer>
</div>
</div>
+2 -2
View File
@@ -1203,8 +1203,8 @@ export function registerAllTests(): void {
})
test('topbar has save & recents buttons', () => {
const saveBtn = document.querySelector('button[title^="Projekt speichern"]')
const recentsBtn = document.querySelector('button[title="Zuletzt verwendete Projekte"]')
const saveBtn = document.querySelector('button[title^="Save project"]')
const recentsBtn = document.querySelector('button[title="Recent projects"]')
expect(saveBtn).toBeTruthy()
expect(recentsBtn).toBeTruthy()
})
+44
View File
@@ -0,0 +1,44 @@
import { ref } from 'vue'
import { en, type Messages } from './messages'
export type Locale = 'en'
const activeLocale = ref<Locale>('en')
const messagesByLocale: Record<Locale, Messages> = { en }
function resolveAt(obj: unknown, path: string): unknown {
let cur = obj
for (const part of path.split('.')) {
if (cur && typeof cur === 'object' && part in (cur as Record<string, unknown>)) {
cur = (cur as Record<string, unknown>)[part]
} else {
return undefined
}
}
return cur
}
function interpolate(template: string, params?: Record<string, string | number>): string {
if (!params) return template
return template.replace(/\{(\w+)\}/g, (_, key) => {
const v = params[key]
return v === undefined ? `{${key}}` : String(v)
})
}
export function t(key: string, params?: Record<string, string | number>): string {
const raw = resolveAt(messagesByLocale[activeLocale.value], key)
if (typeof raw !== 'string') {
if (import.meta.env.DEV) console.warn(`[i18n] Missing key: ${key}`)
return key
}
return interpolate(raw, params)
}
export function useI18n() {
return { t, locale: activeLocale }
}
export function setLocale(l: Locale): void {
activeLocale.value = l
}
+232
View File
@@ -0,0 +1,232 @@
export const en = {
app: {
undo: 'Undo (Ctrl+Z)',
redo: 'Redo (Ctrl+Shift+Z)',
jumpToStart: 'Jump to start',
pause: 'Pause (Space)',
play: 'Play (Space)',
stop: 'Stop',
recentProjects: 'Recent projects',
saveProject: 'Save project (browser storage)',
documentSettings: 'Document settings',
export: 'Export',
mainTimeline: 'Main timeline',
dragResize: 'Drag to resize timeline',
loadFailed: 'Failed to load project.',
savedToast: '"{name}" saved',
saveDialog: {
title: 'Save project',
message: 'What should the project be called?',
confirm: 'Save',
defaultName: 'Project {date}',
},
replaceDialog: {
title: 'Replace project?',
message:
'The current project will be replaced by the loaded file. Unsaved changes will be lost.',
confirm: 'Continue',
},
},
welcome: {
heading: 'Welcome to Animatr',
subheading: 'Recent projects',
loading: 'Loading…',
empty: 'No saved projects yet.',
layerCount: '{n} layers',
today: 'today {time}',
deleteTooltip: 'Delete project',
newProject: 'New project',
importFile: 'Import file…',
exportCurrent: 'Export current',
deleteDialog: {
title: 'Delete project?',
message: '"{name}" will be permanently removed from browser storage.',
confirm: 'Delete',
},
},
sidebar: {
addFile: 'Add SVG / image',
library: 'Library',
thumbnailsHide: 'Hide thumbnails',
thumbnailsShow: 'Show thumbnails',
imageLabel: 'Image',
addLayerTooltip: 'Insert as layer (or drag to stage)',
noImages: 'No images imported',
clips: 'Clips',
createClipTooltip: 'Create new clip',
clipLayerCount: '{n} layers',
renameTooltip: 'Rename',
deleteTooltip: 'Delete',
noClips: 'No clips',
shape: {
rect: 'Rect',
circle: 'Circle',
text: 'Text',
},
newTextDialog: {
title: 'New text object',
message: 'Text:',
default: 'Hello',
confirm: 'Insert',
fallback: 'Text',
},
newClipDialog: {
title: 'New clip',
message: 'Clip name:',
default: 'Clip',
confirm: 'Create',
},
renameClipDialog: {
title: 'Rename clip',
confirm: 'Apply',
},
deleteClipDialog: {
title: 'Delete clip?',
message: 'The clip and all placed instances will be removed.',
confirm: 'Delete',
},
},
properties: {
keyframeLabel: 'Keyframe',
interpolated: 'Interpolated',
removeKeyframe: 'Remove keyframe here',
deleteLayer: 'Delete layer',
noLayer: 'No layer selected.',
},
fullscreen: {
pause: 'Pause',
play: 'Play',
jumpToStart: 'Jump to start',
close: 'Close (Esc)',
},
timeline: {
colors: {
none: 'None',
red: 'Red',
orange: 'Orange',
yellow: 'Yellow',
green: 'Green',
cyan: 'Cyan',
blue: 'Blue',
purple: 'Purple',
pink: 'Pink',
gray: 'Gray',
},
noTracks: 'No tracks yet.',
addClipTrack: 'Add clip track',
addClipTrackTooltip: 'Add another clip track',
clipTrackName: 'Clip track',
toggleTimeUnit: 'Toggle time unit',
snapOn: 'Snapping on (playhead & keyframes snap together)',
snapOff: 'Snapping off',
stillFrameTooltip: 'Still frame {time} — holds until the next keyframe',
keyframeTooltip: 'Keyframe {time} — drag to move, right-click for options',
trimTooltip: '{name} · {duration}s{trims} · Drag edges to trim, double-click to open',
trimStart: 'Start -{value}s',
trimEnd: 'End -{value}s',
easing: 'Easing',
easingLinear: 'Linear',
easingEaseIn: 'Ease In',
easingEaseOut: 'Ease Out',
easingEaseInOut: 'Ease In-Out',
firstKfHint: 'First keyframe — easing has no effect',
holdDisabledHint: 'Still frame — easing disabled',
lastKfHint: 'Last keyframe — still frame has no effect',
stillFrame: 'Still frame',
duplicate: 'Duplicate',
delete: 'Delete',
color: 'Color',
lock: 'Lock',
unlock: 'Unlock',
deleteLayer: 'Delete layer',
deleteClipTrack: 'Delete clip track',
separate: 'Separate Position and Rotation',
rotation: 'Rotation',
position: 'Position',
removeInstanceDialog: {
title: 'Remove clip instance?',
message: 'Remove "{name}" at {time}s from the timeline.',
generic: 'Remove this clip instance from the timeline.',
confirm: 'Remove',
},
},
canvas: {
zoomIn: 'Zoom in',
zoomOut: 'Zoom out',
zoomOneToOne: 'Zoom 1:1',
fitToView: 'Fit to view',
cropOn: 'Show stage only (crop)',
cropOff: 'Show everything',
},
settings: {
title: 'Settings',
tab: {
document: 'Document',
timeline: 'Timeline',
theme: 'UI Theme',
},
canvas: 'Canvas',
width: 'Width',
height: 'Height',
background: 'Background',
duration: 'Duration (s)',
fps: 'FPS',
timelineHint: 'Timeline length sets the ruler scale. Playback stops at the last keyframe.',
themeHint: 'Saved in the browser and reused on next open.',
themeDark: 'Dark',
themeLight: 'Light',
done: 'Done',
},
export: {
title: 'Export',
runtimeBadge: '1× per page',
runtimeDesc: 'Engine — include once; all scenes on the page share the runtime',
sceneBadge: 'Scene',
sceneDesc: 'Scene data only — requires the runtime to be loaded',
exampleTitle: 'Example HTML',
exampleDesc: 'Standalone HTML — just open it in a browser',
mp4Badge: 'Server · Frame-accurate',
mp4Desc: 'H.264, any resolution — requires render server',
webmBadge: 'Browser · Realtime',
webmDesc: 'No server — recorded in real time',
webmNotSupported: 'Browser does not support MediaRecorder',
cancel: 'Cancel',
failed: 'Export failed',
back: 'Back',
labelWebm: 'Rendering WebM (browser, realtime)…',
labelMp4: 'Rendering MP4 (server)…',
},
tests: {
running: 'Tests running',
title: 'Test Runner',
runAgain: 'Run again',
close: 'Close',
copyTooltip: 'Copy report to clipboard',
copiedTooltip: 'Report copied',
copiedBanner: 'Error report copied to clipboard.',
copyFailedBanner: 'Copy failed — use the clipboard icon to trigger manually.',
emptyState: 'No results — click "Run again" or press {shortcut}.',
testsCountLabel: '{n} tests',
passedLabel: '{n} passed',
failedLabel: '{n} failed',
},
dialog: {
confirm: 'Confirm',
input: 'Input',
ok: 'OK',
cancel: 'Cancel',
close: 'Close',
},
} as const
export type Messages = typeof en
+8 -8
View File
@@ -324,11 +324,11 @@ export function generateExampleHtml(scene: Scene, options: ExportOptions = {}):
const sceneBoot = generateSceneScriptBody(scene, options)
const aspect = `${scene.canvas.width} / ${scene.canvas.height}`
return `<!doctype html>
<html lang="de">
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Animatr — Beispiel</title>
<title>Animatr — Example</title>
<style>
:root { color-scheme: light dark; }
body {
@@ -383,26 +383,26 @@ export function generateExampleHtml(scene: Scene, options: ExportOptions = {}):
</head>
<body>
<h1>Animatr Export</h1>
<p>Runtime einmal laden, dann beliebig viele Szenen auf einer Seite.</p>
<p>Load the runtime once, then embed as many scenes on the page as you like.</p>
<!-- Runtime: einmal pro Seite -->
<!-- Runtime: once per page -->
<script>
${runtime}
</script>
<div class="stage">
<!-- Szene: pro Animation -->
<!-- Scene: one per animation -->
<script>
${sceneBoot}
</script>
</div>
<div class="usage">
<h2>Einbinden in eine eigene Seite</h2>
<pre>&lt;!-- einmal pro Seite --&gt;
<h2>Embed on your own page</h2>
<pre>&lt;!-- once per page --&gt;
&lt;script src="animatr-runtime.js"&gt;&lt;/script&gt;
&lt;!-- je Animation ein Container + Scene-Script --&gt;
&lt;!-- per animation: one container + scene script --&gt;
&lt;div style="width: 800px; height: 450px;"&gt;
&lt;script src="animation.js"&gt;&lt;/script&gt;
&lt;/div&gt;
+7 -7
View File
@@ -32,28 +32,28 @@ export function parseProject(text: string): Scene {
try {
data = JSON.parse(text)
} catch {
throw new ProjectFileError('Datei ist kein gültiges JSON.')
throw new ProjectFileError('File is not valid JSON.')
}
if (!data || typeof data !== 'object') {
throw new ProjectFileError('Datei ist leer oder kein Objekt.')
throw new ProjectFileError('File is empty or not an object.')
}
const obj = data as Record<string, unknown>
if (obj.magic !== MAGIC) {
throw new ProjectFileError('Keine Animatr-Projektdatei (magic fehlt).')
throw new ProjectFileError('Not an Animatr project file (magic missing).')
}
const scene = obj.scene
if (!scene || typeof scene !== 'object') {
throw new ProjectFileError('Projektdatei enthält keine Szene.')
throw new ProjectFileError('Project file has no scene.')
}
const s = scene as Record<string, unknown>
if (!s.canvas || typeof s.canvas !== 'object') {
throw new ProjectFileError('Szenendaten unvollständig: canvas fehlt.')
throw new ProjectFileError('Scene data incomplete: canvas missing.')
}
if (!Array.isArray(s.layers)) {
throw new ProjectFileError('Szenendaten unvollständig: layers fehlt.')
throw new ProjectFileError('Scene data incomplete: layers missing.')
}
if (!Array.isArray(s.assets)) {
throw new ProjectFileError('Szenendaten unvollständig: assets fehlt.')
throw new ProjectFileError('Scene data incomplete: assets missing.')
}
return scene as Scene
}
+1 -1
View File
@@ -98,7 +98,7 @@ export async function getStoredProject(id: string): Promise<StoredProject | null
function defaultName(): string {
const d = new Date()
return `Projekt ${d.toLocaleDateString()} ${d.toLocaleTimeString([], {
return `Project ${d.toLocaleDateString()} ${d.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}`
+4 -4
View File
@@ -40,10 +40,10 @@ export async function renderOnServer(
})
if (!startRes.ok) {
const body = await startRes.text().catch(() => '')
throw new Error(`Server start fehlgeschlagen (${startRes.status}): ${body.slice(0, 200)}`)
throw new Error(`Server start failed (${startRes.status}): ${body.slice(0, 200)}`)
}
const { jobId } = (await startRes.json()) as { jobId: string }
if (!jobId) throw new Error('Server lieferte keine jobId')
if (!jobId) throw new Error('Server did not return a jobId')
const downloadUrl = await new Promise<string>((resolve, reject) => {
const es = new EventSource(`${baseUrl}/render/${jobId}/events`)
@@ -78,13 +78,13 @@ export async function renderOnServer(
opts.signal?.removeEventListener('abort', onAbort)
if (data.error) reject(new Error(data.error))
else if (data.url) resolve(`${baseUrl}${data.url}`)
else reject(new Error('Server meldete fertig ohne Download-URL'))
else reject(new Error('Server reported done without download URL'))
}
}
es.onerror = () => {
es.close()
opts.signal?.removeEventListener('abort', onAbort)
reject(new Error('Verbindung zum Render-Server verloren'))
reject(new Error('Connection to render server lost'))
}
})
+1 -1
View File
@@ -46,7 +46,7 @@ export async function renderSceneToVideo(
): Promise<VideoExportResult> {
const mimeType = pickMimeType()
if (!mimeType) {
throw new Error('MediaRecorder wird von diesem Browser nicht unterstützt.')
throw new Error('MediaRecorder is not supported by this browser.')
}
const canvas = document.createElement('canvas')
+12 -1
View File
@@ -48,8 +48,19 @@ das timeline panel muss man per anfasser höher ziehen können
spuren muss man locken können. sie dürfen dann nur noch die halbe höhe haben und an der zeitlinie muss erkennbar sein das nicht bearbeitet werden kann
per box select muss man über mehrere spuren keyframe multiselection durchführen können um sie zusammen in der zeit zu verschieben
rect circle und text brauchen wir nicht als instanzen in der lib. da tauchen nur importierte elemente auf. diese muss man auch mehrmals einfügen können per DnD oder "+"
ALLE texte der gui bitte in englisch
per box select muss man über mehrere spuren keyframe multiselection durchführen können um sie zusammen in der zeit zu verschieben
ein element rein draggen muss das element (grafik) als draggable anzeigen, nicht den bibliothekseintrag
context menüs dürfen nicht unten aus dem viewport rausgehen
wir machen mal ein about popup und verwalten ab sofort eine version. wir starten mit 0.1.0
about öffnet bei click auf den app namen oben links