bak
This commit is contained in:
1 parent
c6863f2bfc
commit
94e9846c13
11 files changed
+985
-136
No files matched your search
+6
-2
@@ -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){
|
||||
|
||||
@@ -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>
|
||||
|
||||
<h4>Bibliothek</h4>
|
||||
<ul class="assets">
|
||||
<div class="lib-header">
|
||||
<h4>Bibliothek</h4>
|
||||
<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 }">
|
||||
<component :is="assetIconMap[item.asset.kind]" :size="14" class="asset-icon" />
|
||||
<span class="name" :title="item.asset.name">{{ item.asset.name }}</span>
|
||||
<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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+353
-36
@@ -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
|
||||
menu.value = null
|
||||
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 {
|
||||
|
||||
+94
-34
@@ -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 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;
|
||||
}
|
||||
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;
|
||||
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;
|
||||
var autoOpts = {};
|
||||
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><div style="width: 800px; height: 450px;">
|
||||
<pre><!-- einmal pro Seite -->
|
||||
<script src="animatr-runtime.js"></script>
|
||||
|
||||
<!-- je Animation ein Container + Scene-Script -->
|
||||
<div style="width: 800px; height: 450px;">
|
||||
<script src="animation.js"></script>
|
||||
</div>
|
||||
|
||||
<div style="width: 400px; height: 225px;">
|
||||
<script src="animation-2.js"></script>
|
||||
</div></pre>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
+33
-11
@@ -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
@@ -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) {
|
||||
|
||||
+200
-33
@@ -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')) {
|
||||
layer.offsetX = asset.width / 2
|
||||
layer.offsetY = asset.height / 2
|
||||
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) {
|
||||
if (kf.props.x !== undefined) kf.props.x += dWorldX
|
||||
if (kf.props.y !== undefined) kf.props.y += dWorldY
|
||||
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
|
||||
|
||||
@@ -63,6 +63,9 @@ export interface Layer {
|
||||
visible: boolean
|
||||
base: AnimatableProps
|
||||
keyframes: Keyframe[]
|
||||
separated?: boolean
|
||||
positionKeyframes?: Keyframe[]
|
||||
rotationKeyframes?: Keyframe[]
|
||||
offsetX?: number
|
||||
offsetY?: number
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user