bak
This commit is contained in:
1 parent
e4aa0dce86
commit
cc6b11e61d
23 files changed
+2047
-524
No files matched your search
@@ -51,6 +51,20 @@ function addSampleCircle() {
|
||||
store.addLayer(asset.id)
|
||||
}
|
||||
|
||||
function addSampleText() {
|
||||
const input = prompt('Text?', 'Hallo')
|
||||
if (input === null) return
|
||||
const text = input.trim() || 'Text'
|
||||
const asset = store.addAsset({
|
||||
kind: 'text',
|
||||
name: text.length > 24 ? text.slice(0, 24) + '…' : text,
|
||||
text,
|
||||
fontSize: 48,
|
||||
fill: '#f5d76e',
|
||||
})
|
||||
store.addLayer(asset.id)
|
||||
}
|
||||
|
||||
async function handleFileSelected(event: Event) {
|
||||
uploadError.value = null
|
||||
const input = event.target as HTMLInputElement
|
||||
@@ -154,6 +168,10 @@ function circlePreviewStyle(asset: Extract<Asset, { kind: 'circle' }>) {
|
||||
<PhCircle :size="14" />
|
||||
Circle
|
||||
</button>
|
||||
<button class="shape-btn" @click="addSampleText">
|
||||
<PhTextT :size="14" />
|
||||
Text
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import type Konva from 'konva'
|
||||
import { PhCornersIn } from '@phosphor-icons/vue'
|
||||
import { PhCornersIn, PhCrop } from '@phosphor-icons/vue'
|
||||
import { useSceneStore } from '@/stores/scene'
|
||||
import { sampleLayer } from '@/lib/interpolate'
|
||||
import { loadImage } from '@/lib/fileLoader'
|
||||
@@ -17,6 +17,7 @@ const transformerRef = ref<{ getNode(): Konva.Transformer } | null>(null)
|
||||
const zoom = ref(1)
|
||||
const viewX = ref(0)
|
||||
const viewY = ref(0)
|
||||
const cropObjects = ref(false)
|
||||
let didInitialFit = false
|
||||
|
||||
const ZOOM_MIN = 0.05
|
||||
@@ -61,6 +62,18 @@ const viewConfig = computed(() => ({
|
||||
scaleY: zoom.value,
|
||||
}))
|
||||
|
||||
const clipGroupConfig = computed(() => {
|
||||
const w = store.scene.canvas.width
|
||||
const h = store.scene.canvas.height
|
||||
const crop = cropObjects.value
|
||||
return {
|
||||
clipFunc: (ctx: CanvasRenderingContext2D) => {
|
||||
if (crop) ctx.rect(0, 0, w, h)
|
||||
else ctx.rect(-1e6, -1e6, 2e6, 2e6)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const backgroundConfig = computed(() => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
@@ -115,6 +128,8 @@ interface RenderedLayer {
|
||||
|
||||
const rendered = computed<RenderedLayer[]>(() =>
|
||||
store.scene.layers
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((layer) => {
|
||||
const asset = assetMap.value.get(layer.assetId)
|
||||
if (!asset) return null
|
||||
@@ -513,33 +528,35 @@ onBeforeUnmount(() => {
|
||||
<v-stage ref="stageRef" :config="stageConfig" @mousedown="handleStageMouseDown">
|
||||
<v-layer>
|
||||
<v-group :config="viewConfig">
|
||||
<v-rect :config="backgroundConfig" />
|
||||
<template v-for="item in rendered" :key="item.layer.id">
|
||||
<v-rect
|
||||
v-if="item.asset.kind === 'rect'"
|
||||
:config="rectConfig(item.layer, item.asset, item.props)"
|
||||
@dragend="handleDragEnd"
|
||||
@transformend="handleTransformEnd"
|
||||
/>
|
||||
<v-circle
|
||||
v-else-if="item.asset.kind === 'circle'"
|
||||
:config="circleConfig(item.layer, item.asset, item.props)"
|
||||
@dragend="handleDragEnd"
|
||||
@transformend="handleTransformEnd"
|
||||
/>
|
||||
<v-text
|
||||
v-else-if="item.asset.kind === 'text'"
|
||||
:config="textConfig(item.layer, item.asset, item.props)"
|
||||
@dragend="handleDragEnd"
|
||||
@transformend="handleTransformEnd"
|
||||
/>
|
||||
<v-image
|
||||
v-else-if="item.asset.kind === 'image' && imageConfig(item.layer, item.asset, item.props)"
|
||||
:config="imageConfig(item.layer, item.asset, item.props)!"
|
||||
@dragend="handleDragEnd"
|
||||
@transformend="handleTransformEnd"
|
||||
/>
|
||||
</template>
|
||||
<v-group :config="clipGroupConfig">
|
||||
<v-rect :config="backgroundConfig" />
|
||||
<template v-for="item in rendered" :key="item.layer.id">
|
||||
<v-rect
|
||||
v-if="item.asset.kind === 'rect'"
|
||||
:config="rectConfig(item.layer, item.asset, item.props)"
|
||||
@dragend="handleDragEnd"
|
||||
@transformend="handleTransformEnd"
|
||||
/>
|
||||
<v-circle
|
||||
v-else-if="item.asset.kind === 'circle'"
|
||||
:config="circleConfig(item.layer, item.asset, item.props)"
|
||||
@dragend="handleDragEnd"
|
||||
@transformend="handleTransformEnd"
|
||||
/>
|
||||
<v-text
|
||||
v-else-if="item.asset.kind === 'text'"
|
||||
:config="textConfig(item.layer, item.asset, item.props)"
|
||||
@dragend="handleDragEnd"
|
||||
@transformend="handleTransformEnd"
|
||||
/>
|
||||
<v-image
|
||||
v-else-if="item.asset.kind === 'image' && imageConfig(item.layer, item.asset, item.props)"
|
||||
:config="imageConfig(item.layer, item.asset, item.props)!"
|
||||
@dragend="handleDragEnd"
|
||||
@transformend="handleTransformEnd"
|
||||
/>
|
||||
</template>
|
||||
</v-group>
|
||||
<v-rect :config="canvasBorderConfig" />
|
||||
<v-line v-if="rotationArmConfig" :config="rotationArmConfig" />
|
||||
<v-group
|
||||
@@ -573,6 +590,15 @@ onBeforeUnmount(() => {
|
||||
<button class="zc-btn" title="An Viewport anpassen" @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)'"
|
||||
@click="cropObjects = !cropObjects"
|
||||
>
|
||||
<PhCrop :size="13" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="dims">{{ store.scene.canvas.width }} × {{ store.scene.canvas.height }}</div>
|
||||
@@ -637,6 +663,16 @@ onBeforeUnmount(() => {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.zc-btn.active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.zc-btn.active:hover:not(:disabled) {
|
||||
background: var(--accent-soft-strong);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.zc-label {
|
||||
min-width: 54px;
|
||||
height: 24px;
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import {
|
||||
PhClock,
|
||||
PhFrameCorners,
|
||||
PhMoon,
|
||||
PhPalette,
|
||||
PhSun,
|
||||
PhX,
|
||||
} from '@phosphor-icons/vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { PhClock, PhFrameCorners, PhMoon, PhPalette, PhSun } from '@phosphor-icons/vue'
|
||||
import { useSceneStore } from '@/stores/scene'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import type { Theme } from '@/composables/useTheme'
|
||||
import { Modal } from '@/components/ui'
|
||||
|
||||
const props = defineProps<{ modelValue: boolean }>()
|
||||
defineProps<{ modelValue: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||
|
||||
const store = useSceneStore()
|
||||
@@ -69,32 +63,20 @@ function close() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && props.modelValue) close()
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', onKeyDown))
|
||||
onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
|
||||
|
||||
function applyTheme(t: Theme) {
|
||||
setTheme(t)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="modelValue" class="backdrop" @mousedown.self="close">
|
||||
<div class="dialog" role="dialog" aria-modal="true">
|
||||
<header class="dialog-header">
|
||||
<h2>Settings</h2>
|
||||
<button class="close-btn" title="Schließen" @click="close">
|
||||
<PhX :size="14" weight="bold" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="body">
|
||||
<nav class="tabs">
|
||||
<Modal
|
||||
:model-value="modelValue"
|
||||
title="Settings"
|
||||
size="lg"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div class="body">
|
||||
<nav class="tabs">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
@@ -196,72 +178,15 @@ function applyTheme(t: Theme) {
|
||||
</div>
|
||||
<p class="hint">Gespeichert im Browser, beim nächsten Öffnen wiederverwendet.</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="dialog-footer">
|
||||
<button class="primary" @click="close">Fertig</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
<template #footer>
|
||||
<button class="primary" @click="close">Fertig</button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
z-index: 900;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
width: min(620px, calc(100% - 32px));
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - 64px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dialog-header h2 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.close-btn:hover:not(:disabled) {
|
||||
background: var(--panel-alt);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
min-height: 340px;
|
||||
@@ -508,14 +433,6 @@ input[type='number'] {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.primary {
|
||||
padding: 6px 14px;
|
||||
background: var(--accent-soft);
|
||||
@@ -528,13 +445,4 @@ input[type='number'] {
|
||||
.primary:hover:not(:disabled) {
|
||||
background: var(--accent-soft-strong);
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 140ms var(--ease);
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
+95
-168
@@ -1,19 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import {
|
||||
PhCode,
|
||||
PhDownloadSimple,
|
||||
PhEngine,
|
||||
PhFileHtml,
|
||||
PhFilmSlate,
|
||||
PhX,
|
||||
} from '@phosphor-icons/vue'
|
||||
import { useSceneStore } from '@/stores/scene'
|
||||
import { downloadExampleHtml, downloadRuntime, downloadSceneExport } from '@/lib/exportPlayer'
|
||||
import { downloadSceneAsVideo, pickMimeType } from '@/lib/videoExport'
|
||||
import { downloadFromServer } from '@/lib/serverExport'
|
||||
import { Modal } from '@/components/ui'
|
||||
|
||||
const props = defineProps<{ modelValue: boolean }>()
|
||||
defineProps<{ modelValue: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||
|
||||
const store = useSceneStore()
|
||||
@@ -34,17 +34,17 @@ function sceneClone() {
|
||||
|
||||
function exportScene() {
|
||||
downloadSceneExport(sceneClone())
|
||||
close()
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function exportRuntime() {
|
||||
downloadRuntime()
|
||||
close()
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function exportHtml() {
|
||||
downloadExampleHtml(sceneClone())
|
||||
close()
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
async function exportWebm() {
|
||||
@@ -111,174 +111,110 @@ function dismissError() {
|
||||
progress.value = null
|
||||
}
|
||||
|
||||
const isLocked = () => !!progress.value && !progress.value.error
|
||||
const isLocked = computed(() => !!progress.value && !progress.value.error)
|
||||
|
||||
function close() {
|
||||
if (isLocked()) return
|
||||
emit('update:modelValue', false)
|
||||
function onUpdate(v: boolean) {
|
||||
if (!v && isLocked.value) return
|
||||
emit('update:modelValue', v)
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && props.modelValue) close()
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', onKeyDown))
|
||||
onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="modelValue" class="backdrop" @mousedown.self="close">
|
||||
<div class="dialog" role="dialog" aria-modal="true">
|
||||
<header class="dialog-header">
|
||||
<h2>Export</h2>
|
||||
<button class="close-btn" :disabled="isLocked()" title="Schließen" @click="close">
|
||||
<PhX :size="14" weight="bold" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="!progress" class="options">
|
||||
<button class="opt" @click="exportRuntime">
|
||||
<div class="opt-icon"><PhEngine :size="20" /></div>
|
||||
<div class="opt-text">
|
||||
<div class="opt-title">
|
||||
animatr-runtime.js
|
||||
<span class="badge">1× pro Seite</span>
|
||||
</div>
|
||||
<div class="opt-desc">Engine — einmal einbinden, beliebig viele Szenen teilen sich die Runtime</div>
|
||||
</div>
|
||||
<PhDownloadSimple :size="14" class="opt-arrow" />
|
||||
</button>
|
||||
|
||||
<button class="opt" @click="exportScene">
|
||||
<div class="opt-icon"><PhCode :size="20" /></div>
|
||||
<div class="opt-text">
|
||||
<div class="opt-title">
|
||||
animation.js
|
||||
<span class="badge">Szene</span>
|
||||
</div>
|
||||
<div class="opt-desc">Nur Szenen-Daten — benötigt geladene Runtime</div>
|
||||
</div>
|
||||
<PhDownloadSimple :size="14" class="opt-arrow" />
|
||||
</button>
|
||||
|
||||
<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>
|
||||
<PhDownloadSimple :size="14" class="opt-arrow" />
|
||||
</button>
|
||||
|
||||
<button class="opt" @click="exportMp4Server">
|
||||
<div class="opt-icon"><PhFilmSlate :size="20" /></div>
|
||||
<div class="opt-text">
|
||||
<div class="opt-title">
|
||||
MP4 Video
|
||||
<span class="badge accent">Server · Frame-exakt</span>
|
||||
</div>
|
||||
<div class="opt-desc">H.264, beliebige Auflösung — benötigt Render-Server</div>
|
||||
</div>
|
||||
<PhDownloadSimple :size="14" class="opt-arrow" />
|
||||
</button>
|
||||
|
||||
<button class="opt" :disabled="!videoSupported" @click="exportWebm">
|
||||
<div class="opt-icon"><PhFilmSlate :size="20" /></div>
|
||||
<div class="opt-text">
|
||||
<div class="opt-title">
|
||||
WebM Video
|
||||
<span class="badge">Browser · Realtime</span>
|
||||
</div>
|
||||
<div class="opt-desc">
|
||||
{{ videoSupported ? 'Ohne Server — Aufnahme in Echtzeit' : 'Browser unterstützt MediaRecorder nicht' }}
|
||||
</div>
|
||||
</div>
|
||||
<PhDownloadSimple :size="14" class="opt-arrow" />
|
||||
</button>
|
||||
<Modal
|
||||
:model-value="modelValue"
|
||||
title="Export"
|
||||
size="md"
|
||||
:dismissable="!isLocked"
|
||||
:close-on-backdrop="!isLocked"
|
||||
:close-on-esc="!isLocked"
|
||||
@update:model-value="onUpdate"
|
||||
>
|
||||
<div v-if="!progress" class="options">
|
||||
<button class="opt" @click="exportRuntime">
|
||||
<div class="opt-icon"><PhEngine :size="20" /></div>
|
||||
<div class="opt-text">
|
||||
<div class="opt-title">
|
||||
animatr-runtime.js
|
||||
<span class="badge">1× pro Seite</span>
|
||||
</div>
|
||||
<div class="opt-desc">Engine — einmal einbinden, beliebig viele Szenen teilen sich die Runtime</div>
|
||||
</div>
|
||||
<PhDownloadSimple :size="14" class="opt-arrow" />
|
||||
</button>
|
||||
|
||||
<div v-else-if="!progress.error" class="rendering">
|
||||
<div class="r-label">
|
||||
<PhFilmSlate :size="14" />
|
||||
<span class="r-text">{{ progress.label }}</span>
|
||||
<span class="r-percent">{{ Math.round(progress.progress * 100) }}%</span>
|
||||
</div>
|
||||
<div class="r-bar">
|
||||
<div class="r-bar-fill" :style="{ width: progress.progress * 100 + '%' }"></div>
|
||||
</div>
|
||||
<div class="r-actions">
|
||||
<button class="secondary" @click="cancel">Abbrechen</button>
|
||||
</div>
|
||||
<button class="opt" @click="exportScene">
|
||||
<div class="opt-icon"><PhCode :size="20" /></div>
|
||||
<div class="opt-text">
|
||||
<div class="opt-title">
|
||||
animation.js
|
||||
<span class="badge">Szene</span>
|
||||
</div>
|
||||
<div class="opt-desc">Nur Szenen-Daten — benötigt geladene Runtime</div>
|
||||
</div>
|
||||
<PhDownloadSimple :size="14" class="opt-arrow" />
|
||||
</button>
|
||||
|
||||
<div v-else class="error-view">
|
||||
<div class="error-title">Export fehlgeschlagen</div>
|
||||
<div class="error-msg">{{ progress.error }}</div>
|
||||
<div class="error-actions">
|
||||
<button class="primary" @click="dismissError">Zurück</button>
|
||||
</div>
|
||||
<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>
|
||||
<PhDownloadSimple :size="14" class="opt-arrow" />
|
||||
</button>
|
||||
|
||||
<button class="opt" @click="exportMp4Server">
|
||||
<div class="opt-icon"><PhFilmSlate :size="20" /></div>
|
||||
<div class="opt-text">
|
||||
<div class="opt-title">
|
||||
MP4 Video
|
||||
<span class="badge accent">Server · Frame-exakt</span>
|
||||
</div>
|
||||
<div class="opt-desc">H.264, beliebige Auflösung — benötigt Render-Server</div>
|
||||
</div>
|
||||
<PhDownloadSimple :size="14" class="opt-arrow" />
|
||||
</button>
|
||||
|
||||
<button class="opt" :disabled="!videoSupported" @click="exportWebm">
|
||||
<div class="opt-icon"><PhFilmSlate :size="20" /></div>
|
||||
<div class="opt-text">
|
||||
<div class="opt-title">
|
||||
WebM Video
|
||||
<span class="badge">Browser · Realtime</span>
|
||||
</div>
|
||||
<div class="opt-desc">
|
||||
{{ videoSupported ? 'Ohne Server — Aufnahme in Echtzeit' : 'Browser unterstützt MediaRecorder nicht' }}
|
||||
</div>
|
||||
</div>
|
||||
<PhDownloadSimple :size="14" class="opt-arrow" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!progress.error" class="rendering">
|
||||
<div class="r-label">
|
||||
<PhFilmSlate :size="14" />
|
||||
<span class="r-text">{{ progress.label }}</span>
|
||||
<span class="r-percent">{{ Math.round(progress.progress * 100) }}%</span>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
<div class="r-bar">
|
||||
<div class="r-bar-fill" :style="{ width: progress.progress * 100 + '%' }"></div>
|
||||
</div>
|
||||
<div class="r-actions">
|
||||
<button class="secondary" @click="cancel">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="error-view">
|
||||
<div class="error-title">Export fehlgeschlagen</div>
|
||||
<div class="error-msg">{{ progress.error }}</div>
|
||||
<div class="error-actions">
|
||||
<button class="primary" @click="dismissError">Zurück</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
z-index: 900;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
width: min(520px, calc(100% - 32px));
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - 64px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.dialog-header h2 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.close-btn:hover:not(:disabled) {
|
||||
background: var(--panel-alt);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -466,13 +402,4 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
|
||||
.error-actions .primary:hover:not(:disabled) {
|
||||
background: var(--accent-soft-strong);
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 140ms var(--ease);
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,458 @@
|
||||
<script setup lang="ts">
|
||||
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'
|
||||
|
||||
const props = defineProps<{ modelValue: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||
|
||||
const store = useSceneStore()
|
||||
|
||||
const canvasEl = ref<HTMLCanvasElement | null>(null)
|
||||
const frameEl = ref<HTMLDivElement | null>(null)
|
||||
const rootEl = ref<HTMLDivElement | null>(null)
|
||||
const progressTrackEl = ref<HTMLDivElement | null>(null)
|
||||
const viewportSize = ref({ w: 0, h: 0 })
|
||||
const images = ref<Record<string, HTMLImageElement>>({})
|
||||
const localTime = ref(0)
|
||||
const playing = ref(false)
|
||||
const showControls = ref(false)
|
||||
const scrubbing = ref(false)
|
||||
|
||||
const HOVER_ZONE_PX = 160
|
||||
let isClosing = false
|
||||
|
||||
const aspect = computed(
|
||||
() => `${store.scene.canvas.width} / ${store.scene.canvas.height}`,
|
||||
)
|
||||
|
||||
const sceneEnd = computed(() => store.effectiveEnd)
|
||||
|
||||
let raf = 0
|
||||
let lastFrame = 0
|
||||
let ro: ResizeObserver | null = null
|
||||
|
||||
function resize() {
|
||||
if (!canvasEl.value || !frameEl.value) return
|
||||
const rect = frameEl.value.getBoundingClientRect()
|
||||
viewportSize.value = { w: rect.width, h: rect.height }
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
canvasEl.value.width = Math.max(1, Math.round(rect.width * dpr))
|
||||
canvasEl.value.height = Math.max(1, Math.round(rect.height * dpr))
|
||||
draw()
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const canvas = canvasEl.value
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const lw = store.scene.canvas.width
|
||||
const lh = store.scene.canvas.height
|
||||
const cw = viewportSize.value.w
|
||||
const ch = viewportSize.value.h
|
||||
if (!cw || !ch) return
|
||||
const scale = Math.min(cw / lw, ch / lh)
|
||||
const ox = (cw - lw * scale) / 2
|
||||
const oy = (ch - lh * scale) / 2
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
ctx.clearRect(0, 0, cw, ch)
|
||||
ctx.save()
|
||||
ctx.translate(ox, oy)
|
||||
ctx.scale(scale, scale)
|
||||
drawSceneFrame(ctx, store.scene, localTime.value, images.value)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
function loop(now: number) {
|
||||
if (!props.modelValue) return
|
||||
if (playing.value) {
|
||||
const end = sceneEnd.value
|
||||
const dt = lastFrame === 0 ? 0 : (now - lastFrame) / 1000
|
||||
if (end > 0) {
|
||||
let next = localTime.value + dt
|
||||
if (next >= end) next = 0
|
||||
localTime.value = next
|
||||
}
|
||||
}
|
||||
lastFrame = now
|
||||
draw()
|
||||
raf = requestAnimationFrame(loop)
|
||||
}
|
||||
|
||||
function startLoop() {
|
||||
cancelAnimationFrame(raf)
|
||||
lastFrame = 0
|
||||
raf = requestAnimationFrame(loop)
|
||||
}
|
||||
|
||||
function stopLoop() {
|
||||
cancelAnimationFrame(raf)
|
||||
raf = 0
|
||||
}
|
||||
|
||||
async function startPreview() {
|
||||
localTime.value = store.playhead
|
||||
playing.value = true
|
||||
showControls.value = false
|
||||
images.value = await loadSceneImages(store.scene)
|
||||
resize()
|
||||
startLoop()
|
||||
await requestBrowserFullscreen()
|
||||
}
|
||||
|
||||
function stopPreview() {
|
||||
stopLoop()
|
||||
playing.value = false
|
||||
showControls.value = false
|
||||
exitBrowserFullscreen()
|
||||
}
|
||||
|
||||
async function requestBrowserFullscreen() {
|
||||
const el = rootEl.value
|
||||
if (!el) return
|
||||
if (document.fullscreenElement === el) return
|
||||
try {
|
||||
await el.requestFullscreen?.()
|
||||
} catch {
|
||||
/* user gesture missing or not supported — gracefully keep overlay */
|
||||
}
|
||||
}
|
||||
|
||||
function exitBrowserFullscreen() {
|
||||
if (document.fullscreenElement) {
|
||||
isClosing = true
|
||||
document.exitFullscreen?.().catch(() => {
|
||||
/* ignore */
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function onFullscreenChange() {
|
||||
if (!props.modelValue) return
|
||||
if (document.fullscreenElement === rootEl.value) return
|
||||
if (isClosing) {
|
||||
isClosing = false
|
||||
return
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
function onRootMouseMove(e: MouseEvent) {
|
||||
const nearBottom = e.clientY > window.innerHeight - HOVER_ZONE_PX
|
||||
if (nearBottom || scrubbing.value) showControls.value = true
|
||||
else showControls.value = false
|
||||
}
|
||||
|
||||
function onRootMouseLeave() {
|
||||
if (!scrubbing.value) showControls.value = false
|
||||
}
|
||||
|
||||
const progressPct = computed(() => {
|
||||
const end = sceneEnd.value
|
||||
if (!end) return 0
|
||||
return Math.max(0, Math.min(1, localTime.value / end)) * 100
|
||||
})
|
||||
|
||||
function seekFromEvent(e: PointerEvent) {
|
||||
const el = progressTrackEl.value
|
||||
if (!el) return
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.width <= 0) return
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
const end = sceneEnd.value
|
||||
localTime.value = ratio * end
|
||||
draw()
|
||||
}
|
||||
|
||||
function onProgressPointerDown(e: PointerEvent) {
|
||||
const el = e.currentTarget as HTMLElement
|
||||
el.setPointerCapture(e.pointerId)
|
||||
scrubbing.value = true
|
||||
seekFromEvent(e)
|
||||
}
|
||||
|
||||
function onProgressPointerMove(e: PointerEvent) {
|
||||
if (!scrubbing.value) return
|
||||
seekFromEvent(e)
|
||||
}
|
||||
|
||||
function onProgressPointerUp(e: PointerEvent) {
|
||||
const el = e.currentTarget as HTMLElement
|
||||
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
|
||||
scrubbing.value = false
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
playing.value = !playing.value
|
||||
}
|
||||
|
||||
function rewind() {
|
||||
localTime.value = 0
|
||||
playing.value = true
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (!props.modelValue) return
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
close()
|
||||
return
|
||||
}
|
||||
if (e.code === 'Space') {
|
||||
e.preventDefault()
|
||||
toggle()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(v) => {
|
||||
if (v) startPreview()
|
||||
else stopPreview()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [store.scene.canvas.width, store.scene.canvas.height],
|
||||
() => {
|
||||
if (props.modelValue) resize()
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
document.addEventListener('fullscreenchange', onFullscreenChange)
|
||||
if (props.modelValue) startPreview()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
||||
stopLoop()
|
||||
exitBrowserFullscreen()
|
||||
if (ro) ro.disconnect()
|
||||
ro = null
|
||||
})
|
||||
|
||||
watch(frameEl, (el, _old, onCleanup) => {
|
||||
if (ro) ro.disconnect()
|
||||
ro = null
|
||||
if (el) {
|
||||
ro = new ResizeObserver(() => resize())
|
||||
ro.observe(el)
|
||||
}
|
||||
onCleanup(() => {
|
||||
if (ro) ro.disconnect()
|
||||
ro = null
|
||||
})
|
||||
})
|
||||
|
||||
const timeLabel = computed(() => {
|
||||
const end = sceneEnd.value || 0
|
||||
return `${localTime.value.toFixed(2)}s / ${end.toFixed(2)}s`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="modelValue"
|
||||
ref="rootEl"
|
||||
class="fs-root"
|
||||
:class="{ 'controls-visible': showControls || scrubbing }"
|
||||
@mousemove="onRootMouseMove"
|
||||
@mouseleave="onRootMouseLeave"
|
||||
@dblclick.self="close"
|
||||
>
|
||||
<div ref="frameEl" class="fs-frame" :style="{ aspectRatio: aspect }">
|
||||
<canvas ref="canvasEl" />
|
||||
</div>
|
||||
|
||||
<div class="fs-controls" @mousedown.stop>
|
||||
<div
|
||||
ref="progressTrackEl"
|
||||
class="fs-progress"
|
||||
@pointerdown="onProgressPointerDown"
|
||||
@pointermove="onProgressPointerMove"
|
||||
@pointerup="onProgressPointerUp"
|
||||
@pointercancel="onProgressPointerUp"
|
||||
>
|
||||
<div class="fs-progress-track">
|
||||
<div class="fs-progress-fill" :style="{ width: progressPct + '%' }"></div>
|
||||
</div>
|
||||
<div class="fs-progress-handle" :style="{ left: progressPct + '%' }"></div>
|
||||
</div>
|
||||
<div class="fs-toolbar">
|
||||
<button class="fs-btn" :title="playing ? 'Pause' : '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">
|
||||
<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">
|
||||
<PhX :size="15" weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fs-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2500;
|
||||
background: #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.fs-frame {
|
||||
width: min(100vw, 100vh * (16 / 9));
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.fs-frame canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.fs-controls {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 24px;
|
||||
transform: translateX(-50%) translateY(12px);
|
||||
width: min(720px, calc(100vw - 48px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px 14px 10px;
|
||||
background: rgba(20, 20, 24, 0.92);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(10px);
|
||||
opacity: 0;
|
||||
transition: opacity 180ms ease, transform 180ms ease;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.fs-root.controls-visible .fs-controls {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.fs-progress {
|
||||
position: relative;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.fs-progress-track {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fs-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent, #6ea8ff);
|
||||
border-radius: 999px;
|
||||
transition: width 60ms linear;
|
||||
}
|
||||
|
||||
.fs-progress-handle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-left: -6px;
|
||||
margin-top: -6px;
|
||||
background: #fff;
|
||||
border: 2px solid var(--accent, #6ea8ff);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fs-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.fs-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.fs-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fs-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.fs-btn.close:hover {
|
||||
background: rgba(255, 85, 85, 0.2);
|
||||
color: #ff7a7a;
|
||||
}
|
||||
|
||||
.fs-time {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
padding: 0 6px;
|
||||
min-width: 96px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 160ms var(--ease, ease);
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -28,6 +28,11 @@ const selectedAsset = computed(() => {
|
||||
return store.scene.assets.find((a) => a.id === selected.value!.assetId) ?? null
|
||||
})
|
||||
|
||||
const textAsset = computed(() => {
|
||||
const a = selectedAsset.value
|
||||
return a && a.kind === 'text' ? a : null
|
||||
})
|
||||
|
||||
const canFill = computed(() => {
|
||||
const a = selectedAsset.value
|
||||
return !!a && (a.kind === 'rect' || a.kind === 'circle' || a.kind === 'text')
|
||||
@@ -98,6 +103,10 @@ function removeKeyframeHere() {
|
||||
<label>Scale Y<input type="number" step="0.1" v-model.number="scaleY" /></label>
|
||||
</div>
|
||||
<label>Rotation<input type="number" v-model.number="rotation" /></label>
|
||||
<template v-if="textAsset">
|
||||
<label>Text<input type="text" v-model="textAsset.text" /></label>
|
||||
<label>Font Size<input type="number" min="1" v-model.number="textAsset.fontSize" /></label>
|
||||
</template>
|
||||
<label v-if="canFill" class="color-label">
|
||||
Color
|
||||
<div class="color-row">
|
||||
|
||||
+211
-8
@@ -1,6 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { PhArrowsSplit, PhCheck, PhCopy, PhMagnet, PhTrash } from '@phosphor-icons/vue'
|
||||
import {
|
||||
PhArrowsSplit,
|
||||
PhCheck,
|
||||
PhCopy,
|
||||
PhDotsSixVertical,
|
||||
PhMagnet,
|
||||
PhPauseCircle,
|
||||
PhTrash,
|
||||
} from '@phosphor-icons/vue'
|
||||
import { useSceneStore } from '@/stores/scene'
|
||||
import { sampleLayer } from '@/lib/interpolate'
|
||||
import { snapToCandidates } from '@/lib/timelineSnap'
|
||||
@@ -13,6 +21,7 @@ interface TimelineTrack {
|
||||
kind: 'main' | 'position' | 'rotation'
|
||||
label: string
|
||||
isParent: boolean
|
||||
layerIndex: number
|
||||
}
|
||||
|
||||
const store = useSceneStore()
|
||||
@@ -208,7 +217,7 @@ function onKeyframeDblClick(e: MouseEvent, layerId: string, keyframeId: string)
|
||||
|
||||
const tracks = computed<TimelineTrack[]>(() => {
|
||||
const out: TimelineTrack[] = []
|
||||
for (const layer of store.scene.layers) {
|
||||
store.scene.layers.forEach((layer, layerIndex) => {
|
||||
if (!layer.separated) {
|
||||
out.push({
|
||||
layerId: layer.id,
|
||||
@@ -217,6 +226,7 @@ const tracks = computed<TimelineTrack[]>(() => {
|
||||
kind: 'main',
|
||||
label: layer.name,
|
||||
isParent: true,
|
||||
layerIndex,
|
||||
})
|
||||
} else {
|
||||
out.push({
|
||||
@@ -226,6 +236,7 @@ const tracks = computed<TimelineTrack[]>(() => {
|
||||
kind: 'main',
|
||||
label: layer.name,
|
||||
isParent: true,
|
||||
layerIndex,
|
||||
})
|
||||
out.push({
|
||||
layerId: layer.id,
|
||||
@@ -234,6 +245,7 @@ const tracks = computed<TimelineTrack[]>(() => {
|
||||
kind: 'position',
|
||||
label: 'Position',
|
||||
isParent: false,
|
||||
layerIndex,
|
||||
})
|
||||
out.push({
|
||||
layerId: layer.id,
|
||||
@@ -242,12 +254,30 @@ const tracks = computed<TimelineTrack[]>(() => {
|
||||
kind: 'rotation',
|
||||
label: 'Rotation',
|
||||
isParent: false,
|
||||
layerIndex,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
return out
|
||||
})
|
||||
|
||||
function isDropBefore(track: TimelineTrack): boolean {
|
||||
return (
|
||||
dropIndex.value !== null &&
|
||||
track.isParent &&
|
||||
dropIndex.value === track.layerIndex &&
|
||||
dragLayerId.value !== track.layerId
|
||||
)
|
||||
}
|
||||
|
||||
function isDropAfter(track: TimelineTrack, nextTrack: TimelineTrack | undefined): boolean {
|
||||
if (dropIndex.value === null) return false
|
||||
if (dragLayerId.value === track.layerId) return false
|
||||
if (dropIndex.value !== track.layerIndex + 1) return false
|
||||
if (nextTrack && nextTrack.layerIndex === track.layerIndex) return false
|
||||
return true
|
||||
}
|
||||
|
||||
interface KfDrag {
|
||||
layerId: string
|
||||
keyframeId: string
|
||||
@@ -305,6 +335,7 @@ interface KfSegment {
|
||||
fromTime: number
|
||||
toTime: number
|
||||
easing: Easing
|
||||
held: boolean
|
||||
}
|
||||
|
||||
function segmentsOf(kfs: Keyframe[]): KfSegment[] {
|
||||
@@ -319,6 +350,7 @@ function segmentsOf(kfs: Keyframe[]): KfSegment[] {
|
||||
fromTime: a.time,
|
||||
toTime: b.time,
|
||||
easing: b.easing,
|
||||
held: !!a.hold,
|
||||
})
|
||||
}
|
||||
return out
|
||||
@@ -346,6 +378,8 @@ interface KfMenuState {
|
||||
y: number
|
||||
currentEasing: Easing
|
||||
isFirst: boolean
|
||||
isLast: boolean
|
||||
isHeld: boolean
|
||||
}
|
||||
|
||||
const menu = ref<KfMenuState | null>(null)
|
||||
@@ -371,10 +405,18 @@ function onKeyframeContextMenu(
|
||||
y: e.clientY,
|
||||
currentEasing: kf.easing,
|
||||
isFirst: sorted[0]?.id === kf.id,
|
||||
isLast: sorted[sorted.length - 1]?.id === kf.id,
|
||||
isHeld: !!kf.hold,
|
||||
}
|
||||
store.selectLayer(layerId)
|
||||
}
|
||||
|
||||
function toggleHold() {
|
||||
if (!menu.value) return
|
||||
store.setKeyframeHold(menu.value.layerId, menu.value.keyframeId, !menu.value.isHeld)
|
||||
menu.value = null
|
||||
}
|
||||
|
||||
function applyEasing(easing: Easing) {
|
||||
if (!menu.value) return
|
||||
store.setKeyframeEasing(menu.value.layerId, menu.value.keyframeId, easing)
|
||||
@@ -411,6 +453,56 @@ interface TrackMenuState {
|
||||
y: number
|
||||
separated: boolean
|
||||
}
|
||||
|
||||
const dragLayerId = ref<string | null>(null)
|
||||
const dropIndex = ref<number | null>(null)
|
||||
|
||||
function onTrackDragStart(e: DragEvent, layerId: string) {
|
||||
if (!e.dataTransfer) return
|
||||
dragLayerId.value = layerId
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
try {
|
||||
e.dataTransfer.setData('text/plain', layerId)
|
||||
} catch {
|
||||
/* Safari older versions */
|
||||
}
|
||||
}
|
||||
|
||||
function onTrackDragOver(e: DragEvent, layerId: string) {
|
||||
if (!dragLayerId.value) return
|
||||
if (!e.dataTransfer) return
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
const el = e.currentTarget as HTMLElement
|
||||
const rect = el.getBoundingClientRect()
|
||||
const midY = rect.top + rect.height / 2
|
||||
const index = store.scene.layers.findIndex((l) => l.id === layerId)
|
||||
if (index < 0) return
|
||||
dropIndex.value = e.clientY < midY ? index : index + 1
|
||||
}
|
||||
|
||||
function onTrackDragLeave() {
|
||||
/* dropIndex updates on next dragover, keep as-is to avoid flicker */
|
||||
}
|
||||
|
||||
function onTrackDrop(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
const srcId = dragLayerId.value
|
||||
const target = dropIndex.value
|
||||
dragLayerId.value = null
|
||||
dropIndex.value = null
|
||||
if (!srcId || target === null) return
|
||||
const from = store.scene.layers.findIndex((l) => l.id === srcId)
|
||||
if (from < 0) return
|
||||
let to = target
|
||||
if (from < to) to -= 1
|
||||
store.moveLayer(from, to)
|
||||
}
|
||||
|
||||
function onTrackDragEnd() {
|
||||
dragLayerId.value = null
|
||||
dropIndex.value = null
|
||||
}
|
||||
const trackMenu = ref<TrackMenuState | null>(null)
|
||||
const trackMenuRef = ref<HTMLDivElement | null>(null)
|
||||
|
||||
@@ -574,7 +666,13 @@ onBeforeUnmount(() => {
|
||||
:class="{
|
||||
'sub-track': !track.isParent,
|
||||
inactive: store.selectedLayerId !== null && track.layerId !== store.selectedLayerId,
|
||||
'drop-before': isDropBefore(track),
|
||||
'drop-after': isDropAfter(track, tracks[idx + 1]),
|
||||
dragging: track.isParent && dragLayerId === track.layerId,
|
||||
}"
|
||||
@dragover="track.isParent ? onTrackDragOver($event, track.layerId) : null"
|
||||
@drop="track.isParent ? onTrackDrop($event) : null"
|
||||
@dragleave="onTrackDragLeave"
|
||||
>
|
||||
<span
|
||||
class="track-name"
|
||||
@@ -583,9 +681,17 @@ onBeforeUnmount(() => {
|
||||
parent: track.isParent,
|
||||
sub: !track.isParent,
|
||||
}"
|
||||
:draggable="track.isParent"
|
||||
@click="store.selectLayer(track.layerId)"
|
||||
@contextmenu="track.isParent ? onTrackContextMenu($event, track.layerId) : null"
|
||||
@dragstart="track.isParent ? onTrackDragStart($event, track.layerId) : null"
|
||||
@dragend="onTrackDragEnd"
|
||||
>
|
||||
<PhDotsSixVertical
|
||||
v-if="track.isParent"
|
||||
:size="12"
|
||||
class="drag-handle"
|
||||
/>
|
||||
<PhArrowsSplit
|
||||
v-if="track.isParent && track.layer.separated"
|
||||
:size="11"
|
||||
@@ -605,15 +711,20 @@ onBeforeUnmount(() => {
|
||||
v-for="seg in segmentsOf(track.kfs)"
|
||||
:key="seg.id"
|
||||
class="kf-seg"
|
||||
:class="`easing-${seg.easing}`"
|
||||
:class="[seg.held ? 'held' : `easing-${seg.easing}`]"
|
||||
:style="segmentStyle(seg)"
|
||||
/>
|
||||
<span
|
||||
v-for="kf in track.kfs"
|
||||
:key="kf.id"
|
||||
class="kf"
|
||||
:class="{ hold: kf.hold }"
|
||||
:style="{ left: pxFromTime(kf.time) + 'px' }"
|
||||
:title="`Keyframe ${formatKfTime(kf.time)} — ziehen zum Verschieben, Rechtsklick für Optionen`"
|
||||
: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`
|
||||
"
|
||||
@pointerdown="onKeyframePointerDown($event, track.layerId, kf.id)"
|
||||
@pointermove="onKeyframePointerMove"
|
||||
@pointerup="onKeyframePointerUp"
|
||||
@@ -641,18 +752,37 @@ onBeforeUnmount(() => {
|
||||
v-for="opt in EASING_OPTIONS"
|
||||
:key="opt.value"
|
||||
class="kf-menu-item"
|
||||
:class="{ active: menu.currentEasing === opt.value }"
|
||||
:disabled="menu.isFirst"
|
||||
:class="{ active: menu.currentEasing === opt.value && !menu.isHeld }"
|
||||
:disabled="menu.isFirst || menu.isHeld"
|
||||
@click="applyEasing(opt.value)"
|
||||
>
|
||||
<span class="check">
|
||||
<PhCheck v-if="menu.currentEasing === opt.value" :size="12" weight="bold" />
|
||||
<PhCheck
|
||||
v-if="menu.currentEasing === opt.value && !menu.isHeld"
|
||||
:size="12"
|
||||
weight="bold"
|
||||
/>
|
||||
</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>
|
||||
<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' : ''"
|
||||
@click="toggleHold"
|
||||
>
|
||||
<span class="check">
|
||||
<PhCheck v-if="menu.isHeld" :size="12" weight="bold" />
|
||||
</span>
|
||||
<PhPauseCircle :size="12" />
|
||||
Still Frame
|
||||
</button>
|
||||
<div class="kf-menu-divider"></div>
|
||||
<button class="kf-menu-item" @click="duplicateFromMenu">
|
||||
<PhCopy :size="12" />
|
||||
Duplizieren
|
||||
@@ -872,6 +1002,32 @@ onBeforeUnmount(() => {
|
||||
height: 30px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition: background 140ms var(--ease);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.track.dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.track.drop-before::before,
|
||||
.track.drop-after::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--accent);
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 0 0 1px rgba(110, 168, 255, 0.35);
|
||||
}
|
||||
|
||||
.track.drop-before::before {
|
||||
top: -1px;
|
||||
}
|
||||
|
||||
.track.drop-after::after {
|
||||
bottom: -1px;
|
||||
}
|
||||
|
||||
.track.inactive .track-lane {
|
||||
@@ -951,6 +1107,24 @@ onBeforeUnmount(() => {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
color: var(--text-dim);
|
||||
margin-right: 4px;
|
||||
flex-shrink: 0;
|
||||
cursor: grab;
|
||||
opacity: 0;
|
||||
transition: opacity 140ms var(--ease);
|
||||
}
|
||||
|
||||
.track-name:hover .drag-handle,
|
||||
.track-name.selected .drag-handle {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.sub-indent {
|
||||
color: var(--text-dim);
|
||||
margin-right: 4px;
|
||||
@@ -1020,6 +1194,17 @@ onBeforeUnmount(() => {
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.kf-seg.held {
|
||||
background: repeating-linear-gradient(
|
||||
90deg,
|
||||
var(--text-dim) 0,
|
||||
var(--text-dim) 2px,
|
||||
transparent 2px,
|
||||
transparent 5px
|
||||
);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.kf {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
@@ -1046,6 +1231,24 @@ onBeforeUnmount(() => {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.kf.hold {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
margin-left: -7.5px;
|
||||
margin-top: -7.5px;
|
||||
transform: none;
|
||||
border-radius: 50%;
|
||||
background: var(--panel);
|
||||
border: 2px solid var(--accent);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.kf.hold:hover {
|
||||
background: var(--panel);
|
||||
border-color: #fff;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
|
||||
@@ -6,13 +6,13 @@ import {
|
||||
PhFolderOpen,
|
||||
PhPlus,
|
||||
PhTrash,
|
||||
PhX,
|
||||
} from '@phosphor-icons/vue'
|
||||
import {
|
||||
deleteStoredProject,
|
||||
listRecentProjects,
|
||||
type StoredProject,
|
||||
} from '@/lib/projectStore'
|
||||
import { Modal } from '@/components/ui'
|
||||
|
||||
const props = defineProps<{ modelValue: boolean }>()
|
||||
const emit = defineEmits<{
|
||||
@@ -101,21 +101,18 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="modelValue" class="backdrop" @mousedown.self="close">
|
||||
<div class="dialog" role="dialog" aria-modal="true">
|
||||
<header class="hdr">
|
||||
<div>
|
||||
<h2>Willkommen bei Animatr</h2>
|
||||
<p class="sub">Zuletzt bearbeitete Projekte</p>
|
||||
</div>
|
||||
<button class="close" title="Schließen" @click="close">
|
||||
<PhX :size="14" weight="bold" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="body">
|
||||
<Modal
|
||||
:model-value="modelValue"
|
||||
size="md"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<template #header>
|
||||
<div class="hdr-text">
|
||||
<h2>Willkommen bei Animatr</h2>
|
||||
<p class="sub">Zuletzt bearbeitete Projekte</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="body">
|
||||
<div v-if="loading" class="empty">Laden…</div>
|
||||
<div v-else-if="!recents.length" class="empty">
|
||||
Keine gespeicherten Projekte vorhanden.
|
||||
@@ -146,61 +143,26 @@ watch(
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<footer class="ftr">
|
||||
<button class="ftr-btn" @click="startFresh">
|
||||
<PhPlus :size="13" weight="bold" />
|
||||
Neues Projekt
|
||||
</button>
|
||||
<button class="ftr-btn" @click="importFile">
|
||||
<PhFileArrowUp :size="13" />
|
||||
Datei importieren…
|
||||
</button>
|
||||
<button class="ftr-btn" @click="exportFile">
|
||||
<PhDownloadSimple :size="13" />
|
||||
Aktuelles exportieren
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
<template #footer>
|
||||
<button class="ftr-btn" @click="startFresh">
|
||||
<PhPlus :size="13" weight="bold" />
|
||||
Neues Projekt
|
||||
</button>
|
||||
<button class="ftr-btn" @click="importFile">
|
||||
<PhFileArrowUp :size="13" />
|
||||
Datei importieren…
|
||||
</button>
|
||||
<button class="ftr-btn" @click="exportFile">
|
||||
<PhDownloadSimple :size="13" />
|
||||
Aktuelles exportieren
|
||||
</button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 800;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
width: min(560px, calc(100% - 32px));
|
||||
max-height: calc(100vh - 64px);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hdr {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 16px 18px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.hdr h2 {
|
||||
.hdr-text h2 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
@@ -212,24 +174,6 @@ watch(
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.close {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-dim);
|
||||
border-radius: var(--radius-sm);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
background: var(--panel-alt);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 8px 12px;
|
||||
overflow: auto;
|
||||
@@ -334,14 +278,6 @@ watch(
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.ftr {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--panel-alt);
|
||||
}
|
||||
|
||||
.ftr-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -358,13 +294,4 @@ watch(
|
||||
background: var(--panel-hover);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 160ms var(--ease);
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
|
||||
type Variant = 'default' | 'ghost' | 'primary' | 'danger'
|
||||
type Size = 'xs' | 'sm' | 'md'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
icon?: Component
|
||||
iconSize?: number
|
||||
iconWeight?: 'regular' | 'bold' | 'fill'
|
||||
size?: Size
|
||||
variant?: Variant
|
||||
active?: boolean
|
||||
disabled?: boolean
|
||||
rounded?: boolean
|
||||
title?: string
|
||||
}>(),
|
||||
{
|
||||
iconWeight: 'regular',
|
||||
size: 'sm',
|
||||
variant: 'default',
|
||||
active: false,
|
||||
disabled: false,
|
||||
rounded: false,
|
||||
},
|
||||
)
|
||||
|
||||
const iconPx: Record<Size, number> = { xs: 12, sm: 14, md: 15 }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
:class="[`size-${size}`, `variant-${variant}`, { active, rounded }]"
|
||||
:disabled="disabled"
|
||||
:title="title"
|
||||
>
|
||||
<slot>
|
||||
<component
|
||||
v-if="icon"
|
||||
:is="icon"
|
||||
:size="iconSize ?? iconPx[size]"
|
||||
:weight="iconWeight"
|
||||
/>
|
||||
</slot>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background 140ms var(--ease), color 140ms var(--ease),
|
||||
border-color 140ms var(--ease);
|
||||
}
|
||||
|
||||
.icon-btn.rounded {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.icon-btn.size-xs {
|
||||
width: 22px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.icon-btn.size-sm {
|
||||
width: 30px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.icon-btn.size-md {
|
||||
width: 32px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.icon-btn:hover:not(:disabled) {
|
||||
background: var(--panel-alt);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.icon-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.icon-btn.variant-ghost {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.icon-btn.variant-primary {
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.icon-btn.variant-primary:hover:not(:disabled) {
|
||||
background: var(--accent-soft-strong);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.icon-btn.variant-danger:hover:not(:disabled) {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.icon-btn.active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.icon-btn.active:hover:not(:disabled) {
|
||||
background: var(--accent-soft-strong);
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,174 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted } from 'vue'
|
||||
import { PhX } from '@phosphor-icons/vue'
|
||||
|
||||
type Size = 'sm' | 'md' | 'lg'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
title?: string
|
||||
size?: Size
|
||||
closeOnBackdrop?: boolean
|
||||
closeOnEsc?: boolean
|
||||
dismissable?: boolean
|
||||
}>(),
|
||||
{
|
||||
size: 'md',
|
||||
closeOnBackdrop: true,
|
||||
closeOnEsc: true,
|
||||
dismissable: true,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||
|
||||
function close() {
|
||||
if (!props.dismissable) return
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function onBackdropMouseDown(e: MouseEvent) {
|
||||
if (!props.closeOnBackdrop) return
|
||||
if (e.target === e.currentTarget) close()
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (!props.modelValue) return
|
||||
if (!props.closeOnEsc) return
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', onKeyDown))
|
||||
onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal-fade">
|
||||
<div v-if="modelValue" class="backdrop" @mousedown="onBackdropMouseDown">
|
||||
<div class="dialog" :class="`size-${size}`" role="dialog" aria-modal="true">
|
||||
<header v-if="title || $slots.header" class="hdr">
|
||||
<slot name="header">
|
||||
<h2>{{ title }}</h2>
|
||||
</slot>
|
||||
<button
|
||||
v-if="dismissable"
|
||||
class="close"
|
||||
:disabled="!dismissable"
|
||||
title="Schließen"
|
||||
@click="close"
|
||||
>
|
||||
<PhX :size="14" weight="bold" />
|
||||
</button>
|
||||
</header>
|
||||
<div class="body">
|
||||
<slot />
|
||||
</div>
|
||||
<footer v-if="$slots.footer" class="ftr">
|
||||
<slot name="footer" />
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
z-index: 900;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - 64px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dialog.size-sm {
|
||||
width: min(420px, calc(100% - 32px));
|
||||
}
|
||||
|
||||
.dialog.size-md {
|
||||
width: min(560px, calc(100% - 32px));
|
||||
}
|
||||
|
||||
.dialog.size-lg {
|
||||
width: min(720px, calc(100% - 32px));
|
||||
}
|
||||
|
||||
.hdr {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hdr :deep(h2) {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.close {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-dim);
|
||||
border-radius: var(--radius-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.close:hover:not(:disabled) {
|
||||
background: var(--panel-alt);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.body {
|
||||
overflow: auto;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ftr {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-fade-enter-active,
|
||||
.modal-fade-leave-active {
|
||||
transition: opacity 140ms var(--ease);
|
||||
}
|
||||
|
||||
.modal-fade-enter-from,
|
||||
.modal-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
type Variant = 'info' | 'success' | 'error'
|
||||
type Position = 'bottom' | 'top'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
visible: boolean
|
||||
variant?: Variant
|
||||
position?: Position
|
||||
title?: string
|
||||
message?: string
|
||||
}>(),
|
||||
{
|
||||
variant: 'info',
|
||||
position: 'bottom',
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="toast-fade">
|
||||
<div
|
||||
v-if="visible"
|
||||
class="toast"
|
||||
:class="[`variant-${variant}`, `position-${position}`]"
|
||||
role="status"
|
||||
>
|
||||
<slot>
|
||||
<strong v-if="title">{{ title }}</strong>
|
||||
<span v-if="message">{{ message }}</span>
|
||||
</slot>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1500;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-md);
|
||||
font-size: 12px;
|
||||
max-width: 520px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.toast.position-bottom {
|
||||
bottom: 240px;
|
||||
}
|
||||
|
||||
.toast.position-top {
|
||||
top: 64px;
|
||||
}
|
||||
|
||||
.toast.variant-info {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.toast.variant-success {
|
||||
background: rgba(71, 191, 130, 0.14);
|
||||
border: 1px solid #47bf82;
|
||||
color: #47bf82;
|
||||
}
|
||||
|
||||
.toast.variant-error {
|
||||
background: var(--danger-soft);
|
||||
border: 1px solid var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.toast-fade-enter-active,
|
||||
.toast-fade-leave-active {
|
||||
transition: opacity 140ms var(--ease);
|
||||
}
|
||||
|
||||
.toast-fade-enter-from,
|
||||
.toast-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as IconButton } from './IconButton.vue'
|
||||
export { default as Modal } from './Modal.vue'
|
||||
export { default as Toast } from './Toast.vue'
|
||||
Reference in new issue
Block a user