0.3.0: freeform drawing, stroke properties, polygon edit gestures
- Freeform drawing tool: new sidebar button enters a click-to-draw mode that appends points, shows a live ghost preview line, and exits via Enter or double-click (near the start point closes the path). Works end-to-end through all four render pipelines. - Polygon authoring gestures: double-click on a path inserts a vertex on the nearest edge; double-click on an open polygon's endpoint resumes the drawing session from that end, reversing point order when needed. - Stroke + closed-path properties: all shape types (rect, circle, text, polygon) expose stroke color, stroke width, and (for polygons) a closed toggle. Fill and stroke each support a "transparent" switch so shapes can render as outline-only or fill-only. - Mask preview no longer inherits the user's stroke — the dashed ghost keeps its own outline regardless of stroke-width changes. - Playhead no longer dims on inactive timeline tracks; only keyframes, segments and clip blocks fade. - Properties panel point-count control for polygons, plus per-keyframe points animation that correctly interpolates once a baseline exists. - Pivot drag hides the rotation handle until release. - Transformer attach retries on a short timer to avoid races where the Konva node hasn't mounted yet when selection changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
a05396e734
commit
276d621d20
13 files changed
+935
-55
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "animatr",
|
"name": "animatr",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+33
-10
@@ -57,6 +57,12 @@
|
|||||||
o.points=lp;
|
o.points=lp;
|
||||||
} else if(b.points) o.points=b.points;
|
} else if(b.points) o.points=b.points;
|
||||||
else if(a.points) o.points=a.points;
|
else if(a.points) o.points=a.points;
|
||||||
|
if(a.stroke && b.stroke) o.stroke=lerpColor(a.stroke,b.stroke,t);
|
||||||
|
else if(b.stroke) o.stroke=b.stroke;
|
||||||
|
else if(a.stroke) o.stroke=a.stroke;
|
||||||
|
if(a.strokeWidth!=null && b.strokeWidth!=null) o.strokeWidth=lerp(a.strokeWidth,b.strokeWidth,t);
|
||||||
|
else if(b.strokeWidth!=null) o.strokeWidth=b.strokeWidth;
|
||||||
|
else if(a.strokeWidth!=null) o.strokeWidth=a.strokeWidth;
|
||||||
return o;
|
return o;
|
||||||
}
|
}
|
||||||
function sample(layer,time){
|
function sample(layer,time){
|
||||||
@@ -122,7 +128,8 @@
|
|||||||
var mpX=layer.offsetX!=null?layer.offsetX:0;
|
var mpX=layer.offsetX!=null?layer.offsetX:0;
|
||||||
var mpY=layer.offsetY!=null?layer.offsetY:0;
|
var mpY=layer.offsetY!=null?layer.offsetY:0;
|
||||||
var mpPts=p.points||asset.points||[];
|
var mpPts=p.points||asset.points||[];
|
||||||
if(mpPts.length>=3){
|
var mpClosed=asset.closed!==false;
|
||||||
|
if(mpClosed && mpPts.length>=3){
|
||||||
ctxm.beginPath();
|
ctxm.beginPath();
|
||||||
ctxm.moveTo(mpPts[0].x-mpX,mpPts[0].y-mpY);
|
ctxm.moveTo(mpPts[0].x-mpX,mpPts[0].y-mpY);
|
||||||
for(var mpi=1;mpi<mpPts.length;mpi++) ctxm.lineTo(mpPts[mpi].x-mpX,mpPts[mpi].y-mpY);
|
for(var mpi=1;mpi<mpPts.length;mpi++) ctxm.lineTo(mpPts[mpi].x-mpX,mpPts[mpi].y-mpY);
|
||||||
@@ -153,42 +160,58 @@
|
|||||||
ctx.shadowOffsetY=Math.sin(sAn)*sDi;
|
ctx.shadowOffsetY=Math.sin(sAn)*sDi;
|
||||||
}
|
}
|
||||||
var fill = p.fill || asset.fill;
|
var fill = p.fill || asset.fill;
|
||||||
|
var lRawStroke = p.stroke || asset.stroke;
|
||||||
|
var lSW = (p.strokeWidth!=null ? p.strokeWidth : (asset.strokeWidth||0));
|
||||||
|
var lStroke = lSW>0 ? (lRawStroke||'#000000') : null;
|
||||||
|
var lHasStroke = !!lStroke && lStroke!=='transparent' && lSW>0;
|
||||||
if(asset.kind==='rect'){
|
if(asset.kind==='rect'){
|
||||||
var rOffX=layer.offsetX!=null?layer.offsetX:asset.width/2;
|
var rOffX=layer.offsetX!=null?layer.offsetX:asset.width/2;
|
||||||
var rOffY=layer.offsetY!=null?layer.offsetY:asset.height/2;
|
var rOffY=layer.offsetY!=null?layer.offsetY:asset.height/2;
|
||||||
var rBlur=(p.blur!=null?p.blur:(asset.blur||0));
|
var rBlur=(p.blur!=null?p.blur:(asset.blur||0));
|
||||||
ctx.fillStyle=fill;
|
|
||||||
if(rBlur>0) ctx.filter='blur('+rBlur+'px)';
|
if(rBlur>0) ctx.filter='blur('+rBlur+'px)';
|
||||||
ctx.fillRect(-rOffX,-rOffY,asset.width,asset.height);
|
if(fill && fill!=='transparent'){ ctx.fillStyle=fill; ctx.fillRect(-rOffX,-rOffY,asset.width,asset.height); }
|
||||||
|
if(lHasStroke){ ctx.strokeStyle=lStroke; ctx.lineWidth=lSW; ctx.strokeRect(-rOffX,-rOffY,asset.width,asset.height); }
|
||||||
ctx.filter='none';
|
ctx.filter='none';
|
||||||
} else if(asset.kind==='circle'){
|
} else if(asset.kind==='circle'){
|
||||||
var cOffX=layer.offsetX!=null?layer.offsetX:0;
|
var cOffX=layer.offsetX!=null?layer.offsetX:0;
|
||||||
var cOffY=layer.offsetY!=null?layer.offsetY:0;
|
var cOffY=layer.offsetY!=null?layer.offsetY:0;
|
||||||
var cBlur=(p.blur!=null?p.blur:(asset.blur||0));
|
var cBlur=(p.blur!=null?p.blur:(asset.blur||0));
|
||||||
ctx.fillStyle=fill;
|
|
||||||
if(cBlur>0) ctx.filter='blur('+cBlur+'px)';
|
if(cBlur>0) ctx.filter='blur('+cBlur+'px)';
|
||||||
ctx.beginPath(); ctx.arc(-cOffX,-cOffY,asset.radius,0,Math.PI*2); ctx.fill();
|
ctx.beginPath(); ctx.arc(-cOffX,-cOffY,asset.radius,0,Math.PI*2);
|
||||||
|
if(fill && fill!=='transparent'){ ctx.fillStyle=fill; ctx.fill(); }
|
||||||
|
if(lHasStroke){ ctx.strokeStyle=lStroke; ctx.lineWidth=lSW; ctx.stroke(); }
|
||||||
ctx.filter='none';
|
ctx.filter='none';
|
||||||
} else if(asset.kind==='text'){
|
} else if(asset.kind==='text'){
|
||||||
var tOffX=layer.offsetX!=null?layer.offsetX:0;
|
var tOffX=layer.offsetX!=null?layer.offsetX:0;
|
||||||
var tOffY=layer.offsetY!=null?layer.offsetY:0;
|
var tOffY=layer.offsetY!=null?layer.offsetY:0;
|
||||||
ctx.fillStyle=fill;
|
|
||||||
ctx.font=asset.fontSize+'px sans-serif';
|
ctx.font=asset.fontSize+'px sans-serif';
|
||||||
ctx.textBaseline='top';
|
ctx.textBaseline='top';
|
||||||
ctx.fillText(asset.text,-tOffX,-tOffY);
|
if(fill && fill!=='transparent'){ ctx.fillStyle=fill; ctx.fillText(asset.text,-tOffX,-tOffY); }
|
||||||
|
if(lHasStroke){ ctx.strokeStyle=lStroke; ctx.lineWidth=lSW; ctx.strokeText(asset.text,-tOffX,-tOffY); }
|
||||||
} else if(asset.kind==='polygon'){
|
} else if(asset.kind==='polygon'){
|
||||||
var pOffX=layer.offsetX!=null?layer.offsetX:0;
|
var pOffX=layer.offsetX!=null?layer.offsetX:0;
|
||||||
var pOffY=layer.offsetY!=null?layer.offsetY:0;
|
var pOffY=layer.offsetY!=null?layer.offsetY:0;
|
||||||
var pBlur=(p.blur!=null?p.blur:(asset.blur||0));
|
var pBlur=(p.blur!=null?p.blur:(asset.blur||0));
|
||||||
var pPts=p.points||asset.points||[];
|
var pPts=p.points||asset.points||[];
|
||||||
if(pPts.length>=3){
|
var pClosed=asset.closed!==false;
|
||||||
ctx.fillStyle=fill;
|
var pNeeded=pClosed?3:2;
|
||||||
|
if(pPts.length>=pNeeded){
|
||||||
if(pBlur>0) ctx.filter='blur('+pBlur+'px)';
|
if(pBlur>0) ctx.filter='blur('+pBlur+'px)';
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(pPts[0].x-pOffX,pPts[0].y-pOffY);
|
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);
|
for(var pI=1;pI<pPts.length;pI++) ctx.lineTo(pPts[pI].x-pOffX,pPts[pI].y-pOffY);
|
||||||
ctx.closePath();
|
if(pClosed) ctx.closePath();
|
||||||
|
if(pClosed && fill && fill!=='transparent'){
|
||||||
|
ctx.fillStyle=fill;
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
}
|
||||||
|
if(lHasStroke){
|
||||||
|
ctx.strokeStyle=lStroke;
|
||||||
|
ctx.lineWidth=lSW;
|
||||||
|
ctx.lineJoin='round';
|
||||||
|
ctx.lineCap='round';
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
ctx.filter='none';
|
ctx.filter='none';
|
||||||
}
|
}
|
||||||
} else if(asset.kind==='image'){
|
} else if(asset.kind==='image'){
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
PhImageSquare,
|
PhImageSquare,
|
||||||
PhListBullets,
|
PhListBullets,
|
||||||
PhPencilSimple,
|
PhPencilSimple,
|
||||||
|
PhPencilSimpleLine,
|
||||||
PhPlus,
|
PhPlus,
|
||||||
PhPolygon,
|
PhPolygon,
|
||||||
PhSquare,
|
PhSquare,
|
||||||
@@ -57,9 +58,21 @@ function addSampleCircle() {
|
|||||||
store.addLayer(asset.id)
|
store.addLayer(asset.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function addSamplePolygon() {
|
function startFreeformDrawing() {
|
||||||
|
store.startFreeformDrawing()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addSamplePolygon() {
|
||||||
|
const input = await promptDialog({
|
||||||
|
title: t('sidebar.newPolygonDialog.title'),
|
||||||
|
message: t('sidebar.newPolygonDialog.message'),
|
||||||
|
defaultValue: '6',
|
||||||
|
confirmLabel: t('sidebar.newPolygonDialog.confirm'),
|
||||||
|
})
|
||||||
|
if (input === null) return
|
||||||
|
const parsed = parseInt(input.trim(), 10)
|
||||||
|
const sides = Number.isFinite(parsed) ? Math.max(3, Math.min(24, parsed)) : 6
|
||||||
const radius = 60
|
const radius = 60
|
||||||
const sides = 6
|
|
||||||
const points: { x: number; y: number }[] = []
|
const points: { x: number; y: number }[] = []
|
||||||
for (let i = 0; i < sides; i++) {
|
for (let i = 0; i < sides; i++) {
|
||||||
const a = (i / sides) * Math.PI * 2 - Math.PI / 2
|
const a = (i / sides) * Math.PI * 2 - Math.PI / 2
|
||||||
@@ -67,7 +80,7 @@ function addSamplePolygon() {
|
|||||||
}
|
}
|
||||||
const asset = store.addAsset({
|
const asset = store.addAsset({
|
||||||
kind: 'polygon',
|
kind: 'polygon',
|
||||||
name: 'Polygon',
|
name: `Polygon ${sides}`,
|
||||||
points,
|
points,
|
||||||
fill: '#9b7ede',
|
fill: '#9b7ede',
|
||||||
})
|
})
|
||||||
@@ -249,6 +262,14 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
|
|||||||
<button class="shape-btn" :title="t('sidebar.shape.polygon')" @click="addSamplePolygon">
|
<button class="shape-btn" :title="t('sidebar.shape.polygon')" @click="addSamplePolygon">
|
||||||
<PhPolygon :size="16" />
|
<PhPolygon :size="16" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="shape-btn"
|
||||||
|
:class="{ active: store.drawingMode === 'freeform' }"
|
||||||
|
:title="store.drawingMode === 'freeform' ? t('sidebar.shape.freeformActive') : t('sidebar.shape.freeform')"
|
||||||
|
@click="startFreeformDrawing"
|
||||||
|
>
|
||||||
|
<PhPencilSimpleLine :size="16" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
@@ -399,6 +420,12 @@ h4 {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shape-btn.active {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|||||||
+333
-19
@@ -318,6 +318,17 @@ function makeCircleSceneFunc(blur: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function strokeCfg(
|
||||||
|
props: AnimatableProps,
|
||||||
|
asset: { stroke?: string; strokeWidth?: number },
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const strokeWidth = props.strokeWidth ?? asset.strokeWidth ?? 0
|
||||||
|
if (strokeWidth <= 0) return { strokeWidth: 0, stroke: undefined }
|
||||||
|
const stroke = (props.stroke ?? asset.stroke) || '#000000'
|
||||||
|
if (stroke === 'transparent') return { strokeWidth: 0, stroke: undefined }
|
||||||
|
return { stroke, strokeWidth, strokeScaleEnabled: false }
|
||||||
|
}
|
||||||
|
|
||||||
function rectConfig(layer: Layer, asset: Extract<Asset, { kind: 'rect' }>, props: AnimatableProps) {
|
function rectConfig(layer: Layer, asset: Extract<Asset, { kind: 'rect' }>, props: AnimatableProps) {
|
||||||
const fill = props.fill ?? asset.fill
|
const fill = props.fill ?? asset.fill
|
||||||
const blur = props.blur ?? asset.blur ?? 0
|
const blur = props.blur ?? asset.blur ?? 0
|
||||||
@@ -327,8 +338,9 @@ function rectConfig(layer: Layer, asset: Extract<Asset, { kind: 'rect' }>, props
|
|||||||
height: asset.height,
|
height: asset.height,
|
||||||
offsetX: layer.offsetX ?? asset.width / 2,
|
offsetX: layer.offsetX ?? asset.width / 2,
|
||||||
offsetY: layer.offsetY ?? asset.height / 2,
|
offsetY: layer.offsetY ?? asset.height / 2,
|
||||||
fill,
|
...(layer.isMask ? {} : strokeCfg(props, asset)),
|
||||||
}
|
}
|
||||||
|
if (fill && fill !== 'transparent') cfg.fill = fill
|
||||||
if (blur > 0) cfg.sceneFunc = makeRectSceneFunc(blur)
|
if (blur > 0) cfg.sceneFunc = makeRectSceneFunc(blur)
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
@@ -345,21 +357,25 @@ function circleConfig(
|
|||||||
radius: asset.radius,
|
radius: asset.radius,
|
||||||
offsetX: layer.offsetX ?? 0,
|
offsetX: layer.offsetX ?? 0,
|
||||||
offsetY: layer.offsetY ?? 0,
|
offsetY: layer.offsetY ?? 0,
|
||||||
fill,
|
...(layer.isMask ? {} : strokeCfg(props, asset)),
|
||||||
}
|
}
|
||||||
|
if (fill && fill !== 'transparent') cfg.fill = fill
|
||||||
if (blur > 0) cfg.sceneFunc = makeCircleSceneFunc(blur)
|
if (blur > 0) cfg.sceneFunc = makeCircleSceneFunc(blur)
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
function textConfig(layer: Layer, asset: Extract<Asset, { kind: 'text' }>, props: AnimatableProps) {
|
function textConfig(layer: Layer, asset: Extract<Asset, { kind: 'text' }>, props: AnimatableProps) {
|
||||||
return {
|
const fill = props.fill ?? asset.fill
|
||||||
|
const cfg: Record<string, unknown> = {
|
||||||
...baseNodeConfig(layer, props),
|
...baseNodeConfig(layer, props),
|
||||||
text: asset.text,
|
text: asset.text,
|
||||||
fontSize: asset.fontSize,
|
fontSize: asset.fontSize,
|
||||||
offsetX: layer.offsetX ?? 0,
|
offsetX: layer.offsetX ?? 0,
|
||||||
offsetY: layer.offsetY ?? 0,
|
offsetY: layer.offsetY ?? 0,
|
||||||
fill: props.fill ?? asset.fill,
|
...(layer.isMask ? {} : strokeCfg(props, asset)),
|
||||||
}
|
}
|
||||||
|
if (fill && fill !== 'transparent') cfg.fill = fill
|
||||||
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
function polygonConfig(
|
function polygonConfig(
|
||||||
@@ -368,10 +384,14 @@ function polygonConfig(
|
|||||||
props: AnimatableProps,
|
props: AnimatableProps,
|
||||||
) {
|
) {
|
||||||
const fill = props.fill ?? asset.fill
|
const fill = props.fill ?? asset.fill
|
||||||
|
const rawStroke = props.stroke ?? asset.stroke
|
||||||
|
const strokeWidth = props.strokeWidth ?? asset.strokeWidth ?? 0
|
||||||
|
const stroke = strokeWidth > 0 ? (rawStroke || '#000000') : undefined
|
||||||
const blur = props.blur ?? asset.blur ?? 0
|
const blur = props.blur ?? asset.blur ?? 0
|
||||||
const offX = layer.offsetX ?? 0
|
const offX = layer.offsetX ?? 0
|
||||||
const offY = layer.offsetY ?? 0
|
const offY = layer.offsetY ?? 0
|
||||||
const pts = props.points ?? asset.points
|
const pts = props.points ?? asset.points
|
||||||
|
const closed = asset.closed !== false
|
||||||
const flat: number[] = []
|
const flat: number[] = []
|
||||||
for (const p of pts) {
|
for (const p of pts) {
|
||||||
flat.push(p.x - offX, p.y - offY)
|
flat.push(p.x - offX, p.y - offY)
|
||||||
@@ -379,9 +399,21 @@ function polygonConfig(
|
|||||||
const cfg: Record<string, unknown> = {
|
const cfg: Record<string, unknown> = {
|
||||||
...baseNodeConfig(layer, props),
|
...baseNodeConfig(layer, props),
|
||||||
points: flat,
|
points: flat,
|
||||||
closed: true,
|
closed,
|
||||||
fill,
|
lineJoin: 'round',
|
||||||
|
lineCap: 'round',
|
||||||
}
|
}
|
||||||
|
if (closed && fill && fill !== 'transparent') cfg.fill = fill
|
||||||
|
if (!layer.isMask && stroke && strokeWidth > 0 && stroke !== 'transparent') {
|
||||||
|
cfg.stroke = stroke
|
||||||
|
cfg.strokeWidth = strokeWidth
|
||||||
|
cfg.strokeScaleEnabled = false
|
||||||
|
} else {
|
||||||
|
cfg.stroke = undefined
|
||||||
|
cfg.strokeWidth = 0
|
||||||
|
}
|
||||||
|
// Widen hit area on edges so double-click to insert a vertex is easy
|
||||||
|
cfg.hitStrokeWidth = Math.max(strokeWidth, 14 / zoom.value)
|
||||||
if (blur > 0) {
|
if (blur > 0) {
|
||||||
cfg.sceneFunc = (ctx: KonvaCtx, shape: KonvaShape) => {
|
cfg.sceneFunc = (ctx: KonvaCtx, shape: KonvaShape) => {
|
||||||
ctx.save()
|
ctx.save()
|
||||||
@@ -391,7 +423,7 @@ function polygonConfig(
|
|||||||
ctx.moveTo(pts[0].x - offX, pts[0].y - offY)
|
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)
|
for (let j = 1; j < pts.length; j++) ctx.lineTo(pts[j].x - offX, pts[j].y - offY)
|
||||||
}
|
}
|
||||||
ctx.closePath()
|
if (closed) ctx.closePath()
|
||||||
ctx.fillStrokeShape(shape)
|
ctx.fillStrokeShape(shape)
|
||||||
ctx.restore()
|
ctx.restore()
|
||||||
}
|
}
|
||||||
@@ -442,8 +474,9 @@ function clipRectConfig(item: RenderedClipLayer) {
|
|||||||
height: asset.height,
|
height: asset.height,
|
||||||
offsetX: item.layer.offsetX ?? asset.width / 2,
|
offsetX: item.layer.offsetX ?? asset.width / 2,
|
||||||
offsetY: item.layer.offsetY ?? asset.height / 2,
|
offsetY: item.layer.offsetY ?? asset.height / 2,
|
||||||
fill,
|
...strokeCfg(item.props, asset),
|
||||||
}
|
}
|
||||||
|
if (fill && fill !== 'transparent') cfg.fill = fill
|
||||||
if (blur > 0) cfg.sceneFunc = makeRectSceneFunc(blur)
|
if (blur > 0) cfg.sceneFunc = makeRectSceneFunc(blur)
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
@@ -457,22 +490,26 @@ function clipCircleConfig(item: RenderedClipLayer) {
|
|||||||
radius: asset.radius,
|
radius: asset.radius,
|
||||||
offsetX: item.layer.offsetX ?? 0,
|
offsetX: item.layer.offsetX ?? 0,
|
||||||
offsetY: item.layer.offsetY ?? 0,
|
offsetY: item.layer.offsetY ?? 0,
|
||||||
fill,
|
...strokeCfg(item.props, asset),
|
||||||
}
|
}
|
||||||
|
if (fill && fill !== 'transparent') cfg.fill = fill
|
||||||
if (blur > 0) cfg.sceneFunc = makeCircleSceneFunc(blur)
|
if (blur > 0) cfg.sceneFunc = makeCircleSceneFunc(blur)
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
function clipTextConfig(item: RenderedClipLayer) {
|
function clipTextConfig(item: RenderedClipLayer) {
|
||||||
const asset = item.asset as Extract<Asset, { kind: 'text' }>
|
const asset = item.asset as Extract<Asset, { kind: 'text' }>
|
||||||
return {
|
const fill = item.props.fill ?? asset.fill
|
||||||
|
const cfg: Record<string, unknown> = {
|
||||||
...clipBaseConfig(item.instanceId, item.layer, item.props),
|
...clipBaseConfig(item.instanceId, item.layer, item.props),
|
||||||
text: asset.text,
|
text: asset.text,
|
||||||
fontSize: asset.fontSize,
|
fontSize: asset.fontSize,
|
||||||
offsetX: item.layer.offsetX ?? 0,
|
offsetX: item.layer.offsetX ?? 0,
|
||||||
offsetY: item.layer.offsetY ?? 0,
|
offsetY: item.layer.offsetY ?? 0,
|
||||||
fill: item.props.fill ?? asset.fill,
|
...strokeCfg(item.props, asset),
|
||||||
}
|
}
|
||||||
|
if (fill && fill !== 'transparent') cfg.fill = fill
|
||||||
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
function clipPolygonConfig(item: RenderedClipLayer) {
|
function clipPolygonConfig(item: RenderedClipLayer) {
|
||||||
@@ -480,16 +517,28 @@ function clipPolygonConfig(item: RenderedClipLayer) {
|
|||||||
const offX = item.layer.offsetX ?? 0
|
const offX = item.layer.offsetX ?? 0
|
||||||
const offY = item.layer.offsetY ?? 0
|
const offY = item.layer.offsetY ?? 0
|
||||||
const pts = item.props.points ?? asset.points
|
const pts = item.props.points ?? asset.points
|
||||||
|
const closed = asset.closed !== false
|
||||||
|
const stroke = item.props.stroke ?? asset.stroke
|
||||||
|
const strokeWidth = item.props.strokeWidth ?? asset.strokeWidth ?? 0
|
||||||
|
const fill = item.props.fill ?? asset.fill
|
||||||
const flat: number[] = []
|
const flat: number[] = []
|
||||||
for (const p of pts) {
|
for (const p of pts) {
|
||||||
flat.push(p.x - offX, p.y - offY)
|
flat.push(p.x - offX, p.y - offY)
|
||||||
}
|
}
|
||||||
return {
|
const cfg: Record<string, unknown> = {
|
||||||
...clipBaseConfig(item.instanceId, item.layer, item.props),
|
...clipBaseConfig(item.instanceId, item.layer, item.props),
|
||||||
points: flat,
|
points: flat,
|
||||||
closed: true,
|
closed,
|
||||||
fill: item.props.fill ?? asset.fill,
|
lineJoin: 'round',
|
||||||
|
lineCap: 'round',
|
||||||
}
|
}
|
||||||
|
if (closed && fill && fill !== 'transparent') cfg.fill = fill
|
||||||
|
if (stroke && strokeWidth > 0) {
|
||||||
|
cfg.stroke = stroke
|
||||||
|
cfg.strokeWidth = strokeWidth
|
||||||
|
cfg.strokeScaleEnabled = false
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
function clipImageConfig(item: RenderedClipLayer) {
|
function clipImageConfig(item: RenderedClipLayer) {
|
||||||
@@ -654,7 +703,15 @@ function onVertexDragEnd(e: Konva.KonvaEventObject<DragEvent>, index: number) {
|
|||||||
onVertexDragMove(e, index)
|
onVertexDragMove(e, index)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const draggingPivot = ref(false)
|
||||||
|
|
||||||
|
function onPivotDragStart() {
|
||||||
|
draggingPivot.value = true
|
||||||
|
}
|
||||||
|
|
||||||
function onPivotDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
|
function onPivotDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
|
||||||
|
draggingPivot.value = false
|
||||||
const info = pivotInfo.value
|
const info = pivotInfo.value
|
||||||
if (!info) return
|
if (!info) return
|
||||||
const node = e.target
|
const node = e.target
|
||||||
@@ -693,7 +750,13 @@ const transformerConfig = {
|
|||||||
ignoreStroke: true,
|
ignoreStroke: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachTransformer() {
|
let attachRetryTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
function attachTransformer(retriesLeft = 3) {
|
||||||
|
if (attachRetryTimer) {
|
||||||
|
clearTimeout(attachRetryTimer)
|
||||||
|
attachRetryTimer = null
|
||||||
|
}
|
||||||
const stage = stageRef.value?.getStage()
|
const stage = stageRef.value?.getStage()
|
||||||
const tr = transformerRef.value?.getNode()
|
const tr = transformerRef.value?.getNode()
|
||||||
if (!stage || !tr) return
|
if (!stage || !tr) return
|
||||||
@@ -703,18 +766,34 @@ function attachTransformer() {
|
|||||||
tr.getLayer()?.batchDraw()
|
tr.getLayer()?.batchDraw()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Polygons are edited via vertex handles — no bulk transformer
|
||||||
|
const layer = store.activeLayers.find((l) => l.id === id)
|
||||||
|
if (layer) {
|
||||||
|
const asset = assetMap.value.get(layer.assetId)
|
||||||
|
if (asset?.kind === 'polygon') {
|
||||||
|
tr.nodes([])
|
||||||
|
tr.getLayer()?.batchDraw()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
const node = stage.findOne<Konva.Node>(`#${id}`)
|
const node = stage.findOne<Konva.Node>(`#${id}`)
|
||||||
|
if (!node && retriesLeft > 0) {
|
||||||
|
// The Konva node for a freshly-rendered layer may not be attached yet.
|
||||||
|
// Retry on the next animation frame a few times before giving up.
|
||||||
|
attachRetryTimer = setTimeout(() => attachTransformer(retriesLeft - 1), 16)
|
||||||
|
return
|
||||||
|
}
|
||||||
tr.nodes(node ? [node] : [])
|
tr.nodes(node ? [node] : [])
|
||||||
tr.getLayer()?.batchDraw()
|
tr.getLayer()?.batchDraw()
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [store.selectedLayerId, stackItems.value.length, store.playhead] as const,
|
() => [store.selectedLayerId, stackItems.value, store.playhead] as const,
|
||||||
async () => {
|
async () => {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
attachTransformer()
|
attachTransformer()
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true, deep: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
function updateLivePose(node: Konva.Node, layerId: string) {
|
function updateLivePose(node: Konva.Node, layerId: string) {
|
||||||
@@ -742,7 +821,46 @@ function handleTransform(e: Konva.KonvaEventObject<Event>) {
|
|||||||
updateLivePose(node, id)
|
updateLivePose(node, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const drawingPreview = ref<{ x: number; y: number } | null>(null)
|
||||||
|
|
||||||
|
function pointerToWorld(e: Konva.KonvaEventObject<MouseEvent>) {
|
||||||
|
const stage = stageRef.value?.getStage()
|
||||||
|
if (!stage) return null
|
||||||
|
const pos = stage.getPointerPosition()
|
||||||
|
if (!pos) return null
|
||||||
|
return {
|
||||||
|
x: (pos.x - viewX.value) / zoom.value,
|
||||||
|
y: (pos.y - viewY.value) / zoom.value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
|
function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
|
||||||
|
if (store.drawingMode === 'freeform') {
|
||||||
|
const native = e.evt as MouseEvent
|
||||||
|
// Middle-click or Ctrl+click belong to the pan gesture, not drawing
|
||||||
|
if (native.button === 1 || native.ctrlKey) return
|
||||||
|
const wp = pointerToWorld(e)
|
||||||
|
if (!wp) return
|
||||||
|
const layerId = store.drawingLayerId
|
||||||
|
const layer = layerId ? store.activeLayers.find((l) => l.id === layerId) : null
|
||||||
|
const local = layer ? worldToPolygonLocal(layer, wp.x, wp.y) : wp
|
||||||
|
// Close-on-click near first point (compare in asset-local space)
|
||||||
|
const assetId = store.drawingAssetId
|
||||||
|
const asset = assetId ? store.scene.assets.find((a) => a.id === assetId) : null
|
||||||
|
if (asset && asset.kind === 'polygon' && asset.points.length >= 3) {
|
||||||
|
const sampled = layer ? sampleLayer(layer, store.playhead) : null
|
||||||
|
const scale = sampled ? Math.max(Math.abs(sampled.scaleX), Math.abs(sampled.scaleY), 0.01) : 1
|
||||||
|
const first = asset.points[0]
|
||||||
|
const threshold = 10 / zoom.value / scale
|
||||||
|
if (Math.hypot(local.x - first.x, local.y - first.y) <= threshold) {
|
||||||
|
store.setPolygonClosed(asset.id, true)
|
||||||
|
store.finishDrawing()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
store.addDrawingPoint(local.x, local.y)
|
||||||
|
return
|
||||||
|
}
|
||||||
const target = e.target
|
const target = e.target
|
||||||
const tr = transformerRef.value?.getNode()
|
const tr = transformerRef.value?.getNode()
|
||||||
if (tr) {
|
if (tr) {
|
||||||
@@ -768,6 +886,180 @@ function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
|
|||||||
store.selectLayer(match ? match.id : null)
|
store.selectLayer(match ? match.id : null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleStageMouseMove(e: Konva.KonvaEventObject<MouseEvent>) {
|
||||||
|
if (store.drawingMode !== 'freeform') {
|
||||||
|
drawingPreview.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const wp = pointerToWorld(e)
|
||||||
|
if (!wp) return
|
||||||
|
const layerId = store.drawingLayerId
|
||||||
|
const layer = layerId ? store.activeLayers.find((l) => l.id === layerId) : null
|
||||||
|
drawingPreview.value = layer ? worldToPolygonLocal(layer, wp.x, wp.y) : wp
|
||||||
|
}
|
||||||
|
|
||||||
|
function onContainerDblClick(e: MouseEvent) {
|
||||||
|
const rect = container.value?.getBoundingClientRect()
|
||||||
|
if (!rect) return
|
||||||
|
const wx = (e.clientX - rect.left - viewX.value) / zoom.value
|
||||||
|
const wy = (e.clientY - rect.top - viewY.value) / zoom.value
|
||||||
|
|
||||||
|
// While drawing: finish (optionally close if near first point)
|
||||||
|
if (store.drawingMode === 'freeform') {
|
||||||
|
const assetId = store.drawingAssetId
|
||||||
|
const asset = assetId ? store.scene.assets.find((a) => a.id === assetId) : null
|
||||||
|
if (asset && asset.kind === 'polygon' && asset.points.length >= 3) {
|
||||||
|
const first = asset.points[0]
|
||||||
|
const threshold = 12 / zoom.value
|
||||||
|
if (Math.hypot(wx - first.x, wy - first.y) <= threshold) {
|
||||||
|
store.setPolygonClosed(asset.id, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
store.finishDrawing()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not drawing: polygon-specific gestures on selected polygon
|
||||||
|
const selId = store.selectedLayerId
|
||||||
|
if (!selId) return
|
||||||
|
const layer = store.activeLayers.find((l) => l.id === selId)
|
||||||
|
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 pts = sampled.points ?? asset.points
|
||||||
|
if (pts.length < 1) return
|
||||||
|
const closed = asset.closed !== false
|
||||||
|
const local = worldToPolygonLocal(layer, wx, wy)
|
||||||
|
|
||||||
|
// Near an endpoint of an open polygon → resume drawing
|
||||||
|
if (!closed) {
|
||||||
|
const first = pts[0]
|
||||||
|
const last = pts[pts.length - 1]
|
||||||
|
const threshold = 14 / zoom.value
|
||||||
|
const nearFirst = Math.hypot(local.x - first.x, local.y - first.y) <= threshold
|
||||||
|
const nearLast =
|
||||||
|
pts.length > 1 && Math.hypot(local.x - last.x, local.y - last.y) <= threshold
|
||||||
|
if (nearLast || nearFirst) {
|
||||||
|
if (nearFirst && !nearLast) store.reversePolygonPoints(asset.id)
|
||||||
|
store.startFreeformDrawing()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click on (or near) an edge → insert a new vertex there
|
||||||
|
const edges = closed ? pts.length : pts.length - 1
|
||||||
|
if (edges < 1) return
|
||||||
|
let bestIdx = 1
|
||||||
|
let bestDist = Infinity
|
||||||
|
let bestT = 0
|
||||||
|
for (let i = 0; i < edges; i++) {
|
||||||
|
const a = pts[i]
|
||||||
|
const b = pts[(i + 1) % pts.length]
|
||||||
|
const dx = b.x - a.x
|
||||||
|
const dy = b.y - a.y
|
||||||
|
const lenSq = dx * dx + dy * dy
|
||||||
|
if (lenSq === 0) continue
|
||||||
|
let t = ((local.x - a.x) * dx + (local.y - a.y) * dy) / lenSq
|
||||||
|
t = Math.max(0, Math.min(1, t))
|
||||||
|
const px = a.x + t * dx
|
||||||
|
const py = a.y + t * dy
|
||||||
|
const d = Math.hypot(local.x - px, local.y - py)
|
||||||
|
if (d < bestDist) {
|
||||||
|
bestDist = d
|
||||||
|
bestIdx = i + 1
|
||||||
|
bestT = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const edgeThreshold = 14 / zoom.value
|
||||||
|
if (bestDist <= edgeThreshold) {
|
||||||
|
store.insertPolygonPointOnEdge(asset.id, bestIdx, bestT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrawingKey(e: KeyboardEvent) {
|
||||||
|
if (store.drawingMode !== 'freeform') return
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
store.cancelDrawing()
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
store.finishDrawing()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformLocalToWorld(
|
||||||
|
layer: Layer,
|
||||||
|
sampled: AnimatableProps,
|
||||||
|
local: { x: number; y: number },
|
||||||
|
): { x: number; y: number } {
|
||||||
|
const offX = layer.offsetX ?? 0
|
||||||
|
const offY = layer.offsetY ?? 0
|
||||||
|
const lx = (local.x - offX) * sampled.scaleX
|
||||||
|
const ly = (local.y - offY) * sampled.scaleY
|
||||||
|
const theta = (sampled.rotation * Math.PI) / 180
|
||||||
|
const c = Math.cos(theta)
|
||||||
|
const s = Math.sin(theta)
|
||||||
|
return {
|
||||||
|
x: sampled.x + lx * c - ly * s,
|
||||||
|
y: sampled.y + lx * s + ly * c,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawingPreviewConfig = computed(() => {
|
||||||
|
if (store.drawingMode !== 'freeform') return null
|
||||||
|
const layerId = store.drawingLayerId
|
||||||
|
if (!layerId) return null
|
||||||
|
const layer = store.activeLayers.find((l) => l.id === layerId)
|
||||||
|
if (!layer) return null
|
||||||
|
const asset = assetMap.value.get(layer.assetId)
|
||||||
|
if (!asset || asset.kind !== 'polygon') return null
|
||||||
|
const sampled = sampleLayer(layer, store.playhead)
|
||||||
|
const pts = sampled.points ?? asset.points
|
||||||
|
const preview = drawingPreview.value
|
||||||
|
const flat: number[] = []
|
||||||
|
for (const p of pts) {
|
||||||
|
const w = transformLocalToWorld(layer, sampled, p)
|
||||||
|
flat.push(w.x, w.y)
|
||||||
|
}
|
||||||
|
if (preview && pts.length > 0) {
|
||||||
|
const w = transformLocalToWorld(layer, sampled, preview)
|
||||||
|
flat.push(w.x, w.y)
|
||||||
|
}
|
||||||
|
if (flat.length < 4) return null
|
||||||
|
return {
|
||||||
|
points: flat,
|
||||||
|
stroke: '#6ea8ff',
|
||||||
|
strokeWidth: 1.5 / zoom.value,
|
||||||
|
dash: [6 / zoom.value, 4 / zoom.value],
|
||||||
|
strokeScaleEnabled: false,
|
||||||
|
listening: false,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const drawingDotsConfig = computed(() => {
|
||||||
|
if (store.drawingMode !== 'freeform') return []
|
||||||
|
const layerId = store.drawingLayerId
|
||||||
|
if (!layerId) return []
|
||||||
|
const layer = store.activeLayers.find((l) => l.id === layerId)
|
||||||
|
if (!layer) return []
|
||||||
|
const asset = assetMap.value.get(layer.assetId)
|
||||||
|
if (!asset || asset.kind !== 'polygon') return []
|
||||||
|
const sampled = sampleLayer(layer, store.playhead)
|
||||||
|
const pts = sampled.points ?? asset.points
|
||||||
|
return pts.map((p, i) => {
|
||||||
|
const w = transformLocalToWorld(layer, sampled, p)
|
||||||
|
return {
|
||||||
|
key: `dp-${i}`,
|
||||||
|
x: w.x,
|
||||||
|
y: w.y,
|
||||||
|
radius: 3 / zoom.value,
|
||||||
|
fill: '#6ea8ff',
|
||||||
|
listening: false,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
function handleDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
|
function handleDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
|
||||||
const node = e.target
|
const node = e.target
|
||||||
const id = node.id()
|
const id = node.id()
|
||||||
@@ -809,6 +1101,7 @@ const rotationHandlePos = ref<{ x: number; y: number } | null>(null)
|
|||||||
const rotationHandleConfig = computed(() => {
|
const rotationHandleConfig = computed(() => {
|
||||||
const info = pivotInfo.value
|
const info = pivotInfo.value
|
||||||
if (!info) return null
|
if (!info) return null
|
||||||
|
if (draggingPivot.value) return null
|
||||||
if (rotationHandlePos.value) {
|
if (rotationHandlePos.value) {
|
||||||
return {
|
return {
|
||||||
x: rotationHandlePos.value.x,
|
x: rotationHandlePos.value.x,
|
||||||
@@ -830,6 +1123,7 @@ const rotationHandleConfig = computed(() => {
|
|||||||
const rotationArmConfig = computed(() => {
|
const rotationArmConfig = computed(() => {
|
||||||
const info = pivotInfo.value
|
const info = pivotInfo.value
|
||||||
if (!info) return null
|
if (!info) return null
|
||||||
|
if (draggingPivot.value) return null
|
||||||
let endX: number
|
let endX: number
|
||||||
let endY: number
|
let endY: number
|
||||||
if (rotationHandlePos.value) {
|
if (rotationHandlePos.value) {
|
||||||
@@ -1069,6 +1363,8 @@ onMounted(() => {
|
|||||||
container.value.addEventListener('pointercancel', onContainerPointerUp)
|
container.value.addEventListener('pointercancel', onContainerPointerUp)
|
||||||
container.value.addEventListener('dragover', onContainerDragOver)
|
container.value.addEventListener('dragover', onContainerDragOver)
|
||||||
container.value.addEventListener('drop', onContainerDrop)
|
container.value.addEventListener('drop', onContainerDrop)
|
||||||
|
container.value.addEventListener('dblclick', onContainerDblClick)
|
||||||
|
window.addEventListener('keydown', handleDrawingKey)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
@@ -1082,12 +1378,19 @@ onBeforeUnmount(() => {
|
|||||||
container.value?.removeEventListener('pointercancel', onContainerPointerUp)
|
container.value?.removeEventListener('pointercancel', onContainerPointerUp)
|
||||||
container.value?.removeEventListener('dragover', onContainerDragOver)
|
container.value?.removeEventListener('dragover', onContainerDragOver)
|
||||||
container.value?.removeEventListener('drop', onContainerDrop)
|
container.value?.removeEventListener('drop', onContainerDrop)
|
||||||
|
container.value?.removeEventListener('dblclick', onContainerDblClick)
|
||||||
|
window.removeEventListener('keydown', handleDrawingKey)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="container" class="stage-container">
|
<div ref="container" class="stage-container" :class="{ drawing: store.drawingMode === 'freeform' }">
|
||||||
<v-stage ref="stageRef" :config="stageConfig" @mousedown="handleStageMouseDown">
|
<v-stage
|
||||||
|
ref="stageRef"
|
||||||
|
:config="stageConfig"
|
||||||
|
@mousedown="handleStageMouseDown"
|
||||||
|
@mousemove="handleStageMouseMove"
|
||||||
|
>
|
||||||
<v-layer>
|
<v-layer>
|
||||||
<v-group :config="viewConfig">
|
<v-group :config="viewConfig">
|
||||||
<v-group :config="clipGroupConfig">
|
<v-group :config="clipGroupConfig">
|
||||||
@@ -1171,6 +1474,7 @@ onBeforeUnmount(() => {
|
|||||||
<v-group
|
<v-group
|
||||||
v-if="pivotHandleConfig"
|
v-if="pivotHandleConfig"
|
||||||
:config="pivotHandleConfig"
|
:config="pivotHandleConfig"
|
||||||
|
@dragstart="onPivotDragStart"
|
||||||
@dragend="onPivotDragEnd"
|
@dragend="onPivotDragEnd"
|
||||||
>
|
>
|
||||||
<v-circle :config="pivotRingConfig" />
|
<v-circle :config="pivotRingConfig" />
|
||||||
@@ -1191,6 +1495,12 @@ onBeforeUnmount(() => {
|
|||||||
@dragmove="(e: Konva.KonvaEventObject<DragEvent>) => onVertexDragMove(e, h.index)"
|
@dragmove="(e: Konva.KonvaEventObject<DragEvent>) => onVertexDragMove(e, h.index)"
|
||||||
@dragend="(e: Konva.KonvaEventObject<DragEvent>) => onVertexDragEnd(e, h.index)"
|
@dragend="(e: Konva.KonvaEventObject<DragEvent>) => onVertexDragEnd(e, h.index)"
|
||||||
/>
|
/>
|
||||||
|
<v-line v-if="drawingPreviewConfig" :config="drawingPreviewConfig" />
|
||||||
|
<v-circle
|
||||||
|
v-for="d in drawingDotsConfig"
|
||||||
|
:key="d.key"
|
||||||
|
:config="d"
|
||||||
|
/>
|
||||||
</v-group>
|
</v-group>
|
||||||
<v-transformer ref="transformerRef" :config="transformerConfig" />
|
<v-transformer ref="transformerRef" :config="transformerConfig" />
|
||||||
</v-layer>
|
</v-layer>
|
||||||
@@ -1244,6 +1554,10 @@ onBeforeUnmount(() => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stage-container.drawing {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
.mask-preview-canvas {
|
.mask-preview-canvas {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import { t } from '@/i18n'
|
|||||||
|
|
||||||
const SECTION_STORAGE_KEY = 'animatr:propSections'
|
const SECTION_STORAGE_KEY = 'animatr:propSections'
|
||||||
|
|
||||||
type SectionKey = 'transform' | 'text' | 'appearance' | 'shadow' | 'mask'
|
type SectionKey = 'transform' | 'text' | 'polygon' | 'appearance' | 'shadow' | 'mask'
|
||||||
|
|
||||||
const defaultOpen: Record<SectionKey, boolean> = {
|
const defaultOpen: Record<SectionKey, boolean> = {
|
||||||
transform: true,
|
transform: true,
|
||||||
text: true,
|
text: true,
|
||||||
|
polygon: true,
|
||||||
appearance: true,
|
appearance: true,
|
||||||
shadow: false,
|
shadow: false,
|
||||||
mask: false,
|
mask: false,
|
||||||
@@ -74,6 +75,68 @@ const textAsset = computed(() => {
|
|||||||
return a && a.kind === 'text' ? a : null
|
return a && a.kind === 'text' ? a : null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const polygonAsset = computed(() => {
|
||||||
|
const a = selectedAsset.value
|
||||||
|
return a && a.kind === 'polygon' ? a : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const polygonPointCount = computed<number>({
|
||||||
|
get: () => polygonAsset.value?.points.length ?? 0,
|
||||||
|
set: (v: number) => {
|
||||||
|
const a = polygonAsset.value
|
||||||
|
if (!a) return
|
||||||
|
store.setPolygonPointCount(a.id, v)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const polygonClosed = computed<boolean>({
|
||||||
|
get: () => polygonAsset.value?.closed !== false,
|
||||||
|
set: (v: boolean) => {
|
||||||
|
const a = polygonAsset.value
|
||||||
|
if (!a) return
|
||||||
|
store.setPolygonClosed(a.id, v)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const canStroke = computed(() => {
|
||||||
|
const k = selectedAsset.value?.kind
|
||||||
|
return k === 'polygon' || k === 'rect' || k === 'circle' || k === 'text'
|
||||||
|
})
|
||||||
|
|
||||||
|
const strokeColor = computed<string>({
|
||||||
|
get: () => {
|
||||||
|
if (sampled.value?.stroke) return sampled.value.stroke
|
||||||
|
const a = selectedAsset.value
|
||||||
|
if (a && 'stroke' in a && a.stroke) return a.stroke
|
||||||
|
return '#000000'
|
||||||
|
},
|
||||||
|
set: (v: string) => {
|
||||||
|
if (!selected.value) return
|
||||||
|
store.upsertKeyframe(selected.value.id, store.playhead, { stroke: v })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const strokeWidth = computed<number>({
|
||||||
|
get: () => {
|
||||||
|
if (sampled.value?.strokeWidth !== undefined) return sampled.value.strokeWidth
|
||||||
|
const a = selectedAsset.value
|
||||||
|
if (a && 'strokeWidth' in a && a.strokeWidth !== undefined) return a.strokeWidth
|
||||||
|
return 0
|
||||||
|
},
|
||||||
|
set: (v: number) => {
|
||||||
|
if (!selected.value) return
|
||||||
|
const patch: Partial<AnimatableProps> = { strokeWidth: v }
|
||||||
|
// Always co-write a stroke color alongside a non-zero width so the
|
||||||
|
// outline stays visible regardless of prior width=0 states.
|
||||||
|
if (v > 0) {
|
||||||
|
const a = selectedAsset.value
|
||||||
|
const assetStroke = a && 'stroke' in a ? a.stroke : undefined
|
||||||
|
patch.stroke = sampled.value?.stroke ?? assetStroke ?? '#000000'
|
||||||
|
}
|
||||||
|
store.upsertKeyframe(selected.value.id, store.playhead, patch)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const blurAsset = computed(() => {
|
const blurAsset = computed(() => {
|
||||||
const a = selectedAsset.value
|
const a = selectedAsset.value
|
||||||
return a && (a.kind === 'rect' || a.kind === 'circle' || a.kind === 'polygon') ? a : null
|
return a && (a.kind === 'rect' || a.kind === 'circle' || a.kind === 'polygon') ? a : null
|
||||||
@@ -162,9 +225,10 @@ const canFill = computed(() => {
|
|||||||
|
|
||||||
const fillColor = computed<string>({
|
const fillColor = computed<string>({
|
||||||
get: () => {
|
get: () => {
|
||||||
if (sampled.value?.fill) return sampled.value.fill
|
const samp = sampled.value?.fill
|
||||||
|
if (samp && samp !== 'transparent') return samp
|
||||||
const a = selectedAsset.value
|
const a = selectedAsset.value
|
||||||
if (a && 'fill' in a) return a.fill
|
if (a && 'fill' in a && a.fill && a.fill !== 'transparent') return a.fill
|
||||||
return '#000000'
|
return '#000000'
|
||||||
},
|
},
|
||||||
set: (v: string) => {
|
set: (v: string) => {
|
||||||
@@ -173,6 +237,42 @@ const fillColor = computed<string>({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const fillTransparent = computed<boolean>({
|
||||||
|
get: () => {
|
||||||
|
const samp = sampled.value?.fill
|
||||||
|
if (samp !== undefined) return samp === 'transparent'
|
||||||
|
const a = selectedAsset.value
|
||||||
|
if (a && 'fill' in a) return a.fill === 'transparent'
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
set: (v: boolean) => {
|
||||||
|
if (!selected.value) return
|
||||||
|
if (v) {
|
||||||
|
store.upsertKeyframe(selected.value.id, store.playhead, { fill: 'transparent' })
|
||||||
|
} else {
|
||||||
|
store.upsertKeyframe(selected.value.id, store.playhead, { fill: fillColor.value })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const strokeTransparent = computed<boolean>({
|
||||||
|
get: () => {
|
||||||
|
const s = sampled.value?.stroke
|
||||||
|
if (s !== undefined) return s === 'transparent'
|
||||||
|
const a = selectedAsset.value
|
||||||
|
if (a && 'stroke' in a) return a.stroke === 'transparent'
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
set: (v: boolean) => {
|
||||||
|
if (!selected.value) return
|
||||||
|
if (v) {
|
||||||
|
store.upsertKeyframe(selected.value.id, store.playhead, { stroke: 'transparent' })
|
||||||
|
} else {
|
||||||
|
store.upsertKeyframe(selected.value.id, store.playhead, { stroke: strokeColor.value })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
type NumericKey = 'x' | 'y' | 'scaleX' | 'scaleY' | 'rotation' | 'opacity'
|
type NumericKey = 'x' | 'y' | 'scaleX' | 'scaleY' | 'rotation' | 'opacity'
|
||||||
|
|
||||||
function bind(key: NumericKey, decimals = 0) {
|
function bind(key: NumericKey, decimals = 0) {
|
||||||
@@ -245,6 +345,24 @@ function removeKeyframeHere() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section v-if="polygonAsset" class="prop-group">
|
||||||
|
<button class="group-header" @click="toggleSection('polygon')">
|
||||||
|
<component :is="sectionOpen.polygon ? PhCaretDown : PhCaretRight" :size="11" />
|
||||||
|
<span>{{ t('properties.sections.polygon') }}</span>
|
||||||
|
</button>
|
||||||
|
<div v-if="sectionOpen.polygon" class="group-body">
|
||||||
|
<label class="inline-toggle">
|
||||||
|
<input type="checkbox" v-model="polygonClosed" />
|
||||||
|
<span>{{ t('properties.polygonClosed') }}</span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{{ t('properties.polygonPoints') }}
|
||||||
|
<input type="number" min="3" max="64" v-model.number="polygonPointCount" />
|
||||||
|
</label>
|
||||||
|
<p class="hint">{{ t('properties.polygonPointsHint') }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="prop-group">
|
<section class="prop-group">
|
||||||
<button class="group-header" @click="toggleSection('appearance')">
|
<button class="group-header" @click="toggleSection('appearance')">
|
||||||
<component :is="sectionOpen.appearance ? PhCaretDown : PhCaretRight" :size="11" />
|
<component :is="sectionOpen.appearance ? PhCaretDown : PhCaretRight" :size="11" />
|
||||||
@@ -254,10 +372,47 @@ function removeKeyframeHere() {
|
|||||||
<label v-if="canFill" class="color-label">
|
<label v-if="canFill" class="color-label">
|
||||||
{{ t('properties.fill') }}
|
{{ t('properties.fill') }}
|
||||||
<div class="color-row">
|
<div class="color-row">
|
||||||
<input type="color" class="color-swatch" :value="fillColor" @input="fillColor = ($event.target as HTMLInputElement).value" />
|
<input
|
||||||
<span class="color-hex">{{ fillColor.toUpperCase() }}</span>
|
type="color"
|
||||||
|
class="color-swatch"
|
||||||
|
:value="fillColor"
|
||||||
|
:disabled="fillTransparent"
|
||||||
|
@input="fillColor = ($event.target as HTMLInputElement).value"
|
||||||
|
/>
|
||||||
|
<span class="color-hex">{{ fillTransparent ? '—' : fillColor.toUpperCase() }}</span>
|
||||||
|
<label class="alpha-toggle">
|
||||||
|
<input type="checkbox" v-model="fillTransparent" />
|
||||||
|
<span>{{ t('properties.transparent') }}</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
<label v-if="canStroke" class="color-label">
|
||||||
|
{{ t('properties.stroke') }}
|
||||||
|
<div class="color-row">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
class="color-swatch"
|
||||||
|
:value="strokeColor"
|
||||||
|
:disabled="strokeTransparent"
|
||||||
|
@input="strokeColor = ($event.target as HTMLInputElement).value"
|
||||||
|
/>
|
||||||
|
<span class="color-hex">{{ strokeTransparent ? '—' : strokeColor.toUpperCase() }}</span>
|
||||||
|
<label class="alpha-toggle">
|
||||||
|
<input type="checkbox" v-model="strokeTransparent" />
|
||||||
|
<span>{{ t('properties.transparent') }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label v-if="canStroke" class="slider-label">
|
||||||
|
{{ t('properties.strokeWidth') }}
|
||||||
|
<RangeSlider
|
||||||
|
v-model="strokeWidth"
|
||||||
|
:min="0"
|
||||||
|
:max="32"
|
||||||
|
:step="0.5"
|
||||||
|
:format="(v: number) => `${v}px`"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<label class="slider-label">
|
<label class="slider-label">
|
||||||
{{ t('properties.opacity') }}
|
{{ t('properties.opacity') }}
|
||||||
<RangeSlider v-model="opacity" :min="0" :max="1" :step="0.01" :format="formatPercent" />
|
<RangeSlider v-model="opacity" :min="0" :max="1" :step="0.01" :format="formatPercent" />
|
||||||
@@ -472,6 +627,23 @@ input {
|
|||||||
padding: 0 2px 10px;
|
padding: 0 2px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inline-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-toggle input {
|
||||||
|
width: auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.mask-toggle,
|
.mask-toggle,
|
||||||
.mask-radio {
|
.mask-radio {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -617,6 +789,29 @@ input {
|
|||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.alpha-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
cursor: pointer;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alpha-toggle input {
|
||||||
|
width: auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-swatch:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.danger-btn {
|
.danger-btn {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
|
|||||||
@@ -2254,12 +2254,12 @@ onBeforeUnmount(() => {
|
|||||||
bottom: -1px;
|
bottom: -1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.track.inactive .track-lane {
|
.track.inactive .track-lane > :not(.playhead) {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
transition: opacity 140ms var(--ease);
|
transition: opacity 140ms var(--ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
.track.inactive:hover .track-lane {
|
.track.inactive:hover .track-lane > :not(.playhead) {
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ export const en = {
|
|||||||
circle: 'Circle',
|
circle: 'Circle',
|
||||||
text: 'Text',
|
text: 'Text',
|
||||||
polygon: 'Polygon',
|
polygon: 'Polygon',
|
||||||
|
freeform: 'Freeform (click to draw, Enter to finish, Esc to cancel)',
|
||||||
|
freeformActive: 'Drawing… (Enter to finish, Esc to cancel)',
|
||||||
},
|
},
|
||||||
newTextDialog: {
|
newTextDialog: {
|
||||||
title: 'New text object',
|
title: 'New text object',
|
||||||
@@ -73,6 +75,11 @@ export const en = {
|
|||||||
confirm: 'Insert',
|
confirm: 'Insert',
|
||||||
fallback: 'Text',
|
fallback: 'Text',
|
||||||
},
|
},
|
||||||
|
newPolygonDialog: {
|
||||||
|
title: 'New polygon',
|
||||||
|
message: 'Number of points (3–24):',
|
||||||
|
confirm: 'Create',
|
||||||
|
},
|
||||||
newClipDialog: {
|
newClipDialog: {
|
||||||
title: 'New clip',
|
title: 'New clip',
|
||||||
message: 'Clip name:',
|
message: 'Clip name:',
|
||||||
@@ -97,13 +104,20 @@ export const en = {
|
|||||||
deleteLayer: 'Delete layer',
|
deleteLayer: 'Delete layer',
|
||||||
noLayer: 'No layer selected.',
|
noLayer: 'No layer selected.',
|
||||||
fill: 'Fill',
|
fill: 'Fill',
|
||||||
|
transparent: 'Transparent',
|
||||||
opacity: 'Opacity',
|
opacity: 'Opacity',
|
||||||
edgeBlur: 'Edge blur',
|
edgeBlur: 'Edge blur',
|
||||||
sections: {
|
sections: {
|
||||||
transform: 'Transform',
|
transform: 'Transform',
|
||||||
text: 'Text',
|
text: 'Text',
|
||||||
|
polygon: 'Polygon',
|
||||||
appearance: 'Appearance',
|
appearance: 'Appearance',
|
||||||
},
|
},
|
||||||
|
polygonPoints: 'Points',
|
||||||
|
polygonPointsHint: 'Changing the point count rewrites all keyframes; new points are inserted at the midpoint of the last edge.',
|
||||||
|
polygonClosed: 'Closed path (fill)',
|
||||||
|
stroke: 'Stroke',
|
||||||
|
strokeWidth: 'Stroke width',
|
||||||
shadow: 'Shadow',
|
shadow: 'Shadow',
|
||||||
shadowBlur: 'Shadow blur',
|
shadowBlur: 'Shadow blur',
|
||||||
shadowOpacity: 'Shadow opacity',
|
shadowOpacity: 'Shadow opacity',
|
||||||
|
|||||||
+54
-7
@@ -55,6 +55,12 @@ const ENGINE_SOURCE = `(function(){
|
|||||||
out.points=lp;
|
out.points=lp;
|
||||||
} else if(b.points) out.points=b.points;
|
} else if(b.points) out.points=b.points;
|
||||||
else if(a.points) out.points=a.points;
|
else if(a.points) out.points=a.points;
|
||||||
|
if(a.stroke&&b.stroke) out.stroke=lerpColor(a.stroke,b.stroke,t);
|
||||||
|
else if(b.stroke) out.stroke=b.stroke;
|
||||||
|
else if(a.stroke) out.stroke=a.stroke;
|
||||||
|
if(a.strokeWidth!=null&&b.strokeWidth!=null) out.strokeWidth=lerp(a.strokeWidth,b.strokeWidth,t);
|
||||||
|
else if(b.strokeWidth!=null) out.strokeWidth=b.strokeWidth;
|
||||||
|
else if(a.strokeWidth!=null) out.strokeWidth=a.strokeWidth;
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
function sample(layer,time){
|
function sample(layer,time){
|
||||||
@@ -178,7 +184,8 @@ const ENGINE_SOURCE = `(function(){
|
|||||||
var mpOffX=layer.offsetX!=null?layer.offsetX:0;
|
var mpOffX=layer.offsetX!=null?layer.offsetX:0;
|
||||||
var mpOffY=layer.offsetY!=null?layer.offsetY:0;
|
var mpOffY=layer.offsetY!=null?layer.offsetY:0;
|
||||||
var mpPts=p.points||asset.points||[];
|
var mpPts=p.points||asset.points||[];
|
||||||
if(mpPts.length>=3){
|
var mpClosed=asset.closed!==false;
|
||||||
|
if(mpClosed && mpPts.length>=3){
|
||||||
ctxm.beginPath();
|
ctxm.beginPath();
|
||||||
ctxm.moveTo(mpPts[0].x-mpOffX,mpPts[0].y-mpOffY);
|
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);
|
for(var mpI=1;mpI<mpPts.length;mpI++)ctxm.lineTo(mpPts[mpI].x-mpOffX,mpPts[mpI].y-mpOffY);
|
||||||
@@ -211,44 +218,84 @@ const ENGINE_SOURCE = `(function(){
|
|||||||
ctx.shadowOffsetX=Math.cos(sAn)*sDi;
|
ctx.shadowOffsetX=Math.cos(sAn)*sDi;
|
||||||
ctx.shadowOffsetY=Math.sin(sAn)*sDi;
|
ctx.shadowOffsetY=Math.sin(sAn)*sDi;
|
||||||
}
|
}
|
||||||
|
var lRawStroke=p.stroke||asset.stroke;
|
||||||
|
var lSW=(p.strokeWidth!=null?p.strokeWidth:(asset.strokeWidth||0));
|
||||||
|
var lStroke=lSW>0?(lRawStroke||'#000000'):null;
|
||||||
|
var lHasStroke=!!lStroke && lStroke!=='transparent' && lSW>0;
|
||||||
if(asset.kind==='rect'){
|
if(asset.kind==='rect'){
|
||||||
var rOffX=layer.offsetX!=null?layer.offsetX:asset.width/2;
|
var rOffX=layer.offsetX!=null?layer.offsetX:asset.width/2;
|
||||||
var rOffY=layer.offsetY!=null?layer.offsetY:asset.height/2;
|
var rOffY=layer.offsetY!=null?layer.offsetY:asset.height/2;
|
||||||
var rBlur=(p.blur!=null?p.blur:(asset.blur||0));
|
var rBlur=(p.blur!=null?p.blur:(asset.blur||0));
|
||||||
ctx.fillStyle=p.fill||asset.fill;
|
|
||||||
if(rBlur>0) ctx.filter='blur('+rBlur+'px)';
|
if(rBlur>0) ctx.filter='blur('+rBlur+'px)';
|
||||||
|
var rFill=p.fill||asset.fill;
|
||||||
|
if(rFill && rFill!=='transparent'){
|
||||||
|
ctx.fillStyle=rFill;
|
||||||
ctx.fillRect(-rOffX,-rOffY,asset.width,asset.height);
|
ctx.fillRect(-rOffX,-rOffY,asset.width,asset.height);
|
||||||
|
}
|
||||||
|
if(lHasStroke){
|
||||||
|
ctx.strokeStyle=lStroke;
|
||||||
|
ctx.lineWidth=lSW;
|
||||||
|
ctx.strokeRect(-rOffX,-rOffY,asset.width,asset.height);
|
||||||
|
}
|
||||||
ctx.filter='none';
|
ctx.filter='none';
|
||||||
}else if(asset.kind==='circle'){
|
}else if(asset.kind==='circle'){
|
||||||
var cOffX=layer.offsetX!=null?layer.offsetX:0;
|
var cOffX=layer.offsetX!=null?layer.offsetX:0;
|
||||||
var cOffY=layer.offsetY!=null?layer.offsetY:0;
|
var cOffY=layer.offsetY!=null?layer.offsetY:0;
|
||||||
var cBlur=(p.blur!=null?p.blur:(asset.blur||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)';
|
if(cBlur>0) ctx.filter='blur('+cBlur+'px)';
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(-cOffX,-cOffY,asset.radius,0,Math.PI*2);
|
ctx.arc(-cOffX,-cOffY,asset.radius,0,Math.PI*2);
|
||||||
|
var cFill=p.fill||asset.fill;
|
||||||
|
if(cFill && cFill!=='transparent'){
|
||||||
|
ctx.fillStyle=cFill;
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
}
|
||||||
|
if(lHasStroke){
|
||||||
|
ctx.strokeStyle=lStroke;
|
||||||
|
ctx.lineWidth=lSW;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
ctx.filter='none';
|
ctx.filter='none';
|
||||||
}else if(asset.kind==='text'){
|
}else if(asset.kind==='text'){
|
||||||
var tOffX=layer.offsetX!=null?layer.offsetX:0;
|
var tOffX=layer.offsetX!=null?layer.offsetX:0;
|
||||||
var tOffY=layer.offsetY!=null?layer.offsetY:0;
|
var tOffY=layer.offsetY!=null?layer.offsetY:0;
|
||||||
ctx.fillStyle=p.fill||asset.fill;
|
|
||||||
ctx.font=asset.fontSize+'px sans-serif';
|
ctx.font=asset.fontSize+'px sans-serif';
|
||||||
ctx.textBaseline='top';
|
ctx.textBaseline='top';
|
||||||
|
var tFill=p.fill||asset.fill;
|
||||||
|
if(tFill && tFill!=='transparent'){
|
||||||
|
ctx.fillStyle=tFill;
|
||||||
ctx.fillText(asset.text,-tOffX,-tOffY);
|
ctx.fillText(asset.text,-tOffX,-tOffY);
|
||||||
|
}
|
||||||
|
if(lHasStroke){
|
||||||
|
ctx.strokeStyle=lStroke;
|
||||||
|
ctx.lineWidth=lSW;
|
||||||
|
ctx.strokeText(asset.text,-tOffX,-tOffY);
|
||||||
|
}
|
||||||
}else if(asset.kind==='polygon'){
|
}else if(asset.kind==='polygon'){
|
||||||
var pOffX=layer.offsetX!=null?layer.offsetX:0;
|
var pOffX=layer.offsetX!=null?layer.offsetX:0;
|
||||||
var pOffY=layer.offsetY!=null?layer.offsetY:0;
|
var pOffY=layer.offsetY!=null?layer.offsetY:0;
|
||||||
var pBlur=(p.blur!=null?p.blur:(asset.blur||0));
|
var pBlur=(p.blur!=null?p.blur:(asset.blur||0));
|
||||||
var pPts=p.points||asset.points||[];
|
var pPts=p.points||asset.points||[];
|
||||||
if(pPts.length>=3){
|
var pClosed=asset.closed!==false;
|
||||||
ctx.fillStyle=p.fill||asset.fill;
|
var pNeeded=pClosed?3:2;
|
||||||
|
if(pPts.length>=pNeeded){
|
||||||
if(pBlur>0) ctx.filter='blur('+pBlur+'px)';
|
if(pBlur>0) ctx.filter='blur('+pBlur+'px)';
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(pPts[0].x-pOffX,pPts[0].y-pOffY);
|
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);
|
for(var pI=1;pI<pPts.length;pI++)ctx.lineTo(pPts[pI].x-pOffX,pPts[pI].y-pOffY);
|
||||||
ctx.closePath();
|
if(pClosed) ctx.closePath();
|
||||||
|
var pFill=p.fill||asset.fill;
|
||||||
|
if(pClosed && pFill && pFill!=='transparent'){
|
||||||
|
ctx.fillStyle=pFill;
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
}
|
||||||
|
if(lHasStroke){
|
||||||
|
ctx.strokeStyle=lStroke;
|
||||||
|
ctx.lineWidth=lSW;
|
||||||
|
ctx.lineJoin='round';
|
||||||
|
ctx.lineCap='round';
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
ctx.filter='none';
|
ctx.filter='none';
|
||||||
}
|
}
|
||||||
}else if(asset.kind==='image'){
|
}else if(asset.kind==='image'){
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ function lerpProps(a: AnimatableProps, b: AnimatableProps, t: number): Animatabl
|
|||||||
if (a.shadowColor && b.shadowColor) out.shadowColor = lerpColor(a.shadowColor, b.shadowColor, t)
|
if (a.shadowColor && b.shadowColor) out.shadowColor = lerpColor(a.shadowColor, b.shadowColor, t)
|
||||||
else if (b.shadowColor) out.shadowColor = b.shadowColor
|
else if (b.shadowColor) out.shadowColor = b.shadowColor
|
||||||
else if (a.shadowColor) out.shadowColor = a.shadowColor
|
else if (a.shadowColor) out.shadowColor = a.shadowColor
|
||||||
|
if (a.stroke && b.stroke) out.stroke = lerpColor(a.stroke, b.stroke, t)
|
||||||
|
else if (b.stroke) out.stroke = b.stroke
|
||||||
|
else if (a.stroke) out.stroke = a.stroke
|
||||||
|
if (a.strokeWidth !== undefined && b.strokeWidth !== undefined) out.strokeWidth = lerp(a.strokeWidth, b.strokeWidth, t)
|
||||||
|
else if (b.strokeWidth !== undefined) out.strokeWidth = b.strokeWidth
|
||||||
|
else if (a.strokeWidth !== undefined) out.strokeWidth = a.strokeWidth
|
||||||
if (a.points && b.points && a.points.length === b.points.length) {
|
if (a.points && b.points && a.points.length === b.points.length) {
|
||||||
out.points = a.points.map((pa, i) => {
|
out.points = a.points.map((pa, i) => {
|
||||||
const pb = b.points![i]
|
const pb = b.points![i]
|
||||||
|
|||||||
+47
-7
@@ -103,47 +103,86 @@ function drawLayers(
|
|||||||
const shadow = computeShadow(p)
|
const shadow = computeShadow(p)
|
||||||
applyShadow(ctx, shadow)
|
applyShadow(ctx, shadow)
|
||||||
|
|
||||||
|
const rawStroke = p.stroke ?? ('stroke' in asset ? asset.stroke : undefined)
|
||||||
|
const strokeWidth =
|
||||||
|
p.strokeWidth ?? ('strokeWidth' in asset ? asset.strokeWidth : undefined) ?? 0
|
||||||
|
const stroke = strokeWidth > 0 ? rawStroke || '#000000' : undefined
|
||||||
|
const hasStroke = !!stroke && stroke !== 'transparent' && strokeWidth > 0
|
||||||
|
|
||||||
if (asset.kind === 'rect') {
|
if (asset.kind === 'rect') {
|
||||||
const offX = layer.offsetX ?? asset.width / 2
|
const offX = layer.offsetX ?? asset.width / 2
|
||||||
const offY = layer.offsetY ?? asset.height / 2
|
const offY = layer.offsetY ?? asset.height / 2
|
||||||
const fill = p.fill ?? asset.fill
|
const fill = p.fill ?? asset.fill
|
||||||
const blur = p.blur ?? asset.blur ?? 0
|
const blur = p.blur ?? asset.blur ?? 0
|
||||||
ctx.fillStyle = fill
|
|
||||||
if (blur > 0) ctx.filter = `blur(${blur}px)`
|
if (blur > 0) ctx.filter = `blur(${blur}px)`
|
||||||
|
if (fill && fill !== 'transparent') {
|
||||||
|
ctx.fillStyle = fill
|
||||||
ctx.fillRect(-offX, -offY, asset.width, asset.height)
|
ctx.fillRect(-offX, -offY, asset.width, asset.height)
|
||||||
|
}
|
||||||
|
if (hasStroke) {
|
||||||
|
ctx.strokeStyle = stroke!
|
||||||
|
ctx.lineWidth = strokeWidth
|
||||||
|
ctx.strokeRect(-offX, -offY, asset.width, asset.height)
|
||||||
|
}
|
||||||
ctx.filter = 'none'
|
ctx.filter = 'none'
|
||||||
} else if (asset.kind === 'circle') {
|
} else if (asset.kind === 'circle') {
|
||||||
const offX = layer.offsetX ?? 0
|
const offX = layer.offsetX ?? 0
|
||||||
const offY = layer.offsetY ?? 0
|
const offY = layer.offsetY ?? 0
|
||||||
const fill = p.fill ?? asset.fill
|
const fill = p.fill ?? asset.fill
|
||||||
const blur = p.blur ?? asset.blur ?? 0
|
const blur = p.blur ?? asset.blur ?? 0
|
||||||
ctx.fillStyle = fill
|
|
||||||
if (blur > 0) ctx.filter = `blur(${blur}px)`
|
if (blur > 0) ctx.filter = `blur(${blur}px)`
|
||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
ctx.arc(-offX, -offY, asset.radius, 0, Math.PI * 2)
|
ctx.arc(-offX, -offY, asset.radius, 0, Math.PI * 2)
|
||||||
|
if (fill && fill !== 'transparent') {
|
||||||
|
ctx.fillStyle = fill
|
||||||
ctx.fill()
|
ctx.fill()
|
||||||
|
}
|
||||||
|
if (hasStroke) {
|
||||||
|
ctx.strokeStyle = stroke!
|
||||||
|
ctx.lineWidth = strokeWidth
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
ctx.filter = 'none'
|
ctx.filter = 'none'
|
||||||
} else if (asset.kind === 'text') {
|
} else if (asset.kind === 'text') {
|
||||||
const offX = layer.offsetX ?? 0
|
const offX = layer.offsetX ?? 0
|
||||||
const offY = layer.offsetY ?? 0
|
const offY = layer.offsetY ?? 0
|
||||||
ctx.fillStyle = p.fill ?? asset.fill
|
const fill = p.fill ?? asset.fill
|
||||||
ctx.font = `${asset.fontSize}px sans-serif`
|
ctx.font = `${asset.fontSize}px sans-serif`
|
||||||
ctx.textBaseline = 'top'
|
ctx.textBaseline = 'top'
|
||||||
|
if (fill && fill !== 'transparent') {
|
||||||
|
ctx.fillStyle = fill
|
||||||
ctx.fillText(asset.text, -offX, -offY)
|
ctx.fillText(asset.text, -offX, -offY)
|
||||||
|
}
|
||||||
|
if (hasStroke) {
|
||||||
|
ctx.strokeStyle = stroke!
|
||||||
|
ctx.lineWidth = strokeWidth
|
||||||
|
ctx.strokeText(asset.text, -offX, -offY)
|
||||||
|
}
|
||||||
} else if (asset.kind === 'polygon') {
|
} else if (asset.kind === 'polygon') {
|
||||||
const offX = layer.offsetX ?? 0
|
const offX = layer.offsetX ?? 0
|
||||||
const offY = layer.offsetY ?? 0
|
const offY = layer.offsetY ?? 0
|
||||||
const fill = p.fill ?? asset.fill
|
const fill = p.fill ?? asset.fill
|
||||||
const blur = p.blur ?? asset.blur ?? 0
|
const blur = p.blur ?? asset.blur ?? 0
|
||||||
const pts = p.points ?? asset.points
|
const pts = p.points ?? asset.points
|
||||||
if (pts.length >= 3) {
|
const closed = asset.closed !== false
|
||||||
ctx.fillStyle = fill
|
const needed = closed ? 3 : 2
|
||||||
|
if (pts.length >= needed) {
|
||||||
if (blur > 0) ctx.filter = `blur(${blur}px)`
|
if (blur > 0) ctx.filter = `blur(${blur}px)`
|
||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
ctx.moveTo(pts[0].x - offX, pts[0].y - offY)
|
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)
|
for (let j = 1; j < pts.length; j++) ctx.lineTo(pts[j].x - offX, pts[j].y - offY)
|
||||||
ctx.closePath()
|
if (closed) ctx.closePath()
|
||||||
|
if (closed && fill && fill !== 'transparent') {
|
||||||
|
ctx.fillStyle = fill
|
||||||
ctx.fill()
|
ctx.fill()
|
||||||
|
}
|
||||||
|
if (hasStroke) {
|
||||||
|
ctx.strokeStyle = stroke!
|
||||||
|
ctx.lineWidth = strokeWidth
|
||||||
|
ctx.lineJoin = 'round'
|
||||||
|
ctx.lineCap = 'round'
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
ctx.filter = 'none'
|
ctx.filter = 'none'
|
||||||
}
|
}
|
||||||
} else if (asset.kind === 'image') {
|
} else if (asset.kind === 'image') {
|
||||||
@@ -201,7 +240,8 @@ function drawMaskShape(
|
|||||||
const offX = layer.offsetX ?? 0
|
const offX = layer.offsetX ?? 0
|
||||||
const offY = layer.offsetY ?? 0
|
const offY = layer.offsetY ?? 0
|
||||||
const pts = p.points ?? asset.points
|
const pts = p.points ?? asset.points
|
||||||
if (pts.length >= 3) {
|
const closed = asset.closed !== false
|
||||||
|
if (closed && pts.length >= 3) {
|
||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
ctx.moveTo(pts[0].x - offX, pts[0].y - offY)
|
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)
|
for (let j = 1; j < pts.length; j++) ctx.lineTo(pts[j].x - offX, pts[j].y - offY)
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ interface State {
|
|||||||
playhead: number
|
playhead: number
|
||||||
playing: boolean
|
playing: boolean
|
||||||
activeClipId: string | null
|
activeClipId: string | null
|
||||||
|
drawingMode: 'freeform' | null
|
||||||
|
drawingAssetId: string | null
|
||||||
|
drawingLayerId: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function allKeyframes(layer: Layer): Keyframe[] {
|
function allKeyframes(layer: Layer): Keyframe[] {
|
||||||
@@ -165,6 +168,9 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
playhead: 0,
|
playhead: 0,
|
||||||
playing: false,
|
playing: false,
|
||||||
activeClipId: null,
|
activeClipId: null,
|
||||||
|
drawingMode: null,
|
||||||
|
drawingAssetId: null,
|
||||||
|
drawingLayerId: null,
|
||||||
}),
|
}),
|
||||||
getters: {
|
getters: {
|
||||||
activeClip(state): Clip | null {
|
activeClip(state): Clip | null {
|
||||||
@@ -251,6 +257,12 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
layer.offsetX = 0
|
layer.offsetX = 0
|
||||||
layer.offsetY = 0
|
layer.offsetY = 0
|
||||||
}
|
}
|
||||||
|
if (asset.kind === 'polygon') {
|
||||||
|
layer.keyframes[0].props = {
|
||||||
|
...layer.keyframes[0].props,
|
||||||
|
points: asset.points.map((p) => ({ x: p.x, y: p.y })),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.activeLayers.unshift(layer)
|
this.activeLayers.unshift(layer)
|
||||||
if (!this.activeClipId) {
|
if (!this.activeClipId) {
|
||||||
@@ -472,6 +484,18 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
if (!asset || asset.kind !== 'polygon') return
|
if (!asset || asset.kind !== 'polygon') return
|
||||||
if (pointIndex < 0 || pointIndex >= asset.points.length) return
|
if (pointIndex < 0 || pointIndex >= asset.points.length) return
|
||||||
const t = time ?? this.playhead
|
const t = time ?? this.playhead
|
||||||
|
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
|
||||||
|
if (!arr) continue
|
||||||
|
for (const kf of arr) {
|
||||||
|
if (kf.props.points) continue
|
||||||
|
const samp = sampleLayer(layer, kf.time)
|
||||||
|
const basePts = samp.points ?? asset.points
|
||||||
|
kf.props = {
|
||||||
|
...kf.props,
|
||||||
|
points: basePts.map((p) => ({ x: p.x, y: p.y })),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
const sampled = sampleLayer(layer, t)
|
const sampled = sampleLayer(layer, t)
|
||||||
const current = sampled.points ?? asset.points
|
const current = sampled.points ?? asset.points
|
||||||
const next = current.map((p, i) => (i === pointIndex ? { x, y } : { x: p.x, y: p.y }))
|
const next = current.map((p, i) => (i === pointIndex ? { x, y } : { x: p.x, y: p.y }))
|
||||||
@@ -495,6 +519,33 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
setPolygonPointCount(assetId: string, targetCount: number) {
|
||||||
|
const asset = this.scene.assets.find((a) => a.id === assetId)
|
||||||
|
if (!asset || asset.kind !== 'polygon') return
|
||||||
|
const target = Math.max(3, Math.min(64, Math.round(targetCount)))
|
||||||
|
if (asset.points.length === target) return
|
||||||
|
const resize = (pts: { x: number; y: number }[]) => {
|
||||||
|
const arr = pts.map((p) => ({ x: p.x, y: p.y }))
|
||||||
|
while (arr.length > target && arr.length > 3) arr.pop()
|
||||||
|
while (arr.length < target) {
|
||||||
|
const last = arr[arr.length - 1]
|
||||||
|
const first = arr[0]
|
||||||
|
arr.push({ x: (last.x + first.x) / 2, y: (last.y + first.y) / 2 })
|
||||||
|
}
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
asset.points = resize(asset.points)
|
||||||
|
const layersUsing = this.scene.layers.filter((l) => l.assetId === assetId)
|
||||||
|
for (const layer of layersUsing) {
|
||||||
|
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
|
||||||
|
if (!arr) continue
|
||||||
|
for (const kf of arr) {
|
||||||
|
if (!kf.props.points) continue
|
||||||
|
kf.props = { ...kf.props, points: resize(kf.props.points) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
removePolygonPoint(assetId: string, pointIndex: number) {
|
removePolygonPoint(assetId: string, pointIndex: number) {
|
||||||
const asset = this.scene.assets.find((a) => a.id === assetId)
|
const asset = this.scene.assets.find((a) => a.id === assetId)
|
||||||
if (!asset || asset.kind !== 'polygon') return
|
if (!asset || asset.kind !== 'polygon') return
|
||||||
@@ -514,6 +565,158 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
startFreeformDrawing() {
|
||||||
|
if (this.drawingMode) return
|
||||||
|
// If the selected layer is already an open polygon, resume drawing on it
|
||||||
|
const selId = this.selectedLayerId
|
||||||
|
if (selId) {
|
||||||
|
const layer = this.activeLayers.find((l) => l.id === selId)
|
||||||
|
const asset = layer ? this.scene.assets.find((a) => a.id === layer.assetId) : null
|
||||||
|
if (layer && asset && asset.kind === 'polygon' && asset.closed === false) {
|
||||||
|
this.drawingMode = 'freeform'
|
||||||
|
this.drawingAssetId = asset.id
|
||||||
|
this.drawingLayerId = layer.id
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const asset = this.addAsset({
|
||||||
|
kind: 'polygon',
|
||||||
|
name: 'Freeform',
|
||||||
|
points: [],
|
||||||
|
fill: 'transparent',
|
||||||
|
closed: false,
|
||||||
|
stroke: '#000000',
|
||||||
|
strokeWidth: 2,
|
||||||
|
}) as Extract<Asset, { kind: 'polygon' }>
|
||||||
|
const layer: Layer = {
|
||||||
|
id: uid(),
|
||||||
|
name: asset.name,
|
||||||
|
assetId: asset.id,
|
||||||
|
visible: true,
|
||||||
|
base: { ...DEFAULT_BASE_PROPS },
|
||||||
|
keyframes: [
|
||||||
|
{
|
||||||
|
id: uid(),
|
||||||
|
time: 0,
|
||||||
|
props: { ...DEFAULT_BASE_PROPS, points: [] },
|
||||||
|
easing: 'linear',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
offsetX: 0,
|
||||||
|
offsetY: 0,
|
||||||
|
}
|
||||||
|
this.activeLayers.unshift(layer)
|
||||||
|
if (!this.activeClipId) {
|
||||||
|
;(this.scene.trackOrder ||= []).unshift(layer.id)
|
||||||
|
}
|
||||||
|
this.drawingMode = 'freeform'
|
||||||
|
this.drawingAssetId = asset.id
|
||||||
|
this.drawingLayerId = layer.id
|
||||||
|
this.selectedLayerId = layer.id
|
||||||
|
},
|
||||||
|
addDrawingPoint(x: number, y: number) {
|
||||||
|
if (this.drawingMode !== 'freeform') return
|
||||||
|
const assetId = this.drawingAssetId
|
||||||
|
const layerId = this.drawingLayerId
|
||||||
|
if (!assetId || !layerId) return
|
||||||
|
const asset = this.scene.assets.find((a) => a.id === assetId)
|
||||||
|
if (!asset || asset.kind !== 'polygon') return
|
||||||
|
asset.points.push({ x, y })
|
||||||
|
const layersUsing = this.scene.layers.filter((l) => l.assetId === assetId)
|
||||||
|
for (const layer of layersUsing) {
|
||||||
|
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
|
||||||
|
if (!arr) continue
|
||||||
|
for (const kf of arr) {
|
||||||
|
if (!kf.props.points) continue
|
||||||
|
kf.props = {
|
||||||
|
...kf.props,
|
||||||
|
points: [...kf.props.points, { x, y }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ensure the first keyframe of the drawing layer always has a points array
|
||||||
|
if (layer.id === layerId && layer.keyframes[0] && !layer.keyframes[0].props.points) {
|
||||||
|
layer.keyframes[0].props = {
|
||||||
|
...layer.keyframes[0].props,
|
||||||
|
points: asset.points.map((p) => ({ x: p.x, y: p.y })),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
finishDrawing() {
|
||||||
|
const layerId = this.drawingLayerId
|
||||||
|
const assetId = this.drawingAssetId
|
||||||
|
this.drawingMode = null
|
||||||
|
this.drawingAssetId = null
|
||||||
|
this.drawingLayerId = null
|
||||||
|
// If we have fewer than 2 points, drop it — not a usable shape
|
||||||
|
if (!assetId || !layerId) return
|
||||||
|
const asset = this.scene.assets.find((a) => a.id === assetId)
|
||||||
|
if (!asset || asset.kind !== 'polygon') return
|
||||||
|
if (asset.points.length < 2) {
|
||||||
|
this.removeLayer(layerId)
|
||||||
|
this.scene.assets = this.scene.assets.filter((a) => a.id !== assetId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancelDrawing() {
|
||||||
|
const layerId = this.drawingLayerId
|
||||||
|
const assetId = this.drawingAssetId
|
||||||
|
this.drawingMode = null
|
||||||
|
this.drawingAssetId = null
|
||||||
|
this.drawingLayerId = null
|
||||||
|
if (layerId) this.removeLayer(layerId)
|
||||||
|
if (assetId) this.scene.assets = this.scene.assets.filter((a) => a.id !== assetId)
|
||||||
|
},
|
||||||
|
reversePolygonPoints(assetId: string) {
|
||||||
|
const asset = this.scene.assets.find((a) => a.id === assetId)
|
||||||
|
if (!asset || asset.kind !== 'polygon') return
|
||||||
|
asset.points.reverse()
|
||||||
|
const layersUsing = this.scene.layers.filter((l) => l.assetId === assetId)
|
||||||
|
for (const layer of layersUsing) {
|
||||||
|
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
|
||||||
|
if (!arr) continue
|
||||||
|
for (const kf of arr) {
|
||||||
|
if (!kf.props.points) continue
|
||||||
|
kf.props = { ...kf.props, points: [...kf.props.points].reverse() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setPolygonClosed(assetId: string, closed: boolean) {
|
||||||
|
const asset = this.scene.assets.find((a) => a.id === assetId)
|
||||||
|
if (!asset || asset.kind !== 'polygon') return
|
||||||
|
asset.closed = closed
|
||||||
|
},
|
||||||
|
insertPolygonPointOnEdge(assetId: string, atIndex: number, tOnEdge: number) {
|
||||||
|
const asset = this.scene.assets.find((a) => a.id === assetId)
|
||||||
|
if (!asset || asset.kind !== 'polygon') return
|
||||||
|
const n = asset.points.length
|
||||||
|
if (atIndex < 1 || atIndex > n) return
|
||||||
|
const prev = asset.points[atIndex - 1]
|
||||||
|
const next = asset.points[atIndex % n]
|
||||||
|
asset.points.splice(atIndex, 0, {
|
||||||
|
x: prev.x + (next.x - prev.x) * tOnEdge,
|
||||||
|
y: prev.y + (next.y - prev.y) * tOnEdge,
|
||||||
|
})
|
||||||
|
const layersUsing = this.scene.layers.filter((l) => l.assetId === assetId)
|
||||||
|
for (const layer of layersUsing) {
|
||||||
|
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
|
||||||
|
if (!arr) continue
|
||||||
|
for (const kf of arr) {
|
||||||
|
if (!kf.props.points) continue
|
||||||
|
const pts = kf.props.points
|
||||||
|
const p = pts[atIndex - 1]
|
||||||
|
const q = pts[atIndex % pts.length]
|
||||||
|
const next = pts.slice()
|
||||||
|
next.splice(atIndex, 0, {
|
||||||
|
x: p.x + (q.x - p.x) * tOnEdge,
|
||||||
|
y: p.y + (q.y - p.y) * tOnEdge,
|
||||||
|
})
|
||||||
|
kf.props = { ...kf.props, points: next }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
setLayerMask(layerId: string, isMask: boolean, mode?: MaskMode) {
|
setLayerMask(layerId: string, isMask: boolean, mode?: MaskMode) {
|
||||||
const layer = this.activeLayers.find((l) => l.id === layerId)
|
const layer = this.activeLayers.find((l) => l.id === layerId)
|
||||||
if (!layer) return
|
if (!layer) return
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export interface AnimatableProps {
|
|||||||
shadowDistance?: number
|
shadowDistance?: number
|
||||||
shadowColor?: string
|
shadowColor?: string
|
||||||
points?: Point[]
|
points?: Point[]
|
||||||
|
stroke?: string
|
||||||
|
strokeWidth?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Keyframe {
|
export interface Keyframe {
|
||||||
@@ -49,6 +51,8 @@ export interface RectAsset {
|
|||||||
height: number
|
height: number
|
||||||
fill: string
|
fill: string
|
||||||
blur?: number
|
blur?: number
|
||||||
|
stroke?: string
|
||||||
|
strokeWidth?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CircleAsset {
|
export interface CircleAsset {
|
||||||
@@ -58,6 +62,8 @@ export interface CircleAsset {
|
|||||||
radius: number
|
radius: number
|
||||||
fill: string
|
fill: string
|
||||||
blur?: number
|
blur?: number
|
||||||
|
stroke?: string
|
||||||
|
strokeWidth?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TextAsset {
|
export interface TextAsset {
|
||||||
@@ -67,6 +73,8 @@ export interface TextAsset {
|
|||||||
text: string
|
text: string
|
||||||
fontSize: number
|
fontSize: number
|
||||||
fill: string
|
fill: string
|
||||||
|
stroke?: string
|
||||||
|
strokeWidth?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PolygonAsset {
|
export interface PolygonAsset {
|
||||||
@@ -76,6 +84,9 @@ export interface PolygonAsset {
|
|||||||
points: Point[]
|
points: Point[]
|
||||||
fill: string
|
fill: string
|
||||||
blur?: number
|
blur?: number
|
||||||
|
closed?: boolean
|
||||||
|
stroke?: string
|
||||||
|
strokeWidth?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Asset = ImageAsset | RectAsset | CircleAsset | TextAsset | PolygonAsset
|
export type Asset = ImageAsset | RectAsset | CircleAsset | TextAsset | PolygonAsset
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
export const APP_VERSION = '0.2.0'
|
export const APP_VERSION = '0.3.0'
|
||||||
export const APP_NAME = 'Animatr'
|
export const APP_NAME = 'Animatr'
|
||||||
Reference in new issue
Block a user