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

No files matched your search

+63 -74
View File
@@ -16,6 +16,8 @@ import {
import { useSceneStore } from '@/stores/scene'
import { isSvgFile, loadImage, parseSvgDimensions, readAsDataUrl } from '@/lib/fileLoader'
import type { Asset } from '@/types/scene'
import { confirmDialog, promptDialog } from '@/components/ui'
import { t } from '@/i18n'
const store = useSceneStore()
@@ -54,10 +56,15 @@ function addSampleCircle() {
store.addLayer(asset.id)
}
function addSampleText() {
const input = prompt('Text?', 'Hallo')
async function addSampleText() {
const input = await promptDialog({
title: t('sidebar.newTextDialog.title'),
message: t('sidebar.newTextDialog.message'),
defaultValue: t('sidebar.newTextDialog.default'),
confirmLabel: t('sidebar.newTextDialog.confirm'),
})
if (input === null) return
const text = input.trim() || 'Text'
const text = input.trim() || t('sidebar.newTextDialog.fallback')
const asset = store.addAsset({
kind: 'text',
name: text.length > 24 ? text.slice(0, 24) + '…' : text,
@@ -70,20 +77,35 @@ function addSampleText() {
const clips = computed(() => store.scene.clips ?? [])
function addClip() {
const input = prompt('Name des Clips?', 'Clip')
async function addClip() {
const input = await promptDialog({
title: t('sidebar.newClipDialog.title'),
message: t('sidebar.newClipDialog.message'),
defaultValue: t('sidebar.newClipDialog.default'),
confirmLabel: t('sidebar.newClipDialog.confirm'),
})
if (input === null) return
store.addClip(input)
}
function renameClipPrompt(clipId: string, current: string) {
const input = prompt('Neuer Name?', current)
async function renameClipPrompt(clipId: string, current: string) {
const input = await promptDialog({
title: t('sidebar.renameClipDialog.title'),
defaultValue: current,
confirmLabel: t('sidebar.renameClipDialog.confirm'),
})
if (input === null) return
store.renameClip(clipId, input)
}
function deleteClip(clipId: string) {
if (!confirm('Clip und alle Instanzen wirklich löschen?')) return
async function deleteClip(clipId: string) {
const ok = await confirmDialog({
title: t('sidebar.deleteClipDialog.title'),
message: t('sidebar.deleteClipDialog.message'),
confirmLabel: t('sidebar.deleteClipDialog.confirm'),
variant: 'danger',
})
if (!ok) return
store.removeClip(clipId)
}
@@ -149,34 +171,17 @@ const assetIconMap: Record<Asset['kind'], typeof PhSquare> = {
text: PhTextT,
}
const assetsWithUsage = computed(() =>
store.scene.assets.map((a) => ({
asset: a,
inUse: store.scene.layers.some((l) => l.assetId === a.id),
})),
const imageAssets = computed(() =>
store.scene.assets.filter((a): a is Extract<Asset, { kind: 'image' }> => a.kind === 'image'),
)
const THUMB_SIZE = 40
function rectPreviewStyle(asset: Extract<Asset, { kind: 'rect' }>) {
const scale = Math.min(THUMB_SIZE / asset.width, THUMB_SIZE / asset.height) * 0.9
return {
width: `${Math.max(6, asset.width * scale)}px`,
height: `${Math.max(6, asset.height * scale)}px`,
background: asset.fill,
borderRadius: '2px',
} as const
function onAssetDragStart(e: DragEvent, assetId: string) {
if (!e.dataTransfer) return
e.dataTransfer.effectAllowed = 'copy'
e.dataTransfer.setData('application/x-animatr-asset', assetId)
e.dataTransfer.setData('text/plain', assetId)
}
function circlePreviewStyle(asset: Extract<Asset, { kind: 'circle' }>) {
const d = Math.min(THUMB_SIZE, asset.radius * 2) * 0.9
return {
width: `${d}px`,
height: `${d}px`,
background: asset.fill,
borderRadius: '50%',
} as const
}
</script>
<template>
@@ -185,21 +190,21 @@ function circlePreviewStyle(asset: Extract<Asset, { kind: 'circle' }>) {
<button class="upload-btn" @click="triggerUpload">
<PhUploadSimple :size="16" />
<span>SVG / Bild hinzufügen</span>
<span>{{ t('sidebar.addFile') }}</span>
</button>
<div class="actions">
<button class="shape-btn" @click="addSampleRect">
<PhSquare :size="14" />
Rect
{{ t('sidebar.shape.rect') }}
</button>
<button class="shape-btn" @click="addSampleCircle">
<PhCircle :size="14" />
Circle
{{ t('sidebar.shape.circle') }}
</button>
<button class="shape-btn" @click="addSampleText">
<PhTextT :size="14" />
Text
{{ t('sidebar.shape.text') }}
</button>
</div>
@@ -214,10 +219,10 @@ function circlePreviewStyle(asset: Extract<Asset, { kind: 'circle' }>) {
<p v-if="uploadError" class="error">{{ uploadError }}</p>
<div class="lib-header">
<h4>Bibliothek</h4>
<h4>{{ t('sidebar.library') }}</h4>
<button
class="thumb-toggle"
:title="showThumbs ? 'Thumbnails ausblenden' : 'Thumbnails anzeigen'"
:title="showThumbs ? t('sidebar.thumbnailsHide') : t('sidebar.thumbnailsShow')"
@click="showThumbs = !showThumbs"
>
<PhImageSquare v-if="showThumbs" :size="13" />
@@ -225,54 +230,38 @@ function circlePreviewStyle(asset: Extract<Asset, { kind: 'circle' }>) {
</button>
</div>
<ul class="assets" :class="{ compact: !showThumbs }">
<li v-for="item in assetsWithUsage" :key="item.asset.id" :class="{ unused: !item.inUse }">
<li
v-for="asset in imageAssets"
:key="asset.id"
draggable="true"
@dragstart="onAssetDragStart($event, asset.id)"
>
<template v-if="showThumbs">
<div class="thumb">
<img
v-if="item.asset.kind === 'image'"
:src="item.asset.src"
:alt="item.asset.name"
draggable="false"
/>
<div
v-else-if="item.asset.kind === 'rect'"
class="shape-preview"
:style="rectPreviewStyle(item.asset)"
/>
<div
v-else-if="item.asset.kind === 'circle'"
class="shape-preview"
:style="circlePreviewStyle(item.asset)"
/>
<span
v-else-if="item.asset.kind === 'text'"
class="text-preview"
:style="{ color: item.asset.fill }"
>Aa</span>
<img :src="asset.src" :alt="asset.name" draggable="false" />
</div>
</template>
<template v-else>
<component :is="assetIconMap[item.asset.kind]" :size="14" class="asset-icon" />
<component :is="assetIconMap[asset.kind]" :size="14" class="asset-icon" />
</template>
<div class="meta">
<span class="name" :title="item.asset.name">{{ item.asset.name }}</span>
<span v-if="showThumbs" class="sub">{{ item.asset.kind }}</span>
<span class="name" :title="asset.name">{{ asset.name }}</span>
<span v-if="showThumbs" class="sub">{{ t('sidebar.imageLabel') }}</span>
</div>
<button
v-if="!item.inUse"
class="add-layer"
title="Als Layer einfügen"
@click="store.addLayer(item.asset.id)"
:title="t('sidebar.addLayerTooltip')"
@click="store.addLayer(asset.id)"
>
<PhPlus :size="12" weight="bold" />
</button>
</li>
<li v-if="!store.scene.assets.length" class="empty">keine Assets</li>
<li v-if="!imageAssets.length" class="empty">{{ t('sidebar.noImages') }}</li>
</ul>
<div class="lib-header clips-header">
<h4>Clips</h4>
<button class="thumb-toggle" title="Neuen Clip anlegen" @click="addClip">
<h4>{{ t('sidebar.clips') }}</h4>
<button class="thumb-toggle" :title="t('sidebar.createClipTooltip')" @click="addClip">
<PhPlus :size="12" weight="bold" />
</button>
</div>
@@ -292,23 +281,23 @@ function circlePreviewStyle(asset: Extract<Asset, { kind: 'circle' }>) {
<div class="clip-meta">
<span class="clip-name" :title="clip.name">{{ clip.name }}</span>
<span class="clip-sub">
{{ clip.layers.length }} Layer · {{ clip.duration.toFixed(1) }}s
{{ t('sidebar.clipLayerCount', { n: clip.layers.length }) }} · {{ clip.duration.toFixed(1) }}s
</span>
</div>
<div class="clip-actions">
<button
class="clip-btn"
title="Umbenennen"
:title="t('sidebar.renameTooltip')"
@click.stop="renameClipPrompt(clip.id, clip.name)"
>
<PhPencilSimple :size="11" />
</button>
<button class="clip-btn danger" title="Löschen" @click.stop="deleteClip(clip.id)">
<button class="clip-btn danger" :title="t('sidebar.deleteTooltip')" @click.stop="deleteClip(clip.id)">
<PhTrash :size="11" />
</button>
</div>
</li>
<li v-if="!clips.length" class="empty">keine Clips</li>
<li v-if="!clips.length" class="empty">{{ t('sidebar.noClips') }}</li>
</ul>
</div>
</template>
+34 -6
View File
@@ -6,6 +6,7 @@ import { useSceneStore } from '@/stores/scene'
import { sampleLayer } from '@/lib/interpolate'
import { loadImage } from '@/lib/fileLoader'
import type { AnimatableProps, Asset, Layer } from '@/types/scene'
import { t } from '@/i18n'
const store = useSceneStore()
@@ -606,6 +607,29 @@ function onContainerPointerUp(e: PointerEvent) {
if (container.value) container.value.style.cursor = ''
}
function onContainerDragOver(e: DragEvent) {
if (!e.dataTransfer) return
if (!e.dataTransfer.types.includes('application/x-animatr-asset')) return
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
}
function onContainerDrop(e: DragEvent) {
const assetId = e.dataTransfer?.getData('application/x-animatr-asset')
if (!assetId) return
e.preventDefault()
const rect = container.value?.getBoundingClientRect()
if (!rect) return
const sx = e.clientX - rect.left
const sy = e.clientY - rect.top
const worldX = (sx - viewX.value) / zoom.value
const worldY = (sy - viewY.value) / zoom.value
const layer = store.addLayer(assetId)
if (layer) {
store.upsertKeyframe(layer.id, 0, { x: worldX, y: worldY })
}
}
function zoomBy(factor: number) {
const cw = containerSize.value.width
const ch = containerSize.value.height
@@ -638,6 +662,8 @@ onMounted(() => {
container.value.addEventListener('pointermove', onContainerPointerMove)
container.value.addEventListener('pointerup', onContainerPointerUp)
container.value.addEventListener('pointercancel', onContainerPointerUp)
container.value.addEventListener('dragover', onContainerDragOver)
container.value.addEventListener('drop', onContainerDrop)
})
onBeforeUnmount(() => {
@@ -649,6 +675,8 @@ onBeforeUnmount(() => {
container.value?.removeEventListener('pointermove', onContainerPointerMove)
container.value?.removeEventListener('pointerup', onContainerPointerUp)
container.value?.removeEventListener('pointercancel', onContainerPointerUp)
container.value?.removeEventListener('dragover', onContainerDragOver)
container.value?.removeEventListener('drop', onContainerDrop)
})
</script>
@@ -736,21 +764,21 @@ onBeforeUnmount(() => {
</v-stage>
<div class="zoom-controls" @pointerdown.stop>
<button class="zc-btn" title="Zoom out" @click="zoomBy(1 / 1.25)">−</button>
<button class="zc-label" title="Fit to view" @click="fitToContainer">
<button class="zc-btn" :title="t('canvas.zoomOut')" @click="zoomBy(1 / 1.25)">−</button>
<button class="zc-label" :title="t('canvas.fitToView')" @click="fitToContainer">
{{ zoomPercent }}%
</button>
<button class="zc-btn" title="Zoom in" @click="zoomBy(1.25)">+</button>
<button class="zc-btn" :title="t('canvas.zoomIn')" @click="zoomBy(1.25)">+</button>
<div class="zc-sep"></div>
<button class="zc-btn wide" title="Zoom 1:1" @click="zoomToOne">1:1</button>
<button class="zc-btn" title="An Viewport anpassen" @click="fitToContainer">
<button class="zc-btn wide" :title="t('canvas.zoomOneToOne')" @click="zoomToOne">1:1</button>
<button class="zc-btn" :title="t('canvas.fitToView')" @click="fitToContainer">
<PhCornersIn :size="13" />
</button>
<div class="zc-sep"></div>
<button
class="zc-btn crop-btn"
:class="{ active: cropObjects }"
:title="cropObjects ? 'Alles zeigen' : 'Nur Bühnenausschnitt zeigen (crop)'"
:title="cropObjects ? t('canvas.cropOff') : t('canvas.cropOn')"
@click="cropObjects = !cropObjects"
>
<PhCrop :size="13" />
+18 -17
View File
@@ -5,6 +5,7 @@ import { useSceneStore } from '@/stores/scene'
import { useTheme } from '@/composables/useTheme'
import type { Theme } from '@/composables/useTheme'
import { Modal } from '@/components/ui'
import { t } from '@/i18n'
defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
@@ -15,9 +16,9 @@ const { theme, setTheme } = useTheme()
type TabId = 'document' | 'timeline' | 'theme'
const tabs: { id: TabId; label: string; icon: typeof PhFrameCorners }[] = [
{ id: 'document', label: 'Document', icon: PhFrameCorners },
{ id: 'timeline', label: 'Timeline', icon: PhClock },
{ id: 'theme', label: 'UI Theme', icon: PhPalette },
{ id: 'document', label: t('settings.tab.document'), icon: PhFrameCorners },
{ id: 'timeline', label: t('settings.tab.timeline'), icon: PhClock },
{ id: 'theme', label: t('settings.tab.theme'), icon: PhPalette },
]
const activeTab = ref<TabId>('document')
@@ -71,7 +72,7 @@ function applyTheme(t: Theme) {
<template>
<Modal
:model-value="modelValue"
title="Settings"
:title="t('settings.title')"
size="lg"
@update:model-value="emit('update:modelValue', $event)"
>
@@ -91,14 +92,14 @@ function applyTheme(t: Theme) {
<div class="content">
<section v-if="activeTab === 'document'">
<h3>Canvas</h3>
<h3>{{ t('settings.canvas') }}</h3>
<div class="row">
<label>
Width
{{ t('settings.width') }}
<input type="number" min="1" step="1" v-model.number="width" />
</label>
<label>
Height
{{ t('settings.height') }}
<input type="number" min="1" step="1" v-model.number="height" />
</label>
</div>
@@ -114,7 +115,7 @@ function applyTheme(t: Theme) {
</button>
</div>
<label class="stacked">
Background
{{ t('settings.background') }}
<div class="color-row">
<input
type="color"
@@ -128,24 +129,24 @@ function applyTheme(t: Theme) {
</section>
<section v-else-if="activeTab === 'timeline'">
<h3>Timeline</h3>
<h3>{{ t('settings.tab.timeline') }}</h3>
<div class="row">
<label>
Duration (s)
{{ t('settings.duration') }}
<input type="number" min="0.1" step="0.1" v-model.number="duration" />
</label>
<label>
FPS
{{ t('settings.fps') }}
<input type="number" min="1" step="1" v-model.number="fps" />
</label>
</div>
<p class="hint">
Die Timeline-Länge setzt die Ruler-Skala. Playback stoppt am letzten gesetzten Keyframe.
{{ t('settings.timelineHint') }}
</p>
</section>
<section v-else-if="activeTab === 'theme'">
<h3>UI Theme</h3>
<h3>{{ t('settings.tab.theme') }}</h3>
<div class="theme-grid">
<button
class="theme-card"
@@ -158,7 +159,7 @@ function applyTheme(t: Theme) {
</div>
<div class="theme-label">
<PhMoon :size="14" />
<span>Dark</span>
<span>{{ t('settings.themeDark') }}</span>
</div>
</button>
<button
@@ -172,16 +173,16 @@ function applyTheme(t: Theme) {
</div>
<div class="theme-label">
<PhSun :size="14" />
<span>Light</span>
<span>{{ t('settings.themeLight') }}</span>
</div>
</button>
</div>
<p class="hint">Gespeichert im Browser, beim nächsten Öffnen wiederverwendet.</p>
<p class="hint">{{ t('settings.themeHint') }}</p>
</section>
</div>
</div>
<template #footer>
<button class="primary" @click="close">Fertig</button>
<button class="primary" @click="close">{{ t('settings.done') }}</button>
</template>
</Modal>
</template>
+17 -16
View File
@@ -12,6 +12,7 @@ import { downloadExampleHtml, downloadRuntime, downloadSceneExport } from '@/lib
import { downloadSceneAsVideo, pickMimeType } from '@/lib/videoExport'
import { downloadFromServer } from '@/lib/serverExport'
import { Modal } from '@/components/ui'
import { t } from '@/i18n'
defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
@@ -53,7 +54,7 @@ async function exportWebm() {
progress.value = {
progress: 0,
controller,
label: 'Rendering WebM (Browser, Realtime)…',
label: t('export.labelWebm'),
error: null,
}
store.pause()
@@ -81,7 +82,7 @@ async function exportMp4Server() {
progress.value = {
progress: 0,
controller,
label: 'Rendering MP4 (Server)…',
label: t('export.labelMp4'),
error: null,
}
store.pause()
@@ -122,7 +123,7 @@ function onUpdate(v: boolean) {
<template>
<Modal
:model-value="modelValue"
title="Export"
:title="t('export.title')"
size="md"
:dismissable="!isLocked"
:close-on-backdrop="!isLocked"
@@ -135,9 +136,9 @@ function onUpdate(v: boolean) {
<div class="opt-text">
<div class="opt-title">
animatr-runtime.js
<span class="badge">1× pro Seite</span>
<span class="badge">{{ t('export.runtimeBadge') }}</span>
</div>
<div class="opt-desc">Engine — einmal einbinden, beliebig viele Szenen teilen sich die Runtime</div>
<div class="opt-desc">{{ t('export.runtimeDesc') }}</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
@@ -147,9 +148,9 @@ function onUpdate(v: boolean) {
<div class="opt-text">
<div class="opt-title">
animation.js
<span class="badge">Szene</span>
<span class="badge">{{ t('export.sceneBadge') }}</span>
</div>
<div class="opt-desc">Nur Szenen-Daten — benötigt geladene Runtime</div>
<div class="opt-desc">{{ t('export.sceneDesc') }}</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
@@ -157,8 +158,8 @@ function onUpdate(v: boolean) {
<button class="opt" @click="exportHtml">
<div class="opt-icon"><PhFileHtml :size="20" /></div>
<div class="opt-text">
<div class="opt-title">Beispiel HTML</div>
<div class="opt-desc">Standalone HTML — Browser öffnen und fertig</div>
<div class="opt-title">{{ t('export.exampleTitle') }}</div>
<div class="opt-desc">{{ t('export.exampleDesc') }}</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
@@ -168,9 +169,9 @@ function onUpdate(v: boolean) {
<div class="opt-text">
<div class="opt-title">
MP4 Video
<span class="badge accent">Server · Frame-exakt</span>
<span class="badge accent">{{ t('export.mp4Badge') }}</span>
</div>
<div class="opt-desc">H.264, beliebige Auflösung — benötigt Render-Server</div>
<div class="opt-desc">{{ t('export.mp4Desc') }}</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
@@ -180,10 +181,10 @@ function onUpdate(v: boolean) {
<div class="opt-text">
<div class="opt-title">
WebM Video
<span class="badge">Browser · Realtime</span>
<span class="badge">{{ t('export.webmBadge') }}</span>
</div>
<div class="opt-desc">
{{ videoSupported ? 'Ohne Server — Aufnahme in Echtzeit' : 'Browser unterstützt MediaRecorder nicht' }}
{{ videoSupported ? t('export.webmDesc') : t('export.webmNotSupported') }}
</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
@@ -200,15 +201,15 @@ function onUpdate(v: boolean) {
<div class="r-bar-fill" :style="{ width: progress.progress * 100 + '%' }"></div>
</div>
<div class="r-actions">
<button class="secondary" @click="cancel">Abbrechen</button>
<button class="secondary" @click="cancel">{{ t('export.cancel') }}</button>
</div>
</div>
<div v-else class="error-view">
<div class="error-title">Export fehlgeschlagen</div>
<div class="error-title">{{ t('export.failed') }}</div>
<div class="error-msg">{{ progress.error }}</div>
<div class="error-actions">
<button class="primary" @click="dismissError">Zurück</button>
<button class="primary" @click="dismissError">{{ t('export.back') }}</button>
</div>
</div>
</Modal>
+4 -3
View File
@@ -3,6 +3,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { PhPause, PhPlay, PhSkipBack, PhX } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { drawSceneFrame, loadSceneImages } from '@/lib/render'
import { t } from '@/i18n'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
@@ -291,16 +292,16 @@ const timeLabel = computed(() => {
<div class="fs-progress-handle" :style="{ left: progressPct + '%' }"></div>
</div>
<div class="fs-toolbar">
<button class="fs-btn" :title="playing ? 'Pause' : 'Play'" @click="toggle">
<button class="fs-btn" :title="playing ? t('fullscreen.pause') : t('fullscreen.play')" @click="toggle">
<PhPause v-if="playing" :size="15" weight="fill" />
<PhPlay v-else :size="15" weight="fill" />
</button>
<button class="fs-btn" title="Zum Anfang" @click="rewind">
<button class="fs-btn" :title="t('fullscreen.jumpToStart')" @click="rewind">
<PhSkipBack :size="15" weight="fill" />
</button>
<span class="fs-time">{{ timeLabel }}</span>
<div class="fs-spacer"></div>
<button class="fs-btn close" title="Schließen (Esc)" @click="close">
<button class="fs-btn close" :title="t('fullscreen.close')" @click="close">
<PhX :size="15" weight="bold" />
</button>
</div>
+5 -4
View File
@@ -5,6 +5,7 @@ import { useSceneStore } from '@/stores/scene'
import { sampleLayer } from '@/lib/interpolate'
import type { AnimatableProps } from '@/types/scene'
import RangeSlider from './RangeSlider.vue'
import { t } from '@/i18n'
const formatPercent = (v: number) => `${Math.round(v * 100)}%`
@@ -86,10 +87,10 @@ function removeKeyframeHere() {
<div class="kf-row" :class="{ on: hasKeyframeHere }">
<PhDiamond :size="11" :weight="hasKeyframeHere ? 'fill' : 'regular'" class="kf-dot" />
<span class="kf-label">
{{ hasKeyframeHere ? 'Keyframe' : 'Interpoliert' }}
{{ hasKeyframeHere ? t('properties.keyframeLabel') : t('properties.interpolated') }}
<span class="kf-time">@ {{ store.playhead.toFixed(2) }}s</span>
</span>
<button v-if="hasKeyframeHere" class="kf-btn" title="Keyframe hier löschen" @click="removeKeyframeHere">
<button v-if="hasKeyframeHere" class="kf-btn" :title="t('properties.removeKeyframe')" @click="removeKeyframeHere">
<PhX :size="12" weight="bold" />
</button>
</div>
@@ -121,10 +122,10 @@ function removeKeyframeHere() {
<button class="danger-btn" @click="store.removeLayer(selected.id)">
<PhTrash :size="13" />
Layer löschen
{{ t('properties.deleteLayer') }}
</button>
</template>
<p v-else class="hint">Kein Layer ausgewählt.</p>
<p v-else class="hint">{{ t('properties.noLayer') }}</p>
</div>
</template>
+373 -49
View File
@@ -14,6 +14,8 @@ import {
import { useSceneStore } from '@/stores/scene'
import { sampleLayer } from '@/lib/interpolate'
import { snapToCandidates } from '@/lib/timelineSnap'
import { confirmDialog } from '@/components/ui'
import { t } from '@/i18n'
import type {
AnimatableProps,
Clip,
@@ -263,8 +265,8 @@ const tracks = computed<TimelineTrack[]>(() => {
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex })
} else {
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex })
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.positionKeyframes ?? [], kind: 'position', label: 'Position', isParent: false, orderIndex })
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.rotationKeyframes ?? [], kind: 'rotation', label: 'Rotation', isParent: false, orderIndex })
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.positionKeyframes ?? [], kind: 'position', label: t('timeline.position'), isParent: false, orderIndex })
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.rotationKeyframes ?? [], kind: 'rotation', label: t('timeline.rotation'), isParent: false, orderIndex })
}
})
return out
@@ -279,14 +281,14 @@ const tracks = computed<TimelineTrack[]>(() => {
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex })
} else {
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex })
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.positionKeyframes ?? [], kind: 'position', label: 'Position', isParent: false, orderIndex })
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.rotationKeyframes ?? [], kind: 'rotation', label: 'Rotation', isParent: false, orderIndex })
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.positionKeyframes ?? [], kind: 'position', label: t('timeline.position'), isParent: false, orderIndex })
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.rotationKeyframes ?? [], kind: 'rotation', label: t('timeline.rotation'), isParent: false, orderIndex })
}
return
}
const ct = clipTrackById.value.get(id)
if (ct) {
out.push({ type: 'clip', clipTrackId: ct.id, name: ct.name ?? 'Clip-Spur', orderIndex })
out.push({ type: 'clip', clipTrackId: ct.id, name: ct.name ?? t('timeline.clipTrackName'), orderIndex })
}
})
return out
@@ -316,33 +318,110 @@ function isDropAfter(track: TimelineTrack, nextTrack: TimelineTrack | undefined)
return true
}
interface SelectionKey {
layerId: string
kind: 'main' | 'position' | 'rotation'
keyframeId: string
}
const selectedKfs = ref<SelectionKey[]>([])
function selectionKey(k: SelectionKey): string {
return `${k.layerId}:${k.kind}:${k.keyframeId}`
}
const selectedKfSet = computed(() => {
const s = new Set<string>()
for (const k of selectedKfs.value) s.add(selectionKey(k))
return s
})
function isKfSelected(layerId: string, kind: 'main' | 'position' | 'rotation', keyframeId: string): boolean {
return selectedKfSet.value.has(`${layerId}:${kind}:${keyframeId}`)
}
function clearKfSelection() {
selectedKfs.value = []
}
interface SelectionSnapshotEntry {
layerId: string
keyframeId: string
kind: 'main' | 'position' | 'rotation'
initialTime: number
}
function snapshotSelection(): SelectionSnapshotEntry[] {
const out: SelectionSnapshotEntry[] = []
for (const s of selectedKfs.value) {
const layer = store.activeLayers.find((l) => l.id === s.layerId)
if (!layer) continue
const arr =
s.kind === 'position'
? layer.positionKeyframes
: s.kind === 'rotation'
? layer.rotationKeyframes
: layer.keyframes
const kf = arr?.find((k) => k.id === s.keyframeId)
if (kf) out.push({ ...s, initialTime: kf.time })
}
return out
}
interface KfDrag {
layerId: string
keyframeId: string
kind: 'main' | 'position' | 'rotation'
lane: HTMLElement
pointerId: number
startX: number
moved: boolean
initialTime: number
snapshot: SelectionSnapshotEntry[]
}
const kfDrag = ref<KfDrag | null>(null)
function onKeyframePointerDown(e: PointerEvent, layerId: string, keyframeId: string) {
function onKeyframePointerDown(
e: PointerEvent,
layerId: string,
keyframeId: string,
kind: 'main' | 'position' | 'rotation',
) {
if (e.button !== 0) return
const layer = store.activeLayers.find((l) => l.id === layerId)
if (layer?.locked) return
const kfEl = e.currentTarget as HTMLElement
const lane = kfEl.parentElement
if (!lane) return
// If this keyframe isn't in the current selection, replace selection with just this one.
const key = `${layerId}:${kind}:${keyframeId}`
if (!selectedKfSet.value.has(key)) {
selectedKfs.value = [{ layerId, kind, keyframeId }]
}
const arr =
kind === 'position'
? layer?.positionKeyframes
: kind === 'rotation'
? layer?.rotationKeyframes
: layer?.keyframes
const srcKf = arr?.find((k) => k.id === keyframeId)
const initialTime = srcKf?.time ?? 0
e.stopPropagation()
kfEl.setPointerCapture(e.pointerId)
kfDrag.value = {
layerId,
keyframeId,
kind,
lane,
pointerId: e.pointerId,
startX: e.clientX,
moved: false,
initialTime,
snapshot: snapshotSelection(),
}
store.selectLayer(layerId)
}
@@ -355,9 +434,50 @@ function onKeyframePointerMove(e: PointerEvent) {
const rect = d.lane.getBoundingClientRect()
if (rect.width <= 0) return
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const newTime = snapTime(ratio * duration.value, d.keyframeId)
store.setKeyframeTime(d.layerId, d.keyframeId, newTime)
store.setPlayhead(newTime)
const rawNewTime = ratio * duration.value
// For snapping, ignore all currently selected kfs so we don't snap to ourselves
const ignoreIds = new Set<string>()
for (const s of d.snapshot) ignoreIds.add(s.keyframeId)
const snappedPrimary = snapToCandidates(
rawNewTime,
allKeyframeTimesExcluding(ignoreIds),
{
enabled: snapEnabled.value,
pxPerSec: effectivePxPerSec.value,
pixelThreshold: SNAP_PX,
duration: duration.value,
},
)
// Group delta clamped so nothing leaves [0, duration]
let delta = snappedPrimary - d.initialTime
let minDelta = -Infinity
let maxDelta = Infinity
for (const s of d.snapshot) {
if (-s.initialTime > minDelta) minDelta = -s.initialTime
if (duration.value - s.initialTime < maxDelta) maxDelta = duration.value - s.initialTime
}
delta = Math.max(minDelta, Math.min(maxDelta, delta))
if (d.snapshot.length <= 1) {
store.setKeyframeTime(d.layerId, d.keyframeId, d.initialTime + delta)
} else {
for (const s of d.snapshot) {
store.setKeyframeTime(s.layerId, s.keyframeId, s.initialTime + delta)
}
}
store.setPlayhead(d.initialTime + delta)
}
function allKeyframeTimesExcluding(ignoreIds: Set<string>): number[] {
const out: number[] = []
for (const layer of store.activeLayers) {
for (const k of layer.keyframes) if (!ignoreIds.has(k.id)) out.push(k.time)
if (layer.positionKeyframes)
for (const k of layer.positionKeyframes) if (!ignoreIds.has(k.id)) out.push(k.time)
if (layer.rotationKeyframes)
for (const k of layer.rotationKeyframes) if (!ignoreIds.has(k.id)) out.push(k.time)
}
return out
}
function onKeyframePointerUp(e: PointerEvent) {
@@ -404,10 +524,10 @@ function segmentStyle(seg: KfSegment) {
}
const EASING_OPTIONS: { value: Easing; label: string }[] = [
{ value: 'linear', label: 'Linear' },
{ value: 'easeIn', label: 'Ease In' },
{ value: 'easeOut', label: 'Ease Out' },
{ value: 'easeInOut', label: 'Ease In-Out' },
{ value: 'linear', label: t('timeline.easingLinear') },
{ value: 'easeIn', label: t('timeline.easingEaseIn') },
{ value: 'easeOut', label: t('timeline.easingEaseOut') },
{ value: 'easeInOut', label: t('timeline.easingEaseInOut') },
]
interface KfMenuState {
@@ -498,16 +618,16 @@ interface TrackMenuState {
}
const TRACK_COLORS: Array<{ value: string | null; label: string }> = [
{ value: null, label: 'Keine' },
{ value: '#ff6b6b', label: 'Rot' },
{ value: '#ffa94d', label: 'Orange' },
{ value: '#ffd43b', label: 'Gelb' },
{ value: '#51cf66', label: 'Grün' },
{ value: '#4dc4ff', label: 'Türkis' },
{ value: '#6ea8ff', label: 'Blau' },
{ value: '#b197fc', label: 'Lila' },
{ value: '#ff8cc6', label: 'Pink' },
{ value: '#adb5bd', label: 'Grau' },
{ value: null, label: t('timeline.colors.none') },
{ value: '#ff6b6b', label: t('timeline.colors.red') },
{ value: '#ffa94d', label: t('timeline.colors.orange') },
{ value: '#ffd43b', label: t('timeline.colors.yellow') },
{ value: '#51cf66', label: t('timeline.colors.green') },
{ value: '#4dc4ff', label: t('timeline.colors.cyan') },
{ value: '#6ea8ff', label: t('timeline.colors.blue') },
{ value: '#b197fc', label: t('timeline.colors.purple') },
{ value: '#ff8cc6', label: t('timeline.colors.pink') },
{ value: '#adb5bd', label: t('timeline.colors.gray') },
]
const dragTrackId = ref<string | null>(null)
@@ -559,6 +679,105 @@ function onTrackDragEnd() {
dropIndex.value = null
}
interface BoxSelectState {
pointerId: number
originClientX: number
originClientY: number
currentClientX: number
currentClientY: number
tracksEl: HTMLElement
moved: boolean
}
const boxSelect = ref<BoxSelectState | null>(null)
const boxRectStyle = computed(() => {
const b = boxSelect.value
if (!b || !b.moved) return null
const tRect = b.tracksEl.getBoundingClientRect()
const left = Math.min(b.originClientX, b.currentClientX) - tRect.left
const top = Math.min(b.originClientY, b.currentClientY) - tRect.top
const width = Math.abs(b.currentClientX - b.originClientX)
const height = Math.abs(b.currentClientY - b.originClientY)
return {
left: left + 'px',
top: top + 'px',
width: width + 'px',
height: height + 'px',
}
})
function onTracksPointerDown(e: PointerEvent) {
if (e.button !== 0) return
const target = e.target as HTMLElement | null
if (!target) return
if (
target.closest(
'.kf, .clip-block, .clip-handle, .track-name, .drag-handle, .add-clip-btn, .playhead',
)
) {
return
}
const tracksEl = e.currentTarget as HTMLElement
tracksEl.setPointerCapture(e.pointerId)
boxSelect.value = {
pointerId: e.pointerId,
originClientX: e.clientX,
originClientY: e.clientY,
currentClientX: e.clientX,
currentClientY: e.clientY,
tracksEl,
moved: false,
}
}
function onTracksPointerMove(e: PointerEvent) {
const b = boxSelect.value
if (!b || b.pointerId !== e.pointerId) return
b.currentClientX = e.clientX
b.currentClientY = e.clientY
if (!b.moved) {
const dx = b.currentClientX - b.originClientX
const dy = b.currentClientY - b.originClientY
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) b.moved = true
}
}
function onTracksPointerUp(e: PointerEvent) {
const b = boxSelect.value
if (!b || b.pointerId !== e.pointerId) return
if (b.tracksEl.hasPointerCapture(e.pointerId)) b.tracksEl.releasePointerCapture(e.pointerId)
if (b.moved) {
commitBoxSelection(b)
} else {
// bare click on empty space → clear selection
clearKfSelection()
}
boxSelect.value = null
}
function commitBoxSelection(b: BoxSelectState) {
const left = Math.min(b.originClientX, b.currentClientX)
const right = Math.max(b.originClientX, b.currentClientX)
const top = Math.min(b.originClientY, b.currentClientY)
const bottom = Math.max(b.originClientY, b.currentClientY)
const nodes = b.tracksEl.querySelectorAll<HTMLElement>('.kf[data-kf-id]')
const next: SelectionKey[] = []
for (const node of Array.from(nodes)) {
const r = node.getBoundingClientRect()
const cx = r.left + r.width / 2
const cy = r.top + r.height / 2
if (cx < left || cx > right || cy < top || cy > bottom) continue
const layerId = node.dataset.layerId
const kind = node.dataset.kind as 'main' | 'position' | 'rotation' | undefined
const keyframeId = node.dataset.kfId
if (!layerId || !kind || !keyframeId) continue
next.push({ layerId, kind, keyframeId })
}
selectedKfs.value = next
}
function pixelTimeFromEvent(e: DragEvent | PointerEvent, el: HTMLElement): number {
const rect = el.getBoundingClientRect()
if (rect.width <= 0) return 0
@@ -665,10 +884,10 @@ function trimTooltip(ci: ClipInstance): string {
const name = clipById.value.get(ci.clipId)?.name ?? '?'
const dur = visibleDurationOf(ci).toFixed(2)
const trims: string[] = []
if ((ci.trimStart ?? 0) > 0) trims.push(`Start -${(ci.trimStart ?? 0).toFixed(2)}s`)
if ((ci.trimEnd ?? 0) > 0) trims.push(`Ende -${(ci.trimEnd ?? 0).toFixed(2)}s`)
const suffix = trims.length ? ` · ${trims.join(' · ')}` : ''
return `${name} · ${dur}s${suffix} · Kanten ziehen zum Verkürzen, Doppelklick zum Öffnen`
if ((ci.trimStart ?? 0) > 0) trims.push(t('timeline.trimStart', { value: (ci.trimStart ?? 0).toFixed(2) }))
if ((ci.trimEnd ?? 0) > 0) trims.push(t('timeline.trimEnd', { value: (ci.trimEnd ?? 0).toFixed(2) }))
const trimsStr = trims.length ? ` · ${trims.join(' · ')}` : ''
return t('timeline.trimTooltip', { name, duration: dur, trims: trimsStr })
}
interface ClipTrimState {
@@ -734,9 +953,21 @@ function onClipBlockDblClick(ci: ClipInstance) {
store.enterClip(ci.clipId)
}
function onClipBlockContextMenu(e: MouseEvent, ci: ClipInstance) {
async function onClipBlockContextMenu(e: MouseEvent, ci: ClipInstance) {
e.preventDefault()
if (!confirm('Clip-Instanz entfernen?')) return
const clip = clipById.value.get(ci.clipId)
const ok = await confirmDialog({
title: t('timeline.removeInstanceDialog.title'),
message: clip
? t('timeline.removeInstanceDialog.message', {
name: clip.name,
time: ci.startTime.toFixed(2),
})
: t('timeline.removeInstanceDialog.generic'),
confirmLabel: t('timeline.removeInstanceDialog.confirm'),
variant: 'danger',
})
if (!ok) return
store.removeClipInstance(ci.id)
}
@@ -829,6 +1060,12 @@ function onKeyDown(e: KeyboardEvent) {
}
}
function onDocumentKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape' && selectedKfs.value.length) {
selectedKfs.value = []
}
}
watch([menu, trackMenu], ([m, tm]) => {
if (m || tm) {
document.addEventListener('pointerdown', onDocumentPointerDown, true)
@@ -874,6 +1111,7 @@ onMounted(() => {
viewportWidth.value = rect.width
})
ro.observe(scrollEl.value)
document.addEventListener('keydown', onDocumentKeyDown)
})
onBeforeUnmount(() => {
@@ -881,6 +1119,7 @@ onBeforeUnmount(() => {
ro = null
document.removeEventListener('pointerdown', onDocumentPointerDown, true)
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('keydown', onDocumentKeyDown)
})
</script>
@@ -890,7 +1129,7 @@ onBeforeUnmount(() => {
<div class="tl-content" :style="{ width: totalWidth + 'px' }">
<div class="scrub-bar">
<div class="name-spacer">
<div class="mode-toggle" title="Zeit-Einheit umschalten">
<div class="mode-toggle" :title="t('timeline.toggleTimeUnit')">
<button
class="mode-btn"
:class="{ active: displayMode === 'seconds' }"
@@ -905,7 +1144,7 @@ onBeforeUnmount(() => {
<button
class="snap-btn"
:class="{ active: snapEnabled }"
:title="snapEnabled ? 'Snapping aktiv (Playhead & Keyframes rasten aneinander ein)' : 'Snapping aus'"
:title="snapEnabled ? t('timeline.snapOn') : t('timeline.snapOff')"
@click="snapEnabled = !snapEnabled"
>
<PhMagnet :size="12" :weight="snapEnabled ? 'fill' : 'regular'" />
@@ -935,7 +1174,14 @@ onBeforeUnmount(() => {
</span>
</div>
</div>
<div class="tracks">
<div
class="tracks"
@pointerdown="onTracksPointerDown"
@pointermove="onTracksPointerMove"
@pointerup="onTracksPointerUp"
@pointercancel="onTracksPointerUp"
>
<div v-if="boxRectStyle" class="box-select-rect" :style="boxRectStyle"></div>
<template v-for="(track, idx) in tracks" :key="track.type === 'layer' ? `l:${track.layerId}:${track.kind}` : `c:${track.clipTrackId}`">
<!-- Layer track -->
<div
@@ -1000,14 +1246,20 @@ onBeforeUnmount(() => {
v-for="kf in track.kfs"
:key="kf.id"
class="kf"
:class="{ hold: kf.hold }"
:class="{
hold: kf.hold,
selected: isKfSelected(track.layerId, track.kind, kf.id),
}"
:style="{ left: pxFromTime(kf.time) + 'px' }"
:data-layer-id="track.layerId"
:data-kind="track.kind"
:data-kf-id="kf.id"
:title="
kf.hold
? `Still Frame ${formatKfTime(kf.time)} — hält bis zum nächsten Keyframe`
: `Keyframe ${formatKfTime(kf.time)} — ziehen zum Verschieben, Rechtsklick für Optionen`
? t('timeline.stillFrameTooltip', { time: formatKfTime(kf.time) })
: t('timeline.keyframeTooltip', { time: formatKfTime(kf.time) })
"
@pointerdown="onKeyframePointerDown($event, track.layerId, kf.id)"
@pointerdown="onKeyframePointerDown($event, track.layerId, kf.id, track.kind)"
@pointermove="onKeyframePointerMove"
@pointerup="onKeyframePointerUp"
@pointercancel="onKeyframePointerUp"
@@ -1104,11 +1356,11 @@ onBeforeUnmount(() => {
</div>
</div>
</template>
<p v-if="!tracks.length" class="empty">Noch keine Tracks.</p>
<p v-if="!tracks.length" class="empty">{{ t('timeline.noTracks') }}</p>
<div v-if="!inClipEdit" class="track clip-track add-clip-track">
<button class="add-clip-btn" title="Weitere Clip-Spur" @click="addClipTrack">
<button class="add-clip-btn" :title="t('timeline.addClipTrackTooltip')" @click="addClipTrack">
<PhPlus :size="11" weight="bold" />
<span>Clip-Spur hinzufügen</span>
<span>{{ t('timeline.addClipTrack') }}</span>
</button>
</div>
</div>
@@ -1141,31 +1393,31 @@ onBeforeUnmount(() => {
</span>
{{ opt.label }}
</button>
<div v-if="menu.isFirst" class="kf-menu-note">Erster Keyframe — Easing ohne Effekt</div>
<div v-else-if="menu.isHeld" class="kf-menu-note">Still Frame — Easing deaktiviert</div>
<div v-if="menu.isFirst" class="kf-menu-note">{{ t('timeline.firstKfHint') }}</div>
<div v-else-if="menu.isHeld" class="kf-menu-note">{{ t('timeline.holdDisabledHint') }}</div>
</div>
<div class="kf-menu-divider"></div>
<button
class="kf-menu-item"
:class="{ active: menu.isHeld }"
:disabled="menu.isLast"
:title="menu.isLast ? 'Letzter Keyframe — Still Frame ohne Effekt' : ''"
:title="menu.isLast ? t('timeline.lastKfHint') : ''"
@click="toggleHold"
>
<span class="check">
<PhCheck v-if="menu.isHeld" :size="12" weight="bold" />
</span>
<PhPauseCircle :size="12" />
Still Frame
{{ t('timeline.stillFrame') }}
</button>
<div class="kf-menu-divider"></div>
<button class="kf-menu-item" @click="duplicateFromMenu">
<PhCopy :size="12" />
Duplizieren
{{ t('timeline.duplicate') }}
</button>
<button class="kf-menu-item danger" @click="deleteFromMenu">
<PhTrash :size="12" />
Löschen
{{ t('timeline.delete') }}
</button>
</div>
@@ -1176,7 +1428,7 @@ onBeforeUnmount(() => {
:style="{ left: trackMenu.x + 'px', top: trackMenu.y + 'px' }"
>
<div class="kf-menu-section">
<div class="kf-menu-header">Farbe</div>
<div class="kf-menu-header">{{ t('timeline.color') }}</div>
<div class="color-tiles">
<button
v-for="c in TRACK_COLORS"
@@ -1201,7 +1453,7 @@ onBeforeUnmount(() => {
<PhCheck v-if="trackMenu.locked" :size="12" weight="bold" />
</span>
<component :is="trackMenu.locked ? PhLockOpen : PhLock" :size="12" />
{{ trackMenu.locked ? 'Entsperren' : 'Sperren' }}
{{ trackMenu.locked ? t('timeline.unlock') : t('timeline.lock') }}
</button>
<template v-if="trackMenu.kind === 'layer'">
<div class="kf-menu-divider"></div>
@@ -1213,13 +1465,13 @@ onBeforeUnmount(() => {
<span class="check">
<PhCheck v-if="trackMenu.separated" :size="12" weight="bold" />
</span>
Separate Position and Rotation
{{ t('timeline.separate') }}
</button>
</template>
<div class="kf-menu-divider"></div>
<button class="kf-menu-item danger" @click="deleteTrack">
<PhTrash :size="12" />
{{ trackMenu.kind === 'layer' ? 'Layer löschen' : 'Clip-Spur löschen' }}
{{ trackMenu.kind === 'layer' ? t('timeline.deleteLayer') : t('timeline.deleteClipTrack') }}
</button>
</div>
</div>
@@ -1405,6 +1657,26 @@ onBeforeUnmount(() => {
.tracks {
display: flex;
flex-direction: column;
position: relative;
}
.box-select-rect {
position: absolute;
z-index: 50;
border: 1px solid var(--accent);
background: rgba(110, 168, 255, 0.12);
pointer-events: none;
border-radius: 2px;
}
.kf.selected {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.kf.hold.selected {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.track {
@@ -1495,6 +1767,58 @@ onBeforeUnmount(() => {
opacity: 0.85;
}
/* Neutralize hover states on locked tracks */
.track.locked .track-name:hover {
background: var(--panel);
color: var(--text-dim);
}
.track.locked .track-name.selected:hover {
background:
linear-gradient(var(--accent-soft), var(--accent-soft)),
var(--panel);
color: var(--accent);
}
.track.locked.sub-track .track-name:hover {
background: var(--panel-alt);
}
.track.locked.inactive:hover .track-lane {
opacity: 0.4;
}
.track.locked .clip-block:hover {
background: color-mix(in srgb, var(--track-color, var(--accent)) 14%, transparent);
transform: none;
}
.track.locked .clip-block:hover .clip-handle {
opacity: 0;
}
.track.locked .clip-block.trimmed-start:hover .clip-handle.start,
.track.locked .clip-block.trimmed-end:hover .clip-handle.end {
opacity: 0.55;
}
.track.locked .clip-handle:hover,
.track.locked .clip-handle:active {
background: transparent !important;
opacity: 0 !important;
}
.track.locked .kf:hover {
background: var(--track-color, var(--accent));
transform: rotate(45deg);
}
.track.locked .kf.hold:hover {
background: var(--panel);
border-color: var(--track-color, var(--accent));
transform: none;
}
.track-name {
position: sticky;
left: 0;
+21 -12
View File
@@ -12,7 +12,8 @@ import {
listRecentProjects,
type StoredProject,
} from '@/lib/projectStore'
import { Modal } from '@/components/ui'
import { confirmDialog, Modal } from '@/components/ui'
import { t } from '@/i18n'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{
@@ -43,7 +44,9 @@ function formatDate(ts: number): string {
d.getMonth() === now.getMonth() &&
d.getDate() === now.getDate()
if (sameDay) {
return `heute ${d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`
return t('welcome.today', {
time: d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
})
}
return d.toLocaleString([], {
day: '2-digit',
@@ -65,7 +68,13 @@ function openProject(p: StoredProject) {
async function removeProject(e: MouseEvent, p: StoredProject) {
e.stopPropagation()
if (!confirm(`"${p.name}" wirklich löschen?`)) return
const ok = await confirmDialog({
title: t('welcome.deleteDialog.title'),
message: t('welcome.deleteDialog.message', { name: p.name }),
confirmLabel: t('welcome.deleteDialog.confirm'),
variant: 'danger',
})
if (!ok) return
await deleteStoredProject(p.id)
await reload()
}
@@ -108,14 +117,14 @@ watch(
>
<template #header>
<div class="hdr-text">
<h2>Willkommen bei Animatr</h2>
<p class="sub">Zuletzt bearbeitete Projekte</p>
<h2>{{ t('welcome.heading') }}</h2>
<p class="sub">{{ t('welcome.subheading') }}</p>
</div>
</template>
<div class="body">
<div v-if="loading" class="empty">Laden…</div>
<div v-if="loading" class="empty">{{ t('welcome.loading') }}</div>
<div v-else-if="!recents.length" class="empty">
Keine gespeicherten Projekte vorhanden.
{{ t('welcome.empty') }}
</div>
<ul v-else class="recents">
<li
@@ -131,12 +140,12 @@ watch(
<div class="meta">
<div class="name">{{ p.name }}</div>
<div class="sub-info">
{{ layerCount(p) }} Layer · {{ formatDate(p.savedAt) }}
{{ t('welcome.layerCount', { n: layerCount(p) }) }} · {{ formatDate(p.savedAt) }}
</div>
</div>
<button
class="del"
title="Projekt löschen"
:title="t('welcome.deleteTooltip')"
@click="removeProject($event, p)"
>
<PhTrash :size="13" />
@@ -147,15 +156,15 @@ watch(
<template #footer>
<button class="ftr-btn" @click="startFresh">
<PhPlus :size="13" weight="bold" />
Neues Projekt
{{ t('welcome.newProject') }}
</button>
<button class="ftr-btn" @click="importFile">
<PhFileArrowUp :size="13" />
Datei importieren…
{{ t('welcome.importFile') }}
</button>
<button class="ftr-btn" @click="exportFile">
<PhDownloadSimple :size="13" />
Aktuelles exportieren
{{ t('welcome.exportCurrent') }}
</button>
</template>
</Modal>
+176
View File
@@ -0,0 +1,176 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import Modal from './Modal.vue'
import {
cancelDialog,
dialogState,
resolveDialog,
type DialogState,
} from './dialogs'
import { t } from '@/i18n'
const current = computed<DialogState | null>(() => dialogState.value)
const open = computed({
get: () => current.value !== null,
set: (v: boolean) => {
if (!v) cancelDialog()
},
})
const inputValue = ref('')
const inputEl = ref<HTMLInputElement | null>(null)
watch(
() => current.value,
async (s) => {
if (s?.kind === 'prompt') {
inputValue.value = s.opts.defaultValue ?? ''
await nextTick()
inputEl.value?.focus()
inputEl.value?.select()
}
},
{ immediate: true },
)
function onConfirm() {
const s = current.value
if (!s) return
if (s.kind === 'confirm') resolveDialog(true)
else resolveDialog(inputValue.value)
}
function onCancel() {
cancelDialog()
}
function onSubmit(e: Event) {
e.preventDefault()
onConfirm()
}
function title(): string {
const s = current.value
if (!s) return ''
return s.opts.title ?? (s.kind === 'confirm' ? t('dialog.confirm') : t('dialog.input'))
}
function confirmLabel(): string {
return current.value?.opts.confirmLabel ?? t('dialog.ok')
}
function cancelLabel(): string {
return current.value?.opts.cancelLabel ?? t('dialog.cancel')
}
function isDanger(): boolean {
const s = current.value
return s?.kind === 'confirm' && s.opts.variant === 'danger'
}
</script>
<template>
<Modal
:model-value="open"
:title="title()"
size="sm"
@update:model-value="open = $event"
>
<div v-if="current?.kind === 'confirm'" class="body confirm-body">
<p class="msg">{{ current.opts.message }}</p>
</div>
<form
v-else-if="current?.kind === 'prompt'"
class="body prompt-body"
@submit="onSubmit"
>
<p v-if="current.opts.message" class="msg">{{ current.opts.message }}</p>
<input
ref="inputEl"
v-model="inputValue"
type="text"
class="prompt-input"
:placeholder="current.opts.placeholder ?? ''"
@keydown.enter.prevent="onConfirm"
@keydown.esc.prevent="onCancel"
/>
</form>
<template #footer>
<button class="btn secondary" @click="onCancel">{{ cancelLabel() }}</button>
<button
class="btn primary"
:class="{ danger: isDanger() }"
@click="onConfirm"
>
{{ confirmLabel() }}
</button>
</template>
</Modal>
</template>
<style scoped>
.body {
padding: 18px 20px 14px;
}
.msg {
margin: 0 0 12px;
font-size: 13px;
color: var(--text);
line-height: 1.45;
white-space: pre-wrap;
}
.prompt-body .msg {
margin-bottom: 8px;
}
.prompt-input {
width: 100%;
padding: 8px 10px;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
border-radius: var(--radius-sm);
font: inherit;
font-size: 13px;
}
.prompt-input:focus {
outline: none;
border-color: var(--accent);
}
.btn {
padding: 6px 14px;
border-radius: var(--radius-sm);
font: inherit;
font-size: 12px;
font-weight: 500;
border: 1px solid var(--border);
background: var(--panel-alt);
color: var(--text);
cursor: pointer;
}
.btn:hover {
background: var(--panel-hover);
border-color: var(--border-strong);
}
.btn.primary {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent);
}
.btn.primary:hover {
background: var(--accent-soft-strong);
}
.btn.primary.danger {
background: var(--danger-soft);
border-color: var(--danger);
color: var(--danger);
}
</style>
+2 -1
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted } from 'vue'
import { PhX } from '@phosphor-icons/vue'
import { t } from '@/i18n'
type Size = 'sm' | 'md' | 'lg'
@@ -59,7 +60,7 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
v-if="dismissable"
class="close"
:disabled="!dismissable"
title="Schließen"
:title="t('dialog.close')"
@click="close"
>
<PhX :size="14" weight="bold" />
+71
View File
@@ -0,0 +1,71 @@
import { shallowRef } from 'vue'
export interface ConfirmOptions {
title?: string
message: string
confirmLabel?: string
cancelLabel?: string
variant?: 'default' | 'danger'
}
export interface PromptOptions {
title?: string
message?: string
defaultValue?: string
placeholder?: string
confirmLabel?: string
cancelLabel?: string
}
interface ConfirmState {
kind: 'confirm'
opts: ConfirmOptions
resolve: (v: boolean) => void
}
interface PromptState {
kind: 'prompt'
opts: PromptOptions
resolve: (v: string | null) => void
}
export type DialogState = ConfirmState | PromptState
export const dialogState = shallowRef<DialogState | null>(null)
export function confirmDialog(opts: ConfirmOptions): Promise<boolean> {
// Cancel any pending dialog with a negative result
if (dialogState.value) {
if (dialogState.value.kind === 'confirm') dialogState.value.resolve(false)
else dialogState.value.resolve(null)
}
return new Promise<boolean>((resolve) => {
dialogState.value = { kind: 'confirm', opts, resolve }
})
}
export function promptDialog(opts: PromptOptions): Promise<string | null> {
if (dialogState.value) {
if (dialogState.value.kind === 'confirm') dialogState.value.resolve(false)
else dialogState.value.resolve(null)
}
return new Promise<string | null>((resolve) => {
dialogState.value = { kind: 'prompt', opts, resolve }
})
}
export function resolveDialog(value: boolean | string | null): void {
const s = dialogState.value
if (!s) return
if (s.kind === 'confirm') s.resolve(typeof value === 'boolean' ? value : false)
else s.resolve(typeof value === 'string' ? value : null)
dialogState.value = null
}
export function cancelDialog(): void {
const s = dialogState.value
if (!s) return
if (s.kind === 'confirm') s.resolve(false)
else s.resolve(null)
dialogState.value = null
}
+3
View File
@@ -1,3 +1,6 @@
export { default as DialogsHost } from './DialogsHost.vue'
export { default as IconButton } from './IconButton.vue'
export { default as Modal } from './Modal.vue'
export { default as Toast } from './Toast.vue'
export { confirmDialog, promptDialog } from './dialogs'
export type { ConfirmOptions, PromptOptions } from './dialogs'