diff --git a/package.json b/package.json
index 6fbdecd..15c6884 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "animatr",
"private": true,
- "version": "0.0.0",
+ "version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/server/render.html b/server/render.html
index 024fa66..61013a3 100644
--- a/server/render.html
+++ b/server/render.html
@@ -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){
diff --git a/src/App.vue b/src/App.vue
index d2c17f2..f839ec2 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -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)
})
@@ -283,10 +293,10 @@ 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 {
diff --git a/src/components/AboutDialog.vue b/src/components/AboutDialog.vue
new file mode 100644
index 0000000..ee689d0
--- /dev/null
+++ b/src/components/AboutDialog.vue
@@ -0,0 +1,177 @@
+
+
+
+
+
+
+
+
+
{{ APP_NAME }}
+
{{ t('about.versionLabel') }} {{ APP_VERSION }}
+
+
+
+
{{ t('about.tagline') }}
+
+
+ {{ t('about.shortcutsHeading') }}
+
+ -
+ {{ t(s.labelKey) }}
+ {{ t(s.keysKey) }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/AssetSidebar.vue b/src/components/AssetSidebar.vue
index fcd7751..3de2c1a 100644
--- a/src/components/AssetSidebar.vue
+++ b/src/components/AssetSidebar.vue
@@ -175,11 +175,35 @@ const imageAssets = computed(() =>
store.scene.assets.filter((a): a is Extract => a.kind === 'image'),
)
-function onAssetDragStart(e: DragEvent, assetId: string) {
+function onAssetDragStart(e: DragEvent, asset: Extract) {
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)
}
@@ -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)"
>
diff --git a/src/components/CanvasStage.vue b/src/components/CanvasStage.vue
index d500e9b..4369926 100644
--- a/src/components/CanvasStage.vue
+++ b/src/components/CanvasStage.vue
@@ -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
(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(() => {
// 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(() => {
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(() => {
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(() => {
return out
})
+function shadowCfg(props: AnimatableProps): Record {
+ 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 }
+
+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, props: AnimatableProps) {
- return {
+ const fill = props.fill ?? asset.fill
+ const blur = props.blur ?? asset.blur ?? 0
+ const cfg: Record = {
...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,
props: AnimatableProps,
) {
- return {
+ const fill = props.fill ?? asset.fill
+ const blur = props.blur ?? asset.blur ?? 0
+ const cfg: Record = {
...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, 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
- return {
+ const fill = item.props.fill ?? asset.fill
+ const blur = item.props.blur ?? asset.blur ?? 0
+ const cfg: Record = {
...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
- return {
+ const fill = item.props.fill ?? asset.fill
+ const blur = item.props.blur ?? asset.blur ?? 0
+ const cfg: Record = {
...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(null)
+
const pivotInfo = computed(() => {
const id = store.selectedLayerId
if (!id) return null
@@ -351,7 +457,22 @@ const pivotInfo = computed(() => {
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) {
+ 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) {
+ 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) {
const target = e.target
const tr = transformerRef.value?.getNode()
@@ -469,11 +615,21 @@ function handleStageMouseDown(e: Konva.KonvaEventObject) {
function handleDragEnd(e: Konva.KonvaEventObject) {
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) {
@@ -487,13 +643,24 @@ function handleTransformEnd(e: Konva.KonvaEventObject) {
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) {
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(() => {
@@ -755,6 +956,7 @@ onBeforeUnmount(() => {
v-if="rotationHandleConfig"
:config="rotationHandleConfig"
@dragmove="onRotationHandleDragMove"
+ @dragend="onRotationHandleDragEnd"
>
diff --git a/src/components/PropertiesPanel.vue b/src/components/PropertiesPanel.vue
index 68d0338..863b604 100644
--- a/src/components/PropertiesPanel.vue
+++ b/src/components/PropertiesPanel.vue
@@ -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({
+ 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) {
+ 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),
+ })
+}
+
+const shadowBlur = shadowBinding('shadowBlur')
+const shadowOpacity = shadowBinding('shadowOpacity', 2)
+const shadowAngle = shadowBinding('shadowAngle')
+const shadowDistance = shadowBinding('shadowDistance')
+
+const shadowColorValue = computed({
+ 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() {
+
+