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

No files matched your search

+18
View File
@@ -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
+64 -28
View File
@@ -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;
+17 -109
View File
@@ -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
View File
@@ -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>
+458
View File
@@ -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>
+9
View File
@@ -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
View File
@@ -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;
+30 -103
View File
@@ -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>
+125
View File
@@ -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>
+174
View File
@@ -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>
+90
View File
@@ -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>
+3
View File
@@ -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'