This commit is contained in:
th committed 2026-04-20 00:07:08 +02:00
1 parent 6b6157d786
commit 3ef5fb5a2d
19 files changed
+1244 -94

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "animatr",
"private": true,
"version": "0.0.0",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
+21
View File
@@ -95,17 +95,34 @@
ctx.translate(p.x,p.y);
ctx.rotate(p.rotation*Math.PI/180);
ctx.scale(p.scaleX,p.scaleY);
var sOp=p.shadowOpacity||0, sBl=p.shadowBlur||0, sDi=p.shadowDistance||0;
if(sOp>0 && (sBl>0 || sDi>0)){
var sAn=(p.shadowAngle==null?90:p.shadowAngle)*Math.PI/180;
var sHex=(p.shadowColor||'#000000').replace('#','');
if(sHex.length===3){sHex=sHex[0]+sHex[0]+sHex[1]+sHex[1]+sHex[2]+sHex[2];}
var sNum=parseInt(sHex.slice(0,6),16); if(isNaN(sNum))sNum=0;
ctx.shadowColor='rgba('+((sNum>>16)&255)+','+((sNum>>8)&255)+','+(sNum&255)+','+sOp+')';
ctx.shadowBlur=sBl;
ctx.shadowOffsetX=Math.cos(sAn)*sDi;
ctx.shadowOffsetY=Math.sin(sAn)*sDi;
}
var fill = p.fill || asset.fill;
if(asset.kind==='rect'){
var rOffX=layer.offsetX!=null?layer.offsetX:asset.width/2;
var rOffY=layer.offsetY!=null?layer.offsetY:asset.height/2;
var rBlur=(p.blur!=null?p.blur:(asset.blur||0));
ctx.fillStyle=fill;
if(rBlur>0) ctx.filter='blur('+rBlur+'px)';
ctx.fillRect(-rOffX,-rOffY,asset.width,asset.height);
ctx.filter='none';
} else if(asset.kind==='circle'){
var cOffX=layer.offsetX!=null?layer.offsetX:0;
var cOffY=layer.offsetY!=null?layer.offsetY:0;
var cBlur=(p.blur!=null?p.blur:(asset.blur||0));
ctx.fillStyle=fill;
if(cBlur>0) ctx.filter='blur('+cBlur+'px)';
ctx.beginPath(); ctx.arc(-cOffX,-cOffY,asset.radius,0,Math.PI*2); ctx.fill();
ctx.filter='none';
} else if(asset.kind==='text'){
var tOffX=layer.offsetX!=null?layer.offsetX:0;
var tOffY=layer.offsetY!=null?layer.offsetY:0;
@@ -121,6 +138,10 @@
ctx.drawImage(img,-iOffX,-iOffY,asset.width,asset.height);
}
}
ctx.shadowColor='transparent';
ctx.shadowBlur=0;
ctx.shadowOffsetX=0;
ctx.shadowOffsetY=0;
ctx.restore();
}
function drawFrame(time){
+25 -2
View File
@@ -15,6 +15,7 @@ import {
PhSparkle,
PhStop,
} from '@phosphor-icons/vue'
import AboutDialog from './components/AboutDialog.vue'
import AssetSidebar from './components/AssetSidebar.vue'
import CanvasStage from './components/CanvasStage.vue'
import DocumentSettings from './components/DocumentSettings.vue'
@@ -48,6 +49,7 @@ const exportOpen = ref(false)
const testsOpen = ref(false)
const welcomeOpen = ref(false)
const fullscreenOpen = ref(false)
const aboutOpen = ref(false)
const TIMELINE_HEIGHT_KEY = 'animatr:timelineHeight'
const DEFAULT_TIMELINE_HEIGHT = 220
const MIN_TIMELINE_HEIGHT = 120
@@ -237,6 +239,12 @@ function isEditable(el: EventTarget | null): boolean {
return false
}
function onDocumentContextMenu(e: MouseEvent) {
// Allow native context menu only on editable inputs (for paste, etc.).
if (isEditable(e.target)) return
e.preventDefault()
}
function onKeyDown(e: KeyboardEvent) {
if (e.ctrlKey && e.shiftKey && e.altKey && (e.code === 'KeyT' || e.key.toLowerCase() === 't')) {
e.preventDefault()
@@ -270,11 +278,13 @@ function onKeyDown(e: KeyboardEvent) {
onMounted(() => {
document.addEventListener('keydown', onKeyDown)
document.addEventListener('contextmenu', onDocumentContextMenu)
window.addEventListener('resize', onWindowResize)
bootWelcome()
})
onBeforeUnmount(() => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('contextmenu', onDocumentContextMenu)
window.removeEventListener('resize', onWindowResize)
})
</script>
@@ -283,10 +293,10 @@ onBeforeUnmount(() => {
<div class="app" :style="appStyle">
<header class="topbar">
<div class="brand-area">
<span class="brand">
<button class="brand" :title="t('about.openTooltip')" @click="aboutOpen = true">
<PhSparkle :size="16" weight="fill" class="brand-icon" />
Animatr
</span>
</button>
</div>
<div class="transport">
<IconButton
@@ -380,6 +390,7 @@ onBeforeUnmount(() => {
<ExportDialog v-model="exportOpen" />
<TestOverlay v-model="testsOpen" />
<FullscreenPreview v-model="fullscreenOpen" />
<AboutDialog v-model="aboutOpen" />
<DialogsHost />
<WelcomeDialog
v-model="welcomeOpen"
@@ -434,6 +445,18 @@ onBeforeUnmount(() => {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
margin-left: -8px;
background: transparent;
border: 1px solid transparent;
color: var(--text);
border-radius: var(--radius-sm);
cursor: pointer;
}
.brand:hover {
background: var(--panel-alt);
border-color: var(--border);
}
.brand-icon {
+177
View File
@@ -0,0 +1,177 @@
<script setup lang="ts">
import { PhSparkle } from '@phosphor-icons/vue'
import { Modal } from '@/components/ui'
import { APP_NAME, APP_VERSION } from '@/version'
import { t } from '@/i18n'
defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
interface Shortcut {
labelKey: string
keysKey: string
}
const shortcuts: Shortcut[] = [
{ labelKey: 'about.shortcutLabel.playPause', keysKey: 'about.shortcut.playPause' },
{ labelKey: 'about.shortcutLabel.fullscreen', keysKey: 'about.shortcut.fullscreen' },
{ labelKey: 'about.shortcutLabel.undo', keysKey: 'about.shortcut.undo' },
{ labelKey: 'about.shortcutLabel.redo', keysKey: 'about.shortcut.redo' },
{ labelKey: 'about.shortcutLabel.panCanvas', keysKey: 'about.shortcut.panCanvas' },
{ labelKey: 'about.shortcutLabel.tests', keysKey: 'about.shortcut.tests' },
]
</script>
<template>
<Modal
:model-value="modelValue"
:title="t('about.title', { name: APP_NAME })"
size="sm"
@update:model-value="emit('update:modelValue', $event)"
>
<div class="about">
<div class="hero">
<div class="logo">
<PhSparkle :size="28" weight="fill" />
</div>
<div class="hero-text">
<div class="app-name">{{ APP_NAME }}</div>
<div class="version">{{ t('about.versionLabel') }} {{ APP_VERSION }}</div>
</div>
</div>
<p class="tagline">{{ t('about.tagline') }}</p>
<section class="shortcuts">
<h4>{{ t('about.shortcutsHeading') }}</h4>
<ul>
<li v-for="s in shortcuts" :key="s.keysKey">
<span class="shortcut-label">{{ t(s.labelKey) }}</span>
<kbd>{{ t(s.keysKey) }}</kbd>
</li>
</ul>
</section>
</div>
<template #footer>
<button class="btn primary" @click="emit('update:modelValue', false)">
{{ t('about.close') }}
</button>
</template>
</Modal>
</template>
<style scoped>
.about {
padding: 18px 20px 14px;
display: flex;
flex-direction: column;
gap: 16px;
}
.hero {
display: flex;
align-items: center;
gap: 14px;
}
.logo {
width: 52px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
background: var(--accent-soft);
color: var(--accent);
border-radius: 12px;
flex-shrink: 0;
}
.hero-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.app-name {
font-size: 18px;
font-weight: 600;
letter-spacing: -0.01em;
}
.version {
font-size: 11px;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
.tagline {
margin: 0;
font-size: 12px;
color: var(--text-dim-strong);
line-height: 1.45;
}
.shortcuts h4 {
margin: 0 0 6px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
}
.shortcuts ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.shortcuts li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 5px 8px;
background: var(--panel-alt);
border-radius: var(--radius-sm);
font-size: 12px;
}
.shortcut-label {
color: var(--text);
}
kbd {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 4px;
padding: 2px 6px;
font-family: ui-monospace, Menlo, monospace;
font-size: 10px;
color: var(--text-dim-strong);
}
.btn {
padding: 6px 14px;
border-radius: var(--radius-sm);
font: inherit;
font-size: 12px;
font-weight: 500;
border: 1px solid var(--border);
background: var(--panel-alt);
color: var(--text);
cursor: pointer;
}
.btn.primary {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent);
}
.btn.primary:hover {
background: var(--accent-soft-strong);
}
</style>
+28 -4
View File
@@ -175,11 +175,35 @@ const imageAssets = computed(() =>
store.scene.assets.filter((a): a is Extract<Asset, { kind: 'image' }> => a.kind === 'image'),
)
function onAssetDragStart(e: DragEvent, assetId: string) {
function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>) {
if (!e.dataTransfer) return
e.dataTransfer.effectAllowed = 'copy'
e.dataTransfer.setData('application/x-animatr-asset', assetId)
e.dataTransfer.setData('text/plain', assetId)
e.dataTransfer.setData('application/x-animatr-asset', asset.id)
e.dataTransfer.setData('text/plain', asset.id)
// Use the actual graphic as drag ghost instead of the library entry.
const target = e.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
const thumbImg = target.querySelector('img') as HTMLImageElement | null
if (thumbImg && thumbImg.complete && thumbImg.naturalWidth > 0) {
const r = thumbImg.getBoundingClientRect()
const ox = Math.max(0, e.clientX - r.left)
const oy = Math.max(0, e.clientY - r.top)
e.dataTransfer.setDragImage(thumbImg, ox, oy)
return
}
// Fallback — create an offscreen image of the asset for use as drag ghost.
const ghost = document.createElement('img')
ghost.src = asset.src
ghost.style.cssText =
'position:fixed;top:-10000px;left:-10000px;width:72px;height:auto;pointer-events:none;'
document.body.appendChild(ghost)
const gx = Math.max(0, e.clientX - rect.left)
const gy = Math.max(0, e.clientY - rect.top)
e.dataTransfer.setDragImage(ghost, gx, gy)
setTimeout(() => {
ghost.remove()
}, 0)
}
</script>
@@ -234,7 +258,7 @@ function onAssetDragStart(e: DragEvent, assetId: string) {
v-for="asset in imageAssets"
:key="asset.id"
draggable="true"
@dragstart="onAssetDragStart($event, asset.id)"
@dragstart="onAssetDragStart($event, asset)"
>
<template v-if="showThumbs">
<div class="thumb">
+231 -29
View File
@@ -7,8 +7,10 @@ import { sampleLayer } from '@/lib/interpolate'
import { loadImage } from '@/lib/fileLoader'
import type { AnimatableProps, Asset, Layer } from '@/types/scene'
import { t } from '@/i18n'
import { useKfSelection } from '@/composables/useKfSelection'
const store = useSceneStore()
const { selectedKfs } = useKfSelection()
const container = ref<HTMLDivElement | null>(null)
const containerSize = ref({ width: 0, height: 0 })
@@ -135,6 +137,19 @@ type StackItem =
| { kind: 'layer'; item: RenderedLayer }
| { kind: 'clip'; item: RenderedClipLayer }
function applyLivePose(props: AnimatableProps, layerId: string): AnimatableProps {
const live = liveLayerPose.value
if (!live || live.layerId !== layerId) return props
return {
...props,
x: live.x,
y: live.y,
scaleX: live.scaleX,
scaleY: live.scaleY,
rotation: live.rotation,
}
}
const stackItems = computed<StackItem[]>(() => {
// Clip-edit scope: just render active clip's layers (top of timeline = top of canvas)
if (store.activeClipId) {
@@ -145,7 +160,11 @@ const stackItems = computed<StackItem[]>(() => {
if (!asset) continue
out.push({
kind: 'layer',
item: { layer, asset, props: sampleLayer(layer, store.playhead) },
item: {
layer,
asset,
props: applyLivePose(sampleLayer(layer, store.playhead), layer.id),
},
})
}
return out
@@ -173,7 +192,11 @@ const stackItems = computed<StackItem[]>(() => {
if (!asset) continue
out.push({
kind: 'layer',
item: { layer, asset, props: sampleLayer(layer, store.playhead) },
item: {
layer,
asset,
props: applyLivePose(sampleLayer(layer, store.playhead), layer.id),
},
})
continue
}
@@ -208,6 +231,23 @@ const stackItems = computed<StackItem[]>(() => {
return out
})
function shadowCfg(props: AnimatableProps): Record<string, unknown> {
const opacity = props.shadowOpacity ?? 0
if (opacity <= 0) return {}
const blur = props.shadowBlur ?? 0
const distance = props.shadowDistance ?? 0
if (blur <= 0 && distance <= 0) return {}
const angle = props.shadowAngle ?? 90
const rad = (angle * Math.PI) / 180
return {
shadowColor: props.shadowColor ?? '#000000',
shadowBlur: blur,
shadowOpacity: opacity,
shadowOffsetX: Math.cos(rad) * distance,
shadowOffsetY: Math.sin(rad) * distance,
}
}
function baseNodeConfig(layer: Layer, props: AnimatableProps) {
return {
id: layer.id,
@@ -219,18 +259,60 @@ function baseNodeConfig(layer: Layer, props: AnimatableProps) {
opacity: props.opacity,
visible: layer.visible,
draggable: true,
...shadowCfg(props),
}
}
type KonvaCtx = {
beginPath: () => void
closePath: () => void
rect: (x: number, y: number, w: number, h: number) => void
arc: (x: number, y: number, r: number, s: number, e: number, cc?: boolean) => void
fillStrokeShape: (shape: unknown) => void
save: () => void
restore: () => void
setAttr: (attr: string, value: unknown) => void
}
type KonvaShape = { width(): number; height(): number; radius(): number; attrs: Record<string, unknown> }
function makeRectSceneFunc(blur: number) {
return (ctx: KonvaCtx, shape: KonvaShape) => {
ctx.save()
if (blur > 0) ctx.setAttr('filter', `blur(${blur}px)`)
ctx.beginPath()
ctx.rect(0, 0, shape.width(), shape.height())
ctx.closePath()
ctx.fillStrokeShape(shape)
ctx.restore()
}
}
function makeCircleSceneFunc(blur: number) {
return (ctx: KonvaCtx, shape: KonvaShape) => {
ctx.save()
if (blur > 0) ctx.setAttr('filter', `blur(${blur}px)`)
ctx.beginPath()
ctx.arc(0, 0, shape.radius(), 0, Math.PI * 2, false)
ctx.closePath()
ctx.fillStrokeShape(shape)
ctx.restore()
}
}
function rectConfig(layer: Layer, asset: Extract<Asset, { kind: 'rect' }>, props: AnimatableProps) {
return {
const fill = props.fill ?? asset.fill
const blur = props.blur ?? asset.blur ?? 0
const cfg: Record<string, unknown> = {
...baseNodeConfig(layer, props),
width: asset.width,
height: asset.height,
offsetX: layer.offsetX ?? asset.width / 2,
offsetY: layer.offsetY ?? asset.height / 2,
fill: props.fill ?? asset.fill,
fill,
}
if (blur > 0) cfg.sceneFunc = makeRectSceneFunc(blur)
return cfg
}
function circleConfig(
@@ -238,13 +320,17 @@ function circleConfig(
asset: Extract<Asset, { kind: 'circle' }>,
props: AnimatableProps,
) {
return {
const fill = props.fill ?? asset.fill
const blur = props.blur ?? asset.blur ?? 0
const cfg: Record<string, unknown> = {
...baseNodeConfig(layer, props),
radius: asset.radius,
offsetX: layer.offsetX ?? 0,
offsetY: layer.offsetY ?? 0,
fill: props.fill ?? asset.fill,
fill,
}
if (blur > 0) cfg.sceneFunc = makeCircleSceneFunc(blur)
return cfg
}
function textConfig(layer: Layer, asset: Extract<Asset, { kind: 'text' }>, props: AnimatableProps) {
@@ -287,30 +373,39 @@ function clipBaseConfig(instanceId: string, layer: Layer, props: AnimatableProps
visible: layer.visible,
draggable: false,
listening: false,
...shadowCfg(props),
}
}
function clipRectConfig(item: RenderedClipLayer) {
const asset = item.asset as Extract<Asset, { kind: 'rect' }>
return {
const fill = item.props.fill ?? asset.fill
const blur = item.props.blur ?? asset.blur ?? 0
const cfg: Record<string, unknown> = {
...clipBaseConfig(item.instanceId, item.layer, item.props),
width: asset.width,
height: asset.height,
offsetX: item.layer.offsetX ?? asset.width / 2,
offsetY: item.layer.offsetY ?? asset.height / 2,
fill: item.props.fill ?? asset.fill,
fill,
}
if (blur > 0) cfg.sceneFunc = makeRectSceneFunc(blur)
return cfg
}
function clipCircleConfig(item: RenderedClipLayer) {
const asset = item.asset as Extract<Asset, { kind: 'circle' }>
return {
const fill = item.props.fill ?? asset.fill
const blur = item.props.blur ?? asset.blur ?? 0
const cfg: Record<string, unknown> = {
...clipBaseConfig(item.instanceId, item.layer, item.props),
radius: asset.radius,
offsetX: item.layer.offsetX ?? 0,
offsetY: item.layer.offsetY ?? 0,
fill: item.props.fill ?? asset.fill,
fill,
}
if (blur > 0) cfg.sceneFunc = makeCircleSceneFunc(blur)
return cfg
}
function clipTextConfig(item: RenderedClipLayer) {
@@ -344,6 +439,17 @@ interface PivotInfo {
sampled: AnimatableProps
}
interface LiveLayerPose {
layerId: string
x: number
y: number
scaleX: number
scaleY: number
rotation: number
}
const liveLayerPose = ref<LiveLayerPose | null>(null)
const pivotInfo = computed<PivotInfo | null>(() => {
const id = store.selectedLayerId
if (!id) return null
@@ -351,7 +457,22 @@ const pivotInfo = computed<PivotInfo | null>(() => {
if (!layer) return null
const asset = assetMap.value.get(layer.assetId)
if (!asset) return null
return { layerId: layer.id, sampled: sampleLayer(layer, store.playhead) }
const sampled = sampleLayer(layer, store.playhead)
const live = liveLayerPose.value
if (live && live.layerId === id) {
return {
layerId: layer.id,
sampled: {
...sampled,
x: live.x,
y: live.y,
scaleX: live.scaleX,
scaleY: live.scaleY,
rotation: live.rotation,
},
}
}
return { layerId: layer.id, sampled }
})
const pivotHandleConfig = computed(() => {
@@ -441,6 +562,31 @@ watch(
{ immediate: true },
)
function updateLivePose(node: Konva.Node, layerId: string) {
liveLayerPose.value = {
layerId,
x: node.x(),
y: node.y(),
scaleX: node.scaleX(),
scaleY: node.scaleY(),
rotation: node.rotation(),
}
}
function handleDragMove(e: Konva.KonvaEventObject<DragEvent>) {
const node = e.target
const id = node.id()
if (!store.activeLayers.some((l) => l.id === id)) return
updateLivePose(node, id)
}
function handleTransform(e: Konva.KonvaEventObject<Event>) {
const node = e.target
const id = node.id()
if (!store.activeLayers.some((l) => l.id === id)) return
updateLivePose(node, id)
}
function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
const target = e.target
const tr = transformerRef.value?.getNode()
@@ -469,11 +615,21 @@ function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
function handleDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
const node = e.target
const id = node.id()
if (!store.activeLayers.some((l) => l.id === id)) return
store.upsertKeyframe(id, store.playhead, {
x: node.x(),
y: node.y(),
})
const layer = store.activeLayers.find((l) => l.id === id)
if (!layer) return
const newX = node.x()
const newY = node.y()
if (selectedKfs.value.length > 0) {
const samp = sampleLayer(layer, store.playhead)
const dx = newX - samp.x
const dy = newY - samp.y
if (dx !== 0 || dy !== 0) {
store.shiftKeyframesPosition(selectedKfs.value, dx, dy)
}
} else {
store.upsertKeyframe(id, store.playhead, { x: newX, y: newY })
}
liveLayerPose.value = null
}
function handleTransformEnd(e: Konva.KonvaEventObject<Event>) {
@@ -487,13 +643,24 @@ function handleTransformEnd(e: Konva.KonvaEventObject<Event>) {
scaleY: node.scaleY(),
rotation: node.rotation(),
})
liveLayerPose.value = null
}
const ROT_ARM_PX = 44
const rotationHandlePos = ref<{ x: number; y: number } | null>(null)
const rotationHandleConfig = computed(() => {
const info = pivotInfo.value
if (!info) return null
if (rotationHandlePos.value) {
return {
x: rotationHandlePos.value.x,
y: rotationHandlePos.value.y,
draggable: true,
name: 'rotation-handle',
}
}
const arm = ROT_ARM_PX / zoom.value
const rad = (info.sampled.rotation * Math.PI) / 180
return {
@@ -507,15 +674,19 @@ const rotationHandleConfig = computed(() => {
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
let endX: number
let endY: number
if (rotationHandlePos.value) {
endX = rotationHandlePos.value.x
endY = rotationHandlePos.value.y
} else {
const arm = ROT_ARM_PX / zoom.value
const rad = (info.sampled.rotation * Math.PI) / 180
endX = info.sampled.x + arm * Math.sin(rad)
endY = info.sampled.y - arm * Math.cos(rad)
}
return {
points: [
info.sampled.x,
info.sampled.y,
info.sampled.x + arm * Math.sin(rad),
info.sampled.y - arm * Math.cos(rad),
],
points: [info.sampled.x, info.sampled.y, endX, endY],
stroke: '#6ea8ff',
strokeWidth: 1.25 / zoom.value,
dash: [3 / zoom.value, 3 / zoom.value],
@@ -537,11 +708,33 @@ function onRotationHandleDragMove(e: Konva.KonvaEventObject<DragEvent>) {
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 })
const raw = (Math.atan2(dx, -dy) * 180) / Math.PI
// Snap to the 360°-equivalent nearest to the previous rotation so small
// motions don't flip a full turn across the ±180° boundary.
const prev = liveLayerPose.value?.rotation ?? info.sampled.rotation
let deg = raw
while (deg - prev > 180) deg -= 360
while (deg - prev < -180) deg += 360
const base = liveLayerPose.value ?? {
layerId: info.layerId,
x: info.sampled.x,
y: info.sampled.y,
scaleX: info.sampled.scaleX,
scaleY: info.sampled.scaleY,
rotation: info.sampled.rotation,
}
liveLayerPose.value = { ...base, layerId: info.layerId, rotation: deg }
rotationHandlePos.value = { x: node.x(), y: node.y() }
}
function onRotationHandleDragEnd() {
const info = pivotInfo.value
const live = liveLayerPose.value
if (info && live && live.layerId === info.layerId) {
store.upsertKeyframe(info.layerId, store.playhead, { rotation: live.rotation })
}
rotationHandlePos.value = null
liveLayerPose.value = null
}
let ro: ResizeObserver | null = null
@@ -699,25 +892,33 @@ onBeforeUnmount(() => {
<v-rect
v-if="entry.item.asset.kind === 'rect'"
:config="rectConfig(entry.item.layer, entry.item.asset, entry.item.props)"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@transformend="handleTransformEnd"
/>
<v-circle
v-else-if="entry.item.asset.kind === 'circle'"
:config="circleConfig(entry.item.layer, entry.item.asset, entry.item.props)"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@transformend="handleTransformEnd"
/>
<v-text
v-else-if="entry.item.asset.kind === 'text'"
:config="textConfig(entry.item.layer, entry.item.asset, entry.item.props)"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@transformend="handleTransformEnd"
/>
<v-image
v-else-if="entry.item.asset.kind === 'image' && imageConfig(entry.item.layer, entry.item.asset, entry.item.props)"
:config="imageConfig(entry.item.layer, entry.item.asset, entry.item.props)!"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@transformend="handleTransformEnd"
/>
</template>
@@ -755,6 +956,7 @@ onBeforeUnmount(() => {
v-if="rotationHandleConfig"
:config="rotationHandleConfig"
@dragmove="onRotationHandleDragMove"
@dragend="onRotationHandleDragEnd"
>
<v-circle :config="rotationKnobConfig" />
</v-group>
+152
View File
@@ -34,6 +34,76 @@ const textAsset = computed(() => {
return a && a.kind === 'text' ? a : null
})
const blurAsset = computed(() => {
const a = selectedAsset.value
return a && (a.kind === 'rect' || a.kind === 'circle') ? a : null
})
const blurValue = computed<number>({
get: () => {
const a = blurAsset.value
if (!a) return 0
return sampled.value?.blur ?? a.blur ?? 0
},
set: (v: number) => {
if (!selected.value) return
store.upsertKeyframe(selected.value.id, store.playhead, { blur: v })
},
})
type ShadowNumKey = 'shadowBlur' | 'shadowOpacity' | 'shadowAngle' | 'shadowDistance'
interface ShadowAll {
shadowBlur: number
shadowOpacity: number
shadowAngle: number
shadowDistance: number
shadowColor: string
}
const SHADOW_DEFAULTS: ShadowAll = {
shadowBlur: 8,
shadowOpacity: 0,
shadowAngle: 90,
shadowDistance: 6,
shadowColor: '#000000',
}
function currentShadow(): ShadowAll {
const samp = sampled.value
return {
shadowBlur: samp?.shadowBlur ?? SHADOW_DEFAULTS.shadowBlur,
shadowOpacity: samp?.shadowOpacity ?? SHADOW_DEFAULTS.shadowOpacity,
shadowAngle: samp?.shadowAngle ?? SHADOW_DEFAULTS.shadowAngle,
shadowDistance: samp?.shadowDistance ?? SHADOW_DEFAULTS.shadowDistance,
shadowColor: samp?.shadowColor ?? SHADOW_DEFAULTS.shadowColor,
}
}
function writeShadow(patch: Partial<ShadowAll>) {
if (!selected.value) return
const next = { ...currentShadow(), ...patch }
store.upsertKeyframe(selected.value.id, store.playhead, next)
}
function shadowBinding(key: ShadowNumKey, decimals = 0) {
const factor = 10 ** decimals
return computed({
get: () => Math.round(currentShadow()[key] * factor) / factor,
set: (v: number) => writeShadow({ [key]: v } as Partial<ShadowAll>),
})
}
const shadowBlur = shadowBinding('shadowBlur')
const shadowOpacity = shadowBinding('shadowOpacity', 2)
const shadowAngle = shadowBinding('shadowAngle')
const shadowDistance = shadowBinding('shadowDistance')
const shadowColorValue = computed<string>({
get: () => currentShadow().shadowColor,
set: (v: string) => writeShadow({ shadowColor: v }),
})
const canFill = computed(() => {
const a = selectedAsset.value
return !!a && (a.kind === 'rect' || a.kind === 'circle' || a.kind === 'text')
@@ -108,6 +178,71 @@ function removeKeyframeHere() {
<label>Text<input type="text" v-model="textAsset.text" /></label>
<label>Font Size<input type="number" min="1" v-model.number="textAsset.fontSize" /></label>
</template>
<label v-if="blurAsset" class="slider-label">
{{ t('properties.edgeBlur') }}
<RangeSlider
v-model="blurValue"
:min="0"
:max="64"
:step="1"
:format="(v: number) => `${Math.round(v)}px`"
/>
</label>
<section class="shadow-group">
<h4>{{ t('properties.shadow') }}</h4>
<label class="color-label">
{{ t('properties.shadowColor') }}
<div class="color-row">
<input
type="color"
class="color-swatch"
:value="shadowColorValue"
@input="shadowColorValue = ($event.target as HTMLInputElement).value"
/>
<span class="color-hex">{{ shadowColorValue.toUpperCase() }}</span>
</div>
</label>
<label class="slider-label">
{{ t('properties.shadowOpacity') }}
<RangeSlider
v-model="shadowOpacity"
:min="0"
:max="1"
:step="0.01"
:format="formatPercent"
/>
</label>
<label class="slider-label">
{{ t('properties.shadowBlur') }}
<RangeSlider
v-model="shadowBlur"
:min="0"
:max="64"
:step="1"
:format="(v: number) => `${Math.round(v)}px`"
/>
</label>
<label class="slider-label">
{{ t('properties.shadowDistance') }}
<RangeSlider
v-model="shadowDistance"
:min="0"
:max="100"
:step="1"
:format="(v: number) => `${Math.round(v)}px`"
/>
</label>
<label class="slider-label">
{{ t('properties.shadowAngle') }}
<RangeSlider
v-model="shadowAngle"
:min="0"
:max="360"
:step="1"
:format="(v: number) => `${Math.round(v)}°`"
/>
</label>
</section>
<label v-if="canFill" class="color-label">
Color
<div class="color-row">
@@ -180,6 +315,23 @@ input {
padding: 8px 0;
}
.shadow-group {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 8px;
border-top: 1px solid var(--border);
}
.shadow-group h4 {
margin: 0;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
font-weight: 500;
}
.kf-row {
display: flex;
align-items: center;
+149 -50
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
PhArrowsSplit,
PhCheck,
@@ -16,6 +16,7 @@ import { sampleLayer } from '@/lib/interpolate'
import { snapToCandidates } from '@/lib/timelineSnap'
import { confirmDialog } from '@/components/ui'
import { t } from '@/i18n'
import { kfSelectionKey, useKfSelection, type KfSelectionKey } from '@/composables/useKfSelection'
import type {
AnimatableProps,
Clip,
@@ -318,23 +319,9 @@ function isDropAfter(track: TimelineTrack, nextTrack: TimelineTrack | undefined)
return true
}
interface SelectionKey {
layerId: string
kind: 'main' | 'position' | 'rotation'
keyframeId: string
}
const selectedKfs = ref<SelectionKey[]>([])
function selectionKey(k: SelectionKey): string {
return `${k.layerId}:${k.kind}:${k.keyframeId}`
}
const selectedKfSet = computed(() => {
const s = new Set<string>()
for (const k of selectedKfs.value) s.add(selectionKey(k))
return s
})
type SelectionKey = KfSelectionKey
const { selectedKfs, selectedKfSet } = useKfSelection()
const selectionKey = kfSelectionKey
function isKfSelected(layerId: string, kind: 'main' | 'position' | 'rotation', keyframeId: string): boolean {
return selectedKfSet.value.has(`${layerId}:${kind}:${keyframeId}`)
@@ -687,6 +674,7 @@ interface BoxSelectState {
currentClientY: number
tracksEl: HTMLElement
moved: boolean
additive: boolean
}
const boxSelect = ref<BoxSelectState | null>(null)
@@ -728,6 +716,7 @@ function onTracksPointerDown(e: PointerEvent) {
currentClientY: e.clientY,
tracksEl,
moved: false,
additive: e.shiftKey,
}
}
@@ -749,8 +738,8 @@ function onTracksPointerUp(e: PointerEvent) {
if (b.tracksEl.hasPointerCapture(e.pointerId)) b.tracksEl.releasePointerCapture(e.pointerId)
if (b.moved) {
commitBoxSelection(b)
} else {
// bare click on empty space → clear selection
} else if (!b.additive) {
// bare click on empty space without Shift → clear selection
clearKfSelection()
}
boxSelect.value = null
@@ -763,7 +752,7 @@ function commitBoxSelection(b: BoxSelectState) {
const bottom = Math.max(b.originClientY, b.currentClientY)
const nodes = b.tracksEl.querySelectorAll<HTMLElement>('.kf[data-kf-id]')
const next: SelectionKey[] = []
const hits: SelectionKey[] = []
for (const node of Array.from(nodes)) {
const r = node.getBoundingClientRect()
const cx = r.left + r.width / 2
@@ -773,9 +762,22 @@ function commitBoxSelection(b: BoxSelectState) {
const kind = node.dataset.kind as 'main' | 'position' | 'rotation' | undefined
const keyframeId = node.dataset.kfId
if (!layerId || !kind || !keyframeId) continue
next.push({ layerId, kind, keyframeId })
hits.push({ layerId, kind, keyframeId })
}
if (b.additive) {
const seen = new Set(selectedKfs.value.map(selectionKey))
const merged = [...selectedKfs.value]
for (const h of hits) {
const k = selectionKey(h)
if (!seen.has(k)) {
seen.add(k)
merged.push(h)
}
}
selectedKfs.value = merged
} else {
selectedKfs.value = hits
}
selectedKfs.value = next
}
function pixelTimeFromEvent(e: DragEvent | PointerEvent, el: HTMLElement): number {
@@ -858,8 +860,46 @@ function onClipBlockPointerUp(e: PointerEvent) {
if (wasMoved) e.preventDefault()
}
function addClipTrack() {
store.addClipTrack()
const clipDragOverDropzone = ref(false)
function onNewTrackDragEnter(e: DragEvent) {
if (!e.dataTransfer?.types.includes('application/x-animatr-clip')) return
e.preventDefault()
clipDragOverDropzone.value = true
}
function onNewTrackDragOver(e: DragEvent) {
if (!e.dataTransfer?.types.includes('application/x-animatr-clip')) return
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
clipDragOverDropzone.value = true
}
function onNewTrackDragLeave() {
clipDragOverDropzone.value = false
}
function onNewTrackDrop(e: DragEvent) {
clipDragOverDropzone.value = false
const clipId = e.dataTransfer?.getData('application/x-animatr-clip')
if (!clipId) return
e.preventDefault()
const el = e.currentTarget as HTMLElement
const time = pixelTimeFromLane(e, el)
const track = store.addClipTrack()
store.addClipInstance(clipId, time, track.id)
}
function pixelTimeFromLane(e: DragEvent, el: HTMLElement): number {
// Account for sticky track-name column offset: compute based on the full tl-content lane.
const rect = el.getBoundingClientRect()
if (rect.width <= 0) return 0
const NAME_COL = 140
const laneLeft = rect.left + NAME_COL
const laneWidth = rect.width - NAME_COL
if (laneWidth <= 0) return 0
const ratio = Math.max(0, Math.min(1, (e.clientX - laneLeft) / laneWidth))
return ratio * duration.value
}
function clipDurationOf(ci: ClipInstance): number {
@@ -1076,6 +1116,36 @@ watch([menu, trackMenu], ([m, tm]) => {
}
})
async function clampMenuIntoViewport(
getEl: () => HTMLElement | null,
x: number,
y: number,
) {
await nextTick()
const el = getEl()
if (!el) return
const rect = el.getBoundingClientRect()
const vw = window.innerWidth
const vh = window.innerHeight
const margin = 8
let left = x
let top = y
if (left + rect.width + margin > vw) left = Math.max(margin, vw - rect.width - margin)
if (top + rect.height + margin > vh) top = Math.max(margin, vh - rect.height - margin)
el.style.left = `${left}px`
el.style.top = `${top}px`
}
watch(menu, (m) => {
if (!m) return
clampMenuIntoViewport(() => menuRef.value, m.x, m.y)
})
watch(trackMenu, (tm) => {
if (!tm) return
clampMenuIntoViewport(() => trackMenuRef.value, tm.x, tm.y)
})
function onWheel(e: WheelEvent) {
if (!scrollEl.value) return
if (e.ctrlKey || e.metaKey) {
@@ -1308,6 +1378,7 @@ onBeforeUnmount(() => {
@dragover.stop="onClipLaneDragOver($event, track.clipTrackId)"
@drop.stop="onClipLaneDrop($event, track.clipTrackId)"
>
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
<span
v-for="ci in clipInstancesByTrack[track.clipTrackId] ?? []"
:key="ci.id"
@@ -1357,11 +1428,16 @@ onBeforeUnmount(() => {
</div>
</template>
<p v-if="!tracks.length" class="empty">{{ t('timeline.noTracks') }}</p>
<div v-if="!inClipEdit" class="track clip-track add-clip-track">
<button class="add-clip-btn" :title="t('timeline.addClipTrackTooltip')" @click="addClipTrack">
<PhPlus :size="11" weight="bold" />
<span>{{ t('timeline.addClipTrack') }}</span>
</button>
<div
v-if="!inClipEdit"
class="track clip-track-dropzone"
:class="{ hot: clipDragOverDropzone }"
@dragenter="onNewTrackDragEnter"
@dragover="onNewTrackDragOver"
@dragleave="onNewTrackDragLeave"
@drop="onNewTrackDrop"
>
<span class="dropzone-hint">{{ t('timeline.dropNewTrackHint') }}</span>
</div>
</div>
</div>
@@ -1391,6 +1467,7 @@ onBeforeUnmount(() => {
weight="bold"
/>
</span>
<span class="kf-menu-icon-spacer"></span>
{{ opt.label }}
</button>
<div v-if="menu.isFirst" class="kf-menu-note">{{ t('timeline.firstKfHint') }}</div>
@@ -1407,16 +1484,18 @@ onBeforeUnmount(() => {
<span class="check">
<PhCheck v-if="menu.isHeld" :size="12" weight="bold" />
</span>
<PhPauseCircle :size="12" />
<PhPauseCircle :size="12" class="kf-menu-icon" />
{{ t('timeline.stillFrame') }}
</button>
<div class="kf-menu-divider"></div>
<button class="kf-menu-item" @click="duplicateFromMenu">
<PhCopy :size="12" />
<span class="check"></span>
<PhCopy :size="12" class="kf-menu-icon" />
{{ t('timeline.duplicate') }}
</button>
<button class="kf-menu-item danger" @click="deleteFromMenu">
<PhTrash :size="12" />
<span class="check"></span>
<PhTrash :size="12" class="kf-menu-icon" />
{{ t('timeline.delete') }}
</button>
</div>
@@ -1428,7 +1507,6 @@ onBeforeUnmount(() => {
:style="{ left: trackMenu.x + 'px', top: trackMenu.y + 'px' }"
>
<div class="kf-menu-section">
<div class="kf-menu-header">{{ t('timeline.color') }}</div>
<div class="color-tiles">
<button
v-for="c in TRACK_COLORS"
@@ -1452,11 +1530,10 @@ onBeforeUnmount(() => {
<span class="check">
<PhCheck v-if="trackMenu.locked" :size="12" weight="bold" />
</span>
<component :is="trackMenu.locked ? PhLockOpen : PhLock" :size="12" />
<component :is="trackMenu.locked ? PhLockOpen : PhLock" :size="12" class="kf-menu-icon" />
{{ trackMenu.locked ? t('timeline.unlock') : t('timeline.lock') }}
</button>
<template v-if="trackMenu.kind === 'layer'">
<div class="kf-menu-divider"></div>
<button
class="kf-menu-item"
:disabled="trackMenu.locked"
@@ -1465,12 +1542,14 @@ onBeforeUnmount(() => {
<span class="check">
<PhCheck v-if="trackMenu.separated" :size="12" weight="bold" />
</span>
<PhArrowsSplit :size="12" class="kf-menu-icon" />
{{ t('timeline.separate') }}
</button>
</template>
<div class="kf-menu-divider"></div>
<button class="kf-menu-item danger" @click="deleteTrack">
<PhTrash :size="12" />
<span class="check"></span>
<PhTrash :size="12" class="kf-menu-icon" />
{{ trackMenu.kind === 'layer' ? t('timeline.deleteLayer') : t('timeline.deleteClipTrack') }}
</button>
</div>
@@ -1822,7 +1901,7 @@ onBeforeUnmount(() => {
.track-name {
position: sticky;
left: 0;
z-index: 3;
z-index: 5;
width: var(--track-name-width);
padding: 0 12px;
color: var(--text-dim);
@@ -1931,6 +2010,7 @@ onBeforeUnmount(() => {
background: var(--accent);
pointer-events: none;
transform: translateX(-0.5px);
z-index: 4;
}
.kf-seg {
@@ -2193,28 +2273,34 @@ onBeforeUnmount(() => {
opacity: 1 !important;
}
.add-clip-track {
.clip-track-dropzone {
height: 26px;
background: transparent;
border-bottom: 1px dashed var(--border);
}
.add-clip-btn {
display: inline-flex;
display: flex;
align-items: center;
gap: 6px;
height: 100%;
padding: 0 14px;
justify-content: center;
background: transparent;
border: none;
border: 1px dashed var(--border);
border-left: none;
border-right: none;
transition: background 140ms var(--ease), border-color 140ms var(--ease);
color: var(--text-dim);
font-size: 11px;
}
.add-clip-btn:hover {
.clip-track-dropzone.hot {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent);
}
.dropzone-hint {
position: sticky;
left: 50%;
transform: translateX(-50%);
pointer-events: none;
white-space: nowrap;
}
.kf-menu {
position: fixed;
@@ -2280,9 +2366,22 @@ onBeforeUnmount(() => {
.check {
width: 14px;
height: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--accent);
flex-shrink: 0;
}
.kf-menu-icon,
.kf-menu-icon-spacer {
width: 12px;
height: 12px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.kf-menu-note {
+23
View File
@@ -0,0 +1,23 @@
import { computed, ref } from 'vue'
export interface KfSelectionKey {
layerId: string
kind: 'main' | 'position' | 'rotation'
keyframeId: string
}
const selectedKfs = ref<KfSelectionKey[]>([])
export function kfSelectionKey(k: KfSelectionKey): string {
return `${k.layerId}:${k.kind}:${k.keyframeId}`
}
const selectedKfSet = computed(() => {
const s = new Set<string>()
for (const k of selectedKfs.value) s.add(kfSelectionKey(k))
return s
})
export function useKfSelection() {
return { selectedKfs, selectedKfSet }
}
+254
View File
@@ -9,6 +9,9 @@ import {
} from '@/lib/exportPlayer'
import { ProjectFileError, parseProject, serializeProject } from '@/lib/projectFile'
import { createHistory } from '@/composables/useHistory'
import { useKfSelection, kfSelectionKey } from '@/composables/useKfSelection'
import { APP_NAME, APP_VERSION } from '@/version'
import { drawSceneFrame } from '@/lib/render'
import {
deleteStoredProject,
getStoredProject,
@@ -26,6 +29,34 @@ interface MountedHandle {
unmount: () => void
}
interface RecordingCtx {
ctx: CanvasRenderingContext2D
filterSets: string[]
}
function makeRecordingCtx(): RecordingCtx {
const canvas = document.createElement('canvas')
canvas.width = 200
canvas.height = 200
const real = canvas.getContext('2d')!
const filterSets: string[] = []
const proxy = new Proxy(real, {
set(target, prop, value) {
if (prop === 'filter') filterSets.push(value as string)
;(target as unknown as Record<string | symbol, unknown>)[prop as string | symbol] = value
return true
},
get(target, prop) {
const v = (target as unknown as Record<string | symbol, unknown>)[
prop as string | symbol
]
if (typeof v === 'function') return v.bind(target)
return v
},
})
return { ctx: proxy as CanvasRenderingContext2D, filterSets }
}
function mountComponent(
component: Component,
props: Record<string, unknown>,
@@ -1213,5 +1244,228 @@ export function registerAllTests(): void {
const cropBtn = document.querySelector('.crop-btn')
expect(cropBtn).toBeTruthy()
})
test('brand button opens about dialog', () => {
const brand = document.querySelector('button.brand')
expect(brand).toBeTruthy()
})
test('timeline has new-track dropzone', () => {
const dz = document.querySelector('.clip-track-dropzone')
expect(dz).toBeTruthy()
})
})
suite('version — app metadata', () => {
test('APP_VERSION is a semver-like string', () => {
expect(typeof APP_VERSION === 'string').toBe(true)
expect(/^\d+\.\d+\.\d+/.test(APP_VERSION)).toBe(true)
})
test('APP_NAME is non-empty', () => {
expect(APP_NAME.length).toBeGreaterThan(0)
})
})
suite('kf selection — shared composable', () => {
test('setting selection reflects across handles', () => {
const { selectedKfs } = useKfSelection()
const snapshot = selectedKfs.value
try {
selectedKfs.value = [
{ layerId: 'L1', kind: 'main', keyframeId: 'K1' },
{ layerId: 'L2', kind: 'position', keyframeId: 'K2' },
]
const second = useKfSelection()
expect(second.selectedKfs.value).toHaveLength(2)
expect(second.selectedKfSet.value.has('L1:main:K1')).toBe(true)
expect(second.selectedKfSet.value.has('L2:position:K2')).toBe(true)
} finally {
selectedKfs.value = snapshot
}
})
test('kfSelectionKey builds stable id', () => {
expect(kfSelectionKey({ layerId: 'a', kind: 'main', keyframeId: 'b' })).toBe('a:main:b')
expect(kfSelectionKey({ layerId: 'x', kind: 'position', keyframeId: 'y' })).toBe(
'x:position:y',
)
})
})
suite('assets — edge blur', () => {
test('rect asset stores blur value', () => {
const s = makeFreshStore()
const a = s.addAsset({
kind: 'rect',
name: 'R',
width: 40,
height: 40,
fill: '#ff0000',
blur: 12,
})
const stored = s.scene.assets.find((x) => x.id === a.id)!
expect(stored.kind).toBe('rect')
expect((stored as Extract<typeof stored, { kind: 'rect' }>).blur).toBe(12)
})
test('circle asset stores blur value', () => {
const s = makeFreshStore()
const a = s.addAsset({
kind: 'circle',
name: 'C',
radius: 30,
fill: '#0000ff',
blur: 6,
})
const stored = s.scene.assets.find((x) => x.id === a.id)!
expect(stored.kind).toBe('circle')
expect((stored as Extract<typeof stored, { kind: 'circle' }>).blur).toBe(6)
})
test('drawSceneFrame sets ctx.filter to blur() for blurred rect', () => {
const s = makeFreshStore()
const a = s.addAsset({
kind: 'rect',
name: 'Blurred',
width: 40,
height: 40,
fill: '#ff4488',
blur: 18,
})
s.addLayer(a.id)
const { ctx, filterSets } = makeRecordingCtx()
drawSceneFrame(ctx, s.scene, 0, {})
expect(filterSets.some((v) => v === 'blur(18px)')).toBe(true)
})
test('drawSceneFrame does not apply blur when blur is unset or zero', () => {
const s = makeFreshStore()
const a = s.addAsset({
kind: 'rect',
name: 'Sharp',
width: 40,
height: 40,
fill: '#123456',
})
s.addLayer(a.id)
const { ctx, filterSets } = makeRecordingCtx()
drawSceneFrame(ctx, s.scene, 0, {})
// The only filter writes should be the post-draw reset to 'none'
expect(filterSets.every((v) => v === 'none')).toBe(true)
})
test('drawSceneFrame applies blur for circle asset', () => {
const s = makeFreshStore()
const a = s.addAsset({
kind: 'circle',
name: 'CB',
radius: 20,
fill: '#22aa22',
blur: 9,
})
s.addLayer(a.id)
const { ctx, filterSets } = makeRecordingCtx()
drawSceneFrame(ctx, s.scene, 0, {})
expect(filterSets.some((v) => v === 'blur(9px)')).toBe(true)
})
})
suite('scene store — shiftKeyframesPosition', () => {
test('shifts x/y of a single keyframe by delta', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Shift', width: 40, height: 40, fill: '#a55' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 1, { x: 100, y: 50 })
const kf = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
s.shiftKeyframesPosition(
[{ layerId: l.id, kind: 'main', keyframeId: kf.id }],
25,
-10,
)
await tick()
expect(kf.props.x).toBe(125)
expect(kf.props.y).toBe(40)
})
test('shifts multiple keyframes across layers by the same delta', async () => {
const s = makeFreshStore()
const a1 = s.addAsset({ kind: 'rect', name: 'A', width: 10, height: 10, fill: '#111' })
const a2 = s.addAsset({ kind: 'rect', name: 'B', width: 10, height: 10, fill: '#222' })
const l1 = s.addLayer(a1.id)
const l2 = s.addLayer(a2.id)
s.upsertKeyframe(l1.id, 1, { x: 100, y: 100 })
s.upsertKeyframe(l2.id, 2, { x: 200, y: 300 })
const k1 = l1.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
const k2 = l2.keyframes.find((k) => Math.abs(k.time - 2) < 1e-3)!
s.shiftKeyframesPosition(
[
{ layerId: l1.id, kind: 'main', keyframeId: k1.id },
{ layerId: l2.id, kind: 'main', keyframeId: k2.id },
],
10,
5,
)
await tick()
expect(k1.props.x).toBe(110)
expect(k1.props.y).toBe(105)
expect(k2.props.x).toBe(210)
expect(k2.props.y).toBe(305)
})
test('skips rotation keyframes (no x/y)', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#333' })
const l = s.addLayer(a.id)
s.setLayerSeparated(l.id, true)
s.upsertKeyframe(l.id, 1, { rotation: 45 })
const rotKf = (l.rotationKeyframes ?? []).find((k) => Math.abs(k.time - 1) < 1e-3)!
s.shiftKeyframesPosition(
[{ layerId: l.id, kind: 'rotation', keyframeId: rotKf.id }],
100,
100,
)
await tick()
expect((rotKf.props as Record<string, unknown>).x).toBe(undefined)
expect((rotKf.props as Record<string, unknown>).y).toBe(undefined)
expect(rotKf.props.rotation).toBe(45)
})
test('uses sampled fallback when keyframe has no explicit x/y', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Fb', width: 10, height: 10, fill: '#444' })
const l = s.addLayer(a.id)
// initial keyframe at t=0 has full base props (including x=640 from canvas center)
// Create a later keyframe that only sets scaleX
s.upsertKeyframe(l.id, 2, { scaleX: 2 })
const kf = l.keyframes.find((k) => Math.abs(k.time - 2) < 1e-3)!
// ensure x is not explicitly set
delete (kf.props as Record<string, unknown>).x
delete (kf.props as Record<string, unknown>).y
const sampledBefore = sampleLayer(l, 2)
s.shiftKeyframesPosition([{ layerId: l.id, kind: 'main', keyframeId: kf.id }], 7, 11)
await tick()
expect(kf.props.x).toBe(sampledBefore.x + 7)
expect(kf.props.y).toBe(sampledBefore.y + 11)
})
test('non-existing layer/keyframe refs are safely ignored', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#555' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 1, { x: 50 })
// Call with mix of valid and invalid refs — should not throw
s.shiftKeyframesPosition(
[
{ layerId: 'missing-layer', kind: 'main', keyframeId: 'missing-kf' },
{ layerId: l.id, kind: 'main', keyframeId: 'also-missing' },
],
10,
10,
)
// Valid keyframe at t=1 remained unshifted (we didn't target it)
const kf = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
expect(kf.props.x).toBe(50)
})
})
}
+34
View File
@@ -95,6 +95,13 @@ export const en = {
removeKeyframe: 'Remove keyframe here',
deleteLayer: 'Delete layer',
noLayer: 'No layer selected.',
edgeBlur: 'Edge blur',
shadow: 'Shadow',
shadowBlur: 'Shadow blur',
shadowOpacity: 'Shadow opacity',
shadowAngle: 'Shadow angle',
shadowDistance: 'Shadow distance',
shadowColor: 'Shadow color',
},
fullscreen: {
@@ -120,6 +127,7 @@ export const en = {
noTracks: 'No tracks yet.',
addClipTrack: 'Add clip track',
addClipTrackTooltip: 'Add another clip track',
dropNewTrackHint: 'Drop a clip here to add a new track',
clipTrackName: 'Clip track',
toggleTimeUnit: 'Toggle time unit',
snapOn: 'Snapping on (playhead & keyframes snap together)',
@@ -227,6 +235,32 @@ export const en = {
cancel: 'Cancel',
close: 'Close',
},
about: {
openTooltip: 'About Animatr',
title: 'About {name}',
tagline: 'A timeline-based animation editor that runs in your browser.',
versionLabel: 'Version',
buildLabel: 'Build',
close: 'Close',
shortcutsHeading: 'Shortcuts',
shortcut: {
undo: 'Ctrl+Z',
redo: 'Ctrl+Shift+Z',
playPause: 'Space',
fullscreen: 'Shift+Space',
tests: 'Ctrl+Shift+Alt+T',
panCanvas: 'Middle-click / Ctrl+Drag',
},
shortcutLabel: {
undo: 'Undo',
redo: 'Redo',
playPause: 'Play / Pause',
fullscreen: 'Fullscreen preview',
tests: 'Open test runner',
panCanvas: 'Pan canvas',
},
},
} as const
export type Messages = typeof en
+24
View File
@@ -149,18 +149,38 @@ const ENGINE_SOURCE = `(function(){
ctx.translate(p.x,p.y);
ctx.rotate(p.rotation*Math.PI/180);
ctx.scale(p.scaleX,p.scaleY);
var sOp=p.shadowOpacity||0;
var sBl=p.shadowBlur||0;
var sDi=p.shadowDistance||0;
if(sOp>0 && (sBl>0 || sDi>0)){
var sAn=(p.shadowAngle==null?90:p.shadowAngle)*Math.PI/180;
var sHex=(p.shadowColor||'#000000').replace('#','');
if(sHex.length===3){sHex=sHex[0]+sHex[0]+sHex[1]+sHex[1]+sHex[2]+sHex[2];}
var sNum=parseInt(sHex.slice(0,6),16);
if(isNaN(sNum))sNum=0;
ctx.shadowColor='rgba('+((sNum>>16)&255)+','+((sNum>>8)&255)+','+(sNum&255)+','+sOp+')';
ctx.shadowBlur=sBl;
ctx.shadowOffsetX=Math.cos(sAn)*sDi;
ctx.shadowOffsetY=Math.sin(sAn)*sDi;
}
if(asset.kind==='rect'){
var rOffX=layer.offsetX!=null?layer.offsetX:asset.width/2;
var rOffY=layer.offsetY!=null?layer.offsetY:asset.height/2;
var rBlur=(p.blur!=null?p.blur:(asset.blur||0));
ctx.fillStyle=p.fill||asset.fill;
if(rBlur>0) ctx.filter='blur('+rBlur+'px)';
ctx.fillRect(-rOffX,-rOffY,asset.width,asset.height);
ctx.filter='none';
}else if(asset.kind==='circle'){
var cOffX=layer.offsetX!=null?layer.offsetX:0;
var cOffY=layer.offsetY!=null?layer.offsetY:0;
var cBlur=(p.blur!=null?p.blur:(asset.blur||0));
ctx.fillStyle=p.fill||asset.fill;
if(cBlur>0) ctx.filter='blur('+cBlur+'px)';
ctx.beginPath();
ctx.arc(-cOffX,-cOffY,asset.radius,0,Math.PI*2);
ctx.fill();
ctx.filter='none';
}else if(asset.kind==='text'){
var tOffX=layer.offsetX!=null?layer.offsetX:0;
var tOffY=layer.offsetY!=null?layer.offsetY:0;
@@ -176,6 +196,10 @@ const ENGINE_SOURCE = `(function(){
ctx.drawImage(img,-iOffX,-iOffY,asset.width,asset.height);
}
}
ctx.shadowColor='transparent';
ctx.shadowBlur=0;
ctx.shadowOffsetX=0;
ctx.shadowOffsetY=0;
ctx.restore();
}
function draw(time){
+13
View File
@@ -49,6 +49,19 @@ function lerpProps(a: AnimatableProps, b: AnimatableProps, t: number): Animatabl
if (a.fill && b.fill) out.fill = lerpColor(a.fill, b.fill, t)
else if (b.fill) out.fill = b.fill
else if (a.fill) out.fill = a.fill
if (a.blur !== undefined && b.blur !== undefined) out.blur = lerp(a.blur, b.blur, t)
else if (b.blur !== undefined) out.blur = b.blur
else if (a.blur !== undefined) out.blur = a.blur
for (const key of ['shadowBlur', 'shadowOpacity', 'shadowAngle', 'shadowDistance'] as const) {
const av = a[key]
const bv = b[key]
if (av !== undefined && bv !== undefined) out[key] = lerp(av, bv, t)
else if (bv !== undefined) out[key] = bv
else if (av !== undefined) out[key] = av
}
if (a.shadowColor && b.shadowColor) out.shadowColor = lerpColor(a.shadowColor, b.shadowColor, t)
else if (b.shadowColor) out.shadowColor = b.shadowColor
else if (a.shadowColor) out.shadowColor = a.shadowColor
return out
}
+71 -3
View File
@@ -1,4 +1,4 @@
import type { Asset, Layer, Scene } from '@/types/scene'
import type { AnimatableProps, Asset, Layer, Scene } from '@/types/scene'
import { sampleLayer } from './interpolate'
export async function loadSceneImages(scene: Scene): Promise<Record<string, HTMLImageElement>> {
@@ -24,6 +24,62 @@ export async function loadSceneImages(scene: Scene): Promise<Record<string, HTML
return result
}
interface ShadowState {
sx: number
sy: number
blur: number
color: string
}
function hexToRgba(hex: string, alpha: number): string {
const h = hex.trim().replace('#', '')
const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h
if (full.length < 6) return `rgba(0, 0, 0, ${alpha})`
const n = parseInt(full.slice(0, 6), 16)
if (Number.isNaN(n)) return `rgba(0, 0, 0, ${alpha})`
const r = (n >> 16) & 255
const g = (n >> 8) & 255
const b = n & 255
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
function computeShadow(p: AnimatableProps): ShadowState | null {
const opacity = p.shadowOpacity ?? 0
if (opacity <= 0) return null
const blur = p.shadowBlur ?? 0
const distance = p.shadowDistance ?? 0
if (blur <= 0 && distance <= 0) return null
const angle = p.shadowAngle ?? 90
const rad = (angle * Math.PI) / 180
return {
sx: Math.cos(rad) * distance,
sy: Math.sin(rad) * distance,
blur,
color: hexToRgba(p.shadowColor ?? '#000000', opacity),
}
}
function applyShadow(ctx: CanvasRenderingContext2D, s: ShadowState | null) {
if (!s) {
ctx.shadowColor = 'transparent'
ctx.shadowBlur = 0
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 0
return
}
ctx.shadowColor = s.color
ctx.shadowBlur = s.blur
ctx.shadowOffsetX = s.sx
ctx.shadowOffsetY = s.sy
}
function clearShadow(ctx: CanvasRenderingContext2D) {
ctx.shadowColor = 'transparent'
ctx.shadowBlur = 0
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 0
}
function drawLayers(
ctx: CanvasRenderingContext2D,
layers: Layer[],
@@ -44,18 +100,29 @@ function drawLayers(
ctx.rotate((p.rotation * Math.PI) / 180)
ctx.scale(p.scaleX, p.scaleY)
const shadow = computeShadow(p)
applyShadow(ctx, shadow)
if (asset.kind === 'rect') {
const offX = layer.offsetX ?? asset.width / 2
const offY = layer.offsetY ?? asset.height / 2
ctx.fillStyle = p.fill ?? asset.fill
const fill = p.fill ?? asset.fill
const blur = p.blur ?? asset.blur ?? 0
ctx.fillStyle = fill
if (blur > 0) ctx.filter = `blur(${blur}px)`
ctx.fillRect(-offX, -offY, asset.width, asset.height)
ctx.filter = 'none'
} else if (asset.kind === 'circle') {
const offX = layer.offsetX ?? 0
const offY = layer.offsetY ?? 0
ctx.fillStyle = p.fill ?? asset.fill
const fill = p.fill ?? asset.fill
const blur = p.blur ?? asset.blur ?? 0
ctx.fillStyle = fill
if (blur > 0) ctx.filter = `blur(${blur}px)`
ctx.beginPath()
ctx.arc(-offX, -offY, asset.radius, 0, Math.PI * 2)
ctx.fill()
ctx.filter = 'none'
} else if (asset.kind === 'text') {
const offX = layer.offsetX ?? 0
const offY = layer.offsetY ?? 0
@@ -72,6 +139,7 @@ function drawLayers(
}
}
clearShadow(ctx)
ctx.restore()
}
}
+19
View File
@@ -409,6 +409,25 @@ export const useSceneStore = defineStore('scene', {
else clipTrack.color = color
}
},
shiftKeyframesPosition(
refs: Array<{ layerId: string; kind: 'main' | 'position' | 'rotation'; keyframeId: string }>,
dx: number,
dy: number,
) {
for (const ref of refs) {
if (ref.kind === 'rotation') continue
const layer = this.activeLayers.find((l) => l.id === ref.layerId)
if (!layer) continue
const arr =
ref.kind === 'position' ? layer.positionKeyframes : layer.keyframes
const kf = arr?.find((k) => k.id === ref.keyframeId)
if (!kf) continue
const sampled = sampleLayer(layer, kf.time)
const curX = kf.props.x ?? sampled.x
const curY = kf.props.y ?? sampled.y
kf.props = { ...kf.props, x: curX + dx, y: curY + dy }
}
},
setTrackLocked(trackId: string, locked: boolean) {
const layer = this.activeLayers.find((l) => l.id === trackId)
if (layer) {
+8
View File
@@ -88,6 +88,14 @@ button {
gap: 6px;
}
button:focus,
button:focus-visible {
outline: none;
}
button::-moz-focus-inner {
border: 0;
}
button:hover:not(:disabled) {
background: var(--panel-hover);
border-color: var(--border-strong);
+8
View File
@@ -8,6 +8,12 @@ export interface AnimatableProps {
rotation: number
opacity: number
fill?: string
blur?: number
shadowBlur?: number
shadowOpacity?: number
shadowAngle?: number
shadowDistance?: number
shadowColor?: string
}
export interface Keyframe {
@@ -36,6 +42,7 @@ export interface RectAsset {
width: number
height: number
fill: string
blur?: number
}
export interface CircleAsset {
@@ -44,6 +51,7 @@ export interface CircleAsset {
name: string
radius: number
fill: string
blur?: number
}
export interface TextAsset {
+2
View File
@@ -0,0 +1,2 @@
export const APP_VERSION = '0.1.0'
export const APP_NAME = 'Animatr'
+4 -5
View File
@@ -55,12 +55,11 @@ rect circle und text brauchen wir nicht als instanzen in der lib. da tauchen nur
ALLE texte der gui bitte in englisch
wir machen mal ein about popup und verwalten ab sofort eine version. wir starten mit 0.1.0
about öffnet bei click auf den app namen oben links
ein element rein draggen muss das element (grafik) als draggable anzeigen, nicht den bibliothekseintrag
context menüs dürfen nicht unten aus dem viewport rausgehen
context menüs ragen unten immernoch aus dem viewport raus
wir machen mal ein about popup und verwalten ab sofort eine version. wir starten mit 0.1.0
about öffnet bei click auf den app namen oben links
rechts click menu muss überall unterdrückt werden wo wir noch keins haben. das browser context menu darf nirgends angezeigt werden