bak
This commit is contained in:
1 parent
fcefd258eb
commit
6b6157d786
23 files changed
+1140
-237
No files matched your search
+30
-20
@@ -24,7 +24,8 @@ import PropertiesPanel from './components/PropertiesPanel.vue'
|
|||||||
import Timeline from './components/Timeline.vue'
|
import Timeline from './components/Timeline.vue'
|
||||||
import WelcomeDialog from './components/WelcomeDialog.vue'
|
import WelcomeDialog from './components/WelcomeDialog.vue'
|
||||||
import TestOverlay from './devtools/TestOverlay.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 { useHistory } from './composables/useHistory'
|
||||||
import { usePlaybackLoop } from './composables/usePlaybackLoop'
|
import { usePlaybackLoop } from './composables/usePlaybackLoop'
|
||||||
import {
|
import {
|
||||||
@@ -140,8 +141,13 @@ async function saveProject() {
|
|||||||
const scene = JSON.parse(JSON.stringify(store.scene))
|
const scene = JSON.parse(JSON.stringify(store.scene))
|
||||||
let name = currentProjectName.value
|
let name = currentProjectName.value
|
||||||
if (!name) {
|
if (!name) {
|
||||||
const suggested = `Projekt ${new Date().toLocaleDateString()}`
|
const suggested = t('app.saveDialog.defaultName', { date: new Date().toLocaleDateString() })
|
||||||
const entered = prompt('Projektname?', suggested)
|
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
|
if (entered === null) return
|
||||||
name = entered.trim() || suggested
|
name = entered.trim() || suggested
|
||||||
}
|
}
|
||||||
@@ -152,7 +158,7 @@ async function saveProject() {
|
|||||||
currentProjectId.value = rec.id
|
currentProjectId.value = rec.id
|
||||||
currentProjectName.value = rec.name
|
currentProjectName.value = rec.name
|
||||||
setLastOpenedId(rec.id)
|
setLastOpenedId(rec.id)
|
||||||
showSavedToast(`"${rec.name}" gespeichert`)
|
showSavedToast(t('app.savedToast', { name: rec.name }))
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportProjectFile() {
|
function exportProjectFile() {
|
||||||
@@ -167,9 +173,12 @@ async function importFromFile() {
|
|||||||
if (!file) return
|
if (!file) return
|
||||||
const hasContent = store.scene.layers.length > 0 || store.scene.assets.length > 0
|
const hasContent = store.scene.layers.length > 0 || store.scene.assets.length > 0
|
||||||
if (hasContent) {
|
if (hasContent) {
|
||||||
const ok = confirm(
|
const ok = await confirmDialog({
|
||||||
'Aktuelles Projekt wird durch die geladene Datei ersetzt. Ungespeicherte Änderungen gehen verloren. Fortfahren?',
|
title: t('app.replaceDialog.title'),
|
||||||
)
|
message: t('app.replaceDialog.message'),
|
||||||
|
confirmLabel: t('app.replaceDialog.confirm'),
|
||||||
|
variant: 'danger',
|
||||||
|
})
|
||||||
if (!ok) return
|
if (!ok) return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -285,7 +294,7 @@ onBeforeUnmount(() => {
|
|||||||
icon-weight="bold"
|
icon-weight="bold"
|
||||||
size="md"
|
size="md"
|
||||||
:disabled="!history.canUndo.value"
|
:disabled="!history.canUndo.value"
|
||||||
title="Rückgängig (Ctrl+Z)"
|
:title="t('app.undo')"
|
||||||
@click="history.undo"
|
@click="history.undo"
|
||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -293,7 +302,7 @@ onBeforeUnmount(() => {
|
|||||||
icon-weight="bold"
|
icon-weight="bold"
|
||||||
size="md"
|
size="md"
|
||||||
:disabled="!history.canRedo.value"
|
:disabled="!history.canRedo.value"
|
||||||
title="Wiederholen (Ctrl+Shift+Z)"
|
:title="t('app.redo')"
|
||||||
@click="history.redo"
|
@click="history.redo"
|
||||||
/>
|
/>
|
||||||
<div class="tsep"></div>
|
<div class="tsep"></div>
|
||||||
@@ -301,7 +310,7 @@ onBeforeUnmount(() => {
|
|||||||
:icon="PhSkipBack"
|
:icon="PhSkipBack"
|
||||||
icon-weight="fill"
|
icon-weight="fill"
|
||||||
size="md"
|
size="md"
|
||||||
title="Zum Anfang"
|
:title="t('app.jumpToStart')"
|
||||||
@click="rewind"
|
@click="rewind"
|
||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -309,14 +318,14 @@ onBeforeUnmount(() => {
|
|||||||
icon-weight="fill"
|
icon-weight="fill"
|
||||||
size="md"
|
size="md"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
:title="store.playing ? 'Pause (Space)' : 'Play (Space)'"
|
:title="store.playing ? t('app.pause') : t('app.play')"
|
||||||
@click="store.togglePlay()"
|
@click="store.togglePlay()"
|
||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
:icon="PhStop"
|
:icon="PhStop"
|
||||||
icon-weight="fill"
|
icon-weight="fill"
|
||||||
size="md"
|
size="md"
|
||||||
title="Stop"
|
:title="t('app.stop')"
|
||||||
@click="stop"
|
@click="stop"
|
||||||
/>
|
/>
|
||||||
<span class="time">{{ store.playhead.toFixed(2) }}s / {{ store.scene.duration }}s</span>
|
<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">
|
<span v-if="currentProjectName" class="project-name" :title="currentProjectName">
|
||||||
{{ currentProjectName }}
|
{{ currentProjectName }}
|
||||||
</span>
|
</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" />
|
<PhFolderOpen :size="16" />
|
||||||
</button>
|
</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" />
|
<PhFloppyDisk :size="16" />
|
||||||
</button>
|
</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" />
|
<PhGearSix :size="16" />
|
||||||
</button>
|
</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" />
|
<PhExport :size="14" weight="bold" />
|
||||||
Export
|
{{ t('app.export') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -345,7 +354,7 @@ onBeforeUnmount(() => {
|
|||||||
<div v-if="store.activeClipId" class="scope-footer">
|
<div v-if="store.activeClipId" class="scope-footer">
|
||||||
<button class="scope-back" @click="store.exitClip">
|
<button class="scope-back" @click="store.exitClip">
|
||||||
<PhArrowLeft :size="12" weight="bold" />
|
<PhArrowLeft :size="12" weight="bold" />
|
||||||
Haupt-Timeline
|
{{ t('app.mainTimeline') }}
|
||||||
</button>
|
</button>
|
||||||
<span class="scope-current" :title="store.activeClip?.name ?? ''">
|
<span class="scope-current" :title="store.activeClip?.name ?? ''">
|
||||||
<PhFilmStrip :size="12" />
|
<PhFilmStrip :size="12" />
|
||||||
@@ -359,7 +368,7 @@ onBeforeUnmount(() => {
|
|||||||
<div
|
<div
|
||||||
class="tl-resize"
|
class="tl-resize"
|
||||||
:class="{ resizing }"
|
:class="{ resizing }"
|
||||||
title="Timeline-Höhe ziehen"
|
:title="t('app.dragResize')"
|
||||||
@pointerdown="onTimelineResizeStart"
|
@pointerdown="onTimelineResizeStart"
|
||||||
@pointermove="onTimelineResizeMove"
|
@pointermove="onTimelineResizeMove"
|
||||||
@pointerup="onTimelineResizeEnd"
|
@pointerup="onTimelineResizeEnd"
|
||||||
@@ -371,6 +380,7 @@ onBeforeUnmount(() => {
|
|||||||
<ExportDialog v-model="exportOpen" />
|
<ExportDialog v-model="exportOpen" />
|
||||||
<TestOverlay v-model="testsOpen" />
|
<TestOverlay v-model="testsOpen" />
|
||||||
<FullscreenPreview v-model="fullscreenOpen" />
|
<FullscreenPreview v-model="fullscreenOpen" />
|
||||||
|
<DialogsHost />
|
||||||
<WelcomeDialog
|
<WelcomeDialog
|
||||||
v-model="welcomeOpen"
|
v-model="welcomeOpen"
|
||||||
@open="openFromRecent"
|
@open="openFromRecent"
|
||||||
@@ -379,7 +389,7 @@ onBeforeUnmount(() => {
|
|||||||
@export="exportProjectFile"
|
@export="exportProjectFile"
|
||||||
/>
|
/>
|
||||||
<Toast :visible="!!loadError" variant="error">
|
<Toast :visible="!!loadError" variant="error">
|
||||||
<strong>Projekt konnte nicht geladen werden.</strong>
|
<strong>{{ t('app.loadFailed') }}</strong>
|
||||||
<span>{{ loadError }}</span>
|
<span>{{ loadError }}</span>
|
||||||
</Toast>
|
</Toast>
|
||||||
<Toast :visible="!!savedToast" variant="info" :message="savedToast ?? ''" />
|
<Toast :visible="!!savedToast" variant="info" :message="savedToast ?? ''" />
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
import { useSceneStore } from '@/stores/scene'
|
import { useSceneStore } from '@/stores/scene'
|
||||||
import { isSvgFile, loadImage, parseSvgDimensions, readAsDataUrl } from '@/lib/fileLoader'
|
import { isSvgFile, loadImage, parseSvgDimensions, readAsDataUrl } from '@/lib/fileLoader'
|
||||||
import type { Asset } from '@/types/scene'
|
import type { Asset } from '@/types/scene'
|
||||||
|
import { confirmDialog, promptDialog } from '@/components/ui'
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
const store = useSceneStore()
|
const store = useSceneStore()
|
||||||
|
|
||||||
@@ -54,10 +56,15 @@ function addSampleCircle() {
|
|||||||
store.addLayer(asset.id)
|
store.addLayer(asset.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function addSampleText() {
|
async function addSampleText() {
|
||||||
const input = prompt('Text?', 'Hallo')
|
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
|
if (input === null) return
|
||||||
const text = input.trim() || 'Text'
|
const text = input.trim() || t('sidebar.newTextDialog.fallback')
|
||||||
const asset = store.addAsset({
|
const asset = store.addAsset({
|
||||||
kind: 'text',
|
kind: 'text',
|
||||||
name: text.length > 24 ? text.slice(0, 24) + '…' : text,
|
name: text.length > 24 ? text.slice(0, 24) + '…' : text,
|
||||||
@@ -70,20 +77,35 @@ function addSampleText() {
|
|||||||
|
|
||||||
const clips = computed(() => store.scene.clips ?? [])
|
const clips = computed(() => store.scene.clips ?? [])
|
||||||
|
|
||||||
function addClip() {
|
async function addClip() {
|
||||||
const input = prompt('Name des Clips?', 'Clip')
|
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
|
if (input === null) return
|
||||||
store.addClip(input)
|
store.addClip(input)
|
||||||
}
|
}
|
||||||
|
|
||||||
function renameClipPrompt(clipId: string, current: string) {
|
async function renameClipPrompt(clipId: string, current: string) {
|
||||||
const input = prompt('Neuer Name?', current)
|
const input = await promptDialog({
|
||||||
|
title: t('sidebar.renameClipDialog.title'),
|
||||||
|
defaultValue: current,
|
||||||
|
confirmLabel: t('sidebar.renameClipDialog.confirm'),
|
||||||
|
})
|
||||||
if (input === null) return
|
if (input === null) return
|
||||||
store.renameClip(clipId, input)
|
store.renameClip(clipId, input)
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteClip(clipId: string) {
|
async function deleteClip(clipId: string) {
|
||||||
if (!confirm('Clip und alle Instanzen wirklich löschen?')) return
|
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)
|
store.removeClip(clipId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,34 +171,17 @@ const assetIconMap: Record<Asset['kind'], typeof PhSquare> = {
|
|||||||
text: PhTextT,
|
text: PhTextT,
|
||||||
}
|
}
|
||||||
|
|
||||||
const assetsWithUsage = computed(() =>
|
const imageAssets = computed(() =>
|
||||||
store.scene.assets.map((a) => ({
|
store.scene.assets.filter((a): a is Extract<Asset, { kind: 'image' }> => a.kind === 'image'),
|
||||||
asset: a,
|
|
||||||
inUse: store.scene.layers.some((l) => l.assetId === a.id),
|
|
||||||
})),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const THUMB_SIZE = 40
|
function onAssetDragStart(e: DragEvent, assetId: string) {
|
||||||
|
if (!e.dataTransfer) return
|
||||||
function rectPreviewStyle(asset: Extract<Asset, { kind: 'rect' }>) {
|
e.dataTransfer.effectAllowed = 'copy'
|
||||||
const scale = Math.min(THUMB_SIZE / asset.width, THUMB_SIZE / asset.height) * 0.9
|
e.dataTransfer.setData('application/x-animatr-asset', assetId)
|
||||||
return {
|
e.dataTransfer.setData('text/plain', assetId)
|
||||||
width: `${Math.max(6, asset.width * scale)}px`,
|
|
||||||
height: `${Math.max(6, asset.height * scale)}px`,
|
|
||||||
background: asset.fill,
|
|
||||||
borderRadius: '2px',
|
|
||||||
} as const
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -185,21 +190,21 @@ function circlePreviewStyle(asset: Extract<Asset, { kind: 'circle' }>) {
|
|||||||
|
|
||||||
<button class="upload-btn" @click="triggerUpload">
|
<button class="upload-btn" @click="triggerUpload">
|
||||||
<PhUploadSimple :size="16" />
|
<PhUploadSimple :size="16" />
|
||||||
<span>SVG / Bild hinzufügen</span>
|
<span>{{ t('sidebar.addFile') }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="shape-btn" @click="addSampleRect">
|
<button class="shape-btn" @click="addSampleRect">
|
||||||
<PhSquare :size="14" />
|
<PhSquare :size="14" />
|
||||||
Rect
|
{{ t('sidebar.shape.rect') }}
|
||||||
</button>
|
</button>
|
||||||
<button class="shape-btn" @click="addSampleCircle">
|
<button class="shape-btn" @click="addSampleCircle">
|
||||||
<PhCircle :size="14" />
|
<PhCircle :size="14" />
|
||||||
Circle
|
{{ t('sidebar.shape.circle') }}
|
||||||
</button>
|
</button>
|
||||||
<button class="shape-btn" @click="addSampleText">
|
<button class="shape-btn" @click="addSampleText">
|
||||||
<PhTextT :size="14" />
|
<PhTextT :size="14" />
|
||||||
Text
|
{{ t('sidebar.shape.text') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -214,10 +219,10 @@ function circlePreviewStyle(asset: Extract<Asset, { kind: 'circle' }>) {
|
|||||||
<p v-if="uploadError" class="error">{{ uploadError }}</p>
|
<p v-if="uploadError" class="error">{{ uploadError }}</p>
|
||||||
|
|
||||||
<div class="lib-header">
|
<div class="lib-header">
|
||||||
<h4>Bibliothek</h4>
|
<h4>{{ t('sidebar.library') }}</h4>
|
||||||
<button
|
<button
|
||||||
class="thumb-toggle"
|
class="thumb-toggle"
|
||||||
:title="showThumbs ? 'Thumbnails ausblenden' : 'Thumbnails anzeigen'"
|
:title="showThumbs ? t('sidebar.thumbnailsHide') : t('sidebar.thumbnailsShow')"
|
||||||
@click="showThumbs = !showThumbs"
|
@click="showThumbs = !showThumbs"
|
||||||
>
|
>
|
||||||
<PhImageSquare v-if="showThumbs" :size="13" />
|
<PhImageSquare v-if="showThumbs" :size="13" />
|
||||||
@@ -225,54 +230,38 @@ function circlePreviewStyle(asset: Extract<Asset, { kind: 'circle' }>) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<ul class="assets" :class="{ compact: !showThumbs }">
|
<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">
|
<template v-if="showThumbs">
|
||||||
<div class="thumb">
|
<div class="thumb">
|
||||||
<img
|
<img :src="asset.src" :alt="asset.name" draggable="false" />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<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>
|
</template>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<span class="name" :title="item.asset.name">{{ item.asset.name }}</span>
|
<span class="name" :title="asset.name">{{ asset.name }}</span>
|
||||||
<span v-if="showThumbs" class="sub">{{ item.asset.kind }}</span>
|
<span v-if="showThumbs" class="sub">{{ t('sidebar.imageLabel') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
v-if="!item.inUse"
|
|
||||||
class="add-layer"
|
class="add-layer"
|
||||||
title="Als Layer einfügen"
|
:title="t('sidebar.addLayerTooltip')"
|
||||||
@click="store.addLayer(item.asset.id)"
|
@click="store.addLayer(asset.id)"
|
||||||
>
|
>
|
||||||
<PhPlus :size="12" weight="bold" />
|
<PhPlus :size="12" weight="bold" />
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
|
|
||||||
<div class="lib-header clips-header">
|
<div class="lib-header clips-header">
|
||||||
<h4>Clips</h4>
|
<h4>{{ t('sidebar.clips') }}</h4>
|
||||||
<button class="thumb-toggle" title="Neuen Clip anlegen" @click="addClip">
|
<button class="thumb-toggle" :title="t('sidebar.createClipTooltip')" @click="addClip">
|
||||||
<PhPlus :size="12" weight="bold" />
|
<PhPlus :size="12" weight="bold" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -292,23 +281,23 @@ function circlePreviewStyle(asset: Extract<Asset, { kind: 'circle' }>) {
|
|||||||
<div class="clip-meta">
|
<div class="clip-meta">
|
||||||
<span class="clip-name" :title="clip.name">{{ clip.name }}</span>
|
<span class="clip-name" :title="clip.name">{{ clip.name }}</span>
|
||||||
<span class="clip-sub">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="clip-actions">
|
<div class="clip-actions">
|
||||||
<button
|
<button
|
||||||
class="clip-btn"
|
class="clip-btn"
|
||||||
title="Umbenennen"
|
:title="t('sidebar.renameTooltip')"
|
||||||
@click.stop="renameClipPrompt(clip.id, clip.name)"
|
@click.stop="renameClipPrompt(clip.id, clip.name)"
|
||||||
>
|
>
|
||||||
<PhPencilSimple :size="11" />
|
<PhPencilSimple :size="11" />
|
||||||
</button>
|
</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" />
|
<PhTrash :size="11" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li v-if="!clips.length" class="empty">keine Clips</li>
|
<li v-if="!clips.length" class="empty">{{ t('sidebar.noClips') }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useSceneStore } from '@/stores/scene'
|
|||||||
import { sampleLayer } from '@/lib/interpolate'
|
import { sampleLayer } from '@/lib/interpolate'
|
||||||
import { loadImage } from '@/lib/fileLoader'
|
import { loadImage } from '@/lib/fileLoader'
|
||||||
import type { AnimatableProps, Asset, Layer } from '@/types/scene'
|
import type { AnimatableProps, Asset, Layer } from '@/types/scene'
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
const store = useSceneStore()
|
const store = useSceneStore()
|
||||||
|
|
||||||
@@ -606,6 +607,29 @@ function onContainerPointerUp(e: PointerEvent) {
|
|||||||
if (container.value) container.value.style.cursor = ''
|
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) {
|
function zoomBy(factor: number) {
|
||||||
const cw = containerSize.value.width
|
const cw = containerSize.value.width
|
||||||
const ch = containerSize.value.height
|
const ch = containerSize.value.height
|
||||||
@@ -638,6 +662,8 @@ onMounted(() => {
|
|||||||
container.value.addEventListener('pointermove', onContainerPointerMove)
|
container.value.addEventListener('pointermove', onContainerPointerMove)
|
||||||
container.value.addEventListener('pointerup', onContainerPointerUp)
|
container.value.addEventListener('pointerup', onContainerPointerUp)
|
||||||
container.value.addEventListener('pointercancel', onContainerPointerUp)
|
container.value.addEventListener('pointercancel', onContainerPointerUp)
|
||||||
|
container.value.addEventListener('dragover', onContainerDragOver)
|
||||||
|
container.value.addEventListener('drop', onContainerDrop)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
@@ -649,6 +675,8 @@ onBeforeUnmount(() => {
|
|||||||
container.value?.removeEventListener('pointermove', onContainerPointerMove)
|
container.value?.removeEventListener('pointermove', onContainerPointerMove)
|
||||||
container.value?.removeEventListener('pointerup', onContainerPointerUp)
|
container.value?.removeEventListener('pointerup', onContainerPointerUp)
|
||||||
container.value?.removeEventListener('pointercancel', onContainerPointerUp)
|
container.value?.removeEventListener('pointercancel', onContainerPointerUp)
|
||||||
|
container.value?.removeEventListener('dragover', onContainerDragOver)
|
||||||
|
container.value?.removeEventListener('drop', onContainerDrop)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -736,21 +764,21 @@ onBeforeUnmount(() => {
|
|||||||
</v-stage>
|
</v-stage>
|
||||||
|
|
||||||
<div class="zoom-controls" @pointerdown.stop>
|
<div class="zoom-controls" @pointerdown.stop>
|
||||||
<button class="zc-btn" title="Zoom out" @click="zoomBy(1 / 1.25)">−</button>
|
<button class="zc-btn" :title="t('canvas.zoomOut')" @click="zoomBy(1 / 1.25)">−</button>
|
||||||
<button class="zc-label" title="Fit to view" @click="fitToContainer">
|
<button class="zc-label" :title="t('canvas.fitToView')" @click="fitToContainer">
|
||||||
{{ zoomPercent }}%
|
{{ zoomPercent }}%
|
||||||
</button>
|
</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>
|
<div class="zc-sep"></div>
|
||||||
<button class="zc-btn wide" title="Zoom 1:1" @click="zoomToOne">1:1</button>
|
<button class="zc-btn wide" :title="t('canvas.zoomOneToOne')" @click="zoomToOne">1:1</button>
|
||||||
<button class="zc-btn" title="An Viewport anpassen" @click="fitToContainer">
|
<button class="zc-btn" :title="t('canvas.fitToView')" @click="fitToContainer">
|
||||||
<PhCornersIn :size="13" />
|
<PhCornersIn :size="13" />
|
||||||
</button>
|
</button>
|
||||||
<div class="zc-sep"></div>
|
<div class="zc-sep"></div>
|
||||||
<button
|
<button
|
||||||
class="zc-btn crop-btn"
|
class="zc-btn crop-btn"
|
||||||
:class="{ active: cropObjects }"
|
:class="{ active: cropObjects }"
|
||||||
:title="cropObjects ? 'Alles zeigen' : 'Nur Bühnenausschnitt zeigen (crop)'"
|
:title="cropObjects ? t('canvas.cropOff') : t('canvas.cropOn')"
|
||||||
@click="cropObjects = !cropObjects"
|
@click="cropObjects = !cropObjects"
|
||||||
>
|
>
|
||||||
<PhCrop :size="13" />
|
<PhCrop :size="13" />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useSceneStore } from '@/stores/scene'
|
|||||||
import { useTheme } from '@/composables/useTheme'
|
import { useTheme } from '@/composables/useTheme'
|
||||||
import type { Theme } from '@/composables/useTheme'
|
import type { Theme } from '@/composables/useTheme'
|
||||||
import { Modal } from '@/components/ui'
|
import { Modal } from '@/components/ui'
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
defineProps<{ modelValue: boolean }>()
|
defineProps<{ modelValue: boolean }>()
|
||||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||||
@@ -15,9 +16,9 @@ const { theme, setTheme } = useTheme()
|
|||||||
type TabId = 'document' | 'timeline' | 'theme'
|
type TabId = 'document' | 'timeline' | 'theme'
|
||||||
|
|
||||||
const tabs: { id: TabId; label: string; icon: typeof PhFrameCorners }[] = [
|
const tabs: { id: TabId; label: string; icon: typeof PhFrameCorners }[] = [
|
||||||
{ id: 'document', label: 'Document', icon: PhFrameCorners },
|
{ id: 'document', label: t('settings.tab.document'), icon: PhFrameCorners },
|
||||||
{ id: 'timeline', label: 'Timeline', icon: PhClock },
|
{ id: 'timeline', label: t('settings.tab.timeline'), icon: PhClock },
|
||||||
{ id: 'theme', label: 'UI Theme', icon: PhPalette },
|
{ id: 'theme', label: t('settings.tab.theme'), icon: PhPalette },
|
||||||
]
|
]
|
||||||
|
|
||||||
const activeTab = ref<TabId>('document')
|
const activeTab = ref<TabId>('document')
|
||||||
@@ -71,7 +72,7 @@ function applyTheme(t: Theme) {
|
|||||||
<template>
|
<template>
|
||||||
<Modal
|
<Modal
|
||||||
:model-value="modelValue"
|
:model-value="modelValue"
|
||||||
title="Settings"
|
:title="t('settings.title')"
|
||||||
size="lg"
|
size="lg"
|
||||||
@update:model-value="emit('update:modelValue', $event)"
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
>
|
>
|
||||||
@@ -91,14 +92,14 @@ function applyTheme(t: Theme) {
|
|||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<section v-if="activeTab === 'document'">
|
<section v-if="activeTab === 'document'">
|
||||||
<h3>Canvas</h3>
|
<h3>{{ t('settings.canvas') }}</h3>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label>
|
<label>
|
||||||
Width
|
{{ t('settings.width') }}
|
||||||
<input type="number" min="1" step="1" v-model.number="width" />
|
<input type="number" min="1" step="1" v-model.number="width" />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Height
|
{{ t('settings.height') }}
|
||||||
<input type="number" min="1" step="1" v-model.number="height" />
|
<input type="number" min="1" step="1" v-model.number="height" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,7 +115,7 @@ function applyTheme(t: Theme) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<label class="stacked">
|
<label class="stacked">
|
||||||
Background
|
{{ t('settings.background') }}
|
||||||
<div class="color-row">
|
<div class="color-row">
|
||||||
<input
|
<input
|
||||||
type="color"
|
type="color"
|
||||||
@@ -128,24 +129,24 @@ function applyTheme(t: Theme) {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-else-if="activeTab === 'timeline'">
|
<section v-else-if="activeTab === 'timeline'">
|
||||||
<h3>Timeline</h3>
|
<h3>{{ t('settings.tab.timeline') }}</h3>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label>
|
<label>
|
||||||
Duration (s)
|
{{ t('settings.duration') }}
|
||||||
<input type="number" min="0.1" step="0.1" v-model.number="duration" />
|
<input type="number" min="0.1" step="0.1" v-model.number="duration" />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
FPS
|
{{ t('settings.fps') }}
|
||||||
<input type="number" min="1" step="1" v-model.number="fps" />
|
<input type="number" min="1" step="1" v-model.number="fps" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
Die Timeline-Länge setzt die Ruler-Skala. Playback stoppt am letzten gesetzten Keyframe.
|
{{ t('settings.timelineHint') }}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-else-if="activeTab === 'theme'">
|
<section v-else-if="activeTab === 'theme'">
|
||||||
<h3>UI Theme</h3>
|
<h3>{{ t('settings.tab.theme') }}</h3>
|
||||||
<div class="theme-grid">
|
<div class="theme-grid">
|
||||||
<button
|
<button
|
||||||
class="theme-card"
|
class="theme-card"
|
||||||
@@ -158,7 +159,7 @@ function applyTheme(t: Theme) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="theme-label">
|
<div class="theme-label">
|
||||||
<PhMoon :size="14" />
|
<PhMoon :size="14" />
|
||||||
<span>Dark</span>
|
<span>{{ t('settings.themeDark') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -172,16 +173,16 @@ function applyTheme(t: Theme) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="theme-label">
|
<div class="theme-label">
|
||||||
<PhSun :size="14" />
|
<PhSun :size="14" />
|
||||||
<span>Light</span>
|
<span>{{ t('settings.themeLight') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">Gespeichert im Browser, beim nächsten Öffnen wiederverwendet.</p>
|
<p class="hint">{{ t('settings.themeHint') }}</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<button class="primary" @click="close">Fertig</button>
|
<button class="primary" @click="close">{{ t('settings.done') }}</button>
|
||||||
</template>
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { downloadExampleHtml, downloadRuntime, downloadSceneExport } from '@/lib
|
|||||||
import { downloadSceneAsVideo, pickMimeType } from '@/lib/videoExport'
|
import { downloadSceneAsVideo, pickMimeType } from '@/lib/videoExport'
|
||||||
import { downloadFromServer } from '@/lib/serverExport'
|
import { downloadFromServer } from '@/lib/serverExport'
|
||||||
import { Modal } from '@/components/ui'
|
import { Modal } from '@/components/ui'
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
defineProps<{ modelValue: boolean }>()
|
defineProps<{ modelValue: boolean }>()
|
||||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||||
@@ -53,7 +54,7 @@ async function exportWebm() {
|
|||||||
progress.value = {
|
progress.value = {
|
||||||
progress: 0,
|
progress: 0,
|
||||||
controller,
|
controller,
|
||||||
label: 'Rendering WebM (Browser, Realtime)…',
|
label: t('export.labelWebm'),
|
||||||
error: null,
|
error: null,
|
||||||
}
|
}
|
||||||
store.pause()
|
store.pause()
|
||||||
@@ -81,7 +82,7 @@ async function exportMp4Server() {
|
|||||||
progress.value = {
|
progress.value = {
|
||||||
progress: 0,
|
progress: 0,
|
||||||
controller,
|
controller,
|
||||||
label: 'Rendering MP4 (Server)…',
|
label: t('export.labelMp4'),
|
||||||
error: null,
|
error: null,
|
||||||
}
|
}
|
||||||
store.pause()
|
store.pause()
|
||||||
@@ -122,7 +123,7 @@ function onUpdate(v: boolean) {
|
|||||||
<template>
|
<template>
|
||||||
<Modal
|
<Modal
|
||||||
:model-value="modelValue"
|
:model-value="modelValue"
|
||||||
title="Export"
|
:title="t('export.title')"
|
||||||
size="md"
|
size="md"
|
||||||
:dismissable="!isLocked"
|
:dismissable="!isLocked"
|
||||||
:close-on-backdrop="!isLocked"
|
:close-on-backdrop="!isLocked"
|
||||||
@@ -135,9 +136,9 @@ function onUpdate(v: boolean) {
|
|||||||
<div class="opt-text">
|
<div class="opt-text">
|
||||||
<div class="opt-title">
|
<div class="opt-title">
|
||||||
animatr-runtime.js
|
animatr-runtime.js
|
||||||
<span class="badge">1× pro Seite</span>
|
<span class="badge">{{ t('export.runtimeBadge') }}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<PhDownloadSimple :size="14" class="opt-arrow" />
|
<PhDownloadSimple :size="14" class="opt-arrow" />
|
||||||
</button>
|
</button>
|
||||||
@@ -147,9 +148,9 @@ function onUpdate(v: boolean) {
|
|||||||
<div class="opt-text">
|
<div class="opt-text">
|
||||||
<div class="opt-title">
|
<div class="opt-title">
|
||||||
animation.js
|
animation.js
|
||||||
<span class="badge">Szene</span>
|
<span class="badge">{{ t('export.sceneBadge') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="opt-desc">Nur Szenen-Daten — benötigt geladene Runtime</div>
|
<div class="opt-desc">{{ t('export.sceneDesc') }}</div>
|
||||||
</div>
|
</div>
|
||||||
<PhDownloadSimple :size="14" class="opt-arrow" />
|
<PhDownloadSimple :size="14" class="opt-arrow" />
|
||||||
</button>
|
</button>
|
||||||
@@ -157,8 +158,8 @@ function onUpdate(v: boolean) {
|
|||||||
<button class="opt" @click="exportHtml">
|
<button class="opt" @click="exportHtml">
|
||||||
<div class="opt-icon"><PhFileHtml :size="20" /></div>
|
<div class="opt-icon"><PhFileHtml :size="20" /></div>
|
||||||
<div class="opt-text">
|
<div class="opt-text">
|
||||||
<div class="opt-title">Beispiel HTML</div>
|
<div class="opt-title">{{ t('export.exampleTitle') }}</div>
|
||||||
<div class="opt-desc">Standalone HTML — Browser öffnen und fertig</div>
|
<div class="opt-desc">{{ t('export.exampleDesc') }}</div>
|
||||||
</div>
|
</div>
|
||||||
<PhDownloadSimple :size="14" class="opt-arrow" />
|
<PhDownloadSimple :size="14" class="opt-arrow" />
|
||||||
</button>
|
</button>
|
||||||
@@ -168,9 +169,9 @@ function onUpdate(v: boolean) {
|
|||||||
<div class="opt-text">
|
<div class="opt-text">
|
||||||
<div class="opt-title">
|
<div class="opt-title">
|
||||||
MP4 Video
|
MP4 Video
|
||||||
<span class="badge accent">Server · Frame-exakt</span>
|
<span class="badge accent">{{ t('export.mp4Badge') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="opt-desc">H.264, beliebige Auflösung — benötigt Render-Server</div>
|
<div class="opt-desc">{{ t('export.mp4Desc') }}</div>
|
||||||
</div>
|
</div>
|
||||||
<PhDownloadSimple :size="14" class="opt-arrow" />
|
<PhDownloadSimple :size="14" class="opt-arrow" />
|
||||||
</button>
|
</button>
|
||||||
@@ -180,10 +181,10 @@ function onUpdate(v: boolean) {
|
|||||||
<div class="opt-text">
|
<div class="opt-text">
|
||||||
<div class="opt-title">
|
<div class="opt-title">
|
||||||
WebM Video
|
WebM Video
|
||||||
<span class="badge">Browser · Realtime</span>
|
<span class="badge">{{ t('export.webmBadge') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="opt-desc">
|
<div class="opt-desc">
|
||||||
{{ videoSupported ? 'Ohne Server — Aufnahme in Echtzeit' : 'Browser unterstützt MediaRecorder nicht' }}
|
{{ videoSupported ? t('export.webmDesc') : t('export.webmNotSupported') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<PhDownloadSimple :size="14" class="opt-arrow" />
|
<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 class="r-bar-fill" :style="{ width: progress.progress * 100 + '%' }"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="r-actions">
|
<div class="r-actions">
|
||||||
<button class="secondary" @click="cancel">Abbrechen</button>
|
<button class="secondary" @click="cancel">{{ t('export.cancel') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="error-view">
|
<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-msg">{{ progress.error }}</div>
|
||||||
<div class="error-actions">
|
<div class="error-actions">
|
||||||
<button class="primary" @click="dismissError">Zurück</button>
|
<button class="primary" @click="dismissError">{{ t('export.back') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|||||||
import { PhPause, PhPlay, PhSkipBack, PhX } from '@phosphor-icons/vue'
|
import { PhPause, PhPlay, PhSkipBack, PhX } from '@phosphor-icons/vue'
|
||||||
import { useSceneStore } from '@/stores/scene'
|
import { useSceneStore } from '@/stores/scene'
|
||||||
import { drawSceneFrame, loadSceneImages } from '@/lib/render'
|
import { drawSceneFrame, loadSceneImages } from '@/lib/render'
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
const props = defineProps<{ modelValue: boolean }>()
|
const props = defineProps<{ modelValue: boolean }>()
|
||||||
const emit = defineEmits<{ 'update:modelValue': [value: 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 class="fs-progress-handle" :style="{ left: progressPct + '%' }"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="fs-toolbar">
|
<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" />
|
<PhPause v-if="playing" :size="15" weight="fill" />
|
||||||
<PhPlay v-else :size="15" weight="fill" />
|
<PhPlay v-else :size="15" weight="fill" />
|
||||||
</button>
|
</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" />
|
<PhSkipBack :size="15" weight="fill" />
|
||||||
</button>
|
</button>
|
||||||
<span class="fs-time">{{ timeLabel }}</span>
|
<span class="fs-time">{{ timeLabel }}</span>
|
||||||
<div class="fs-spacer"></div>
|
<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" />
|
<PhX :size="15" weight="bold" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useSceneStore } from '@/stores/scene'
|
|||||||
import { sampleLayer } from '@/lib/interpolate'
|
import { sampleLayer } from '@/lib/interpolate'
|
||||||
import type { AnimatableProps } from '@/types/scene'
|
import type { AnimatableProps } from '@/types/scene'
|
||||||
import RangeSlider from './RangeSlider.vue'
|
import RangeSlider from './RangeSlider.vue'
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
const formatPercent = (v: number) => `${Math.round(v * 100)}%`
|
const formatPercent = (v: number) => `${Math.round(v * 100)}%`
|
||||||
|
|
||||||
@@ -86,10 +87,10 @@ function removeKeyframeHere() {
|
|||||||
<div class="kf-row" :class="{ on: hasKeyframeHere }">
|
<div class="kf-row" :class="{ on: hasKeyframeHere }">
|
||||||
<PhDiamond :size="11" :weight="hasKeyframeHere ? 'fill' : 'regular'" class="kf-dot" />
|
<PhDiamond :size="11" :weight="hasKeyframeHere ? 'fill' : 'regular'" class="kf-dot" />
|
||||||
<span class="kf-label">
|
<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 class="kf-time">@ {{ store.playhead.toFixed(2) }}s</span>
|
||||||
</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" />
|
<PhX :size="12" weight="bold" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,10 +122,10 @@ function removeKeyframeHere() {
|
|||||||
|
|
||||||
<button class="danger-btn" @click="store.removeLayer(selected.id)">
|
<button class="danger-btn" @click="store.removeLayer(selected.id)">
|
||||||
<PhTrash :size="13" />
|
<PhTrash :size="13" />
|
||||||
Layer löschen
|
{{ t('properties.deleteLayer') }}
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<p v-else class="hint">Kein Layer ausgewählt.</p>
|
<p v-else class="hint">{{ t('properties.noLayer') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
+373
-49
@@ -14,6 +14,8 @@ import {
|
|||||||
import { useSceneStore } from '@/stores/scene'
|
import { useSceneStore } from '@/stores/scene'
|
||||||
import { sampleLayer } from '@/lib/interpolate'
|
import { sampleLayer } from '@/lib/interpolate'
|
||||||
import { snapToCandidates } from '@/lib/timelineSnap'
|
import { snapToCandidates } from '@/lib/timelineSnap'
|
||||||
|
import { confirmDialog } from '@/components/ui'
|
||||||
|
import { t } from '@/i18n'
|
||||||
import type {
|
import type {
|
||||||
AnimatableProps,
|
AnimatableProps,
|
||||||
Clip,
|
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 })
|
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex })
|
||||||
} else {
|
} 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.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.positionKeyframes ?? [], kind: 'position', label: t('timeline.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.rotationKeyframes ?? [], kind: 'rotation', label: t('timeline.rotation'), isParent: false, orderIndex })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return out
|
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 })
|
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex })
|
||||||
} else {
|
} 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.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.positionKeyframes ?? [], kind: 'position', label: t('timeline.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.rotationKeyframes ?? [], kind: 'rotation', label: t('timeline.rotation'), isParent: false, orderIndex })
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const ct = clipTrackById.value.get(id)
|
const ct = clipTrackById.value.get(id)
|
||||||
if (ct) {
|
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
|
return out
|
||||||
@@ -316,33 +318,110 @@ function isDropAfter(track: TimelineTrack, nextTrack: TimelineTrack | undefined)
|
|||||||
return true
|
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 {
|
interface KfDrag {
|
||||||
layerId: string
|
layerId: string
|
||||||
keyframeId: string
|
keyframeId: string
|
||||||
|
kind: 'main' | 'position' | 'rotation'
|
||||||
lane: HTMLElement
|
lane: HTMLElement
|
||||||
pointerId: number
|
pointerId: number
|
||||||
startX: number
|
startX: number
|
||||||
moved: boolean
|
moved: boolean
|
||||||
|
initialTime: number
|
||||||
|
snapshot: SelectionSnapshotEntry[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const kfDrag = ref<KfDrag | null>(null)
|
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
|
if (e.button !== 0) return
|
||||||
const layer = store.activeLayers.find((l) => l.id === layerId)
|
const layer = store.activeLayers.find((l) => l.id === layerId)
|
||||||
if (layer?.locked) return
|
if (layer?.locked) return
|
||||||
const kfEl = e.currentTarget as HTMLElement
|
const kfEl = e.currentTarget as HTMLElement
|
||||||
const lane = kfEl.parentElement
|
const lane = kfEl.parentElement
|
||||||
if (!lane) return
|
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()
|
e.stopPropagation()
|
||||||
kfEl.setPointerCapture(e.pointerId)
|
kfEl.setPointerCapture(e.pointerId)
|
||||||
kfDrag.value = {
|
kfDrag.value = {
|
||||||
layerId,
|
layerId,
|
||||||
keyframeId,
|
keyframeId,
|
||||||
|
kind,
|
||||||
lane,
|
lane,
|
||||||
pointerId: e.pointerId,
|
pointerId: e.pointerId,
|
||||||
startX: e.clientX,
|
startX: e.clientX,
|
||||||
moved: false,
|
moved: false,
|
||||||
|
initialTime,
|
||||||
|
snapshot: snapshotSelection(),
|
||||||
}
|
}
|
||||||
store.selectLayer(layerId)
|
store.selectLayer(layerId)
|
||||||
}
|
}
|
||||||
@@ -355,9 +434,50 @@ function onKeyframePointerMove(e: PointerEvent) {
|
|||||||
const rect = d.lane.getBoundingClientRect()
|
const rect = d.lane.getBoundingClientRect()
|
||||||
if (rect.width <= 0) return
|
if (rect.width <= 0) return
|
||||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||||
const newTime = snapTime(ratio * duration.value, d.keyframeId)
|
const rawNewTime = ratio * duration.value
|
||||||
store.setKeyframeTime(d.layerId, d.keyframeId, newTime)
|
// For snapping, ignore all currently selected kfs so we don't snap to ourselves
|
||||||
store.setPlayhead(newTime)
|
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) {
|
function onKeyframePointerUp(e: PointerEvent) {
|
||||||
@@ -404,10 +524,10 @@ function segmentStyle(seg: KfSegment) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const EASING_OPTIONS: { value: Easing; label: string }[] = [
|
const EASING_OPTIONS: { value: Easing; label: string }[] = [
|
||||||
{ value: 'linear', label: 'Linear' },
|
{ value: 'linear', label: t('timeline.easingLinear') },
|
||||||
{ value: 'easeIn', label: 'Ease In' },
|
{ value: 'easeIn', label: t('timeline.easingEaseIn') },
|
||||||
{ value: 'easeOut', label: 'Ease Out' },
|
{ value: 'easeOut', label: t('timeline.easingEaseOut') },
|
||||||
{ value: 'easeInOut', label: 'Ease In-Out' },
|
{ value: 'easeInOut', label: t('timeline.easingEaseInOut') },
|
||||||
]
|
]
|
||||||
|
|
||||||
interface KfMenuState {
|
interface KfMenuState {
|
||||||
@@ -498,16 +618,16 @@ interface TrackMenuState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TRACK_COLORS: Array<{ value: string | null; label: string }> = [
|
const TRACK_COLORS: Array<{ value: string | null; label: string }> = [
|
||||||
{ value: null, label: 'Keine' },
|
{ value: null, label: t('timeline.colors.none') },
|
||||||
{ value: '#ff6b6b', label: 'Rot' },
|
{ value: '#ff6b6b', label: t('timeline.colors.red') },
|
||||||
{ value: '#ffa94d', label: 'Orange' },
|
{ value: '#ffa94d', label: t('timeline.colors.orange') },
|
||||||
{ value: '#ffd43b', label: 'Gelb' },
|
{ value: '#ffd43b', label: t('timeline.colors.yellow') },
|
||||||
{ value: '#51cf66', label: 'Grün' },
|
{ value: '#51cf66', label: t('timeline.colors.green') },
|
||||||
{ value: '#4dc4ff', label: 'Türkis' },
|
{ value: '#4dc4ff', label: t('timeline.colors.cyan') },
|
||||||
{ value: '#6ea8ff', label: 'Blau' },
|
{ value: '#6ea8ff', label: t('timeline.colors.blue') },
|
||||||
{ value: '#b197fc', label: 'Lila' },
|
{ value: '#b197fc', label: t('timeline.colors.purple') },
|
||||||
{ value: '#ff8cc6', label: 'Pink' },
|
{ value: '#ff8cc6', label: t('timeline.colors.pink') },
|
||||||
{ value: '#adb5bd', label: 'Grau' },
|
{ value: '#adb5bd', label: t('timeline.colors.gray') },
|
||||||
]
|
]
|
||||||
|
|
||||||
const dragTrackId = ref<string | null>(null)
|
const dragTrackId = ref<string | null>(null)
|
||||||
@@ -559,6 +679,105 @@ function onTrackDragEnd() {
|
|||||||
dropIndex.value = null
|
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 {
|
function pixelTimeFromEvent(e: DragEvent | PointerEvent, el: HTMLElement): number {
|
||||||
const rect = el.getBoundingClientRect()
|
const rect = el.getBoundingClientRect()
|
||||||
if (rect.width <= 0) return 0
|
if (rect.width <= 0) return 0
|
||||||
@@ -665,10 +884,10 @@ function trimTooltip(ci: ClipInstance): string {
|
|||||||
const name = clipById.value.get(ci.clipId)?.name ?? '?'
|
const name = clipById.value.get(ci.clipId)?.name ?? '?'
|
||||||
const dur = visibleDurationOf(ci).toFixed(2)
|
const dur = visibleDurationOf(ci).toFixed(2)
|
||||||
const trims: string[] = []
|
const trims: string[] = []
|
||||||
if ((ci.trimStart ?? 0) > 0) trims.push(`Start -${(ci.trimStart ?? 0).toFixed(2)}s`)
|
if ((ci.trimStart ?? 0) > 0) trims.push(t('timeline.trimStart', { value: (ci.trimStart ?? 0).toFixed(2) }))
|
||||||
if ((ci.trimEnd ?? 0) > 0) trims.push(`Ende -${(ci.trimEnd ?? 0).toFixed(2)}s`)
|
if ((ci.trimEnd ?? 0) > 0) trims.push(t('timeline.trimEnd', { value: (ci.trimEnd ?? 0).toFixed(2) }))
|
||||||
const suffix = trims.length ? ` · ${trims.join(' · ')}` : ''
|
const trimsStr = trims.length ? ` · ${trims.join(' · ')}` : ''
|
||||||
return `${name} · ${dur}s${suffix} · Kanten ziehen zum Verkürzen, Doppelklick zum Öffnen`
|
return t('timeline.trimTooltip', { name, duration: dur, trims: trimsStr })
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClipTrimState {
|
interface ClipTrimState {
|
||||||
@@ -734,9 +953,21 @@ function onClipBlockDblClick(ci: ClipInstance) {
|
|||||||
store.enterClip(ci.clipId)
|
store.enterClip(ci.clipId)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onClipBlockContextMenu(e: MouseEvent, ci: ClipInstance) {
|
async function onClipBlockContextMenu(e: MouseEvent, ci: ClipInstance) {
|
||||||
e.preventDefault()
|
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)
|
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]) => {
|
watch([menu, trackMenu], ([m, tm]) => {
|
||||||
if (m || tm) {
|
if (m || tm) {
|
||||||
document.addEventListener('pointerdown', onDocumentPointerDown, true)
|
document.addEventListener('pointerdown', onDocumentPointerDown, true)
|
||||||
@@ -874,6 +1111,7 @@ onMounted(() => {
|
|||||||
viewportWidth.value = rect.width
|
viewportWidth.value = rect.width
|
||||||
})
|
})
|
||||||
ro.observe(scrollEl.value)
|
ro.observe(scrollEl.value)
|
||||||
|
document.addEventListener('keydown', onDocumentKeyDown)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
@@ -881,6 +1119,7 @@ onBeforeUnmount(() => {
|
|||||||
ro = null
|
ro = null
|
||||||
document.removeEventListener('pointerdown', onDocumentPointerDown, true)
|
document.removeEventListener('pointerdown', onDocumentPointerDown, true)
|
||||||
document.removeEventListener('keydown', onKeyDown)
|
document.removeEventListener('keydown', onKeyDown)
|
||||||
|
document.removeEventListener('keydown', onDocumentKeyDown)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -890,7 +1129,7 @@ onBeforeUnmount(() => {
|
|||||||
<div class="tl-content" :style="{ width: totalWidth + 'px' }">
|
<div class="tl-content" :style="{ width: totalWidth + 'px' }">
|
||||||
<div class="scrub-bar">
|
<div class="scrub-bar">
|
||||||
<div class="name-spacer">
|
<div class="name-spacer">
|
||||||
<div class="mode-toggle" title="Zeit-Einheit umschalten">
|
<div class="mode-toggle" :title="t('timeline.toggleTimeUnit')">
|
||||||
<button
|
<button
|
||||||
class="mode-btn"
|
class="mode-btn"
|
||||||
:class="{ active: displayMode === 'seconds' }"
|
:class="{ active: displayMode === 'seconds' }"
|
||||||
@@ -905,7 +1144,7 @@ onBeforeUnmount(() => {
|
|||||||
<button
|
<button
|
||||||
class="snap-btn"
|
class="snap-btn"
|
||||||
:class="{ active: snapEnabled }"
|
: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"
|
@click="snapEnabled = !snapEnabled"
|
||||||
>
|
>
|
||||||
<PhMagnet :size="12" :weight="snapEnabled ? 'fill' : 'regular'" />
|
<PhMagnet :size="12" :weight="snapEnabled ? 'fill' : 'regular'" />
|
||||||
@@ -935,7 +1174,14 @@ onBeforeUnmount(() => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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}`">
|
<template v-for="(track, idx) in tracks" :key="track.type === 'layer' ? `l:${track.layerId}:${track.kind}` : `c:${track.clipTrackId}`">
|
||||||
<!-- Layer track -->
|
<!-- Layer track -->
|
||||||
<div
|
<div
|
||||||
@@ -1000,14 +1246,20 @@ onBeforeUnmount(() => {
|
|||||||
v-for="kf in track.kfs"
|
v-for="kf in track.kfs"
|
||||||
:key="kf.id"
|
:key="kf.id"
|
||||||
class="kf"
|
class="kf"
|
||||||
:class="{ hold: kf.hold }"
|
:class="{
|
||||||
|
hold: kf.hold,
|
||||||
|
selected: isKfSelected(track.layerId, track.kind, kf.id),
|
||||||
|
}"
|
||||||
:style="{ left: pxFromTime(kf.time) + 'px' }"
|
:style="{ left: pxFromTime(kf.time) + 'px' }"
|
||||||
|
:data-layer-id="track.layerId"
|
||||||
|
:data-kind="track.kind"
|
||||||
|
:data-kf-id="kf.id"
|
||||||
:title="
|
:title="
|
||||||
kf.hold
|
kf.hold
|
||||||
? `Still Frame ${formatKfTime(kf.time)} — hält bis zum nächsten Keyframe`
|
? t('timeline.stillFrameTooltip', { time: formatKfTime(kf.time) })
|
||||||
: `Keyframe ${formatKfTime(kf.time)} — ziehen zum Verschieben, Rechtsklick für Optionen`
|
: 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"
|
@pointermove="onKeyframePointerMove"
|
||||||
@pointerup="onKeyframePointerUp"
|
@pointerup="onKeyframePointerUp"
|
||||||
@pointercancel="onKeyframePointerUp"
|
@pointercancel="onKeyframePointerUp"
|
||||||
@@ -1104,11 +1356,11 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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">
|
<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" />
|
<PhPlus :size="11" weight="bold" />
|
||||||
<span>Clip-Spur hinzufügen</span>
|
<span>{{ t('timeline.addClipTrack') }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1141,31 +1393,31 @@ onBeforeUnmount(() => {
|
|||||||
</span>
|
</span>
|
||||||
{{ opt.label }}
|
{{ opt.label }}
|
||||||
</button>
|
</button>
|
||||||
<div v-if="menu.isFirst" class="kf-menu-note">Erster Keyframe — Easing ohne Effekt</div>
|
<div v-if="menu.isFirst" class="kf-menu-note">{{ t('timeline.firstKfHint') }}</div>
|
||||||
<div v-else-if="menu.isHeld" class="kf-menu-note">Still Frame — Easing deaktiviert</div>
|
<div v-else-if="menu.isHeld" class="kf-menu-note">{{ t('timeline.holdDisabledHint') }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="kf-menu-divider"></div>
|
<div class="kf-menu-divider"></div>
|
||||||
<button
|
<button
|
||||||
class="kf-menu-item"
|
class="kf-menu-item"
|
||||||
:class="{ active: menu.isHeld }"
|
:class="{ active: menu.isHeld }"
|
||||||
:disabled="menu.isLast"
|
:disabled="menu.isLast"
|
||||||
:title="menu.isLast ? 'Letzter Keyframe — Still Frame ohne Effekt' : ''"
|
:title="menu.isLast ? t('timeline.lastKfHint') : ''"
|
||||||
@click="toggleHold"
|
@click="toggleHold"
|
||||||
>
|
>
|
||||||
<span class="check">
|
<span class="check">
|
||||||
<PhCheck v-if="menu.isHeld" :size="12" weight="bold" />
|
<PhCheck v-if="menu.isHeld" :size="12" weight="bold" />
|
||||||
</span>
|
</span>
|
||||||
<PhPauseCircle :size="12" />
|
<PhPauseCircle :size="12" />
|
||||||
Still Frame
|
{{ t('timeline.stillFrame') }}
|
||||||
</button>
|
</button>
|
||||||
<div class="kf-menu-divider"></div>
|
<div class="kf-menu-divider"></div>
|
||||||
<button class="kf-menu-item" @click="duplicateFromMenu">
|
<button class="kf-menu-item" @click="duplicateFromMenu">
|
||||||
<PhCopy :size="12" />
|
<PhCopy :size="12" />
|
||||||
Duplizieren
|
{{ t('timeline.duplicate') }}
|
||||||
</button>
|
</button>
|
||||||
<button class="kf-menu-item danger" @click="deleteFromMenu">
|
<button class="kf-menu-item danger" @click="deleteFromMenu">
|
||||||
<PhTrash :size="12" />
|
<PhTrash :size="12" />
|
||||||
Löschen
|
{{ t('timeline.delete') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1176,7 +1428,7 @@ onBeforeUnmount(() => {
|
|||||||
:style="{ left: trackMenu.x + 'px', top: trackMenu.y + 'px' }"
|
:style="{ left: trackMenu.x + 'px', top: trackMenu.y + 'px' }"
|
||||||
>
|
>
|
||||||
<div class="kf-menu-section">
|
<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">
|
<div class="color-tiles">
|
||||||
<button
|
<button
|
||||||
v-for="c in TRACK_COLORS"
|
v-for="c in TRACK_COLORS"
|
||||||
@@ -1201,7 +1453,7 @@ onBeforeUnmount(() => {
|
|||||||
<PhCheck v-if="trackMenu.locked" :size="12" weight="bold" />
|
<PhCheck v-if="trackMenu.locked" :size="12" weight="bold" />
|
||||||
</span>
|
</span>
|
||||||
<component :is="trackMenu.locked ? PhLockOpen : PhLock" :size="12" />
|
<component :is="trackMenu.locked ? PhLockOpen : PhLock" :size="12" />
|
||||||
{{ trackMenu.locked ? 'Entsperren' : 'Sperren' }}
|
{{ trackMenu.locked ? t('timeline.unlock') : t('timeline.lock') }}
|
||||||
</button>
|
</button>
|
||||||
<template v-if="trackMenu.kind === 'layer'">
|
<template v-if="trackMenu.kind === 'layer'">
|
||||||
<div class="kf-menu-divider"></div>
|
<div class="kf-menu-divider"></div>
|
||||||
@@ -1213,13 +1465,13 @@ onBeforeUnmount(() => {
|
|||||||
<span class="check">
|
<span class="check">
|
||||||
<PhCheck v-if="trackMenu.separated" :size="12" weight="bold" />
|
<PhCheck v-if="trackMenu.separated" :size="12" weight="bold" />
|
||||||
</span>
|
</span>
|
||||||
Separate Position and Rotation
|
{{ t('timeline.separate') }}
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<div class="kf-menu-divider"></div>
|
<div class="kf-menu-divider"></div>
|
||||||
<button class="kf-menu-item danger" @click="deleteTrack">
|
<button class="kf-menu-item danger" @click="deleteTrack">
|
||||||
<PhTrash :size="12" />
|
<PhTrash :size="12" />
|
||||||
{{ trackMenu.kind === 'layer' ? 'Layer löschen' : 'Clip-Spur löschen' }}
|
{{ trackMenu.kind === 'layer' ? t('timeline.deleteLayer') : t('timeline.deleteClipTrack') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1405,6 +1657,26 @@ onBeforeUnmount(() => {
|
|||||||
.tracks {
|
.tracks {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 {
|
.track {
|
||||||
@@ -1495,6 +1767,58 @@ onBeforeUnmount(() => {
|
|||||||
opacity: 0.85;
|
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 {
|
.track-name {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
listRecentProjects,
|
listRecentProjects,
|
||||||
type StoredProject,
|
type StoredProject,
|
||||||
} from '@/lib/projectStore'
|
} from '@/lib/projectStore'
|
||||||
import { Modal } from '@/components/ui'
|
import { confirmDialog, Modal } from '@/components/ui'
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
const props = defineProps<{ modelValue: boolean }>()
|
const props = defineProps<{ modelValue: boolean }>()
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -43,7 +44,9 @@ function formatDate(ts: number): string {
|
|||||||
d.getMonth() === now.getMonth() &&
|
d.getMonth() === now.getMonth() &&
|
||||||
d.getDate() === now.getDate()
|
d.getDate() === now.getDate()
|
||||||
if (sameDay) {
|
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([], {
|
return d.toLocaleString([], {
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
@@ -65,7 +68,13 @@ function openProject(p: StoredProject) {
|
|||||||
|
|
||||||
async function removeProject(e: MouseEvent, p: StoredProject) {
|
async function removeProject(e: MouseEvent, p: StoredProject) {
|
||||||
e.stopPropagation()
|
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 deleteStoredProject(p.id)
|
||||||
await reload()
|
await reload()
|
||||||
}
|
}
|
||||||
@@ -108,14 +117,14 @@ watch(
|
|||||||
>
|
>
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="hdr-text">
|
<div class="hdr-text">
|
||||||
<h2>Willkommen bei Animatr</h2>
|
<h2>{{ t('welcome.heading') }}</h2>
|
||||||
<p class="sub">Zuletzt bearbeitete Projekte</p>
|
<p class="sub">{{ t('welcome.subheading') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="body">
|
<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">
|
<div v-else-if="!recents.length" class="empty">
|
||||||
Keine gespeicherten Projekte vorhanden.
|
{{ t('welcome.empty') }}
|
||||||
</div>
|
</div>
|
||||||
<ul v-else class="recents">
|
<ul v-else class="recents">
|
||||||
<li
|
<li
|
||||||
@@ -131,12 +140,12 @@ watch(
|
|||||||
<div class="meta">
|
<div class="meta">
|
||||||
<div class="name">{{ p.name }}</div>
|
<div class="name">{{ p.name }}</div>
|
||||||
<div class="sub-info">
|
<div class="sub-info">
|
||||||
{{ layerCount(p) }} Layer · {{ formatDate(p.savedAt) }}
|
{{ t('welcome.layerCount', { n: layerCount(p) }) }} · {{ formatDate(p.savedAt) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="del"
|
class="del"
|
||||||
title="Projekt löschen"
|
:title="t('welcome.deleteTooltip')"
|
||||||
@click="removeProject($event, p)"
|
@click="removeProject($event, p)"
|
||||||
>
|
>
|
||||||
<PhTrash :size="13" />
|
<PhTrash :size="13" />
|
||||||
@@ -147,15 +156,15 @@ watch(
|
|||||||
<template #footer>
|
<template #footer>
|
||||||
<button class="ftr-btn" @click="startFresh">
|
<button class="ftr-btn" @click="startFresh">
|
||||||
<PhPlus :size="13" weight="bold" />
|
<PhPlus :size="13" weight="bold" />
|
||||||
Neues Projekt
|
{{ t('welcome.newProject') }}
|
||||||
</button>
|
</button>
|
||||||
<button class="ftr-btn" @click="importFile">
|
<button class="ftr-btn" @click="importFile">
|
||||||
<PhFileArrowUp :size="13" />
|
<PhFileArrowUp :size="13" />
|
||||||
Datei importieren…
|
{{ t('welcome.importFile') }}
|
||||||
</button>
|
</button>
|
||||||
<button class="ftr-btn" @click="exportFile">
|
<button class="ftr-btn" @click="exportFile">
|
||||||
<PhDownloadSimple :size="13" />
|
<PhDownloadSimple :size="13" />
|
||||||
Aktuelles exportieren
|
{{ t('welcome.exportCurrent') }}
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onBeforeUnmount, onMounted } from 'vue'
|
import { onBeforeUnmount, onMounted } from 'vue'
|
||||||
import { PhX } from '@phosphor-icons/vue'
|
import { PhX } from '@phosphor-icons/vue'
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
type Size = 'sm' | 'md' | 'lg'
|
type Size = 'sm' | 'md' | 'lg'
|
||||||
|
|
||||||
@@ -59,7 +60,7 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
|
|||||||
v-if="dismissable"
|
v-if="dismissable"
|
||||||
class="close"
|
class="close"
|
||||||
:disabled="!dismissable"
|
:disabled="!dismissable"
|
||||||
title="Schließen"
|
:title="t('dialog.close')"
|
||||||
@click="close"
|
@click="close"
|
||||||
>
|
>
|
||||||
<PhX :size="14" weight="bold" />
|
<PhX :size="14" weight="bold" />
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
export { default as DialogsHost } from './DialogsHost.vue'
|
||||||
export { default as IconButton } from './IconButton.vue'
|
export { default as IconButton } from './IconButton.vue'
|
||||||
export { default as Modal } from './Modal.vue'
|
export { default as Modal } from './Modal.vue'
|
||||||
export { default as Toast } from './Toast.vue'
|
export { default as Toast } from './Toast.vue'
|
||||||
|
export { confirmDialog, promptDialog } from './dialogs'
|
||||||
|
export type { ConfirmOptions, PromptOptions } from './dialogs'
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { PhArrowClockwise, PhCheck, PhClipboardText, PhWarning, PhX } from '@phosphor-icons/vue'
|
import { PhArrowClockwise, PhCheck, PhClipboardText, PhWarning, PhX } from '@phosphor-icons/vue'
|
||||||
import { useSceneStore } from '@/stores/scene'
|
import { useSceneStore } from '@/stores/scene'
|
||||||
|
import { t } from '@/i18n'
|
||||||
import { runAll, type TestResult } from './testRunner'
|
import { runAll, type TestResult } from './testRunner'
|
||||||
import { registerAllTests } from './tests'
|
import { registerAllTests } from './tests'
|
||||||
|
|
||||||
@@ -190,7 +191,7 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
|
|||||||
<Transition name="hud-pop">
|
<Transition name="hud-pop">
|
||||||
<div v-if="modelValue && running" class="hud" role="status" aria-live="polite">
|
<div v-if="modelValue && running" class="hud" role="status" aria-live="polite">
|
||||||
<div class="hud-top">
|
<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-counts">
|
||||||
<span class="hud-count">{{ done }}/{{ total }}</span>
|
<span class="hud-count">{{ done }}/{{ total }}</span>
|
||||||
<span class="hud-chip ok" v-if="passedSoFar">{{ passedSoFar }} ✓</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">
|
<div class="panel" role="dialog" aria-modal="true">
|
||||||
<header class="hdr">
|
<header class="hdr">
|
||||||
<div class="hdr-title">
|
<div class="hdr-title">
|
||||||
<span>Test Runner</span>
|
<span>{{ t('tests.title') }}</span>
|
||||||
<span v-if="results.length" class="hdr-status">
|
<span v-if="results.length" class="hdr-status">
|
||||||
<span class="ok-badge" :class="{ bad: failed > 0 }">
|
<span class="ok-badge" :class="{ bad: failed > 0 }">
|
||||||
{{ passed }}/{{ results.length }}
|
{{ passed }}/{{ results.length }}
|
||||||
@@ -225,16 +226,16 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
|
|||||||
<button
|
<button
|
||||||
class="tbtn"
|
class="tbtn"
|
||||||
:disabled="!results.length"
|
: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' }"
|
:class="{ copied: clipboardStatus === 'copied', failed: clipboardStatus === 'failed' }"
|
||||||
@click="copyReport"
|
@click="copyReport"
|
||||||
>
|
>
|
||||||
<PhClipboardText :size="14" />
|
<PhClipboardText :size="14" />
|
||||||
</button>
|
</button>
|
||||||
<button class="tbtn" title="Erneut ausführen" @click="run">
|
<button class="tbtn" :title="t('tests.runAgain')" @click="run">
|
||||||
<PhArrowClockwise :size="14" />
|
<PhArrowClockwise :size="14" />
|
||||||
</button>
|
</button>
|
||||||
<button class="tbtn" title="Schließen" @click="close">
|
<button class="tbtn" :title="t('tests.close')" @click="close">
|
||||||
<PhX :size="14" weight="bold" />
|
<PhX :size="14" weight="bold" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -242,16 +243,16 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
|
|||||||
|
|
||||||
<div v-if="clipboardStatus !== 'idle'" class="clip-banner" :class="clipboardStatus">
|
<div v-if="clipboardStatus !== 'idle'" class="clip-banner" :class="clipboardStatus">
|
||||||
<span v-if="clipboardStatus === 'copied'">
|
<span v-if="clipboardStatus === 'copied'">
|
||||||
Fehlerreport wurde in die Zwischenablage kopiert.
|
{{ t('tests.copiedBanner') }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
Kopieren fehlgeschlagen — Report manuell über das Clipboard-Icon auslösen.
|
{{ t('tests.copyFailedBanner') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<div v-if="!results.length" class="empty">
|
<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>
|
||||||
<div v-for="g in grouped" :key="g.suite" class="suite">
|
<div v-for="g in grouped" :key="g.suite" class="suite">
|
||||||
<div class="suite-hdr" :class="{ bad: !g.ok }">
|
<div class="suite-hdr" :class="{ bad: !g.ok }">
|
||||||
@@ -274,9 +275,9 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="ftr">
|
<footer class="ftr">
|
||||||
<span class="ftr-item">{{ results.length }} Tests</span>
|
<span class="ftr-item">{{ t('tests.testsCountLabel', { n: results.length }) }}</span>
|
||||||
<span class="ftr-item ok" v-if="passed">{{ passed }} grün</span>
|
<span class="ftr-item ok" v-if="passed">{{ t('tests.passedLabel', { n: passed }) }}</span>
|
||||||
<span class="ftr-item bad" v-if="failed">{{ failed }} rot</span>
|
<span class="ftr-item bad" v-if="failed">{{ t('tests.failedLabel', { n: failed }) }}</span>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1203,8 +1203,8 @@ export function registerAllTests(): void {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('topbar has save & recents buttons', () => {
|
test('topbar has save & recents buttons', () => {
|
||||||
const saveBtn = document.querySelector('button[title^="Projekt speichern"]')
|
const saveBtn = document.querySelector('button[title^="Save project"]')
|
||||||
const recentsBtn = document.querySelector('button[title="Zuletzt verwendete Projekte"]')
|
const recentsBtn = document.querySelector('button[title="Recent projects"]')
|
||||||
expect(saveBtn).toBeTruthy()
|
expect(saveBtn).toBeTruthy()
|
||||||
expect(recentsBtn).toBeTruthy()
|
expect(recentsBtn).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -324,11 +324,11 @@ export function generateExampleHtml(scene: Scene, options: ExportOptions = {}):
|
|||||||
const sceneBoot = generateSceneScriptBody(scene, options)
|
const sceneBoot = generateSceneScriptBody(scene, options)
|
||||||
const aspect = `${scene.canvas.width} / ${scene.canvas.height}`
|
const aspect = `${scene.canvas.width} / ${scene.canvas.height}`
|
||||||
return `<!doctype html>
|
return `<!doctype html>
|
||||||
<html lang="de">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
<title>Animatr — Beispiel</title>
|
<title>Animatr — Example</title>
|
||||||
<style>
|
<style>
|
||||||
:root { color-scheme: light dark; }
|
:root { color-scheme: light dark; }
|
||||||
body {
|
body {
|
||||||
@@ -383,26 +383,26 @@ export function generateExampleHtml(scene: Scene, options: ExportOptions = {}):
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Animatr Export</h1>
|
<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>
|
<script>
|
||||||
${runtime}
|
${runtime}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="stage">
|
<div class="stage">
|
||||||
<!-- Szene: pro Animation -->
|
<!-- Scene: one per animation -->
|
||||||
<script>
|
<script>
|
||||||
${sceneBoot}
|
${sceneBoot}
|
||||||
</script>
|
</script>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="usage">
|
<div class="usage">
|
||||||
<h2>Einbinden in eine eigene Seite</h2>
|
<h2>Embed on your own page</h2>
|
||||||
<pre><!-- einmal pro Seite -->
|
<pre><!-- once per page -->
|
||||||
<script src="animatr-runtime.js"></script>
|
<script src="animatr-runtime.js"></script>
|
||||||
|
|
||||||
<!-- je Animation ein Container + Scene-Script -->
|
<!-- per animation: one container + scene script -->
|
||||||
<div style="width: 800px; height: 450px;">
|
<div style="width: 800px; height: 450px;">
|
||||||
<script src="animation.js"></script>
|
<script src="animation.js"></script>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,28 +32,28 @@ export function parseProject(text: string): Scene {
|
|||||||
try {
|
try {
|
||||||
data = JSON.parse(text)
|
data = JSON.parse(text)
|
||||||
} catch {
|
} catch {
|
||||||
throw new ProjectFileError('Datei ist kein gültiges JSON.')
|
throw new ProjectFileError('File is not valid JSON.')
|
||||||
}
|
}
|
||||||
if (!data || typeof data !== 'object') {
|
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>
|
const obj = data as Record<string, unknown>
|
||||||
if (obj.magic !== MAGIC) {
|
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
|
const scene = obj.scene
|
||||||
if (!scene || typeof scene !== 'object') {
|
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>
|
const s = scene as Record<string, unknown>
|
||||||
if (!s.canvas || typeof s.canvas !== 'object') {
|
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)) {
|
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)) {
|
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
|
return scene as Scene
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export async function getStoredProject(id: string): Promise<StoredProject | null
|
|||||||
|
|
||||||
function defaultName(): string {
|
function defaultName(): string {
|
||||||
const d = new Date()
|
const d = new Date()
|
||||||
return `Projekt ${d.toLocaleDateString()} ${d.toLocaleTimeString([], {
|
return `Project ${d.toLocaleDateString()} ${d.toLocaleTimeString([], {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
})}`
|
})}`
|
||||||
|
|||||||
@@ -40,10 +40,10 @@ export async function renderOnServer(
|
|||||||
})
|
})
|
||||||
if (!startRes.ok) {
|
if (!startRes.ok) {
|
||||||
const body = await startRes.text().catch(() => '')
|
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 }
|
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 downloadUrl = await new Promise<string>((resolve, reject) => {
|
||||||
const es = new EventSource(`${baseUrl}/render/${jobId}/events`)
|
const es = new EventSource(`${baseUrl}/render/${jobId}/events`)
|
||||||
@@ -78,13 +78,13 @@ export async function renderOnServer(
|
|||||||
opts.signal?.removeEventListener('abort', onAbort)
|
opts.signal?.removeEventListener('abort', onAbort)
|
||||||
if (data.error) reject(new Error(data.error))
|
if (data.error) reject(new Error(data.error))
|
||||||
else if (data.url) resolve(`${baseUrl}${data.url}`)
|
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.onerror = () => {
|
||||||
es.close()
|
es.close()
|
||||||
opts.signal?.removeEventListener('abort', onAbort)
|
opts.signal?.removeEventListener('abort', onAbort)
|
||||||
reject(new Error('Verbindung zum Render-Server verloren'))
|
reject(new Error('Connection to render server lost'))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export async function renderSceneToVideo(
|
|||||||
): Promise<VideoExportResult> {
|
): Promise<VideoExportResult> {
|
||||||
const mimeType = pickMimeType()
|
const mimeType = pickMimeType()
|
||||||
if (!mimeType) {
|
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')
|
const canvas = document.createElement('canvas')
|
||||||
|
|||||||
@@ -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
|
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
|
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
|
||||||
Reference in new issue
Block a user