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.fillRect(-rOffX,-rOffY,asset.width,asset.height);
} 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.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'){
var tOffX=layer.offsetX!=null?layer.offsetX:0;
var tOffY=layer.offsetY!=null?layer.offsetY:0;
ctx.fillStyle=fill;
ctx.font=asset.fontSize+'px sans-serif';
ctx.textBaseline='top';
ctx.fillText(asset.text,0,0);
ctx.fillText(asset.text,-tOffX,-tOffY);
} else if(asset.kind==='image'){
var img=images[asset.id];
if(img){
+155 -6
View File
@@ -1,8 +1,10 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import {
PhCircle,
PhImage,
PhImageSquare,
PhListBullets,
PhPlus,
PhSquare,
PhTextT,
@@ -17,6 +19,17 @@ const store = useSceneStore()
const fileInput = ref<HTMLInputElement | 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() {
const asset = store.addAsset({
kind: 'rect',
@@ -99,6 +112,28 @@ const assetsWithUsage = computed(() =>
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>
<template>
@@ -131,11 +166,51 @@ const assetsWithUsage = computed(() =>
/>
<p v-if="uploadError" class="error">{{ uploadError }}</p>
<div class="lib-header">
<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 }">
<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" />
</template>
<div class="meta">
<span class="name" :title="item.asset.name">{{ item.asset.name }}</span>
<span v-if="showThumbs" class="sub">{{ item.asset.kind }}</span>
</div>
<button
v-if="!item.inUse"
class="add-layer"
@@ -165,7 +240,7 @@ h3 {
}
h4 {
margin: 6px 0 4px;
margin: 0;
font-size: 10px;
text-transform: uppercase;
color: var(--text-dim);
@@ -211,6 +286,29 @@ h4 {
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 {
list-style: none;
margin: 0;
@@ -223,7 +321,7 @@ h4 {
.assets li {
display: flex;
align-items: center;
gap: 8px;
gap: 10px;
padding: 6px 8px;
border-radius: var(--radius-sm);
background: var(--panel-alt);
@@ -236,7 +334,43 @@ h4 {
}
.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 {
@@ -244,13 +378,27 @@ h4 {
flex-shrink: 0;
}
.name {
.meta {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sub {
font-size: 10px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.add-layer {
width: 22px;
height: 22px;
@@ -259,6 +407,7 @@ h4 {
background: transparent;
border: 1px solid var(--border);
color: var(--text-dim);
flex-shrink: 0;
}
.add-layer:hover:not(:disabled) {
+86 -3
View File
@@ -160,6 +160,8 @@ function circleConfig(
return {
...baseNodeConfig(layer, props),
radius: asset.radius,
offsetX: layer.offsetX ?? 0,
offsetY: layer.offsetY ?? 0,
fill: props.fill ?? asset.fill,
}
}
@@ -169,6 +171,8 @@ function textConfig(layer: Layer, asset: Extract<Asset, { kind: 'text' }>, props
...baseNodeConfig(layer, props),
text: asset.text,
fontSize: asset.fontSize,
offsetX: layer.offsetX ?? 0,
offsetY: layer.offsetY ?? 0,
fill: props.fill ?? asset.fill,
}
}
@@ -201,7 +205,7 @@ const pivotInfo = computed<PivotInfo | null>(() => {
const layer = store.scene.layers.find((l) => l.id === id)
if (!layer) return null
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) }
})
@@ -249,7 +253,7 @@ function onPivotDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
}
const transformerConfig = {
rotateEnabled: true,
rotateEnabled: false,
enabledAnchors: [
'top-left',
'top-center',
@@ -303,6 +307,7 @@ function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
}
}
if (target.findAncestor?.('.pivot-handle')) return
if (target.findAncestor?.('.rotation-handle')) return
if (target === target.getStage()) {
store.selectLayer(null)
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
function onWheel(e: WheelEvent) {
@@ -363,8 +423,14 @@ let panStartViewX = 0
let panStartViewY = 0
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) {
if (e.button !== 1) return
if (!isPanTrigger(e)) return
e.preventDefault()
panning = true
panStartX = e.clientX
@@ -376,6 +442,13 @@ function onContainerPointerDown(e: PointerEvent) {
if (container.value) container.value.style.cursor = 'grabbing'
}
function onContainerMouseDownCapture(e: MouseEvent) {
if (isPanTrigger(e)) {
e.stopPropagation()
e.preventDefault()
}
}
function onContainerPointerMove(e: PointerEvent) {
if (!panning || e.pointerId !== panPointerId) return
viewX.value = panStartViewX + (e.clientX - panStartX)
@@ -416,6 +489,7 @@ onMounted(() => {
ro.observe(container.value)
container.value.addEventListener('wheel', onWheel, { passive: false })
container.value.addEventListener('mousedown', onContainerMouseDownCapture, { capture: true })
container.value.addEventListener('pointerdown', onContainerPointerDown)
container.value.addEventListener('pointermove', onContainerPointerMove)
container.value.addEventListener('pointerup', onContainerPointerUp)
@@ -426,6 +500,7 @@ onBeforeUnmount(() => {
ro?.disconnect()
ro = null
container.value?.removeEventListener('wheel', onWheel)
container.value?.removeEventListener('mousedown', onContainerMouseDownCapture, { capture: true } as EventListenerOptions)
container.value?.removeEventListener('pointerdown', onContainerPointerDown)
container.value?.removeEventListener('pointermove', onContainerPointerMove)
container.value?.removeEventListener('pointerup', onContainerPointerUp)
@@ -466,6 +541,7 @@ onBeforeUnmount(() => {
/>
</template>
<v-rect :config="canvasBorderConfig" />
<v-line v-if="rotationArmConfig" :config="rotationArmConfig" />
<v-group
v-if="pivotHandleConfig"
:config="pivotHandleConfig"
@@ -474,6 +550,13 @@ onBeforeUnmount(() => {
<v-circle :config="pivotRingConfig" />
<v-circle :config="pivotDotConfig" />
</v-group>
<v-group
v-if="rotationHandleConfig"
:config="rotationHandleConfig"
@dragmove="onRotationHandleDragMove"
>
<v-circle :config="rotationKnobConfig" />
</v-group>
</v-group>
<v-transformer ref="transformerRef" :config="transformerConfig" />
</v-layer>
+24 -6
View File
@@ -3,12 +3,13 @@ import { onBeforeUnmount, onMounted, ref } from 'vue'
import {
PhCode,
PhDownloadSimple,
PhEngine,
PhFileHtml,
PhFilmSlate,
PhX,
} from '@phosphor-icons/vue'
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 { downloadFromServer } from '@/lib/serverExport'
@@ -31,8 +32,13 @@ function sceneClone() {
return JSON.parse(JSON.stringify(store.scene))
}
function exportJs() {
downloadExport(sceneClone())
function exportScene() {
downloadSceneExport(sceneClone())
close()
}
function exportRuntime() {
downloadRuntime()
close()
}
@@ -133,14 +139,26 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
</header>
<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-text">
<div class="opt-title">
animation.js
<span class="badge">Web Embed</span>
<span class="badge">Szene</span>
</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>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
+352 -35
View File
@@ -1,8 +1,18 @@
<script setup lang="ts">
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 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()
@@ -37,6 +47,49 @@ const totalWidth = computed(() => NAME_WIDTH + laneWidth.value)
type DisplayMode = 'seconds' | 'frames'
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_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()
if (rect.width <= 0) return
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) {
@@ -116,7 +169,42 @@ function onLaneClick(e: MouseEvent) {
const rect = el.getBoundingClientRect()
if (rect.width <= 0) return
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) {
@@ -130,12 +218,54 @@ function onKeyframeClick(e: MouseEvent, layerId: string, time: number) {
store.setPlayhead(time)
}
function onKeyframeDblClick(e: MouseEvent, layerId: string, time: number) {
function onKeyframeDblClick(e: MouseEvent, layerId: string, keyframeId: string) {
e.stopPropagation()
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 {
layerId: string
keyframeId: string
@@ -173,7 +303,7 @@ function onKeyframePointerMove(e: PointerEvent) {
const rect = d.lane.getBoundingClientRect()
if (rect.width <= 0) return
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const newTime = ratio * duration.value
const newTime = snapTime(ratio * duration.value, d.keyframeId)
store.setKeyframeTime(d.layerId, d.keyframeId, newTime)
store.setPlayhead(newTime)
}
@@ -239,13 +369,18 @@ interface KfMenuState {
const menu = ref<KfMenuState | 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.stopPropagation()
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
const sorted = [...layer.keyframes].sort((a, b) => a.time - b.time)
const sorted = [...kfs].sort((a, b) => a.time - b.time)
menu.value = {
layerId,
keyframeId: kf.id,
@@ -260,13 +395,13 @@ function onKeyframeContextMenu(e: MouseEvent, layerId: string, keyframeId: strin
function applyEasing(easing: Easing) {
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
}
function deleteFromMenu() {
if (!menu.value) return
store.removeKeyframeAt(menu.value.layerId, menu.value.time)
store.removeKeyframe(menu.value.layerId, menu.value.keyframeId)
menu.value = null
}
@@ -275,24 +410,76 @@ function duplicateFromMenu() {
const newId = store.duplicateKeyframe(menu.value.layerId, menu.value.keyframeId)
if (newId) {
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)
}
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) {
if (!menu.value) return
if (menuRef.value && e.target instanceof Node && menuRef.value.contains(e.target)) return
if (menu.value && menuRef.value && e.target instanceof Node) {
if (!menuRef.value.contains(e.target)) menu.value = null
} else if (menu.value) {
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) {
if (e.key === 'Escape') menu.value = null
if (e.key === 'Escape') {
menu.value = null
trackMenu.value = null
}
}
watch(menu, (val) => {
if (val) {
watch([menu, trackMenu], ([m, tm]) => {
if (m || tm) {
document.addEventListener('pointerdown', onDocumentPointerDown, true)
document.addEventListener('keydown', onKeyDown)
} else {
@@ -364,6 +551,14 @@ onBeforeUnmount(() => {
@click="displayMode = 'frames'"
>fr</button>
</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
class="scrub-ruler"
@@ -390,40 +585,64 @@ onBeforeUnmount(() => {
</div>
</div>
<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
class="track-name"
:class="{ selected: layer.id === store.selectedLayerId }"
@click="store.selectLayer(layer.id)"
:class="{
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>
<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
v-for="seg in segmentsOf(layer.keyframes)"
v-for="seg in segmentsOf(track.kfs)"
:key="seg.id"
class="kf-seg"
:class="`easing-${seg.easing}`"
:style="segmentStyle(seg)"
/>
<span
v-for="kf in layer.keyframes"
v-for="kf in track.kfs"
:key="kf.id"
class="kf"
:style="{ left: pxFromTime(kf.time) + 'px' }"
: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"
@pointerup="onKeyframePointerUp"
@pointercancel="onKeyframePointerUp"
@click="onKeyframeClick($event, layer.id, kf.time)"
@dblclick="onKeyframeDblClick($event, layer.id, kf.time)"
@contextmenu="onKeyframeContextMenu($event, layer.id, kf.id)"
@click="onKeyframeClick($event, track.layerId, kf.time)"
@dblclick="onKeyframeDblClick($event, track.layerId, kf.id)"
@contextmenu="onKeyframeContextMenu($event, track.layerId, kf.id, track.kfs)"
/>
</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>
@@ -461,6 +680,25 @@ onBeforeUnmount(() => {
Löschen
</button>
</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>
</template>
@@ -506,9 +744,36 @@ onBeforeUnmount(() => {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 6px;
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 {
display: inline-flex;
border: 1px solid var(--border);
@@ -600,13 +865,12 @@ onBeforeUnmount(() => {
}
.scrub-handle-head {
width: 11px;
height: 11px;
width: 12px;
height: 14px;
background: var(--accent);
border-radius: 2px;
transform: rotate(45deg);
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 {
@@ -628,6 +892,15 @@ onBeforeUnmount(() => {
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 {
position: sticky;
left: 0;
@@ -656,7 +929,51 @@ onBeforeUnmount(() => {
.track-name.selected {
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 {
+91 -31
View File
@@ -1,8 +1,9 @@
import type { Scene } from '@/types/scene'
const RUNTIME_SOURCE = `(function(){
var SCENE = __SCENE__;
var OPTIONS = __OPTIONS__;
const RUNTIME_VERSION = '1'
const ENGINE_SOURCE = `(function(){
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
function easing(name, t){
if(name==='easeIn')return t*t;
@@ -156,15 +157,19 @@ const RUNTIME_SOURCE = `(function(){
ctx.fillStyle=p.fill||asset.fill;
ctx.fillRect(-rOffX,-rOffY,asset.width,asset.height);
}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.beginPath();
ctx.arc(0,0,asset.radius,0,Math.PI*2);
ctx.arc(-cOffX,-cOffY,asset.radius,0,Math.PI*2);
ctx.fill();
}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.font=asset.fontSize+'px sans-serif';
ctx.textBaseline='top';
ctx.fillText(asset.text,0,0);
ctx.fillText(asset.text,-tOffX,-tOffY);
}else if(asset.kind==='image'){
var img=imgs[asset.id];
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 target = null;
if(script&&script.dataset&&script.dataset.target){
target=document.querySelector(script.dataset.target);
}else if(script&&script.parentElement){
target=script.parentElement;
}
if (script && script.dataset && script.dataset.target) target = document.querySelector(script.dataset.target);
else if (script && script.parentElement) target = script.parentElement;
var autoOpts = {};
if(script&&script.dataset){
if(script.dataset.loop==='false')autoOpts.loop=false;
}
if(target)mount(target,SCENE,Object.assign({},OPTIONS,autoOpts));
var api={mount:function(c,opts){return mount(c,SCENE,opts||{});}};
if(typeof window!=='undefined'){
window.AnimatrPlayer=window.AnimatrPlayer||api;
if (script && script.dataset && script.dataset.loop === 'false') autoOpts.loop = false;
function boot(){
if (!target || !window.AnimatrPlayer) return;
var merged = {};
for (var k in OPTIONS) merged[k] = OPTIONS[k];
for (var k2 in autoOpts) merged[k2] = autoOpts[k2];
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
}
export function generateExportScript(scene: Scene, options: ExportOptions = {}): string {
const sceneJson = jsonForExport(scene)
const optsJson = JSON.stringify({ loop: options.loop !== false })
export function generateRuntimeScript(): string {
return (
'/* Animatr export — generated ' +
new Date().toISOString() +
' */\n' +
RUNTIME_SOURCE.replace('__SCENE__', sceneJson).replace('__OPTIONS__', optsJson)
'/* Animatr runtime v' + RUNTIME_VERSION + ' — generated ' + new Date().toISOString() + ' */\n' +
ENGINE_SOURCE
)
}
export function downloadExport(scene: Scene, filename = 'animation.js') {
const source = generateExportScript(scene)
triggerDownload(source, filename, 'application/javascript')
export function generateSceneScript(scene: Scene, options: ExportOptions = {}): string {
const sceneJson = jsonForExport(scene)
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 {
const script = generateExportScript(scene, options)
const runtime = ENGINE_SOURCE
const sceneBoot = generateSceneScriptBody(scene, options)
const aspect = `${scene.canvas.width} / ${scene.canvas.height}`
return `<!doctype html>
<html lang="de">
@@ -289,18 +335,32 @@ export function generateExampleHtml(scene: Scene, options: ExportOptions = {}):
</head>
<body>
<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">
<!-- Szene: pro Animation -->
<script>
${script}
${sceneBoot}
</script>
</div>
<div class="usage">
<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;/div&gt;
&lt;div style="width: 400px; height: 225px;"&gt;
&lt;script src="animation-2.js"&gt;&lt;/script&gt;
&lt;/div&gt;</pre>
</div>
</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)
}
export function sampleLayer(layer: Layer, time: number): AnimatableProps {
const kfs = sortedKeyframes(layer)
if (!kfs.length) return { ...layer.base }
if (time <= kfs[0].time) return merge(layer.base, kfs[0].props)
const last = kfs[kfs.length - 1]
if (time >= last.time) return merge(layer.base, last.props)
for (let i = 0; i < kfs.length - 1; i++) {
const a = kfs[i]
const b = kfs[i + 1]
function sampleKeyframeSet(
kfs: Keyframe[] | undefined,
base: AnimatableProps,
time: number,
): AnimatableProps {
if (!kfs || !kfs.length) return { ...base }
const sorted = [...kfs].sort((a, b) => a.time - b.time)
if (time <= sorted[0].time) return merge(base, sorted[0].props)
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) {
const raw = (time - a.time) / (b.time - a.time)
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.fillRect(-offX, -offY, asset.width, asset.height)
} else if (asset.kind === 'circle') {
const offX = layer.offsetX ?? 0
const offY = layer.offsetY ?? 0
ctx.fillStyle = p.fill ?? asset.fill
ctx.beginPath()
ctx.arc(0, 0, asset.radius, 0, Math.PI * 2)
ctx.arc(-offX, -offY, asset.radius, 0, Math.PI * 2)
ctx.fill()
} else if (asset.kind === 'text') {
const offX = layer.offsetX ?? 0
const offY = layer.offsetY ?? 0
ctx.fillStyle = p.fill ?? asset.fill
ctx.font = `${asset.fontSize}px sans-serif`
ctx.textBaseline = 'top'
ctx.fillText(asset.text, 0, 0)
ctx.fillText(asset.text, -offX, -offY)
} else if (asset.kind === 'image') {
const img = images[asset.id]
if (img) {
+196 -29
View File
@@ -16,6 +16,80 @@ interface State {
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', {
state: (): State => ({
scene: {
@@ -34,7 +108,7 @@ export const useSceneStore = defineStore('scene', {
lastKeyframeTime(state): number {
let max = 0
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
}
}
@@ -43,7 +117,7 @@ export const useSceneStore = defineStore('scene', {
effectiveEnd(state): number {
let max = 0
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
}
}
@@ -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.offsetY = asset.height / 2
} else if (asset.kind === 'circle' || asset.kind === 'text') {
layer.offsetX = 0
layer.offsetY = 0
}
}
this.scene.layers.push(layer)
this.selectedLayerId = layer.id
@@ -115,39 +194,56 @@ export const useSceneStore = defineStore('scene', {
upsertKeyframe(layerId: string, time: number, props: Partial<AnimatableProps>) {
const layer = this.scene.layers.find((l) => l.id === layerId)
if (!layer) return
const existing = layer.keyframes.find((k) => Math.abs(k.time - time) < KEYFRAME_EPSILON)
if (existing) {
existing.props = { ...existing.props, ...props }
if (!layer.separated) {
const snapshot = sampleLayer(layer, time)
upsertInto(layer.keyframes, time, props, snapshot)
return
}
const snapshot = sampleLayer(layer, time)
const kf: Keyframe = {
id: uid(),
time,
props: { ...snapshot, ...props },
easing: 'linear',
const posProps = pickProps(props, POSITION_KEYS)
const rotProps = pickProps(props, ROTATION_KEYS)
const otherProps = omitProps(props, new Set(['x', 'y', 'rotation']))
if (hasKeys(posProps)) {
const arr = (layer.positionKeyframes ||= [])
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) {
const layer = this.scene.layers.find((l) => l.id === layerId)
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)
if (!layer) return
const kf = layer.keyframes.find((k) => Math.abs(k.time - time) < KEYFRAME_EPSILON)
if (kf) kf.easing = easing
const found = findKf(layer, keyframeId)
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) {
const layer = this.scene.layers.find((l) => l.id === layerId)
if (!layer) return
const kf = layer.keyframes.find((k) => k.id === keyframeId)
if (!kf) return
kf.time = Math.max(0, Math.min(this.scene.duration, newTime))
layer.keyframes.sort((a, b) => a.time - b.time)
const found = findKf(layer, keyframeId)
if (!found) return
found.kf.time = Math.max(0, Math.min(this.scene.duration, newTime))
sortKfs(found.arr)
},
shiftLayerPivot(
layerId: string,
@@ -162,21 +258,26 @@ export const useSceneStore = defineStore('scene', {
layer.offsetY = (layer.offsetY ?? 0) + dLocalY
layer.base.x += dWorldX
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.y !== undefined) kf.props.y += dWorldY
}
}
shift(layer.keyframes)
shift(layer.positionKeyframes)
},
duplicateKeyframe(layerId: string, keyframeId: string): string | null {
const layer = this.scene.layers.find((l) => l.id === layerId)
if (!layer) return null
const source = layer.keyframes.find((k) => k.id === keyframeId)
if (!source) return null
const found = findKf(layer, keyframeId)
if (!found) return null
const { kf: source, arr } = found
const fps = this.scene.fps > 0 ? this.scene.fps : 60
const offset = Math.max(3 / fps, 0.1)
const duration = this.scene.duration
const occupied = (t: number) =>
layer.keyframes.some((k) => Math.abs(k.time - t) < KEYFRAME_EPSILON)
const occupied = (t: number) => arr.some((k) => Math.abs(k.time - t) < KEYFRAME_EPSILON)
let target = source.time + offset
while (occupied(target) && target < duration) target += offset
if (target > duration) target = duration
@@ -192,10 +293,76 @@ export const useSceneStore = defineStore('scene', {
props: { ...source.props },
easing: source.easing,
}
layer.keyframes.push(newKf)
layer.keyframes.sort((a, b) => a.time - b.time)
arr.push(newKf)
sortKfs(arr)
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() {
if (this.playing) return
this.playing = true
+3
View File
@@ -63,6 +63,9 @@ export interface Layer {
visible: boolean
base: AnimatableProps
keyframes: Keyframe[]
separated?: boolean
positionKeyframes?: Keyframe[]
rotationKeyframes?: Keyframe[]
offsetX?: 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