diff --git a/package.json b/package.json index 51902c4..216a043 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "animatr", "private": true, - "version": "0.2.0", + "version": "0.3.0", "type": "module", "scripts": { "dev": "vite", diff --git a/server/render.html b/server/render.html index 5ef71b9..d62c1c6 100644 --- a/server/render.html +++ b/server/render.html @@ -57,6 +57,12 @@ o.points=lp; } else if(b.points) o.points=b.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; } function sample(layer,time){ @@ -122,7 +128,8 @@ var mpX=layer.offsetX!=null?layer.offsetX:0; var mpY=layer.offsetY!=null?layer.offsetY:0; var mpPts=p.points||asset.points||[]; - if(mpPts.length>=3){ + var mpClosed=asset.closed!==false; + if(mpClosed && mpPts.length>=3){ ctxm.beginPath(); ctxm.moveTo(mpPts[0].x-mpX,mpPts[0].y-mpY); for(var mpi=1;mpi0 ? (lRawStroke||'#000000') : null; + var lHasStroke = !!lStroke && lStroke!=='transparent' && lSW>0; if(asset.kind==='rect'){ var rOffX=layer.offsetX!=null?layer.offsetX:asset.width/2; var rOffY=layer.offsetY!=null?layer.offsetY:asset.height/2; var rBlur=(p.blur!=null?p.blur:(asset.blur||0)); - ctx.fillStyle=fill; if(rBlur>0) ctx.filter='blur('+rBlur+'px)'; - ctx.fillRect(-rOffX,-rOffY,asset.width,asset.height); + 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'; } else if(asset.kind==='circle'){ var cOffX=layer.offsetX!=null?layer.offsetX:0; var cOffY=layer.offsetY!=null?layer.offsetY:0; var cBlur=(p.blur!=null?p.blur:(asset.blur||0)); - ctx.fillStyle=fill; if(cBlur>0) ctx.filter='blur('+cBlur+'px)'; - ctx.beginPath(); ctx.arc(-cOffX,-cOffY,asset.radius,0,Math.PI*2); ctx.fill(); + ctx.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'; } else if(asset.kind==='text'){ var tOffX=layer.offsetX!=null?layer.offsetX:0; var tOffY=layer.offsetY!=null?layer.offsetY:0; - ctx.fillStyle=fill; ctx.font=asset.fontSize+'px sans-serif'; 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'){ 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=fill; + var pClosed=asset.closed!==false; + var pNeeded=pClosed?3:2; + if(pPts.length>=pNeeded){ 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 + { + 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, props: AnimatableProps) { const fill = props.fill ?? asset.fill const blur = props.blur ?? asset.blur ?? 0 @@ -327,8 +338,9 @@ function rectConfig(layer: Layer, asset: Extract, props height: asset.height, offsetX: layer.offsetX ?? asset.width / 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) return cfg } @@ -345,21 +357,25 @@ function circleConfig( radius: asset.radius, offsetX: layer.offsetX ?? 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) return cfg } function textConfig(layer: Layer, asset: Extract, props: AnimatableProps) { - return { + const fill = props.fill ?? asset.fill + const cfg: Record = { ...baseNodeConfig(layer, props), text: asset.text, fontSize: asset.fontSize, offsetX: layer.offsetX ?? 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( @@ -368,10 +384,14 @@ function polygonConfig( props: AnimatableProps, ) { 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 offX = layer.offsetX ?? 0 const offY = layer.offsetY ?? 0 const pts = props.points ?? asset.points + const closed = asset.closed !== false const flat: number[] = [] for (const p of pts) { flat.push(p.x - offX, p.y - offY) @@ -379,9 +399,21 @@ function polygonConfig( const cfg: Record = { ...baseNodeConfig(layer, props), points: flat, - closed: true, - fill, + closed, + 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) { cfg.sceneFunc = (ctx: KonvaCtx, shape: KonvaShape) => { ctx.save() @@ -391,7 +423,7 @@ function polygonConfig( 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() + if (closed) ctx.closePath() ctx.fillStrokeShape(shape) ctx.restore() } @@ -442,8 +474,9 @@ function clipRectConfig(item: RenderedClipLayer) { height: asset.height, offsetX: item.layer.offsetX ?? asset.width / 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) return cfg } @@ -457,22 +490,26 @@ function clipCircleConfig(item: RenderedClipLayer) { radius: asset.radius, offsetX: item.layer.offsetX ?? 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) return cfg } function clipTextConfig(item: RenderedClipLayer) { const asset = item.asset as Extract - return { + const fill = item.props.fill ?? asset.fill + const cfg: Record = { ...clipBaseConfig(item.instanceId, item.layer, item.props), text: asset.text, fontSize: asset.fontSize, offsetX: item.layer.offsetX ?? 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) { @@ -480,16 +517,28 @@ function clipPolygonConfig(item: RenderedClipLayer) { const offX = item.layer.offsetX ?? 0 const offY = item.layer.offsetY ?? 0 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[] = [] for (const p of pts) { flat.push(p.x - offX, p.y - offY) } - return { + const cfg: Record = { ...clipBaseConfig(item.instanceId, item.layer, item.props), points: flat, - closed: true, - fill: item.props.fill ?? asset.fill, + closed, + 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) { @@ -654,7 +703,15 @@ function onVertexDragEnd(e: Konva.KonvaEventObject, index: number) { onVertexDragMove(e, index) } + +const draggingPivot = ref(false) + +function onPivotDragStart() { + draggingPivot.value = true +} + function onPivotDragEnd(e: Konva.KonvaEventObject) { + draggingPivot.value = false const info = pivotInfo.value if (!info) return const node = e.target @@ -693,7 +750,13 @@ const transformerConfig = { ignoreStroke: true, } -function attachTransformer() { +let attachRetryTimer: ReturnType | null = null + +function attachTransformer(retriesLeft = 3) { + if (attachRetryTimer) { + clearTimeout(attachRetryTimer) + attachRetryTimer = null + } const stage = stageRef.value?.getStage() const tr = transformerRef.value?.getNode() if (!stage || !tr) return @@ -703,18 +766,34 @@ function attachTransformer() { tr.getLayer()?.batchDraw() 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(`#${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.getLayer()?.batchDraw() } watch( - () => [store.selectedLayerId, stackItems.value.length, store.playhead] as const, + () => [store.selectedLayerId, stackItems.value, store.playhead] as const, async () => { await nextTick() attachTransformer() }, - { immediate: true }, + { immediate: true, deep: true }, ) function updateLivePose(node: Konva.Node, layerId: string) { @@ -742,7 +821,46 @@ function handleTransform(e: Konva.KonvaEventObject) { updateLivePose(node, id) } +const drawingPreview = ref<{ x: number; y: number } | null>(null) + +function pointerToWorld(e: Konva.KonvaEventObject) { + 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) { + 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 tr = transformerRef.value?.getNode() if (tr) { @@ -768,6 +886,180 @@ function handleStageMouseDown(e: Konva.KonvaEventObject) { store.selectLayer(match ? match.id : null) } +function handleStageMouseMove(e: Konva.KonvaEventObject) { + 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) { const node = e.target const id = node.id() @@ -809,6 +1101,7 @@ const rotationHandlePos = ref<{ x: number; y: number } | null>(null) const rotationHandleConfig = computed(() => { const info = pivotInfo.value if (!info) return null + if (draggingPivot.value) return null if (rotationHandlePos.value) { return { x: rotationHandlePos.value.x, @@ -830,6 +1123,7 @@ const rotationHandleConfig = computed(() => { const rotationArmConfig = computed(() => { const info = pivotInfo.value if (!info) return null + if (draggingPivot.value) return null let endX: number let endY: number if (rotationHandlePos.value) { @@ -1069,6 +1363,8 @@ onMounted(() => { container.value.addEventListener('pointercancel', onContainerPointerUp) container.value.addEventListener('dragover', onContainerDragOver) container.value.addEventListener('drop', onContainerDrop) + container.value.addEventListener('dblclick', onContainerDblClick) + window.addEventListener('keydown', handleDrawingKey) }) onBeforeUnmount(() => { @@ -1082,12 +1378,19 @@ onBeforeUnmount(() => { container.value?.removeEventListener('pointercancel', onContainerPointerUp) container.value?.removeEventListener('dragover', onContainerDragOver) container.value?.removeEventListener('drop', onContainerDrop) + container.value?.removeEventListener('dblclick', onContainerDblClick) + window.removeEventListener('keydown', handleDrawingKey) })