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)" /> + { > + 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 @@