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
@@ -284,6 +284,8 @@ type KonvaCtx = {
|
||||
closePath: () => void
|
||||
rect: (x: number, y: number, w: number, h: number) => void
|
||||
arc: (x: number, y: number, r: number, s: number, e: number, cc?: boolean) => void
|
||||
moveTo: (x: number, y: number) => void
|
||||
lineTo: (x: number, y: number) => void
|
||||
fillStrokeShape: (shape: unknown) => void
|
||||
save: () => void
|
||||
restore: () => void
|
||||
@@ -360,6 +362,43 @@ function textConfig(layer: Layer, asset: Extract<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(
|
||||
layer: Layer,
|
||||
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) {
|
||||
const asset = item.asset as Extract<Asset, { kind: 'image' }>
|
||||
const img = imageCache.value[asset.id]
|
||||
@@ -515,6 +571,89 @@ const pivotDotConfig = computed(() => ({
|
||||
listening: false,
|
||||
}))
|
||||
|
||||
interface VertexHandle {
|
||||
layerId: string
|
||||
index: number
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
const polygonVertexHandles = computed<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>) {
|
||||
const info = pivotInfo.value
|
||||
if (!info) return
|
||||
@@ -615,6 +754,7 @@ function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
|
||||
}
|
||||
if (target.findAncestor?.('.pivot-handle')) return
|
||||
if (target.findAncestor?.('.rotation-handle')) return
|
||||
if (target.name?.() === 'vertex-handle' || target.findAncestor?.('.vertex-handle')) return
|
||||
if (target === target.getStage()) {
|
||||
store.selectLayer(null)
|
||||
return
|
||||
@@ -985,6 +1125,14 @@ onBeforeUnmount(() => {
|
||||
@transform="handleTransform"
|
||||
@transformend="handleTransformEnd"
|
||||
/>
|
||||
<v-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-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)!"
|
||||
@@ -1007,6 +1155,10 @@ onBeforeUnmount(() => {
|
||||
v-else-if="entry.item.asset.kind === 'text'"
|
||||
:config="clipTextConfig(entry.item)"
|
||||
/>
|
||||
<v-line
|
||||
v-else-if="entry.item.asset.kind === 'polygon'"
|
||||
:config="clipPolygonConfig(entry.item)"
|
||||
/>
|
||||
<v-image
|
||||
v-else-if="entry.item.asset.kind === 'image' && clipImageConfig(entry.item)"
|
||||
:config="clipImageConfig(entry.item)!"
|
||||
@@ -1032,6 +1184,13 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<v-circle :config="rotationKnobConfig" />
|
||||
</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-transformer ref="transformerRef" :config="transformerConfig" />
|
||||
</v-layer>
|
||||
|
||||
Reference in new issue
Block a user