This commit is contained in:
th committed 2026-04-19 19:07:51 +02:00
1 parent c6863f2bfc
commit 94e9846c13
11 files changed
+974 -125

No files matched your search

+6 -2
View File
@@ -106,13 +106,17 @@
ctx.fillStyle=fill; ctx.fillStyle=fill;
ctx.fillRect(-rOffX,-rOffY,asset.width,asset.height); ctx.fillRect(-rOffX,-rOffY,asset.width,asset.height);
} else if(asset.kind==='circle'){ } else if(asset.kind==='circle'){
var cOffX=layer.offsetX!=null?layer.offsetX:0;
var cOffY=layer.offsetY!=null?layer.offsetY:0;
ctx.fillStyle=fill; ctx.fillStyle=fill;
ctx.beginPath(); ctx.arc(0,0,asset.radius,0,Math.PI*2); ctx.fill(); ctx.beginPath(); ctx.arc(-cOffX,-cOffY,asset.radius,0,Math.PI*2); ctx.fill();
} else if(asset.kind==='text'){ } else if(asset.kind==='text'){
var tOffX=layer.offsetX!=null?layer.offsetX:0;
var tOffY=layer.offsetY!=null?layer.offsetY:0;
ctx.fillStyle=fill; ctx.fillStyle=fill;
ctx.font=asset.fontSize+'px sans-serif'; ctx.font=asset.fontSize+'px sans-serif';
ctx.textBaseline='top'; ctx.textBaseline='top';
ctx.fillText(asset.text,0,0); ctx.fillText(asset.text,-tOffX,-tOffY);
} else if(asset.kind==='image'){ } else if(asset.kind==='image'){
var img=images[asset.id]; var img=images[asset.id];
if(img){ if(img){
+155 -6
View File
@@ -1,8 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref, watch } from 'vue'
import { import {
PhCircle, PhCircle,
PhImage, PhImage,
PhImageSquare,
PhListBullets,
PhPlus, PhPlus,
PhSquare, PhSquare,
PhTextT, PhTextT,
@@ -17,6 +19,17 @@ const store = useSceneStore()
const fileInput = ref<HTMLInputElement | null>(null) const fileInput = ref<HTMLInputElement | null>(null)
const uploadError = ref<string | null>(null) const uploadError = ref<string | null>(null)
const STORAGE_KEY = 'animatr.assetThumbs'
const storedThumbs = typeof localStorage !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null
const showThumbs = ref(storedThumbs !== 'false')
watch(showThumbs, (v) => {
try {
localStorage.setItem(STORAGE_KEY, String(v))
} catch {
/* ignore */
}
})
function addSampleRect() { function addSampleRect() {
const asset = store.addAsset({ const asset = store.addAsset({
kind: 'rect', kind: 'rect',
@@ -99,6 +112,28 @@ const assetsWithUsage = computed(() =>
inUse: store.scene.layers.some((l) => l.assetId === a.id), inUse: store.scene.layers.some((l) => l.assetId === a.id),
})), })),
) )
const THUMB_SIZE = 40
function rectPreviewStyle(asset: Extract<Asset, { kind: 'rect' }>) {
const scale = Math.min(THUMB_SIZE / asset.width, THUMB_SIZE / asset.height) * 0.9
return {
width: `${Math.max(6, asset.width * scale)}px`,
height: `${Math.max(6, asset.height * scale)}px`,
background: asset.fill,
borderRadius: '2px',
} as const
}
function circlePreviewStyle(asset: Extract<Asset, { kind: 'circle' }>) {
const d = Math.min(THUMB_SIZE, asset.radius * 2) * 0.9
return {
width: `${d}px`,
height: `${d}px`,
background: asset.fill,
borderRadius: '50%',
} as const
}
</script> </script>
<template> <template>
@@ -131,11 +166,51 @@ const assetsWithUsage = computed(() =>
/> />
<p v-if="uploadError" class="error">{{ uploadError }}</p> <p v-if="uploadError" class="error">{{ uploadError }}</p>
<div class="lib-header">
<h4>Bibliothek</h4> <h4>Bibliothek</h4>
<ul class="assets"> <button
class="thumb-toggle"
:title="showThumbs ? 'Thumbnails ausblenden' : 'Thumbnails anzeigen'"
@click="showThumbs = !showThumbs"
>
<PhImageSquare v-if="showThumbs" :size="13" />
<PhListBullets v-else :size="13" />
</button>
</div>
<ul class="assets" :class="{ compact: !showThumbs }">
<li v-for="item in assetsWithUsage" :key="item.asset.id" :class="{ unused: !item.inUse }"> <li v-for="item in assetsWithUsage" :key="item.asset.id" :class="{ unused: !item.inUse }">
<template v-if="showThumbs">
<div class="thumb">
<img
v-if="item.asset.kind === 'image'"
:src="item.asset.src"
:alt="item.asset.name"
draggable="false"
/>
<div
v-else-if="item.asset.kind === 'rect'"
class="shape-preview"
:style="rectPreviewStyle(item.asset)"
/>
<div
v-else-if="item.asset.kind === 'circle'"
class="shape-preview"
:style="circlePreviewStyle(item.asset)"
/>
<span
v-else-if="item.asset.kind === 'text'"
class="text-preview"
:style="{ color: item.asset.fill }"
>Aa</span>
</div>
</template>
<template v-else>
<component :is="assetIconMap[item.asset.kind]" :size="14" class="asset-icon" /> <component :is="assetIconMap[item.asset.kind]" :size="14" class="asset-icon" />
</template>
<div class="meta">
<span class="name" :title="item.asset.name">{{ item.asset.name }}</span> <span class="name" :title="item.asset.name">{{ item.asset.name }}</span>
<span v-if="showThumbs" class="sub">{{ item.asset.kind }}</span>
</div>
<button <button
v-if="!item.inUse" v-if="!item.inUse"
class="add-layer" class="add-layer"
@@ -165,7 +240,7 @@ h3 {
} }
h4 { h4 {
margin: 6px 0 4px; margin: 0;
font-size: 10px; font-size: 10px;
text-transform: uppercase; text-transform: uppercase;
color: var(--text-dim); color: var(--text-dim);
@@ -211,6 +286,29 @@ h4 {
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
.lib-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 6px;
}
.thumb-toggle {
width: 22px;
height: 22px;
padding: 0;
justify-content: center;
background: transparent;
border: 1px solid var(--border);
color: var(--text-dim);
border-radius: var(--radius-sm);
}
.thumb-toggle:hover:not(:disabled) {
background: var(--panel-alt);
color: var(--text);
}
.assets { .assets {
list-style: none; list-style: none;
margin: 0; margin: 0;
@@ -223,7 +321,7 @@ h4 {
.assets li { .assets li {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 10px;
padding: 6px 8px; padding: 6px 8px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--panel-alt); background: var(--panel-alt);
@@ -236,7 +334,43 @@ h4 {
} }
.assets li.unused { .assets li.unused {
opacity: 0.85; opacity: 0.9;
}
.assets.compact li {
gap: 8px;
padding: 6px 8px;
}
.thumb {
width: 40px;
height: 40px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
}
.thumb img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
pointer-events: none;
}
.shape-preview {
display: block;
}
.text-preview {
font-weight: 600;
font-size: 18px;
line-height: 1;
letter-spacing: -0.02em;
} }
.asset-icon { .asset-icon {
@@ -244,13 +378,27 @@ h4 {
flex-shrink: 0; flex-shrink: 0;
} }
.name { .meta {
flex: 1; flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.name {
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.sub {
font-size: 10px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.add-layer { .add-layer {
width: 22px; width: 22px;
height: 22px; height: 22px;
@@ -259,6 +407,7 @@ h4 {
background: transparent; background: transparent;
border: 1px solid var(--border); border: 1px solid var(--border);
color: var(--text-dim); color: var(--text-dim);
flex-shrink: 0;
} }
.add-layer:hover:not(:disabled) { .add-layer:hover:not(:disabled) {
+86 -3
View File
@@ -160,6 +160,8 @@ function circleConfig(
return { return {
...baseNodeConfig(layer, props), ...baseNodeConfig(layer, props),
radius: asset.radius, radius: asset.radius,
offsetX: layer.offsetX ?? 0,
offsetY: layer.offsetY ?? 0,
fill: props.fill ?? asset.fill, fill: props.fill ?? asset.fill,
} }
} }
@@ -169,6 +171,8 @@ function textConfig(layer: Layer, asset: Extract<Asset, { kind: 'text' }>, props
...baseNodeConfig(layer, props), ...baseNodeConfig(layer, props),
text: asset.text, text: asset.text,
fontSize: asset.fontSize, fontSize: asset.fontSize,
offsetX: layer.offsetX ?? 0,
offsetY: layer.offsetY ?? 0,
fill: props.fill ?? asset.fill, fill: props.fill ?? asset.fill,
} }
} }
@@ -201,7 +205,7 @@ const pivotInfo = computed<PivotInfo | null>(() => {
const layer = store.scene.layers.find((l) => l.id === id) const layer = store.scene.layers.find((l) => l.id === id)
if (!layer) return null if (!layer) return null
const asset = assetMap.value.get(layer.assetId) const asset = assetMap.value.get(layer.assetId)
if (!asset || (asset.kind !== 'rect' && asset.kind !== 'image')) return null if (!asset) return null
return { layerId: layer.id, sampled: sampleLayer(layer, store.playhead) } return { layerId: layer.id, sampled: sampleLayer(layer, store.playhead) }
}) })
@@ -249,7 +253,7 @@ function onPivotDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
} }
const transformerConfig = { const transformerConfig = {
rotateEnabled: true, rotateEnabled: false,
enabledAnchors: [ enabledAnchors: [
'top-left', 'top-left',
'top-center', 'top-center',
@@ -303,6 +307,7 @@ function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
} }
} }
if (target.findAncestor?.('.pivot-handle')) return if (target.findAncestor?.('.pivot-handle')) return
if (target.findAncestor?.('.rotation-handle')) return
if (target === target.getStage()) { if (target === target.getStage()) {
store.selectLayer(null) store.selectLayer(null)
return return
@@ -339,6 +344,61 @@ function handleTransformEnd(e: Konva.KonvaEventObject<Event>) {
}) })
} }
const ROT_ARM_PX = 44
const rotationHandleConfig = computed(() => {
const info = pivotInfo.value
if (!info) return null
const arm = ROT_ARM_PX / zoom.value
const rad = (info.sampled.rotation * Math.PI) / 180
return {
x: info.sampled.x + arm * Math.sin(rad),
y: info.sampled.y - arm * Math.cos(rad),
draggable: true,
name: 'rotation-handle',
}
})
const rotationArmConfig = computed(() => {
const info = pivotInfo.value
if (!info) return null
const arm = ROT_ARM_PX / zoom.value
const rad = (info.sampled.rotation * Math.PI) / 180
return {
points: [
info.sampled.x,
info.sampled.y,
info.sampled.x + arm * Math.sin(rad),
info.sampled.y - arm * Math.cos(rad),
],
stroke: '#6ea8ff',
strokeWidth: 1.25 / zoom.value,
dash: [3 / zoom.value, 3 / zoom.value],
listening: false,
}
})
const rotationKnobConfig = computed(() => ({
radius: 6 / zoom.value,
fill: '#ffffff',
stroke: '#6ea8ff',
strokeWidth: 2 / zoom.value,
}))
function onRotationHandleDragMove(e: Konva.KonvaEventObject<DragEvent>) {
const info = pivotInfo.value
if (!info) return
const node = e.target
const dx = node.x() - info.sampled.x
const dy = node.y() - info.sampled.y
if (dx === 0 && dy === 0) return
const rad = Math.atan2(dx, -dy)
let deg = (rad * 180) / Math.PI
if (deg > 180) deg -= 360
if (deg < -180) deg += 360
store.upsertKeyframe(info.layerId, store.playhead, { rotation: deg })
}
let ro: ResizeObserver | null = null let ro: ResizeObserver | null = null
function onWheel(e: WheelEvent) { function onWheel(e: WheelEvent) {
@@ -363,8 +423,14 @@ let panStartViewX = 0
let panStartViewY = 0 let panStartViewY = 0
let panPointerId = -1 let panPointerId = -1
function isPanTrigger(e: PointerEvent | MouseEvent) {
if (e.button === 1) return true
if (e.button === 0 && e.ctrlKey) return true
return false
}
function onContainerPointerDown(e: PointerEvent) { function onContainerPointerDown(e: PointerEvent) {
if (e.button !== 1) return if (!isPanTrigger(e)) return
e.preventDefault() e.preventDefault()
panning = true panning = true
panStartX = e.clientX panStartX = e.clientX
@@ -376,6 +442,13 @@ function onContainerPointerDown(e: PointerEvent) {
if (container.value) container.value.style.cursor = 'grabbing' if (container.value) container.value.style.cursor = 'grabbing'
} }
function onContainerMouseDownCapture(e: MouseEvent) {
if (isPanTrigger(e)) {
e.stopPropagation()
e.preventDefault()
}
}
function onContainerPointerMove(e: PointerEvent) { function onContainerPointerMove(e: PointerEvent) {
if (!panning || e.pointerId !== panPointerId) return if (!panning || e.pointerId !== panPointerId) return
viewX.value = panStartViewX + (e.clientX - panStartX) viewX.value = panStartViewX + (e.clientX - panStartX)
@@ -416,6 +489,7 @@ onMounted(() => {
ro.observe(container.value) ro.observe(container.value)
container.value.addEventListener('wheel', onWheel, { passive: false }) container.value.addEventListener('wheel', onWheel, { passive: false })
container.value.addEventListener('mousedown', onContainerMouseDownCapture, { capture: true })
container.value.addEventListener('pointerdown', onContainerPointerDown) container.value.addEventListener('pointerdown', onContainerPointerDown)
container.value.addEventListener('pointermove', onContainerPointerMove) container.value.addEventListener('pointermove', onContainerPointerMove)
container.value.addEventListener('pointerup', onContainerPointerUp) container.value.addEventListener('pointerup', onContainerPointerUp)
@@ -426,6 +500,7 @@ onBeforeUnmount(() => {
ro?.disconnect() ro?.disconnect()
ro = null ro = null
container.value?.removeEventListener('wheel', onWheel) container.value?.removeEventListener('wheel', onWheel)
container.value?.removeEventListener('mousedown', onContainerMouseDownCapture, { capture: true } as EventListenerOptions)
container.value?.removeEventListener('pointerdown', onContainerPointerDown) container.value?.removeEventListener('pointerdown', onContainerPointerDown)
container.value?.removeEventListener('pointermove', onContainerPointerMove) container.value?.removeEventListener('pointermove', onContainerPointerMove)
container.value?.removeEventListener('pointerup', onContainerPointerUp) container.value?.removeEventListener('pointerup', onContainerPointerUp)
@@ -466,6 +541,7 @@ onBeforeUnmount(() => {
/> />
</template> </template>
<v-rect :config="canvasBorderConfig" /> <v-rect :config="canvasBorderConfig" />
<v-line v-if="rotationArmConfig" :config="rotationArmConfig" />
<v-group <v-group
v-if="pivotHandleConfig" v-if="pivotHandleConfig"
:config="pivotHandleConfig" :config="pivotHandleConfig"
@@ -474,6 +550,13 @@ onBeforeUnmount(() => {
<v-circle :config="pivotRingConfig" /> <v-circle :config="pivotRingConfig" />
<v-circle :config="pivotDotConfig" /> <v-circle :config="pivotDotConfig" />
</v-group> </v-group>
<v-group
v-if="rotationHandleConfig"
:config="rotationHandleConfig"
@dragmove="onRotationHandleDragMove"
>
<v-circle :config="rotationKnobConfig" />
</v-group>
</v-group> </v-group>
<v-transformer ref="transformerRef" :config="transformerConfig" /> <v-transformer ref="transformerRef" :config="transformerConfig" />
</v-layer> </v-layer>
+24 -6
View File
@@ -3,12 +3,13 @@ import { onBeforeUnmount, onMounted, ref } from 'vue'
import { import {
PhCode, PhCode,
PhDownloadSimple, PhDownloadSimple,
PhEngine,
PhFileHtml, PhFileHtml,
PhFilmSlate, PhFilmSlate,
PhX, PhX,
} from '@phosphor-icons/vue' } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene' import { useSceneStore } from '@/stores/scene'
import { downloadExampleHtml, downloadExport } from '@/lib/exportPlayer' import { downloadExampleHtml, downloadRuntime, downloadSceneExport } from '@/lib/exportPlayer'
import { downloadSceneAsVideo, pickMimeType } from '@/lib/videoExport' import { downloadSceneAsVideo, pickMimeType } from '@/lib/videoExport'
import { downloadFromServer } from '@/lib/serverExport' import { downloadFromServer } from '@/lib/serverExport'
@@ -31,8 +32,13 @@ function sceneClone() {
return JSON.parse(JSON.stringify(store.scene)) return JSON.parse(JSON.stringify(store.scene))
} }
function exportJs() { function exportScene() {
downloadExport(sceneClone()) downloadSceneExport(sceneClone())
close()
}
function exportRuntime() {
downloadRuntime()
close() close()
} }
@@ -133,14 +139,26 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
</header> </header>
<div v-if="!progress" class="options"> <div v-if="!progress" class="options">
<button class="opt" @click="exportJs"> <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-icon"><PhCode :size="20" /></div>
<div class="opt-text"> <div class="opt-text">
<div class="opt-title"> <div class="opt-title">
animation.js animation.js
<span class="badge">Web Embed</span> <span class="badge">Szene</span>
</div> </div>
<div class="opt-desc">Selbstständiges JS — in eine eigene Seite einbinden</div> <div class="opt-desc">Nur Szenen-Daten — benötigt geladene Runtime</div>
</div> </div>
<PhDownloadSimple :size="14" class="opt-arrow" /> <PhDownloadSimple :size="14" class="opt-arrow" />
</button> </button>
+352 -35
View File
@@ -1,8 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { PhCheck, PhCopy, PhTrash } from '@phosphor-icons/vue' import { PhArrowsSplit, PhCheck, PhCopy, PhMagnet, PhTrash } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene' import { useSceneStore } from '@/stores/scene'
import type { Easing, Keyframe } from '@/types/scene' import { sampleLayer } from '@/lib/interpolate'
import type { AnimatableProps, Easing, Keyframe, Layer } from '@/types/scene'
interface TimelineTrack {
layerId: string
layer: Layer
kfs: Keyframe[]
kind: 'main' | 'position' | 'rotation'
label: string
isParent: boolean
}
const store = useSceneStore() const store = useSceneStore()
@@ -37,6 +47,49 @@ const totalWidth = computed(() => NAME_WIDTH + laneWidth.value)
type DisplayMode = 'seconds' | 'frames' type DisplayMode = 'seconds' | 'frames'
const displayMode = ref<DisplayMode>('seconds') const displayMode = ref<DisplayMode>('seconds')
const snapEnabled = ref(true)
const SNAP_PX = 8
function allKeyframeTimes(): number[] {
const out: number[] = []
for (const layer of store.scene.layers) {
for (const k of layer.keyframes) out.push(k.time)
if (layer.positionKeyframes) for (const k of layer.positionKeyframes) out.push(k.time)
if (layer.rotationKeyframes) for (const k of layer.rotationKeyframes) out.push(k.time)
}
return out
}
function snapTime(t: number, ignoreKeyframeId?: string): number {
if (!snapEnabled.value) return t
const pps = effectivePxPerSec.value
if (pps <= 0) return t
const thresholdTime = SNAP_PX / pps
let best = t
let bestDist = thresholdTime
const times = ignoreKeyframeId ? keyframeTimesExcept(ignoreKeyframeId) : allKeyframeTimes()
for (const kt of times) {
const d = Math.abs(kt - t)
if (d <= bestDist) {
bestDist = d
best = kt
}
}
return clamp(best, 0, duration.value)
}
function keyframeTimesExcept(id: string): number[] {
const out: number[] = []
for (const layer of store.scene.layers) {
for (const k of layer.keyframes) if (k.id !== id) out.push(k.time)
if (layer.positionKeyframes)
for (const k of layer.positionKeyframes) if (k.id !== id) out.push(k.time)
if (layer.rotationKeyframes)
for (const k of layer.rotationKeyframes) if (k.id !== id) out.push(k.time)
}
return out
}
const TICK_CANDIDATES_SEC = [0.1, 0.2, 0.5, 1, 2, 5, 10, 30, 60, 300] const TICK_CANDIDATES_SEC = [0.1, 0.2, 0.5, 1, 2, 5, 10, 30, 60, 300]
const TICK_CANDIDATES_FRAMES = [1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 1800] const TICK_CANDIDATES_FRAMES = [1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 1800]
@@ -90,7 +143,7 @@ function seekFromEvent(e: PointerEvent, el: HTMLElement) {
const rect = el.getBoundingClientRect() const rect = el.getBoundingClientRect()
if (rect.width <= 0) return if (rect.width <= 0) return
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
store.setPlayhead(ratio * duration.value) store.setPlayhead(snapTime(ratio * duration.value))
} }
function onScrubPointerDown(e: PointerEvent) { function onScrubPointerDown(e: PointerEvent) {
@@ -116,7 +169,42 @@ function onLaneClick(e: MouseEvent) {
const rect = el.getBoundingClientRect() const rect = el.getBoundingClientRect()
if (rect.width <= 0) return if (rect.width <= 0) return
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
store.setPlayhead(ratio * duration.value) store.setPlayhead(snapTime(ratio * duration.value))
}
function onLaneDblClick(e: MouseEvent, track: TimelineTrack) {
e.stopPropagation()
const el = e.currentTarget as HTMLElement
const rect = el.getBoundingClientRect()
if (rect.width <= 0) return
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const time = snapTime(ratio * duration.value)
const layer = store.scene.layers.find((l) => l.id === track.layerId)
if (!layer) return
const sampled = sampleLayer(layer, time)
const props: Partial<AnimatableProps> = {}
if (track.kind === 'position') {
props.x = sampled.x
props.y = sampled.y
} else if (track.kind === 'rotation') {
props.rotation = sampled.rotation
} else if (layer.separated) {
props.scaleX = sampled.scaleX
props.scaleY = sampled.scaleY
props.opacity = sampled.opacity
if (sampled.fill) props.fill = sampled.fill
} else {
props.x = sampled.x
props.y = sampled.y
props.scaleX = sampled.scaleX
props.scaleY = sampled.scaleY
props.rotation = sampled.rotation
props.opacity = sampled.opacity
if (sampled.fill) props.fill = sampled.fill
}
store.selectLayer(track.layerId)
store.setPlayhead(time)
store.upsertKeyframe(track.layerId, time, props)
} }
function onKeyframeClick(e: MouseEvent, layerId: string, time: number) { function onKeyframeClick(e: MouseEvent, layerId: string, time: number) {
@@ -130,12 +218,54 @@ function onKeyframeClick(e: MouseEvent, layerId: string, time: number) {
store.setPlayhead(time) store.setPlayhead(time)
} }
function onKeyframeDblClick(e: MouseEvent, layerId: string, time: number) { function onKeyframeDblClick(e: MouseEvent, layerId: string, keyframeId: string) {
e.stopPropagation() e.stopPropagation()
if (kfDrag.value?.moved) return if (kfDrag.value?.moved) return
store.removeKeyframeAt(layerId, time) store.removeKeyframe(layerId, keyframeId)
} }
const tracks = computed<TimelineTrack[]>(() => {
const out: TimelineTrack[] = []
for (const layer of store.scene.layers) {
if (!layer.separated) {
out.push({
layerId: layer.id,
layer,
kfs: layer.keyframes,
kind: 'main',
label: layer.name,
isParent: true,
})
} else {
out.push({
layerId: layer.id,
layer,
kfs: layer.keyframes,
kind: 'main',
label: layer.name,
isParent: true,
})
out.push({
layerId: layer.id,
layer,
kfs: layer.positionKeyframes ?? [],
kind: 'position',
label: 'Position',
isParent: false,
})
out.push({
layerId: layer.id,
layer,
kfs: layer.rotationKeyframes ?? [],
kind: 'rotation',
label: 'Rotation',
isParent: false,
})
}
}
return out
})
interface KfDrag { interface KfDrag {
layerId: string layerId: string
keyframeId: string keyframeId: string
@@ -173,7 +303,7 @@ function onKeyframePointerMove(e: PointerEvent) {
const rect = d.lane.getBoundingClientRect() const rect = d.lane.getBoundingClientRect()
if (rect.width <= 0) return if (rect.width <= 0) return
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const newTime = ratio * duration.value const newTime = snapTime(ratio * duration.value, d.keyframeId)
store.setKeyframeTime(d.layerId, d.keyframeId, newTime) store.setKeyframeTime(d.layerId, d.keyframeId, newTime)
store.setPlayhead(newTime) store.setPlayhead(newTime)
} }
@@ -239,13 +369,18 @@ interface KfMenuState {
const menu = ref<KfMenuState | null>(null) const menu = ref<KfMenuState | null>(null)
const menuRef = ref<HTMLDivElement | null>(null) const menuRef = ref<HTMLDivElement | null>(null)
function onKeyframeContextMenu(e: MouseEvent, layerId: string, keyframeId: string) { function onKeyframeContextMenu(
e: MouseEvent,
layerId: string,
keyframeId: string,
kfs: Keyframe[],
) {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
const layer = store.scene.layers.find((l) => l.id === layerId) const layer = store.scene.layers.find((l) => l.id === layerId)
const kf = layer?.keyframes.find((k) => k.id === keyframeId) const kf = kfs.find((k) => k.id === keyframeId)
if (!layer || !kf) return if (!layer || !kf) return
const sorted = [...layer.keyframes].sort((a, b) => a.time - b.time) const sorted = [...kfs].sort((a, b) => a.time - b.time)
menu.value = { menu.value = {
layerId, layerId,
keyframeId: kf.id, keyframeId: kf.id,
@@ -260,13 +395,13 @@ function onKeyframeContextMenu(e: MouseEvent, layerId: string, keyframeId: strin
function applyEasing(easing: Easing) { function applyEasing(easing: Easing) {
if (!menu.value) return if (!menu.value) return
store.setKeyframeEasing(menu.value.layerId, menu.value.time, easing) store.setKeyframeEasing(menu.value.layerId, menu.value.keyframeId, easing)
menu.value = null menu.value = null
} }
function deleteFromMenu() { function deleteFromMenu() {
if (!menu.value) return if (!menu.value) return
store.removeKeyframeAt(menu.value.layerId, menu.value.time) store.removeKeyframe(menu.value.layerId, menu.value.keyframeId)
menu.value = null menu.value = null
} }
@@ -275,24 +410,76 @@ function duplicateFromMenu() {
const newId = store.duplicateKeyframe(menu.value.layerId, menu.value.keyframeId) const newId = store.duplicateKeyframe(menu.value.layerId, menu.value.keyframeId)
if (newId) { if (newId) {
const layer = store.scene.layers.find((l) => l.id === menu.value!.layerId) const layer = store.scene.layers.find((l) => l.id === menu.value!.layerId)
const newKf = layer?.keyframes.find((k) => k.id === newId) const all = layer
? [
...layer.keyframes,
...(layer.positionKeyframes ?? []),
...(layer.rotationKeyframes ?? []),
]
: []
const newKf = all.find((k) => k.id === newId)
if (newKf) store.setPlayhead(newKf.time) if (newKf) store.setPlayhead(newKf.time)
} }
menu.value = null menu.value = null
} }
interface TrackMenuState {
layerId: string
x: number
y: number
separated: boolean
}
const trackMenu = ref<TrackMenuState | null>(null)
const trackMenuRef = ref<HTMLDivElement | null>(null)
function onTrackContextMenu(e: MouseEvent, layerId: string) {
e.preventDefault()
e.stopPropagation()
const layer = store.scene.layers.find((l) => l.id === layerId)
if (!layer) return
store.selectLayer(layerId)
trackMenu.value = {
layerId,
x: e.clientX,
y: e.clientY,
separated: !!layer.separated,
}
}
function toggleSeparated() {
if (!trackMenu.value) return
store.setLayerSeparated(trackMenu.value.layerId, !trackMenu.value.separated)
trackMenu.value = null
}
function deleteLayer() {
if (!trackMenu.value) return
store.removeLayer(trackMenu.value.layerId)
trackMenu.value = null
}
function onDocumentPointerDown(e: PointerEvent) { function onDocumentPointerDown(e: PointerEvent) {
if (!menu.value) return if (menu.value && menuRef.value && e.target instanceof Node) {
if (menuRef.value && e.target instanceof Node && menuRef.value.contains(e.target)) return if (!menuRef.value.contains(e.target)) menu.value = null
} else if (menu.value) {
menu.value = null menu.value = null
} }
if (trackMenu.value && trackMenuRef.value && e.target instanceof Node) {
if (!trackMenuRef.value.contains(e.target)) trackMenu.value = null
} else if (trackMenu.value) {
trackMenu.value = null
}
}
function onKeyDown(e: KeyboardEvent) { function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') menu.value = null if (e.key === 'Escape') {
menu.value = null
trackMenu.value = null
}
} }
watch(menu, (val) => { watch([menu, trackMenu], ([m, tm]) => {
if (val) { if (m || tm) {
document.addEventListener('pointerdown', onDocumentPointerDown, true) document.addEventListener('pointerdown', onDocumentPointerDown, true)
document.addEventListener('keydown', onKeyDown) document.addEventListener('keydown', onKeyDown)
} else { } else {
@@ -364,6 +551,14 @@ onBeforeUnmount(() => {
@click="displayMode = 'frames'" @click="displayMode = 'frames'"
>fr</button> >fr</button>
</div> </div>
<button
class="snap-btn"
:class="{ active: snapEnabled }"
:title="snapEnabled ? 'Snapping aktiv (Playhead & Keyframes rasten aneinander ein)' : 'Snapping aus'"
@click="snapEnabled = !snapEnabled"
>
<PhMagnet :size="12" :weight="snapEnabled ? 'fill' : 'regular'" />
</button>
</div> </div>
<div <div
class="scrub-ruler" class="scrub-ruler"
@@ -390,40 +585,64 @@ onBeforeUnmount(() => {
</div> </div>
</div> </div>
<div class="tracks"> <div class="tracks">
<div v-for="layer in store.scene.layers" :key="layer.id" class="track"> <div
v-for="(track, idx) in tracks"
:key="track.layerId + ':' + track.kind"
class="track"
:class="{
'sub-track': !track.isParent,
inactive: store.selectedLayerId !== null && track.layerId !== store.selectedLayerId,
}"
>
<span <span
class="track-name" class="track-name"
:class="{ selected: layer.id === store.selectedLayerId }" :class="{
@click="store.selectLayer(layer.id)" selected: track.layerId === store.selectedLayerId && track.isParent,
parent: track.isParent,
sub: !track.isParent,
}"
@click="store.selectLayer(track.layerId)"
@contextmenu="track.isParent ? onTrackContextMenu($event, track.layerId) : null"
> >
{{ layer.name }} <PhArrowsSplit
v-if="track.isParent && track.layer.separated"
:size="11"
class="name-badge"
/>
<span v-if="!track.isParent" class="sub-indent">↳</span>
{{ track.label }}
</span> </span>
<div class="track-lane" :style="{ width: laneWidth + 'px' }" @click="onLaneClick"> <div
class="track-lane"
:style="{ width: laneWidth + 'px' }"
@click="onLaneClick"
@dblclick="onLaneDblClick($event, track)"
>
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" /> <span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
<span <span
v-for="seg in segmentsOf(layer.keyframes)" v-for="seg in segmentsOf(track.kfs)"
:key="seg.id" :key="seg.id"
class="kf-seg" class="kf-seg"
:class="`easing-${seg.easing}`" :class="`easing-${seg.easing}`"
:style="segmentStyle(seg)" :style="segmentStyle(seg)"
/> />
<span <span
v-for="kf in layer.keyframes" v-for="kf in track.kfs"
:key="kf.id" :key="kf.id"
class="kf" class="kf"
:style="{ left: pxFromTime(kf.time) + 'px' }" :style="{ left: pxFromTime(kf.time) + 'px' }"
:title="`Keyframe ${formatKfTime(kf.time)} — ziehen zum Verschieben, Rechtsklick für Optionen`" :title="`Keyframe ${formatKfTime(kf.time)} — ziehen zum Verschieben, Rechtsklick für Optionen`"
@pointerdown="onKeyframePointerDown($event, layer.id, kf.id)" @pointerdown="onKeyframePointerDown($event, track.layerId, kf.id)"
@pointermove="onKeyframePointerMove" @pointermove="onKeyframePointerMove"
@pointerup="onKeyframePointerUp" @pointerup="onKeyframePointerUp"
@pointercancel="onKeyframePointerUp" @pointercancel="onKeyframePointerUp"
@click="onKeyframeClick($event, layer.id, kf.time)" @click="onKeyframeClick($event, track.layerId, kf.time)"
@dblclick="onKeyframeDblClick($event, layer.id, kf.time)" @dblclick="onKeyframeDblClick($event, track.layerId, kf.id)"
@contextmenu="onKeyframeContextMenu($event, layer.id, kf.id)" @contextmenu="onKeyframeContextMenu($event, track.layerId, kf.id, track.kfs)"
/> />
</div> </div>
</div> </div>
<p v-if="!store.scene.layers.length" class="empty">Noch keine Layer.</p> <p v-if="!tracks.length" class="empty">Noch keine Layer.</p>
</div> </div>
</div> </div>
</div> </div>
@@ -461,6 +680,25 @@ onBeforeUnmount(() => {
Löschen Löschen
</button> </button>
</div> </div>
<div
v-if="trackMenu"
ref="trackMenuRef"
class="kf-menu"
:style="{ left: trackMenu.x + 'px', top: trackMenu.y + 'px' }"
>
<button class="kf-menu-item" @click="toggleSeparated">
<span class="check">
<PhCheck v-if="trackMenu.separated" :size="12" weight="bold" />
</span>
Separate Position and Rotation
</button>
<div class="kf-menu-divider"></div>
<button class="kf-menu-item danger" @click="deleteLayer">
<PhTrash :size="12" />
Layer löschen
</button>
</div>
</div> </div>
</template> </template>
@@ -506,9 +744,36 @@ onBeforeUnmount(() => {
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px;
padding: 0 10px; padding: 0 10px;
} }
.snap-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 20px;
padding: 0;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--text-dim);
cursor: pointer;
transition: background 140ms var(--ease), color 140ms var(--ease);
}
.snap-btn:hover {
background: var(--panel-alt);
color: var(--text);
}
.snap-btn.active {
background: var(--accent-soft);
color: var(--accent);
border-color: var(--accent-soft);
}
.mode-toggle { .mode-toggle {
display: inline-flex; display: inline-flex;
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -600,13 +865,12 @@ onBeforeUnmount(() => {
} }
.scrub-handle-head { .scrub-handle-head {
width: 11px; width: 12px;
height: 11px; height: 14px;
background: var(--accent); background: var(--accent);
border-radius: 2px; border-radius: 2px;
transform: rotate(45deg);
margin-top: 2px; margin-top: 2px;
box-shadow: 0 0 0 2px var(--panel), 0 2px 6px rgba(0, 0, 0, 0.4); box-shadow: 0 0 0 1px var(--panel), 0 2px 6px rgba(0, 0, 0, 0.4);
} }
.scrub-handle-line { .scrub-handle-line {
@@ -628,6 +892,15 @@ onBeforeUnmount(() => {
transition: background 140ms var(--ease); transition: background 140ms var(--ease);
} }
.track.inactive .track-lane {
opacity: 0.4;
transition: opacity 140ms var(--ease);
}
.track.inactive:hover .track-lane {
opacity: 0.75;
}
.track-name { .track-name {
position: sticky; position: sticky;
left: 0; left: 0;
@@ -656,7 +929,51 @@ onBeforeUnmount(() => {
.track-name.selected { .track-name.selected {
color: var(--accent); color: var(--accent);
background: var(--accent-soft); background:
linear-gradient(var(--accent-soft), var(--accent-soft)),
var(--panel);
}
.track.sub-track .track-name {
padding-left: 24px;
font-size: 11px;
color: var(--text-dim);
background: var(--panel-alt);
}
.track.sub-track .track-name:hover {
background: var(--panel-hover);
}
.track.sub-track {
height: 26px;
}
.track.sub-track .track-lane {
background: linear-gradient(
to bottom,
transparent 0%,
transparent 40%,
var(--panel-alt) 40%,
var(--panel-alt) 60%,
transparent 60%
);
background-size: 100% 2px;
background-repeat: no-repeat;
background-position: center;
}
.name-badge {
color: var(--accent);
margin-right: 4px;
flex-shrink: 0;
}
.sub-indent {
color: var(--text-dim);
margin-right: 4px;
font-size: 10px;
opacity: 0.6;
} }
.track-lane { .track-lane {
+91 -31
View File
@@ -1,8 +1,9 @@
import type { Scene } from '@/types/scene' import type { Scene } from '@/types/scene'
const RUNTIME_SOURCE = `(function(){ const RUNTIME_VERSION = '1'
var SCENE = __SCENE__;
var OPTIONS = __OPTIONS__; const ENGINE_SOURCE = `(function(){
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
function easing(name, t){ function easing(name, t){
if(name==='easeIn')return t*t; if(name==='easeIn')return t*t;
@@ -156,15 +157,19 @@ const RUNTIME_SOURCE = `(function(){
ctx.fillStyle=p.fill||asset.fill; ctx.fillStyle=p.fill||asset.fill;
ctx.fillRect(-rOffX,-rOffY,asset.width,asset.height); ctx.fillRect(-rOffX,-rOffY,asset.width,asset.height);
}else if(asset.kind==='circle'){ }else if(asset.kind==='circle'){
var cOffX=layer.offsetX!=null?layer.offsetX:0;
var cOffY=layer.offsetY!=null?layer.offsetY:0;
ctx.fillStyle=p.fill||asset.fill; ctx.fillStyle=p.fill||asset.fill;
ctx.beginPath(); ctx.beginPath();
ctx.arc(0,0,asset.radius,0,Math.PI*2); ctx.arc(-cOffX,-cOffY,asset.radius,0,Math.PI*2);
ctx.fill(); ctx.fill();
}else if(asset.kind==='text'){ }else if(asset.kind==='text'){
var tOffX=layer.offsetX!=null?layer.offsetX:0;
var tOffY=layer.offsetY!=null?layer.offsetY:0;
ctx.fillStyle=p.fill||asset.fill; ctx.fillStyle=p.fill||asset.fill;
ctx.font=asset.fontSize+'px sans-serif'; ctx.font=asset.fontSize+'px sans-serif';
ctx.textBaseline='top'; ctx.textBaseline='top';
ctx.fillText(asset.text,0,0); ctx.fillText(asset.text,-tOffX,-tOffY);
}else if(asset.kind==='image'){ }else if(asset.kind==='image'){
var img=imgs[asset.id]; var img=imgs[asset.id];
if(img){ if(img){
@@ -183,22 +188,33 @@ const RUNTIME_SOURCE = `(function(){
}); });
} }
var api={ __v: '${RUNTIME_VERSION}', mount: mount };
window.AnimatrPlayer = api;
var pending = window.__animatrPending;
window.__animatrPending = null;
if (pending) for (var i=0;i<pending.length;i++) { try { pending[i](); } catch(e) { console.error(e); } }
})();
`
const SCENE_BOOT_SOURCE = `(function(){
var SCENE = __SCENE__;
var OPTIONS = __OPTIONS__;
var script = document.currentScript; var script = document.currentScript;
var target = null; var target = null;
if(script&&script.dataset&&script.dataset.target){ if (script && script.dataset && script.dataset.target) target = document.querySelector(script.dataset.target);
target=document.querySelector(script.dataset.target); else if (script && script.parentElement) target = script.parentElement;
}else if(script&&script.parentElement){
target=script.parentElement;
}
var autoOpts = {}; var autoOpts = {};
if(script&&script.dataset){ if (script && script.dataset && script.dataset.loop === 'false') autoOpts.loop = false;
if(script.dataset.loop==='false')autoOpts.loop=false; function boot(){
} if (!target || !window.AnimatrPlayer) return;
if(target)mount(target,SCENE,Object.assign({},OPTIONS,autoOpts)); var merged = {};
var api={mount:function(c,opts){return mount(c,SCENE,opts||{});}}; for (var k in OPTIONS) merged[k] = OPTIONS[k];
if(typeof window!=='undefined'){ for (var k2 in autoOpts) merged[k2] = autoOpts[k2];
window.AnimatrPlayer=window.AnimatrPlayer||api; window.AnimatrPlayer.mount(target, SCENE, merged);
} }
if (window.AnimatrPlayer) boot();
else (window.__animatrPending = window.__animatrPending || []).push(boot);
})(); })();
` `
@@ -210,24 +226,54 @@ export interface ExportOptions {
loop?: boolean loop?: boolean
} }
export function generateExportScript(scene: Scene, options: ExportOptions = {}): string { export function generateRuntimeScript(): string {
const sceneJson = jsonForExport(scene)
const optsJson = JSON.stringify({ loop: options.loop !== false })
return ( return (
'/* Animatr export — generated ' + '/* Animatr runtime v' + RUNTIME_VERSION + ' — generated ' + new Date().toISOString() + ' */\n' +
new Date().toISOString() + ENGINE_SOURCE
' */\n' +
RUNTIME_SOURCE.replace('__SCENE__', sceneJson).replace('__OPTIONS__', optsJson)
) )
} }
export function downloadExport(scene: Scene, filename = 'animation.js') { export function generateSceneScript(scene: Scene, options: ExportOptions = {}): string {
const source = generateExportScript(scene) const sceneJson = jsonForExport(scene)
triggerDownload(source, filename, 'application/javascript') const optsJson = JSON.stringify({ loop: options.loop !== false })
return (
'/* Animatr scene — generated ' + new Date().toISOString() + '\n' +
' * Requires animatr-runtime.js (v' + RUNTIME_VERSION + ') to be loaded once per page.\n' +
' */\n' +
SCENE_BOOT_SOURCE.replace('__SCENE__', sceneJson).replace('__OPTIONS__', optsJson)
)
}
export function generateBundledScript(scene: Scene, options: ExportOptions = {}): string {
return (
'/* Animatr bundled export — generated ' + new Date().toISOString() + ' */\n' +
ENGINE_SOURCE +
'\n' +
generateSceneScriptBody(scene, options)
)
}
function generateSceneScriptBody(scene: Scene, options: ExportOptions): string {
const sceneJson = jsonForExport(scene)
const optsJson = JSON.stringify({ loop: options.loop !== false })
return SCENE_BOOT_SOURCE.replace('__SCENE__', sceneJson).replace('__OPTIONS__', optsJson)
}
export function downloadRuntime(filename = 'animatr-runtime.js') {
triggerDownload(generateRuntimeScript(), filename, 'application/javascript')
}
export function downloadSceneExport(scene: Scene, filename = 'animation.js') {
triggerDownload(generateSceneScript(scene), filename, 'application/javascript')
}
export function downloadBundledExport(scene: Scene, filename = 'animation-bundled.js') {
triggerDownload(generateBundledScript(scene), filename, 'application/javascript')
} }
export function generateExampleHtml(scene: Scene, options: ExportOptions = {}): string { export function generateExampleHtml(scene: Scene, options: ExportOptions = {}): string {
const script = generateExportScript(scene, options) const runtime = ENGINE_SOURCE
const sceneBoot = generateSceneScriptBody(scene, options)
const aspect = `${scene.canvas.width} / ${scene.canvas.height}` const aspect = `${scene.canvas.width} / ${scene.canvas.height}`
return `<!doctype html> return `<!doctype html>
<html lang="de"> <html lang="de">
@@ -289,18 +335,32 @@ export function generateExampleHtml(scene: Scene, options: ExportOptions = {}):
</head> </head>
<body> <body>
<h1>Animatr Export</h1> <h1>Animatr Export</h1>
<p>Diese HTML-Datei enthält Runtime + Szene inline — einfach im Browser öffnen.</p> <p>Runtime einmal laden, dann beliebig viele Szenen auf einer Seite.</p>
<!-- Runtime: einmal pro Seite -->
<script>
${runtime}
</script>
<div class="stage"> <div class="stage">
<!-- Szene: pro Animation -->
<script> <script>
${script} ${sceneBoot}
</script> </script>
</div> </div>
<div class="usage"> <div class="usage">
<h2>Einbinden in eine eigene Seite</h2> <h2>Einbinden in eine eigene Seite</h2>
<pre>&lt;div style="width: 800px; height: 450px;"&gt; <pre>&lt;!-- einmal pro Seite --&gt;
&lt;script src="animatr-runtime.js"&gt;&lt;/script&gt;
&lt;!-- je Animation ein Container + Scene-Script --&gt;
&lt;div style="width: 800px; height: 450px;"&gt;
&lt;script src="animation.js"&gt;&lt;/script&gt; &lt;script src="animation.js"&gt;&lt;/script&gt;
&lt;/div&gt;
&lt;div style="width: 400px; height: 225px;"&gt;
&lt;script src="animation-2.js"&gt;&lt;/script&gt;
&lt;/div&gt;</pre> &lt;/div&gt;</pre>
</div> </div>
</body> </body>
+33 -11
View File
@@ -56,20 +56,42 @@ export function sortedKeyframes(layer: Layer): Keyframe[] {
return [...layer.keyframes].sort((a, b) => a.time - b.time) return [...layer.keyframes].sort((a, b) => a.time - b.time)
} }
export function sampleLayer(layer: Layer, time: number): AnimatableProps { function sampleKeyframeSet(
const kfs = sortedKeyframes(layer) kfs: Keyframe[] | undefined,
if (!kfs.length) return { ...layer.base } base: AnimatableProps,
if (time <= kfs[0].time) return merge(layer.base, kfs[0].props) time: number,
const last = kfs[kfs.length - 1] ): AnimatableProps {
if (time >= last.time) return merge(layer.base, last.props) if (!kfs || !kfs.length) return { ...base }
for (let i = 0; i < kfs.length - 1; i++) { const sorted = [...kfs].sort((a, b) => a.time - b.time)
const a = kfs[i] if (time <= sorted[0].time) return merge(base, sorted[0].props)
const b = kfs[i + 1] const last = sorted[sorted.length - 1]
if (time >= last.time) return merge(base, last.props)
for (let i = 0; i < sorted.length - 1; i++) {
const a = sorted[i]
const b = sorted[i + 1]
if (time >= a.time && time <= b.time) { if (time >= a.time && time <= b.time) {
const raw = (time - a.time) / (b.time - a.time) const raw = (time - a.time) / (b.time - a.time)
const t = easings[b.easing](raw) const t = easings[b.easing](raw)
return lerpProps(merge(layer.base, a.props), merge(layer.base, b.props), t) return lerpProps(merge(base, a.props), merge(base, b.props), t)
} }
} }
return { ...layer.base } return { ...base }
}
export function sampleLayer(layer: Layer, time: number): AnimatableProps {
const main = sampleKeyframeSet(layer.keyframes, layer.base, time)
if (!layer.separated) return main
const posSource = layer.positionKeyframes
const rotSource = layer.rotationKeyframes
const pos = posSource ? sampleKeyframeSet(posSource, layer.base, time) : main
const rot = rotSource ? sampleKeyframeSet(rotSource, layer.base, time) : main
return {
...main,
x: pos.x,
y: pos.y,
rotation: rot.rotation,
}
} }
+6 -2
View File
@@ -61,15 +61,19 @@ export function drawSceneFrame(
ctx.fillStyle = p.fill ?? asset.fill ctx.fillStyle = p.fill ?? asset.fill
ctx.fillRect(-offX, -offY, asset.width, asset.height) ctx.fillRect(-offX, -offY, asset.width, asset.height)
} else if (asset.kind === 'circle') { } else if (asset.kind === 'circle') {
const offX = layer.offsetX ?? 0
const offY = layer.offsetY ?? 0
ctx.fillStyle = p.fill ?? asset.fill ctx.fillStyle = p.fill ?? asset.fill
ctx.beginPath() ctx.beginPath()
ctx.arc(0, 0, asset.radius, 0, Math.PI * 2) ctx.arc(-offX, -offY, asset.radius, 0, Math.PI * 2)
ctx.fill() ctx.fill()
} else if (asset.kind === 'text') { } else if (asset.kind === 'text') {
const offX = layer.offsetX ?? 0
const offY = layer.offsetY ?? 0
ctx.fillStyle = p.fill ?? asset.fill ctx.fillStyle = p.fill ?? asset.fill
ctx.font = `${asset.fontSize}px sans-serif` ctx.font = `${asset.fontSize}px sans-serif`
ctx.textBaseline = 'top' ctx.textBaseline = 'top'
ctx.fillText(asset.text, 0, 0) ctx.fillText(asset.text, -offX, -offY)
} else if (asset.kind === 'image') { } else if (asset.kind === 'image') {
const img = images[asset.id] const img = images[asset.id]
if (img) { if (img) {
+196 -29
View File
@@ -16,6 +16,80 @@ interface State {
playing: boolean playing: boolean
} }
function allKeyframes(layer: Layer): Keyframe[] {
const out: Keyframe[] = [...layer.keyframes]
if (layer.positionKeyframes) out.push(...layer.positionKeyframes)
if (layer.rotationKeyframes) out.push(...layer.rotationKeyframes)
return out
}
function findKf(
layer: Layer,
keyframeId: string,
): { kf: Keyframe; arr: Keyframe[] } | null {
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
if (!arr) continue
const kf = arr.find((k) => k.id === keyframeId)
if (kf) return { kf, arr }
}
return null
}
function sortKfs(arr: Keyframe[]) {
arr.sort((a, b) => a.time - b.time)
}
function upsertInto(
arr: Keyframe[],
time: number,
props: Partial<AnimatableProps>,
snapshotProps: Partial<AnimatableProps> | null,
) {
const existing = arr.find((k) => Math.abs(k.time - time) < KEYFRAME_EPSILON)
if (existing) {
existing.props = { ...existing.props, ...props }
return
}
const kf: Keyframe = {
id: uid(),
time,
props: snapshotProps ? { ...snapshotProps, ...props } : { ...props },
easing: 'linear',
}
arr.push(kf)
sortKfs(arr)
}
const POSITION_KEYS = new Set(['x', 'y'])
const ROTATION_KEYS = new Set(['rotation'])
function pickProps(
src: Partial<AnimatableProps>,
keys: Set<string>,
): Partial<AnimatableProps> {
const out: Partial<AnimatableProps> = {}
for (const [k, v] of Object.entries(src)) {
if (keys.has(k) && v !== undefined) (out as Record<string, unknown>)[k] = v
}
return out
}
function omitProps(
src: Partial<AnimatableProps>,
keys: Set<string>,
): Partial<AnimatableProps> {
const out: Partial<AnimatableProps> = {}
for (const [k, v] of Object.entries(src)) {
if (!keys.has(k) && v !== undefined) (out as Record<string, unknown>)[k] = v
}
return out
}
function hasKeys(obj: Partial<AnimatableProps>): boolean {
for (const _ in obj) return true
return false
}
export const useSceneStore = defineStore('scene', { export const useSceneStore = defineStore('scene', {
state: (): State => ({ state: (): State => ({
scene: { scene: {
@@ -34,7 +108,7 @@ export const useSceneStore = defineStore('scene', {
lastKeyframeTime(state): number { lastKeyframeTime(state): number {
let max = 0 let max = 0
for (const layer of state.scene.layers) { for (const layer of state.scene.layers) {
for (const kf of layer.keyframes) { for (const kf of allKeyframes(layer)) {
if (kf.time > max) max = kf.time if (kf.time > max) max = kf.time
} }
} }
@@ -43,7 +117,7 @@ export const useSceneStore = defineStore('scene', {
effectiveEnd(state): number { effectiveEnd(state): number {
let max = 0 let max = 0
for (const layer of state.scene.layers) { for (const layer of state.scene.layers) {
for (const kf of layer.keyframes) { for (const kf of allKeyframes(layer)) {
if (kf.time > max) max = kf.time if (kf.time > max) max = kf.time
} }
} }
@@ -80,9 +154,14 @@ export const useSceneStore = defineStore('scene', {
}, },
], ],
} }
if (asset && (asset.kind === 'rect' || asset.kind === 'image')) { if (asset) {
if (asset.kind === 'rect' || asset.kind === 'image') {
layer.offsetX = asset.width / 2 layer.offsetX = asset.width / 2
layer.offsetY = asset.height / 2 layer.offsetY = asset.height / 2
} else if (asset.kind === 'circle' || asset.kind === 'text') {
layer.offsetX = 0
layer.offsetY = 0
}
} }
this.scene.layers.push(layer) this.scene.layers.push(layer)
this.selectedLayerId = layer.id this.selectedLayerId = layer.id
@@ -115,39 +194,56 @@ export const useSceneStore = defineStore('scene', {
upsertKeyframe(layerId: string, time: number, props: Partial<AnimatableProps>) { upsertKeyframe(layerId: string, time: number, props: Partial<AnimatableProps>) {
const layer = this.scene.layers.find((l) => l.id === layerId) const layer = this.scene.layers.find((l) => l.id === layerId)
if (!layer) return if (!layer) return
const existing = layer.keyframes.find((k) => Math.abs(k.time - time) < KEYFRAME_EPSILON) if (!layer.separated) {
if (existing) { const snapshot = sampleLayer(layer, time)
existing.props = { ...existing.props, ...props } upsertInto(layer.keyframes, time, props, snapshot)
return return
} }
const snapshot = sampleLayer(layer, time) const posProps = pickProps(props, POSITION_KEYS)
const kf: Keyframe = { const rotProps = pickProps(props, ROTATION_KEYS)
id: uid(), const otherProps = omitProps(props, new Set(['x', 'y', 'rotation']))
time, if (hasKeys(posProps)) {
props: { ...snapshot, ...props }, const arr = (layer.positionKeyframes ||= [])
easing: 'linear', upsertInto(arr, time, posProps, null)
}
if (hasKeys(rotProps)) {
const arr = (layer.rotationKeyframes ||= [])
upsertInto(arr, time, rotProps, null)
}
if (hasKeys(otherProps)) {
upsertInto(layer.keyframes, time, otherProps, null)
} }
layer.keyframes.push(kf)
layer.keyframes.sort((a, b) => a.time - b.time)
}, },
removeKeyframeAt(layerId: string, time: number) { removeKeyframeAt(layerId: string, time: number) {
const layer = this.scene.layers.find((l) => l.id === layerId) const layer = this.scene.layers.find((l) => l.id === layerId)
if (!layer) return if (!layer) return
layer.keyframes = layer.keyframes.filter((k) => Math.abs(k.time - time) >= KEYFRAME_EPSILON) const filter = (kfs?: Keyframe[]) =>
kfs ? kfs.filter((k) => Math.abs(k.time - time) >= KEYFRAME_EPSILON) : kfs
layer.keyframes = filter(layer.keyframes) ?? []
if (layer.positionKeyframes) layer.positionKeyframes = filter(layer.positionKeyframes)
if (layer.rotationKeyframes) layer.rotationKeyframes = filter(layer.rotationKeyframes)
}, },
setKeyframeEasing(layerId: string, time: number, easing: Easing) { removeKeyframe(layerId: string, keyframeId: string) {
const layer = this.scene.layers.find((l) => l.id === layerId) const layer = this.scene.layers.find((l) => l.id === layerId)
if (!layer) return if (!layer) return
const kf = layer.keyframes.find((k) => Math.abs(k.time - time) < KEYFRAME_EPSILON) const found = findKf(layer, keyframeId)
if (kf) kf.easing = easing if (!found) return
const idx = found.arr.indexOf(found.kf)
if (idx >= 0) found.arr.splice(idx, 1)
},
setKeyframeEasing(layerId: string, keyframeId: string, easing: Easing) {
const layer = this.scene.layers.find((l) => l.id === layerId)
if (!layer) return
const found = findKf(layer, keyframeId)
if (found) found.kf.easing = easing
}, },
setKeyframeTime(layerId: string, keyframeId: string, newTime: number) { setKeyframeTime(layerId: string, keyframeId: string, newTime: number) {
const layer = this.scene.layers.find((l) => l.id === layerId) const layer = this.scene.layers.find((l) => l.id === layerId)
if (!layer) return if (!layer) return
const kf = layer.keyframes.find((k) => k.id === keyframeId) const found = findKf(layer, keyframeId)
if (!kf) return if (!found) return
kf.time = Math.max(0, Math.min(this.scene.duration, newTime)) found.kf.time = Math.max(0, Math.min(this.scene.duration, newTime))
layer.keyframes.sort((a, b) => a.time - b.time) sortKfs(found.arr)
}, },
shiftLayerPivot( shiftLayerPivot(
layerId: string, layerId: string,
@@ -162,21 +258,26 @@ export const useSceneStore = defineStore('scene', {
layer.offsetY = (layer.offsetY ?? 0) + dLocalY layer.offsetY = (layer.offsetY ?? 0) + dLocalY
layer.base.x += dWorldX layer.base.x += dWorldX
layer.base.y += dWorldY layer.base.y += dWorldY
for (const kf of layer.keyframes) { const shift = (kfs?: Keyframe[]) => {
if (!kfs) return
for (const kf of kfs) {
if (kf.props.x !== undefined) kf.props.x += dWorldX if (kf.props.x !== undefined) kf.props.x += dWorldX
if (kf.props.y !== undefined) kf.props.y += dWorldY if (kf.props.y !== undefined) kf.props.y += dWorldY
} }
}
shift(layer.keyframes)
shift(layer.positionKeyframes)
}, },
duplicateKeyframe(layerId: string, keyframeId: string): string | null { duplicateKeyframe(layerId: string, keyframeId: string): string | null {
const layer = this.scene.layers.find((l) => l.id === layerId) const layer = this.scene.layers.find((l) => l.id === layerId)
if (!layer) return null if (!layer) return null
const source = layer.keyframes.find((k) => k.id === keyframeId) const found = findKf(layer, keyframeId)
if (!source) return null if (!found) return null
const { kf: source, arr } = found
const fps = this.scene.fps > 0 ? this.scene.fps : 60 const fps = this.scene.fps > 0 ? this.scene.fps : 60
const offset = Math.max(3 / fps, 0.1) const offset = Math.max(3 / fps, 0.1)
const duration = this.scene.duration const duration = this.scene.duration
const occupied = (t: number) => const occupied = (t: number) => arr.some((k) => Math.abs(k.time - t) < KEYFRAME_EPSILON)
layer.keyframes.some((k) => Math.abs(k.time - t) < KEYFRAME_EPSILON)
let target = source.time + offset let target = source.time + offset
while (occupied(target) && target < duration) target += offset while (occupied(target) && target < duration) target += offset
if (target > duration) target = duration if (target > duration) target = duration
@@ -192,10 +293,76 @@ export const useSceneStore = defineStore('scene', {
props: { ...source.props }, props: { ...source.props },
easing: source.easing, easing: source.easing,
} }
layer.keyframes.push(newKf) arr.push(newKf)
layer.keyframes.sort((a, b) => a.time - b.time) sortKfs(arr)
return newKf.id return newKf.id
}, },
setLayerSeparated(layerId: string, separated: boolean) {
const layer = this.scene.layers.find((l) => l.id === layerId)
if (!layer) return
if (!!layer.separated === separated) return
if (separated) {
const posKfs: Keyframe[] = []
const rotKfs: Keyframe[] = []
const remaining: Keyframe[] = []
for (const kf of layer.keyframes) {
const posProps = pickProps(kf.props, POSITION_KEYS)
const rotProps = pickProps(kf.props, ROTATION_KEYS)
const rest = omitProps(kf.props, new Set(['x', 'y', 'rotation']))
if (hasKeys(posProps)) {
posKfs.push({ id: uid(), time: kf.time, props: posProps, easing: kf.easing })
}
if (hasKeys(rotProps)) {
rotKfs.push({ id: uid(), time: kf.time, props: rotProps, easing: kf.easing })
}
if (hasKeys(rest)) {
remaining.push({ id: uid(), time: kf.time, props: rest, easing: kf.easing })
}
}
sortKfs(posKfs)
sortKfs(rotKfs)
sortKfs(remaining)
layer.positionKeyframes = posKfs
layer.rotationKeyframes = rotKfs
layer.keyframes = remaining
layer.separated = true
} else {
interface Bucket {
props: Partial<AnimatableProps>
easing: Easing
}
const byTime = new Map<number, Bucket>()
const addAll = (kfs?: Keyframe[]) => {
if (!kfs) return
for (const kf of kfs) {
const key = Math.round(kf.time * 1e6)
let bucket = byTime.get(key)
if (!bucket) {
bucket = { props: {}, easing: kf.easing }
byTime.set(key, bucket)
}
bucket.props = { ...bucket.props, ...kf.props }
}
}
addAll(layer.keyframes)
addAll(layer.positionKeyframes)
addAll(layer.rotationKeyframes)
const merged: Keyframe[] = []
for (const [key, bucket] of byTime.entries()) {
merged.push({
id: uid(),
time: key / 1e6,
props: bucket.props,
easing: bucket.easing,
})
}
sortKfs(merged)
layer.keyframes = merged
layer.positionKeyframes = undefined
layer.rotationKeyframes = undefined
layer.separated = false
}
},
play() { play() {
if (this.playing) return if (this.playing) return
this.playing = true this.playing = true
+3
View File
@@ -63,6 +63,9 @@ export interface Layer {
visible: boolean visible: boolean
base: AnimatableProps base: AnimatableProps
keyframes: Keyframe[] keyframes: Keyframe[]
separated?: boolean
positionKeyframes?: Keyframe[]
rotationKeyframes?: Keyframe[]
offsetX?: number offsetX?: number
offsetY?: number offsetY?: number
} }
+22
View File
@@ -0,0 +1,22 @@
rotation povot gibts nur bei rect und das objekt rotiert nicht um den pivot sondern immer ums zentrum
assets sidebar braucht kleine preview thumbnails die man an und aus machen kann
bei bedarf muss man eine spur in der timeline auf "separate position and rotation" umstellen können. dazu führen wir ein context-menü auf spuren ein
die animations-engine muss von der eigentlichen animation getrennt sein weil man ja auch mehrere animationen auf einer webseite haben kann und dann will man das nicht alles redundant haben
die aktuell nicht bearbeiteten timelines müssen etwas weniger opacity haben
control-drag muss die bühne verschieben
bei timeline zoom wird bei der aktiven spur der keyframe und connect line vorne nicht weggeschnitten
der playhead soll kein gedrehtes quadrat sein so wie die keyframes sondern ein aufrechtes rect
baue eine frontent testing engine. mit shift+control+alt+t muss such die app dann selbst durchtesten. schreibe tests für die funktionen die wir bisher haben