From a05396e7341733b1ed61127fb55d097354b594bc Mon Sep 17 00:00:00 2001
From: th
Date: Mon, 20 Apr 2026 01:29:27 +0200
Subject: [PATCH] 0.2.0: polygon tool, mask editor preview, collapsible
properties
- Polygon asset type with per-vertex animation: new sidebar button inserts a
hexagon; vertex handles in the editor write per-keyframe `points` arrays
that interpolate between keyframes (requires matching point counts).
- Canvas mask preview: toggle in the zoom toolbar switches the stage
between the editable ghost view and a rendered view that applies the
actual masking via the shared canvas 2D path.
- Properties panel is grouped into collapsible sections (Transform, Text,
Appearance, Shadow, Mask) with per-section open/closed state persisted
to localStorage.
- Sidebar shape buttons are icon-only to reclaim width for the Polygon
entry.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
package.json | 2 +-
server/render.html | 38 ++-
src/components/AssetSidebar.vue | 39 ++-
src/components/CanvasStage.vue | 159 ++++++++++++
src/components/PropertiesPanel.vue | 381 +++++++++++++++++------------
src/i18n/messages.ts | 8 +
src/lib/exportPlayer.ts | 38 ++-
src/lib/interpolate.ts | 10 +
src/lib/render.ts | 37 ++-
src/stores/scene.ts | 51 +++-
src/types/scene.ts | 19 +-
src/version.ts | 2 +-
12 files changed, 611 insertions(+), 173 deletions(-)
diff --git a/package.json b/package.json
index 15c6884..51902c4 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "animatr",
"private": true,
- "version": "0.1.0",
+ "version": "0.2.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/server/render.html b/server/render.html
index 16e773f..5ef71b9 100644
--- a/server/render.html
+++ b/server/render.html
@@ -49,6 +49,14 @@
if(a.fill && b.fill) o.fill=lerpColor(a.fill,b.fill,t);
else if(b.fill) o.fill=b.fill;
else if(a.fill) o.fill=a.fill;
+ if(a.points && b.points && a.points.length===b.points.length){
+ var lp=[];
+ for(var pi=0;pi=3){
+ ctxm.beginPath();
+ ctxm.moveTo(mpPts[0].x-mpX,mpPts[0].y-mpY);
+ for(var mpi=1;mpi=3){
+ ctx.fillStyle=fill;
+ if(pBlur>0) ctx.filter='blur('+pBlur+'px)';
+ ctx.beginPath();
+ ctx.moveTo(pPts[0].x-pOffX,pPts[0].y-pOffY);
+ for(var pI=1;pI = {
circle: PhCircle,
image: PhImage,
text: PhTextT,
+ polygon: PhPolygon,
}
const imageAssets = computed(() =>
@@ -218,17 +237,17 @@ function onAssetDragStart(e: DragEvent, asset: Extract
-
@@ -375,6 +394,8 @@ h4 {
.shape-btn {
flex: 1;
justify-content: center;
+ align-items: center;
+ padding: 6px 0;
font-size: 12px;
}
diff --git a/src/components/CanvasStage.vue b/src/components/CanvasStage.vue
index e7f8e87..50f7353 100644
--- a/src/components/CanvasStage.vue
+++ b/src/components/CanvasStage.vue
@@ -284,6 +284,8 @@ type KonvaCtx = {
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
+ moveTo: (x: number, y: number) => void
+ lineTo: (x: number, y: number) => void
fillStrokeShape: (shape: unknown) => void
save: () => void
restore: () => void
@@ -360,6 +362,43 @@ function textConfig(layer: Layer, asset: Extract, props
}
}
+function polygonConfig(
+ layer: Layer,
+ asset: Extract,
+ props: AnimatableProps,
+) {
+ const fill = props.fill ?? asset.fill
+ const blur = props.blur ?? asset.blur ?? 0
+ const offX = layer.offsetX ?? 0
+ const offY = layer.offsetY ?? 0
+ const pts = props.points ?? asset.points
+ const flat: number[] = []
+ for (const p of pts) {
+ flat.push(p.x - offX, p.y - offY)
+ }
+ const cfg: Record = {
+ ...baseNodeConfig(layer, props),
+ points: flat,
+ closed: true,
+ fill,
+ }
+ if (blur > 0) {
+ cfg.sceneFunc = (ctx: KonvaCtx, shape: KonvaShape) => {
+ ctx.save()
+ ctx.setAttr('filter', `blur(${blur}px)`)
+ ctx.beginPath()
+ if (pts.length > 0) {
+ ctx.moveTo(pts[0].x - offX, pts[0].y - offY)
+ for (let j = 1; j < pts.length; j++) ctx.lineTo(pts[j].x - offX, pts[j].y - offY)
+ }
+ ctx.closePath()
+ ctx.fillStrokeShape(shape)
+ ctx.restore()
+ }
+ }
+ return cfg
+}
+
function imageConfig(
layer: Layer,
asset: Extract,
@@ -436,6 +475,23 @@ function clipTextConfig(item: RenderedClipLayer) {
}
}
+function clipPolygonConfig(item: RenderedClipLayer) {
+ const asset = item.asset as Extract
+ const offX = item.layer.offsetX ?? 0
+ const offY = item.layer.offsetY ?? 0
+ const pts = item.props.points ?? asset.points
+ const flat: number[] = []
+ for (const p of pts) {
+ flat.push(p.x - offX, p.y - offY)
+ }
+ return {
+ ...clipBaseConfig(item.instanceId, item.layer, item.props),
+ points: flat,
+ closed: true,
+ fill: item.props.fill ?? asset.fill,
+ }
+}
+
function clipImageConfig(item: RenderedClipLayer) {
const asset = item.asset as Extract
const img = imageCache.value[asset.id]
@@ -515,6 +571,89 @@ const pivotDotConfig = computed(() => ({
listening: false,
}))
+interface VertexHandle {
+ layerId: string
+ index: number
+ x: number
+ y: number
+}
+
+const polygonVertexHandles = computed(() => {
+ const id = store.selectedLayerId
+ if (!id) return []
+ const layer = store.activeLayers.find((l) => l.id === id)
+ if (!layer || layer.locked) return []
+ const asset = assetMap.value.get(layer.assetId)
+ if (!asset || asset.kind !== 'polygon') return []
+ const sampled = sampleLayer(layer, store.playhead)
+ const live = liveLayerPose.value
+ const cx = live && live.layerId === id ? live.x : sampled.x
+ const cy = live && live.layerId === id ? live.y : sampled.y
+ const rot = live && live.layerId === id ? live.rotation : sampled.rotation
+ const scX = live && live.layerId === id ? live.scaleX : sampled.scaleX
+ const scY = live && live.layerId === id ? live.scaleY : sampled.scaleY
+ const offX = layer.offsetX ?? 0
+ const offY = layer.offsetY ?? 0
+ const pts = sampled.points ?? asset.points
+ const theta = (rot * Math.PI) / 180
+ const c = Math.cos(theta)
+ const s = Math.sin(theta)
+ return pts.map((p, index) => {
+ const lx = (p.x - offX) * scX
+ const ly = (p.y - offY) * scY
+ return {
+ layerId: id,
+ index,
+ x: cx + lx * c - ly * s,
+ y: cy + lx * s + ly * c,
+ }
+ })
+})
+
+function vertexHandleConfig(h: VertexHandle) {
+ return {
+ x: h.x,
+ y: h.y,
+ radius: 5 / zoom.value,
+ stroke: '#6ea8ff',
+ strokeWidth: 1.5 / zoom.value,
+ fill: '#ffffff',
+ draggable: true,
+ name: 'vertex-handle',
+ }
+}
+
+function worldToPolygonLocal(layer: Layer, wx: number, wy: number): { x: number; y: number } {
+ const sampled = sampleLayer(layer, store.playhead)
+ const dx = wx - sampled.x
+ const dy = wy - sampled.y
+ const theta = (sampled.rotation * Math.PI) / 180
+ const c = Math.cos(theta)
+ const s = Math.sin(theta)
+ const unrotX = dx * c + dy * s
+ const unrotY = -dx * s + dy * c
+ const invSX = sampled.scaleX !== 0 ? 1 / sampled.scaleX : 1
+ const invSY = sampled.scaleY !== 0 ? 1 / sampled.scaleY : 1
+ return {
+ x: unrotX * invSX + (layer.offsetX ?? 0),
+ y: unrotY * invSY + (layer.offsetY ?? 0),
+ }
+}
+
+function onVertexDragMove(e: Konva.KonvaEventObject, index: number) {
+ const id = store.selectedLayerId
+ if (!id) return
+ const layer = store.activeLayers.find((l) => l.id === id)
+ if (!layer) return
+ const node = e.target
+ const local = worldToPolygonLocal(layer, node.x(), node.y())
+ store.movePolygonVertex(id, index, local.x, local.y)
+}
+
+function onVertexDragEnd(e: Konva.KonvaEventObject, index: number) {
+ onVertexDragMove(e, index)
+}
+
function onPivotDragEnd(e: Konva.KonvaEventObject) {
const info = pivotInfo.value
if (!info) return
@@ -615,6 +754,7 @@ function handleStageMouseDown(e: Konva.KonvaEventObject) {
}
if (target.findAncestor?.('.pivot-handle')) return
if (target.findAncestor?.('.rotation-handle')) return
+ if (target.name?.() === 'vertex-handle' || target.findAncestor?.('.vertex-handle')) return
if (target === target.getStage()) {
store.selectLayer(null)
return
@@ -985,6 +1125,14 @@ onBeforeUnmount(() => {
@transform="handleTransform"
@transformend="handleTransformEnd"
/>
+
{
v-else-if="entry.item.asset.kind === 'text'"
:config="clipTextConfig(entry.item)"
/>
+
{
>
+ ) => onVertexDragMove(e, h.index)"
+ @dragend="(e: Konva.KonvaEventObject) => onVertexDragEnd(e, h.index)"
+ />
diff --git a/src/components/PropertiesPanel.vue b/src/components/PropertiesPanel.vue
index 1d00c6c..17c5bd6 100644
--- a/src/components/PropertiesPanel.vue
+++ b/src/components/PropertiesPanel.vue
@@ -1,12 +1,52 @@
|