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) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-20 01:29:27 +02:00
1 parent 03b1147a93
commit a05396e734
12 files changed
+611 -173

No files matched your search

+36 -2
View File
@@ -47,6 +47,14 @@ const ENGINE_SOURCE = `(function(){
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.points&&b.points&&a.points.length===b.points.length){
var lp=[];
for(var lpi=0;lpi<a.points.length;lpi++){
lp.push({x:lerp(a.points[lpi].x,b.points[lpi].x,t),y:lerp(a.points[lpi].y,b.points[lpi].y,t)});
}
out.points=lp;
} else if(b.points) out.points=b.points;
else if(a.points) out.points=a.points;
return out;
}
function sample(layer,time){
@@ -143,7 +151,7 @@ const ENGINE_SOURCE = `(function(){
function drawMaskShape(ctxm,layer,time){
var asset=assetMap[layer.assetId];
if(!asset) return;
if(asset.kind!=='rect' && asset.kind!=='circle' && asset.kind!=='text') return;
if(asset.kind!=='rect' && asset.kind!=='circle' && asset.kind!=='text' && asset.kind!=='polygon') return;
var p=sample(layer,time);
ctxm.save();
ctxm.translate(p.x,p.y);
@@ -160,12 +168,23 @@ const ENGINE_SOURCE = `(function(){
ctxm.beginPath();
ctxm.arc(-mcOff,-mcOffY,asset.radius,0,Math.PI*2);
ctxm.fill();
} else {
} else if(asset.kind==='text'){
var mtOff=layer.offsetX!=null?layer.offsetX:0;
var mtOffY=layer.offsetY!=null?layer.offsetY:0;
ctxm.font=asset.fontSize+'px sans-serif';
ctxm.textBaseline='top';
ctxm.fillText(asset.text,-mtOff,-mtOffY);
} else {
var mpOffX=layer.offsetX!=null?layer.offsetX:0;
var mpOffY=layer.offsetY!=null?layer.offsetY:0;
var mpPts=p.points||asset.points||[];
if(mpPts.length>=3){
ctxm.beginPath();
ctxm.moveTo(mpPts[0].x-mpOffX,mpPts[0].y-mpOffY);
for(var mpI=1;mpI<mpPts.length;mpI++)ctxm.lineTo(mpPts[mpI].x-mpOffX,mpPts[mpI].y-mpOffY);
ctxm.closePath();
ctxm.fill();
}
}
ctxm.restore();
}
@@ -217,6 +236,21 @@ const ENGINE_SOURCE = `(function(){
ctx.font=asset.fontSize+'px sans-serif';
ctx.textBaseline='top';
ctx.fillText(asset.text,-tOffX,-tOffY);
}else if(asset.kind==='polygon'){
var pOffX=layer.offsetX!=null?layer.offsetX:0;
var pOffY=layer.offsetY!=null?layer.offsetY:0;
var pBlur=(p.blur!=null?p.blur:(asset.blur||0));
var pPts=p.points||asset.points||[];
if(pPts.length>=3){
ctx.fillStyle=p.fill||asset.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<pPts.length;pI++)ctx.lineTo(pPts[pI].x-pOffX,pPts[pI].y-pOffY);
ctx.closePath();
ctx.fill();
ctx.filter='none';
}
}else if(asset.kind==='image'){
var img=imgs[asset.id];
if(img){
+10
View File
@@ -62,6 +62,16 @@ function lerpProps(a: AnimatableProps, b: AnimatableProps, t: number): Animatabl
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
if (a.points && b.points && a.points.length === b.points.length) {
out.points = a.points.map((pa, i) => {
const pb = b.points![i]
return { x: lerp(pa.x, pb.x, t), y: lerp(pa.y, pb.y, t) }
})
} else if (b.points) {
out.points = b.points.map((p) => ({ ...p }))
} else if (a.points) {
out.points = a.points.map((p) => ({ ...p }))
}
return out
}
+35 -2
View File
@@ -130,6 +130,22 @@ function drawLayers(
ctx.font = `${asset.fontSize}px sans-serif`
ctx.textBaseline = 'top'
ctx.fillText(asset.text, -offX, -offY)
} else if (asset.kind === 'polygon') {
const offX = layer.offsetX ?? 0
const offY = layer.offsetY ?? 0
const fill = p.fill ?? asset.fill
const blur = p.blur ?? asset.blur ?? 0
const pts = p.points ?? asset.points
if (pts.length >= 3) {
ctx.fillStyle = fill
if (blur > 0) ctx.filter = `blur(${blur}px)`
ctx.beginPath()
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.fill()
ctx.filter = 'none'
}
} else if (asset.kind === 'image') {
const img = images[asset.id]
if (img) {
@@ -152,7 +168,13 @@ function drawMaskShape(
) {
const asset = assetMap.get(layer.assetId)
if (!asset) return
if (asset.kind !== 'rect' && asset.kind !== 'circle' && asset.kind !== 'text') return
if (
asset.kind !== 'rect' &&
asset.kind !== 'circle' &&
asset.kind !== 'text' &&
asset.kind !== 'polygon'
)
return
const p = sampleLayer(layer, time)
ctx.save()
ctx.translate(p.x, p.y)
@@ -169,12 +191,23 @@ function drawMaskShape(
ctx.beginPath()
ctx.arc(-offX, -offY, asset.radius, 0, Math.PI * 2)
ctx.fill()
} else {
} else if (asset.kind === 'text') {
const offX = layer.offsetX ?? 0
const offY = layer.offsetY ?? 0
ctx.font = `${asset.fontSize}px sans-serif`
ctx.textBaseline = 'top'
ctx.fillText(asset.text, -offX, -offY)
} else {
const offX = layer.offsetX ?? 0
const offY = layer.offsetY ?? 0
const pts = p.points ?? asset.points
if (pts.length >= 3) {
ctx.beginPath()
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.fill()
}
}
ctx.restore()
}