0.2.0: polygon tool, mask editor preview, collapsible properties
- Polygon asset type with per-vertex animation: new sidebar button inserts a hexagon; vertex handles in the editor write per-keyframe `points` arrays that interpolate between keyframes (requires matching point counts). - Canvas mask preview: toggle in the zoom toolbar switches the stage between the editable ghost view and a rendered view that applies the actual masking via the shared canvas 2D path. - Properties panel is grouped into collapsible sections (Transform, Text, Appearance, Shadow, Mask) with per-section open/closed state persisted to localStorage. - Sidebar shape buttons are icon-only to reclaim width for the Polygon entry. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
03b1147a93
commit
a05396e734
12 files changed
+611
-173
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "animatr",
|
"name": "animatr",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+36
-2
@@ -49,6 +49,14 @@
|
|||||||
if(a.fill && b.fill) o.fill=lerpColor(a.fill,b.fill,t);
|
if(a.fill && b.fill) o.fill=lerpColor(a.fill,b.fill,t);
|
||||||
else if(b.fill) o.fill=b.fill;
|
else if(b.fill) o.fill=b.fill;
|
||||||
else if(a.fill) o.fill=a.fill;
|
else if(a.fill) o.fill=a.fill;
|
||||||
|
if(a.points && b.points && a.points.length===b.points.length){
|
||||||
|
var lp=[];
|
||||||
|
for(var pi=0;pi<a.points.length;pi++){
|
||||||
|
lp.push({x:lerp(a.points[pi].x,b.points[pi].x,t),y:lerp(a.points[pi].y,b.points[pi].y,t)});
|
||||||
|
}
|
||||||
|
o.points=lp;
|
||||||
|
} else if(b.points) o.points=b.points;
|
||||||
|
else if(a.points) o.points=a.points;
|
||||||
return o;
|
return o;
|
||||||
}
|
}
|
||||||
function sample(layer,time){
|
function sample(layer,time){
|
||||||
@@ -89,7 +97,7 @@
|
|||||||
function drawMaskShape(ctxm, layer, am, time){
|
function drawMaskShape(ctxm, layer, am, time){
|
||||||
var asset=am[layer.assetId];
|
var asset=am[layer.assetId];
|
||||||
if(!asset) return;
|
if(!asset) return;
|
||||||
if(asset.kind!=='rect' && asset.kind!=='circle' && asset.kind!=='text') return;
|
if(asset.kind!=='rect' && asset.kind!=='circle' && asset.kind!=='text' && asset.kind!=='polygon') return;
|
||||||
var p=sample(layer,time);
|
var p=sample(layer,time);
|
||||||
ctxm.save();
|
ctxm.save();
|
||||||
ctxm.translate(p.x,p.y);
|
ctxm.translate(p.x,p.y);
|
||||||
@@ -104,12 +112,23 @@
|
|||||||
var mcX=layer.offsetX!=null?layer.offsetX:0;
|
var mcX=layer.offsetX!=null?layer.offsetX:0;
|
||||||
var mcY=layer.offsetY!=null?layer.offsetY:0;
|
var mcY=layer.offsetY!=null?layer.offsetY:0;
|
||||||
ctxm.beginPath(); ctxm.arc(-mcX,-mcY,asset.radius,0,Math.PI*2); ctxm.fill();
|
ctxm.beginPath(); ctxm.arc(-mcX,-mcY,asset.radius,0,Math.PI*2); ctxm.fill();
|
||||||
} else {
|
} else if(asset.kind==='text'){
|
||||||
var mtX=layer.offsetX!=null?layer.offsetX:0;
|
var mtX=layer.offsetX!=null?layer.offsetX:0;
|
||||||
var mtY=layer.offsetY!=null?layer.offsetY:0;
|
var mtY=layer.offsetY!=null?layer.offsetY:0;
|
||||||
ctxm.font=asset.fontSize+'px sans-serif';
|
ctxm.font=asset.fontSize+'px sans-serif';
|
||||||
ctxm.textBaseline='top';
|
ctxm.textBaseline='top';
|
||||||
ctxm.fillText(asset.text,-mtX,-mtY);
|
ctxm.fillText(asset.text,-mtX,-mtY);
|
||||||
|
} else {
|
||||||
|
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){
|
||||||
|
ctxm.beginPath();
|
||||||
|
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);
|
||||||
|
ctxm.closePath();
|
||||||
|
ctxm.fill();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ctxm.restore();
|
ctxm.restore();
|
||||||
}
|
}
|
||||||
@@ -157,6 +176,21 @@
|
|||||||
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);
|
ctx.fillText(asset.text,-tOffX,-tOffY);
|
||||||
|
} else if(asset.kind==='polygon'){
|
||||||
|
var pOffX=layer.offsetX!=null?layer.offsetX:0;
|
||||||
|
var pOffY=layer.offsetY!=null?layer.offsetY:0;
|
||||||
|
var pBlur=(p.blur!=null?p.blur:(asset.blur||0));
|
||||||
|
var pPts=p.points||asset.points||[];
|
||||||
|
if(pPts.length>=3){
|
||||||
|
ctx.fillStyle=fill;
|
||||||
|
if(pBlur>0) ctx.filter='blur('+pBlur+'px)';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(pPts[0].x-pOffX,pPts[0].y-pOffY);
|
||||||
|
for(var pI=1;pI<pPts.length;pI++) ctx.lineTo(pPts[pI].x-pOffX,pPts[pI].y-pOffY);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.filter='none';
|
||||||
|
}
|
||||||
} else if(asset.kind==='image'){
|
} else if(asset.kind==='image'){
|
||||||
var img=images[asset.id];
|
var img=images[asset.id];
|
||||||
if(img){
|
if(img){
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
PhListBullets,
|
PhListBullets,
|
||||||
PhPencilSimple,
|
PhPencilSimple,
|
||||||
PhPlus,
|
PhPlus,
|
||||||
|
PhPolygon,
|
||||||
PhSquare,
|
PhSquare,
|
||||||
PhTextT,
|
PhTextT,
|
||||||
PhTrash,
|
PhTrash,
|
||||||
@@ -56,6 +57,23 @@ function addSampleCircle() {
|
|||||||
store.addLayer(asset.id)
|
store.addLayer(asset.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addSamplePolygon() {
|
||||||
|
const radius = 60
|
||||||
|
const sides = 6
|
||||||
|
const points: { x: number; y: number }[] = []
|
||||||
|
for (let i = 0; i < sides; i++) {
|
||||||
|
const a = (i / sides) * Math.PI * 2 - Math.PI / 2
|
||||||
|
points.push({ x: Math.cos(a) * radius, y: Math.sin(a) * radius })
|
||||||
|
}
|
||||||
|
const asset = store.addAsset({
|
||||||
|
kind: 'polygon',
|
||||||
|
name: 'Polygon',
|
||||||
|
points,
|
||||||
|
fill: '#9b7ede',
|
||||||
|
})
|
||||||
|
store.addLayer(asset.id)
|
||||||
|
}
|
||||||
|
|
||||||
async function addSampleText() {
|
async function addSampleText() {
|
||||||
const input = await promptDialog({
|
const input = await promptDialog({
|
||||||
title: t('sidebar.newTextDialog.title'),
|
title: t('sidebar.newTextDialog.title'),
|
||||||
@@ -169,6 +187,7 @@ const assetIconMap: Record<Asset['kind'], typeof PhSquare> = {
|
|||||||
circle: PhCircle,
|
circle: PhCircle,
|
||||||
image: PhImage,
|
image: PhImage,
|
||||||
text: PhTextT,
|
text: PhTextT,
|
||||||
|
polygon: PhPolygon,
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageAssets = computed(() =>
|
const imageAssets = computed(() =>
|
||||||
@@ -218,17 +237,17 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="shape-btn" @click="addSampleRect">
|
<button class="shape-btn" :title="t('sidebar.shape.rect')" @click="addSampleRect">
|
||||||
<PhSquare :size="14" />
|
<PhSquare :size="16" />
|
||||||
{{ t('sidebar.shape.rect') }}
|
|
||||||
</button>
|
</button>
|
||||||
<button class="shape-btn" @click="addSampleCircle">
|
<button class="shape-btn" :title="t('sidebar.shape.circle')" @click="addSampleCircle">
|
||||||
<PhCircle :size="14" />
|
<PhCircle :size="16" />
|
||||||
{{ t('sidebar.shape.circle') }}
|
|
||||||
</button>
|
</button>
|
||||||
<button class="shape-btn" @click="addSampleText">
|
<button class="shape-btn" :title="t('sidebar.shape.text')" @click="addSampleText">
|
||||||
<PhTextT :size="14" />
|
<PhTextT :size="16" />
|
||||||
{{ t('sidebar.shape.text') }}
|
</button>
|
||||||
|
<button class="shape-btn" :title="t('sidebar.shape.polygon')" @click="addSamplePolygon">
|
||||||
|
<PhPolygon :size="16" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -375,6 +394,8 @@ h4 {
|
|||||||
.shape-btn {
|
.shape-btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -284,6 +284,8 @@ type KonvaCtx = {
|
|||||||
closePath: () => void
|
closePath: () => void
|
||||||
rect: (x: number, y: number, w: number, h: number) => void
|
rect: (x: number, y: number, w: number, h: number) => void
|
||||||
arc: (x: number, y: number, r: number, s: number, e: number, cc?: boolean) => void
|
arc: (x: number, y: number, r: number, s: number, e: number, cc?: boolean) => void
|
||||||
|
moveTo: (x: number, y: number) => void
|
||||||
|
lineTo: (x: number, y: number) => void
|
||||||
fillStrokeShape: (shape: unknown) => void
|
fillStrokeShape: (shape: unknown) => void
|
||||||
save: () => void
|
save: () => void
|
||||||
restore: () => void
|
restore: () => void
|
||||||
@@ -360,6 +362,43 @@ function textConfig(layer: Layer, asset: Extract<Asset, { kind: 'text' }>, props
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function polygonConfig(
|
||||||
|
layer: Layer,
|
||||||
|
asset: Extract<Asset, { kind: 'polygon' }>,
|
||||||
|
props: AnimatableProps,
|
||||||
|
) {
|
||||||
|
const fill = props.fill ?? asset.fill
|
||||||
|
const blur = props.blur ?? asset.blur ?? 0
|
||||||
|
const offX = layer.offsetX ?? 0
|
||||||
|
const offY = layer.offsetY ?? 0
|
||||||
|
const pts = props.points ?? asset.points
|
||||||
|
const flat: number[] = []
|
||||||
|
for (const p of pts) {
|
||||||
|
flat.push(p.x - offX, p.y - offY)
|
||||||
|
}
|
||||||
|
const cfg: Record<string, unknown> = {
|
||||||
|
...baseNodeConfig(layer, props),
|
||||||
|
points: flat,
|
||||||
|
closed: true,
|
||||||
|
fill,
|
||||||
|
}
|
||||||
|
if (blur > 0) {
|
||||||
|
cfg.sceneFunc = (ctx: KonvaCtx, shape: KonvaShape) => {
|
||||||
|
ctx.save()
|
||||||
|
ctx.setAttr('filter', `blur(${blur}px)`)
|
||||||
|
ctx.beginPath()
|
||||||
|
if (pts.length > 0) {
|
||||||
|
ctx.moveTo(pts[0].x - offX, pts[0].y - offY)
|
||||||
|
for (let j = 1; j < pts.length; j++) ctx.lineTo(pts[j].x - offX, pts[j].y - offY)
|
||||||
|
}
|
||||||
|
ctx.closePath()
|
||||||
|
ctx.fillStrokeShape(shape)
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
function imageConfig(
|
function imageConfig(
|
||||||
layer: Layer,
|
layer: Layer,
|
||||||
asset: Extract<Asset, { kind: 'image' }>,
|
asset: Extract<Asset, { kind: 'image' }>,
|
||||||
@@ -436,6 +475,23 @@ function clipTextConfig(item: RenderedClipLayer) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clipPolygonConfig(item: RenderedClipLayer) {
|
||||||
|
const asset = item.asset as Extract<Asset, { kind: 'polygon' }>
|
||||||
|
const offX = item.layer.offsetX ?? 0
|
||||||
|
const offY = item.layer.offsetY ?? 0
|
||||||
|
const pts = item.props.points ?? asset.points
|
||||||
|
const flat: number[] = []
|
||||||
|
for (const p of pts) {
|
||||||
|
flat.push(p.x - offX, p.y - offY)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...clipBaseConfig(item.instanceId, item.layer, item.props),
|
||||||
|
points: flat,
|
||||||
|
closed: true,
|
||||||
|
fill: item.props.fill ?? asset.fill,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function clipImageConfig(item: RenderedClipLayer) {
|
function clipImageConfig(item: RenderedClipLayer) {
|
||||||
const asset = item.asset as Extract<Asset, { kind: 'image' }>
|
const asset = item.asset as Extract<Asset, { kind: 'image' }>
|
||||||
const img = imageCache.value[asset.id]
|
const img = imageCache.value[asset.id]
|
||||||
@@ -515,6 +571,89 @@ const pivotDotConfig = computed(() => ({
|
|||||||
listening: false,
|
listening: false,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
interface VertexHandle {
|
||||||
|
layerId: string
|
||||||
|
index: number
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const polygonVertexHandles = computed<VertexHandle[]>(() => {
|
||||||
|
const id = store.selectedLayerId
|
||||||
|
if (!id) return []
|
||||||
|
const layer = store.activeLayers.find((l) => l.id === id)
|
||||||
|
if (!layer || layer.locked) return []
|
||||||
|
const asset = assetMap.value.get(layer.assetId)
|
||||||
|
if (!asset || asset.kind !== 'polygon') return []
|
||||||
|
const sampled = sampleLayer(layer, store.playhead)
|
||||||
|
const live = liveLayerPose.value
|
||||||
|
const cx = live && live.layerId === id ? live.x : sampled.x
|
||||||
|
const cy = live && live.layerId === id ? live.y : sampled.y
|
||||||
|
const rot = live && live.layerId === id ? live.rotation : sampled.rotation
|
||||||
|
const scX = live && live.layerId === id ? live.scaleX : sampled.scaleX
|
||||||
|
const scY = live && live.layerId === id ? live.scaleY : sampled.scaleY
|
||||||
|
const offX = layer.offsetX ?? 0
|
||||||
|
const offY = layer.offsetY ?? 0
|
||||||
|
const pts = sampled.points ?? asset.points
|
||||||
|
const theta = (rot * Math.PI) / 180
|
||||||
|
const c = Math.cos(theta)
|
||||||
|
const s = Math.sin(theta)
|
||||||
|
return pts.map((p, index) => {
|
||||||
|
const lx = (p.x - offX) * scX
|
||||||
|
const ly = (p.y - offY) * scY
|
||||||
|
return {
|
||||||
|
layerId: id,
|
||||||
|
index,
|
||||||
|
x: cx + lx * c - ly * s,
|
||||||
|
y: cy + lx * s + ly * c,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function vertexHandleConfig(h: VertexHandle) {
|
||||||
|
return {
|
||||||
|
x: h.x,
|
||||||
|
y: h.y,
|
||||||
|
radius: 5 / zoom.value,
|
||||||
|
stroke: '#6ea8ff',
|
||||||
|
strokeWidth: 1.5 / zoom.value,
|
||||||
|
fill: '#ffffff',
|
||||||
|
draggable: true,
|
||||||
|
name: 'vertex-handle',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function worldToPolygonLocal(layer: Layer, wx: number, wy: number): { x: number; y: number } {
|
||||||
|
const sampled = sampleLayer(layer, store.playhead)
|
||||||
|
const dx = wx - sampled.x
|
||||||
|
const dy = wy - sampled.y
|
||||||
|
const theta = (sampled.rotation * Math.PI) / 180
|
||||||
|
const c = Math.cos(theta)
|
||||||
|
const s = Math.sin(theta)
|
||||||
|
const unrotX = dx * c + dy * s
|
||||||
|
const unrotY = -dx * s + dy * c
|
||||||
|
const invSX = sampled.scaleX !== 0 ? 1 / sampled.scaleX : 1
|
||||||
|
const invSY = sampled.scaleY !== 0 ? 1 / sampled.scaleY : 1
|
||||||
|
return {
|
||||||
|
x: unrotX * invSX + (layer.offsetX ?? 0),
|
||||||
|
y: unrotY * invSY + (layer.offsetY ?? 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVertexDragMove(e: Konva.KonvaEventObject<DragEvent>, index: number) {
|
||||||
|
const id = store.selectedLayerId
|
||||||
|
if (!id) return
|
||||||
|
const layer = store.activeLayers.find((l) => l.id === id)
|
||||||
|
if (!layer) return
|
||||||
|
const node = e.target
|
||||||
|
const local = worldToPolygonLocal(layer, node.x(), node.y())
|
||||||
|
store.movePolygonVertex(id, index, local.x, local.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVertexDragEnd(e: Konva.KonvaEventObject<DragEvent>, index: number) {
|
||||||
|
onVertexDragMove(e, index)
|
||||||
|
}
|
||||||
|
|
||||||
function onPivotDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
|
function onPivotDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
|
||||||
const info = pivotInfo.value
|
const info = pivotInfo.value
|
||||||
if (!info) return
|
if (!info) return
|
||||||
@@ -615,6 +754,7 @@ function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
|
|||||||
}
|
}
|
||||||
if (target.findAncestor?.('.pivot-handle')) return
|
if (target.findAncestor?.('.pivot-handle')) return
|
||||||
if (target.findAncestor?.('.rotation-handle')) return
|
if (target.findAncestor?.('.rotation-handle')) return
|
||||||
|
if (target.name?.() === 'vertex-handle' || target.findAncestor?.('.vertex-handle')) return
|
||||||
if (target === target.getStage()) {
|
if (target === target.getStage()) {
|
||||||
store.selectLayer(null)
|
store.selectLayer(null)
|
||||||
return
|
return
|
||||||
@@ -985,6 +1125,14 @@ onBeforeUnmount(() => {
|
|||||||
@transform="handleTransform"
|
@transform="handleTransform"
|
||||||
@transformend="handleTransformEnd"
|
@transformend="handleTransformEnd"
|
||||||
/>
|
/>
|
||||||
|
<v-line
|
||||||
|
v-else-if="entry.item.asset.kind === 'polygon'"
|
||||||
|
:config="polygonConfig(entry.item.layer, entry.item.asset, entry.item.props)"
|
||||||
|
@dragmove="handleDragMove"
|
||||||
|
@dragend="handleDragEnd"
|
||||||
|
@transform="handleTransform"
|
||||||
|
@transformend="handleTransformEnd"
|
||||||
|
/>
|
||||||
<v-image
|
<v-image
|
||||||
v-else-if="entry.item.asset.kind === 'image' && imageConfig(entry.item.layer, entry.item.asset, entry.item.props)"
|
v-else-if="entry.item.asset.kind === 'image' && imageConfig(entry.item.layer, entry.item.asset, entry.item.props)"
|
||||||
:config="imageConfig(entry.item.layer, entry.item.asset, entry.item.props)!"
|
:config="imageConfig(entry.item.layer, entry.item.asset, entry.item.props)!"
|
||||||
@@ -1007,6 +1155,10 @@ onBeforeUnmount(() => {
|
|||||||
v-else-if="entry.item.asset.kind === 'text'"
|
v-else-if="entry.item.asset.kind === 'text'"
|
||||||
:config="clipTextConfig(entry.item)"
|
:config="clipTextConfig(entry.item)"
|
||||||
/>
|
/>
|
||||||
|
<v-line
|
||||||
|
v-else-if="entry.item.asset.kind === 'polygon'"
|
||||||
|
:config="clipPolygonConfig(entry.item)"
|
||||||
|
/>
|
||||||
<v-image
|
<v-image
|
||||||
v-else-if="entry.item.asset.kind === 'image' && clipImageConfig(entry.item)"
|
v-else-if="entry.item.asset.kind === 'image' && clipImageConfig(entry.item)"
|
||||||
:config="clipImageConfig(entry.item)!"
|
:config="clipImageConfig(entry.item)!"
|
||||||
@@ -1032,6 +1184,13 @@ onBeforeUnmount(() => {
|
|||||||
>
|
>
|
||||||
<v-circle :config="rotationKnobConfig" />
|
<v-circle :config="rotationKnobConfig" />
|
||||||
</v-group>
|
</v-group>
|
||||||
|
<v-circle
|
||||||
|
v-for="h in polygonVertexHandles"
|
||||||
|
:key="`vh-${h.layerId}-${h.index}`"
|
||||||
|
:config="vertexHandleConfig(h)"
|
||||||
|
@dragmove="(e: Konva.KonvaEventObject<DragEvent>) => onVertexDragMove(e, h.index)"
|
||||||
|
@dragend="(e: Konva.KonvaEventObject<DragEvent>) => onVertexDragEnd(e, h.index)"
|
||||||
|
/>
|
||||||
</v-group>
|
</v-group>
|
||||||
<v-transformer ref="transformerRef" :config="transformerConfig" />
|
<v-transformer ref="transformerRef" :config="transformerConfig" />
|
||||||
</v-layer>
|
</v-layer>
|
||||||
|
|||||||
+228
-153
@@ -1,12 +1,52 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { PhDiamond, PhTrash, PhX } from '@phosphor-icons/vue'
|
import { PhCaretDown, PhCaretRight, PhDiamond, PhTrash, PhX } from '@phosphor-icons/vue'
|
||||||
import { useSceneStore } from '@/stores/scene'
|
import { useSceneStore } from '@/stores/scene'
|
||||||
import { sampleLayer } from '@/lib/interpolate'
|
import { sampleLayer } from '@/lib/interpolate'
|
||||||
import type { AnimatableProps } from '@/types/scene'
|
import type { AnimatableProps } from '@/types/scene'
|
||||||
import RangeSlider from './RangeSlider.vue'
|
import RangeSlider from './RangeSlider.vue'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
|
const SECTION_STORAGE_KEY = 'animatr:propSections'
|
||||||
|
|
||||||
|
type SectionKey = 'transform' | 'text' | 'appearance' | 'shadow' | 'mask'
|
||||||
|
|
||||||
|
const defaultOpen: Record<SectionKey, boolean> = {
|
||||||
|
transform: true,
|
||||||
|
text: true,
|
||||||
|
appearance: true,
|
||||||
|
shadow: false,
|
||||||
|
mask: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredOpen(): Record<SectionKey, boolean> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(SECTION_STORAGE_KEY)
|
||||||
|
if (!raw) return { ...defaultOpen }
|
||||||
|
const parsed = JSON.parse(raw) as Partial<Record<SectionKey, boolean>>
|
||||||
|
return { ...defaultOpen, ...parsed }
|
||||||
|
} catch {
|
||||||
|
return { ...defaultOpen }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionOpen = ref<Record<SectionKey, boolean>>(readStoredOpen())
|
||||||
|
watch(
|
||||||
|
sectionOpen,
|
||||||
|
(val) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SECTION_STORAGE_KEY, JSON.stringify(val))
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function toggleSection(key: SectionKey) {
|
||||||
|
sectionOpen.value[key] = !sectionOpen.value[key]
|
||||||
|
}
|
||||||
|
|
||||||
const formatPercent = (v: number) => `${Math.round(v * 100)}%`
|
const formatPercent = (v: number) => `${Math.round(v * 100)}%`
|
||||||
|
|
||||||
const store = useSceneStore()
|
const store = useSceneStore()
|
||||||
@@ -36,12 +76,12 @@ const textAsset = computed(() => {
|
|||||||
|
|
||||||
const blurAsset = computed(() => {
|
const blurAsset = computed(() => {
|
||||||
const a = selectedAsset.value
|
const a = selectedAsset.value
|
||||||
return a && (a.kind === 'rect' || a.kind === 'circle') ? a : null
|
return a && (a.kind === 'rect' || a.kind === 'circle' || a.kind === 'polygon') ? a : null
|
||||||
})
|
})
|
||||||
|
|
||||||
const canBeMask = computed(() => {
|
const canBeMask = computed(() => {
|
||||||
const a = selectedAsset.value
|
const a = selectedAsset.value
|
||||||
return !!a && (a.kind === 'rect' || a.kind === 'circle' || a.kind === 'text')
|
return !!a && (a.kind === 'rect' || a.kind === 'circle' || a.kind === 'text' || a.kind === 'polygon')
|
||||||
})
|
})
|
||||||
|
|
||||||
const maskInGroup = computed(() => {
|
const maskInGroup = computed(() => {
|
||||||
@@ -117,7 +157,7 @@ const shadowColorValue = computed<string>({
|
|||||||
|
|
||||||
const canFill = computed(() => {
|
const canFill = computed(() => {
|
||||||
const a = selectedAsset.value
|
const a = selectedAsset.value
|
||||||
return !!a && (a.kind === 'rect' || a.kind === 'circle' || a.kind === 'text')
|
return !!a && (a.kind === 'rect' || a.kind === 'circle' || a.kind === 'text' || a.kind === 'polygon')
|
||||||
})
|
})
|
||||||
|
|
||||||
const fillColor = computed<string>({
|
const fillColor = computed<string>({
|
||||||
@@ -176,131 +216,160 @@ function removeKeyframeHere() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<section class="prop-group">
|
||||||
<label>X<input type="number" v-model.number="x" /></label>
|
<button class="group-header" @click="toggleSection('transform')">
|
||||||
<label>Y<input type="number" v-model.number="y" /></label>
|
<component :is="sectionOpen.transform ? PhCaretDown : PhCaretRight" :size="11" />
|
||||||
</div>
|
<span>{{ t('properties.sections.transform') }}</span>
|
||||||
<div class="row">
|
</button>
|
||||||
<label>Scale X<input type="number" step="0.1" v-model.number="scaleX" /></label>
|
<div v-if="sectionOpen.transform" class="group-body">
|
||||||
<label>Scale Y<input type="number" step="0.1" v-model.number="scaleY" /></label>
|
<div class="row">
|
||||||
</div>
|
<label>X<input type="number" v-model.number="x" /></label>
|
||||||
<label>Rotation<input type="number" v-model.number="rotation" /></label>
|
<label>Y<input type="number" v-model.number="y" /></label>
|
||||||
<template v-if="textAsset">
|
|
||||||
<label>Text<input type="text" v-model="textAsset.text" /></label>
|
|
||||||
<label>Font Size<input type="number" min="1" v-model.number="textAsset.fontSize" /></label>
|
|
||||||
</template>
|
|
||||||
<label v-if="blurAsset" class="slider-label">
|
|
||||||
{{ t('properties.edgeBlur') }}
|
|
||||||
<RangeSlider
|
|
||||||
v-model="blurValue"
|
|
||||||
:min="0"
|
|
||||||
:max="64"
|
|
||||||
:step="1"
|
|
||||||
:format="(v: number) => `${Math.round(v)}px`"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<section class="shadow-group">
|
|
||||||
<h4>{{ t('properties.shadow') }}</h4>
|
|
||||||
<label class="color-label">
|
|
||||||
{{ t('properties.shadowColor') }}
|
|
||||||
<div class="color-row">
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
class="color-swatch"
|
|
||||||
:value="shadowColorValue"
|
|
||||||
@input="shadowColorValue = ($event.target as HTMLInputElement).value"
|
|
||||||
/>
|
|
||||||
<span class="color-hex">{{ shadowColorValue.toUpperCase() }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</label>
|
<div class="row">
|
||||||
<label class="slider-label">
|
<label>Scale X<input type="number" step="0.1" v-model.number="scaleX" /></label>
|
||||||
{{ t('properties.shadowOpacity') }}
|
<label>Scale Y<input type="number" step="0.1" v-model.number="scaleY" /></label>
|
||||||
<RangeSlider
|
</div>
|
||||||
v-model="shadowOpacity"
|
<label>Rotation<input type="number" v-model.number="rotation" /></label>
|
||||||
:min="0"
|
</div>
|
||||||
:max="1"
|
|
||||||
:step="0.01"
|
|
||||||
:format="formatPercent"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="slider-label">
|
|
||||||
{{ t('properties.shadowBlur') }}
|
|
||||||
<RangeSlider
|
|
||||||
v-model="shadowBlur"
|
|
||||||
:min="0"
|
|
||||||
:max="64"
|
|
||||||
:step="1"
|
|
||||||
:format="(v: number) => `${Math.round(v)}px`"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="slider-label">
|
|
||||||
{{ t('properties.shadowDistance') }}
|
|
||||||
<RangeSlider
|
|
||||||
v-model="shadowDistance"
|
|
||||||
:min="0"
|
|
||||||
:max="100"
|
|
||||||
:step="1"
|
|
||||||
:format="(v: number) => `${Math.round(v)}px`"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="slider-label">
|
|
||||||
{{ t('properties.shadowAngle') }}
|
|
||||||
<RangeSlider
|
|
||||||
v-model="shadowAngle"
|
|
||||||
:min="0"
|
|
||||||
:max="360"
|
|
||||||
:step="1"
|
|
||||||
:format="(v: number) => `${Math.round(v)}°`"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</section>
|
</section>
|
||||||
<label v-if="canFill" class="color-label">
|
|
||||||
Color
|
|
||||||
<div class="color-row">
|
|
||||||
<input type="color" class="color-swatch" :value="fillColor" @input="fillColor = ($event.target as HTMLInputElement).value" />
|
|
||||||
<span class="color-hex">{{ fillColor.toUpperCase() }}</span>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<label class="slider-label">
|
|
||||||
Opacity
|
|
||||||
<RangeSlider v-model="opacity" :min="0" :max="1" :step="0.01" :format="formatPercent" />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<section v-if="canBeMask" class="mask-group">
|
<section v-if="textAsset" class="prop-group">
|
||||||
<h4>{{ t('properties.mask') }}</h4>
|
<button class="group-header" @click="toggleSection('text')">
|
||||||
<label class="mask-toggle">
|
<component :is="sectionOpen.text ? PhCaretDown : PhCaretRight" :size="11" />
|
||||||
<input
|
<span>{{ t('properties.sections.text') }}</span>
|
||||||
type="checkbox"
|
</button>
|
||||||
:checked="!!selected.isMask"
|
<div v-if="sectionOpen.text" class="group-body">
|
||||||
@change="store.setLayerMask(selected.id, ($event.target as HTMLInputElement).checked)"
|
<label>Text<input type="text" v-model="textAsset.text" /></label>
|
||||||
/>
|
<label>Font Size<input type="number" min="1" v-model.number="textAsset.fontSize" /></label>
|
||||||
<span>{{ t('properties.maskUse') }}</span>
|
</div>
|
||||||
</label>
|
</section>
|
||||||
<div v-if="selected.isMask" class="mask-mode-row">
|
|
||||||
<label class="mask-radio">
|
<section class="prop-group">
|
||||||
<input
|
<button class="group-header" @click="toggleSection('appearance')">
|
||||||
type="radio"
|
<component :is="sectionOpen.appearance ? PhCaretDown : PhCaretRight" :size="11" />
|
||||||
:checked="(selected.maskMode ?? 'positive') === 'positive'"
|
<span>{{ t('properties.sections.appearance') }}</span>
|
||||||
@change="store.setLayerMaskMode(selected.id, 'positive')"
|
</button>
|
||||||
/>
|
<div v-if="sectionOpen.appearance" class="group-body">
|
||||||
<span>{{ t('properties.maskPositive') }}</span>
|
<label v-if="canFill" class="color-label">
|
||||||
|
{{ t('properties.fill') }}
|
||||||
|
<div class="color-row">
|
||||||
|
<input type="color" class="color-swatch" :value="fillColor" @input="fillColor = ($event.target as HTMLInputElement).value" />
|
||||||
|
<span class="color-hex">{{ fillColor.toUpperCase() }}</span>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<label class="mask-radio">
|
<label class="slider-label">
|
||||||
<input
|
{{ t('properties.opacity') }}
|
||||||
type="radio"
|
<RangeSlider v-model="opacity" :min="0" :max="1" :step="0.01" :format="formatPercent" />
|
||||||
:checked="selected.maskMode === 'negative'"
|
</label>
|
||||||
@change="store.setLayerMaskMode(selected.id, 'negative')"
|
<label v-if="blurAsset" class="slider-label">
|
||||||
|
{{ t('properties.edgeBlur') }}
|
||||||
|
<RangeSlider
|
||||||
|
v-model="blurValue"
|
||||||
|
:min="0"
|
||||||
|
:max="64"
|
||||||
|
:step="1"
|
||||||
|
:format="(v: number) => `${Math.round(v)}px`"
|
||||||
/>
|
/>
|
||||||
<span>{{ t('properties.maskNegative') }}</span>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="selected.isMask && maskInGroup" class="status-active">
|
</section>
|
||||||
{{ t('properties.maskActive') }}
|
|
||||||
</p>
|
<section class="prop-group">
|
||||||
<p v-if="selected.isMask && !maskInGroup" class="status-warn">
|
<button class="group-header" @click="toggleSection('shadow')">
|
||||||
{{ t('properties.maskNeedsGroup') }}
|
<component :is="sectionOpen.shadow ? PhCaretDown : PhCaretRight" :size="11" />
|
||||||
</p>
|
<span>{{ t('properties.shadow') }}</span>
|
||||||
<p v-if="selected.isMask" class="hint">{{ t('properties.maskHint') }}</p>
|
</button>
|
||||||
|
<div v-if="sectionOpen.shadow" class="group-body">
|
||||||
|
<label class="color-label">
|
||||||
|
{{ t('properties.shadowColor') }}
|
||||||
|
<div class="color-row">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
class="color-swatch"
|
||||||
|
:value="shadowColorValue"
|
||||||
|
@input="shadowColorValue = ($event.target as HTMLInputElement).value"
|
||||||
|
/>
|
||||||
|
<span class="color-hex">{{ shadowColorValue.toUpperCase() }}</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="slider-label">
|
||||||
|
{{ t('properties.shadowOpacity') }}
|
||||||
|
<RangeSlider v-model="shadowOpacity" :min="0" :max="1" :step="0.01" :format="formatPercent" />
|
||||||
|
</label>
|
||||||
|
<label class="slider-label">
|
||||||
|
{{ t('properties.shadowBlur') }}
|
||||||
|
<RangeSlider
|
||||||
|
v-model="shadowBlur"
|
||||||
|
:min="0"
|
||||||
|
:max="64"
|
||||||
|
:step="1"
|
||||||
|
:format="(v: number) => `${Math.round(v)}px`"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="slider-label">
|
||||||
|
{{ t('properties.shadowDistance') }}
|
||||||
|
<RangeSlider
|
||||||
|
v-model="shadowDistance"
|
||||||
|
:min="0"
|
||||||
|
:max="100"
|
||||||
|
:step="1"
|
||||||
|
:format="(v: number) => `${Math.round(v)}px`"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="slider-label">
|
||||||
|
{{ t('properties.shadowAngle') }}
|
||||||
|
<RangeSlider
|
||||||
|
v-model="shadowAngle"
|
||||||
|
:min="0"
|
||||||
|
:max="360"
|
||||||
|
:step="1"
|
||||||
|
:format="(v: number) => `${Math.round(v)}°`"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="canBeMask" class="prop-group">
|
||||||
|
<button class="group-header" @click="toggleSection('mask')">
|
||||||
|
<component :is="sectionOpen.mask ? PhCaretDown : PhCaretRight" :size="11" />
|
||||||
|
<span>{{ t('properties.mask') }}</span>
|
||||||
|
</button>
|
||||||
|
<div v-if="sectionOpen.mask" class="group-body">
|
||||||
|
<label class="mask-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="!!selected.isMask"
|
||||||
|
@change="store.setLayerMask(selected.id, ($event.target as HTMLInputElement).checked)"
|
||||||
|
/>
|
||||||
|
<span>{{ t('properties.maskUse') }}</span>
|
||||||
|
</label>
|
||||||
|
<div v-if="selected.isMask" class="mask-mode-row">
|
||||||
|
<label class="mask-radio">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
:checked="(selected.maskMode ?? 'positive') === 'positive'"
|
||||||
|
@change="store.setLayerMaskMode(selected.id, 'positive')"
|
||||||
|
/>
|
||||||
|
<span>{{ t('properties.maskPositive') }}</span>
|
||||||
|
</label>
|
||||||
|
<label class="mask-radio">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
:checked="selected.maskMode === 'negative'"
|
||||||
|
@change="store.setLayerMaskMode(selected.id, 'negative')"
|
||||||
|
/>
|
||||||
|
<span>{{ t('properties.maskNegative') }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p v-if="selected.isMask && maskInGroup" class="status-active">
|
||||||
|
{{ t('properties.maskActive') }}
|
||||||
|
</p>
|
||||||
|
<p v-if="selected.isMask && !maskInGroup" class="status-warn">
|
||||||
|
{{ t('properties.maskNeedsGroup') }}
|
||||||
|
</p>
|
||||||
|
<p v-if="selected.isMask" class="hint">{{ t('properties.maskHint') }}</p>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<button class="danger-btn" @click="store.removeLayer(selected.id)">
|
<button class="danger-btn" @click="store.removeLayer(selected.id)">
|
||||||
@@ -317,7 +386,7 @@ function removeKeyframeHere() {
|
|||||||
padding: 14px;
|
padding: 14px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
@@ -363,38 +432,44 @@ input {
|
|||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shadow-group {
|
.prop-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 4px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-header:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-header svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding-top: 8px;
|
padding: 0 2px 10px;
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shadow-group h4 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 10px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
color: var(--text-dim);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mask-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
padding-top: 8px;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mask-group h4 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 10px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
color: var(--text-dim);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mask-toggle,
|
.mask-toggle,
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export const en = {
|
|||||||
rect: 'Rect',
|
rect: 'Rect',
|
||||||
circle: 'Circle',
|
circle: 'Circle',
|
||||||
text: 'Text',
|
text: 'Text',
|
||||||
|
polygon: 'Polygon',
|
||||||
},
|
},
|
||||||
newTextDialog: {
|
newTextDialog: {
|
||||||
title: 'New text object',
|
title: 'New text object',
|
||||||
@@ -95,7 +96,14 @@ export const en = {
|
|||||||
removeKeyframe: 'Remove keyframe here',
|
removeKeyframe: 'Remove keyframe here',
|
||||||
deleteLayer: 'Delete layer',
|
deleteLayer: 'Delete layer',
|
||||||
noLayer: 'No layer selected.',
|
noLayer: 'No layer selected.',
|
||||||
|
fill: 'Fill',
|
||||||
|
opacity: 'Opacity',
|
||||||
edgeBlur: 'Edge blur',
|
edgeBlur: 'Edge blur',
|
||||||
|
sections: {
|
||||||
|
transform: 'Transform',
|
||||||
|
text: 'Text',
|
||||||
|
appearance: 'Appearance',
|
||||||
|
},
|
||||||
shadow: 'Shadow',
|
shadow: 'Shadow',
|
||||||
shadowBlur: 'Shadow blur',
|
shadowBlur: 'Shadow blur',
|
||||||
shadowOpacity: 'Shadow opacity',
|
shadowOpacity: 'Shadow opacity',
|
||||||
|
|||||||
+36
-2
@@ -47,6 +47,14 @@ const ENGINE_SOURCE = `(function(){
|
|||||||
if(a.fill&&b.fill)out.fill=lerpColor(a.fill,b.fill,t);
|
if(a.fill&&b.fill)out.fill=lerpColor(a.fill,b.fill,t);
|
||||||
else if(b.fill)out.fill=b.fill;
|
else if(b.fill)out.fill=b.fill;
|
||||||
else if(a.fill)out.fill=a.fill;
|
else if(a.fill)out.fill=a.fill;
|
||||||
|
if(a.points&&b.points&&a.points.length===b.points.length){
|
||||||
|
var lp=[];
|
||||||
|
for(var lpi=0;lpi<a.points.length;lpi++){
|
||||||
|
lp.push({x:lerp(a.points[lpi].x,b.points[lpi].x,t),y:lerp(a.points[lpi].y,b.points[lpi].y,t)});
|
||||||
|
}
|
||||||
|
out.points=lp;
|
||||||
|
} else if(b.points) out.points=b.points;
|
||||||
|
else if(a.points) out.points=a.points;
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
function sample(layer,time){
|
function sample(layer,time){
|
||||||
@@ -143,7 +151,7 @@ const ENGINE_SOURCE = `(function(){
|
|||||||
function drawMaskShape(ctxm,layer,time){
|
function drawMaskShape(ctxm,layer,time){
|
||||||
var asset=assetMap[layer.assetId];
|
var asset=assetMap[layer.assetId];
|
||||||
if(!asset) return;
|
if(!asset) return;
|
||||||
if(asset.kind!=='rect' && asset.kind!=='circle' && asset.kind!=='text') return;
|
if(asset.kind!=='rect' && asset.kind!=='circle' && asset.kind!=='text' && asset.kind!=='polygon') return;
|
||||||
var p=sample(layer,time);
|
var p=sample(layer,time);
|
||||||
ctxm.save();
|
ctxm.save();
|
||||||
ctxm.translate(p.x,p.y);
|
ctxm.translate(p.x,p.y);
|
||||||
@@ -160,12 +168,23 @@ const ENGINE_SOURCE = `(function(){
|
|||||||
ctxm.beginPath();
|
ctxm.beginPath();
|
||||||
ctxm.arc(-mcOff,-mcOffY,asset.radius,0,Math.PI*2);
|
ctxm.arc(-mcOff,-mcOffY,asset.radius,0,Math.PI*2);
|
||||||
ctxm.fill();
|
ctxm.fill();
|
||||||
} else {
|
} else if(asset.kind==='text'){
|
||||||
var mtOff=layer.offsetX!=null?layer.offsetX:0;
|
var mtOff=layer.offsetX!=null?layer.offsetX:0;
|
||||||
var mtOffY=layer.offsetY!=null?layer.offsetY:0;
|
var mtOffY=layer.offsetY!=null?layer.offsetY:0;
|
||||||
ctxm.font=asset.fontSize+'px sans-serif';
|
ctxm.font=asset.fontSize+'px sans-serif';
|
||||||
ctxm.textBaseline='top';
|
ctxm.textBaseline='top';
|
||||||
ctxm.fillText(asset.text,-mtOff,-mtOffY);
|
ctxm.fillText(asset.text,-mtOff,-mtOffY);
|
||||||
|
} else {
|
||||||
|
var mpOffX=layer.offsetX!=null?layer.offsetX:0;
|
||||||
|
var mpOffY=layer.offsetY!=null?layer.offsetY:0;
|
||||||
|
var mpPts=p.points||asset.points||[];
|
||||||
|
if(mpPts.length>=3){
|
||||||
|
ctxm.beginPath();
|
||||||
|
ctxm.moveTo(mpPts[0].x-mpOffX,mpPts[0].y-mpOffY);
|
||||||
|
for(var mpI=1;mpI<mpPts.length;mpI++)ctxm.lineTo(mpPts[mpI].x-mpOffX,mpPts[mpI].y-mpOffY);
|
||||||
|
ctxm.closePath();
|
||||||
|
ctxm.fill();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ctxm.restore();
|
ctxm.restore();
|
||||||
}
|
}
|
||||||
@@ -217,6 +236,21 @@ const ENGINE_SOURCE = `(function(){
|
|||||||
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);
|
ctx.fillText(asset.text,-tOffX,-tOffY);
|
||||||
|
}else if(asset.kind==='polygon'){
|
||||||
|
var pOffX=layer.offsetX!=null?layer.offsetX:0;
|
||||||
|
var pOffY=layer.offsetY!=null?layer.offsetY:0;
|
||||||
|
var pBlur=(p.blur!=null?p.blur:(asset.blur||0));
|
||||||
|
var pPts=p.points||asset.points||[];
|
||||||
|
if(pPts.length>=3){
|
||||||
|
ctx.fillStyle=p.fill||asset.fill;
|
||||||
|
if(pBlur>0) ctx.filter='blur('+pBlur+'px)';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(pPts[0].x-pOffX,pPts[0].y-pOffY);
|
||||||
|
for(var pI=1;pI<pPts.length;pI++)ctx.lineTo(pPts[pI].x-pOffX,pPts[pI].y-pOffY);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.filter='none';
|
||||||
|
}
|
||||||
}else if(asset.kind==='image'){
|
}else if(asset.kind==='image'){
|
||||||
var img=imgs[asset.id];
|
var img=imgs[asset.id];
|
||||||
if(img){
|
if(img){
|
||||||
|
|||||||
@@ -62,6 +62,16 @@ function lerpProps(a: AnimatableProps, b: AnimatableProps, t: number): Animatabl
|
|||||||
if (a.shadowColor && b.shadowColor) out.shadowColor = lerpColor(a.shadowColor, b.shadowColor, t)
|
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.points && b.points && a.points.length === b.points.length) {
|
||||||
|
out.points = a.points.map((pa, i) => {
|
||||||
|
const pb = b.points![i]
|
||||||
|
return { x: lerp(pa.x, pb.x, t), y: lerp(pa.y, pb.y, t) }
|
||||||
|
})
|
||||||
|
} else if (b.points) {
|
||||||
|
out.points = b.points.map((p) => ({ ...p }))
|
||||||
|
} else if (a.points) {
|
||||||
|
out.points = a.points.map((p) => ({ ...p }))
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+35
-2
@@ -130,6 +130,22 @@ function drawLayers(
|
|||||||
ctx.font = `${asset.fontSize}px sans-serif`
|
ctx.font = `${asset.fontSize}px sans-serif`
|
||||||
ctx.textBaseline = 'top'
|
ctx.textBaseline = 'top'
|
||||||
ctx.fillText(asset.text, -offX, -offY)
|
ctx.fillText(asset.text, -offX, -offY)
|
||||||
|
} else if (asset.kind === 'polygon') {
|
||||||
|
const offX = layer.offsetX ?? 0
|
||||||
|
const offY = layer.offsetY ?? 0
|
||||||
|
const fill = p.fill ?? asset.fill
|
||||||
|
const blur = p.blur ?? asset.blur ?? 0
|
||||||
|
const pts = p.points ?? asset.points
|
||||||
|
if (pts.length >= 3) {
|
||||||
|
ctx.fillStyle = fill
|
||||||
|
if (blur > 0) ctx.filter = `blur(${blur}px)`
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(pts[0].x - offX, pts[0].y - offY)
|
||||||
|
for (let j = 1; j < pts.length; j++) ctx.lineTo(pts[j].x - offX, pts[j].y - offY)
|
||||||
|
ctx.closePath()
|
||||||
|
ctx.fill()
|
||||||
|
ctx.filter = 'none'
|
||||||
|
}
|
||||||
} else if (asset.kind === 'image') {
|
} else if (asset.kind === 'image') {
|
||||||
const img = images[asset.id]
|
const img = images[asset.id]
|
||||||
if (img) {
|
if (img) {
|
||||||
@@ -152,7 +168,13 @@ function drawMaskShape(
|
|||||||
) {
|
) {
|
||||||
const asset = assetMap.get(layer.assetId)
|
const asset = assetMap.get(layer.assetId)
|
||||||
if (!asset) return
|
if (!asset) return
|
||||||
if (asset.kind !== 'rect' && asset.kind !== 'circle' && asset.kind !== 'text') return
|
if (
|
||||||
|
asset.kind !== 'rect' &&
|
||||||
|
asset.kind !== 'circle' &&
|
||||||
|
asset.kind !== 'text' &&
|
||||||
|
asset.kind !== 'polygon'
|
||||||
|
)
|
||||||
|
return
|
||||||
const p = sampleLayer(layer, time)
|
const p = sampleLayer(layer, time)
|
||||||
ctx.save()
|
ctx.save()
|
||||||
ctx.translate(p.x, p.y)
|
ctx.translate(p.x, p.y)
|
||||||
@@ -169,12 +191,23 @@ function drawMaskShape(
|
|||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
ctx.arc(-offX, -offY, asset.radius, 0, Math.PI * 2)
|
ctx.arc(-offX, -offY, asset.radius, 0, Math.PI * 2)
|
||||||
ctx.fill()
|
ctx.fill()
|
||||||
} else {
|
} 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.font = `${asset.fontSize}px sans-serif`
|
ctx.font = `${asset.fontSize}px sans-serif`
|
||||||
ctx.textBaseline = 'top'
|
ctx.textBaseline = 'top'
|
||||||
ctx.fillText(asset.text, -offX, -offY)
|
ctx.fillText(asset.text, -offX, -offY)
|
||||||
|
} else {
|
||||||
|
const offX = layer.offsetX ?? 0
|
||||||
|
const offY = layer.offsetY ?? 0
|
||||||
|
const pts = p.points ?? asset.points
|
||||||
|
if (pts.length >= 3) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(pts[0].x - offX, pts[0].y - offY)
|
||||||
|
for (let j = 1; j < pts.length; j++) ctx.lineTo(pts[j].x - offX, pts[j].y - offY)
|
||||||
|
ctx.closePath()
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ctx.restore()
|
ctx.restore()
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-1
@@ -247,7 +247,7 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
if (asset.kind === 'rect' || asset.kind === 'image') {
|
if (asset.kind === 'rect' || asset.kind === 'image') {
|
||||||
layer.offsetX = asset.width / 2
|
layer.offsetX = asset.width / 2
|
||||||
layer.offsetY = asset.height / 2
|
layer.offsetY = asset.height / 2
|
||||||
} else if (asset.kind === 'circle' || asset.kind === 'text') {
|
} else if (asset.kind === 'circle' || asset.kind === 'text' || asset.kind === 'polygon') {
|
||||||
layer.offsetX = 0
|
layer.offsetX = 0
|
||||||
layer.offsetY = 0
|
layer.offsetY = 0
|
||||||
}
|
}
|
||||||
@@ -465,6 +465,55 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
kf.props = { ...kf.props, x: curX + dx, y: curY + dy }
|
kf.props = { ...kf.props, x: curX + dx, y: curY + dy }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
movePolygonVertex(layerId: string, pointIndex: number, x: number, y: number, time?: number) {
|
||||||
|
const layer = this.activeLayers.find((l) => l.id === layerId)
|
||||||
|
if (!layer) return
|
||||||
|
const asset = this.scene.assets.find((a) => a.id === layer.assetId)
|
||||||
|
if (!asset || asset.kind !== 'polygon') return
|
||||||
|
if (pointIndex < 0 || pointIndex >= asset.points.length) return
|
||||||
|
const t = time ?? this.playhead
|
||||||
|
const sampled = sampleLayer(layer, t)
|
||||||
|
const current = sampled.points ?? asset.points
|
||||||
|
const next = current.map((p, i) => (i === pointIndex ? { x, y } : { x: p.x, y: p.y }))
|
||||||
|
this.upsertKeyframe(layerId, t, { points: next })
|
||||||
|
},
|
||||||
|
addPolygonPoint(assetId: string, atIndex: number, point: { x: number; y: number }) {
|
||||||
|
const asset = this.scene.assets.find((a) => a.id === assetId)
|
||||||
|
if (!asset || asset.kind !== 'polygon') return
|
||||||
|
const insertAt = Math.max(0, Math.min(atIndex, asset.points.length))
|
||||||
|
asset.points.splice(insertAt, 0, { x: point.x, y: point.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
|
||||||
|
const pts = kf.props.points.slice()
|
||||||
|
pts.splice(insertAt, 0, { x: point.x, y: point.y })
|
||||||
|
kf.props = { ...kf.props, points: pts }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
removePolygonPoint(assetId: string, pointIndex: number) {
|
||||||
|
const asset = this.scene.assets.find((a) => a.id === assetId)
|
||||||
|
if (!asset || asset.kind !== 'polygon') return
|
||||||
|
if (asset.points.length <= 3) return
|
||||||
|
if (pointIndex < 0 || pointIndex >= asset.points.length) return
|
||||||
|
asset.points.splice(pointIndex, 1)
|
||||||
|
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.slice()
|
||||||
|
if (pointIndex < pts.length) pts.splice(pointIndex, 1)
|
||||||
|
kf.props = { ...kf.props, points: pts }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
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
|
||||||
|
|||||||
+17
-2
@@ -1,5 +1,10 @@
|
|||||||
export type Easing = 'linear' | 'easeIn' | 'easeOut' | 'easeInOut'
|
export type Easing = 'linear' | 'easeIn' | 'easeOut' | 'easeInOut'
|
||||||
|
|
||||||
|
export interface Point {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface AnimatableProps {
|
export interface AnimatableProps {
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
@@ -14,6 +19,7 @@ export interface AnimatableProps {
|
|||||||
shadowAngle?: number
|
shadowAngle?: number
|
||||||
shadowDistance?: number
|
shadowDistance?: number
|
||||||
shadowColor?: string
|
shadowColor?: string
|
||||||
|
points?: Point[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Keyframe {
|
export interface Keyframe {
|
||||||
@@ -24,7 +30,7 @@ export interface Keyframe {
|
|||||||
hold?: boolean
|
hold?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AssetKind = 'image' | 'rect' | 'circle' | 'text'
|
export type AssetKind = 'image' | 'rect' | 'circle' | 'text' | 'polygon'
|
||||||
|
|
||||||
export interface ImageAsset {
|
export interface ImageAsset {
|
||||||
id: string
|
id: string
|
||||||
@@ -63,7 +69,16 @@ export interface TextAsset {
|
|||||||
fill: string
|
fill: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Asset = ImageAsset | RectAsset | CircleAsset | TextAsset
|
export interface PolygonAsset {
|
||||||
|
id: string
|
||||||
|
kind: 'polygon'
|
||||||
|
name: string
|
||||||
|
points: Point[]
|
||||||
|
fill: string
|
||||||
|
blur?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Asset = ImageAsset | RectAsset | CircleAsset | TextAsset | PolygonAsset
|
||||||
|
|
||||||
export type MaskMode = 'positive' | 'negative'
|
export type MaskMode = 'positive' | 'negative'
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
export const APP_VERSION = '0.1.0'
|
export const APP_VERSION = '0.2.0'
|
||||||
export const APP_NAME = 'Animatr'
|
export const APP_NAME = 'Animatr'
|
||||||
Reference in new issue
Block a user