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
+944
-64
No files matched your search
@@ -27,6 +27,9 @@ interface State {
|
||||
playhead: number
|
||||
playing: boolean
|
||||
activeClipId: string | null
|
||||
drawingMode: 'freeform' | null
|
||||
drawingAssetId: string | null
|
||||
drawingLayerId: string | null
|
||||
}
|
||||
|
||||
function allKeyframes(layer: Layer): Keyframe[] {
|
||||
@@ -165,6 +168,9 @@ export const useSceneStore = defineStore('scene', {
|
||||
playhead: 0,
|
||||
playing: false,
|
||||
activeClipId: null,
|
||||
drawingMode: null,
|
||||
drawingAssetId: null,
|
||||
drawingLayerId: null,
|
||||
}),
|
||||
getters: {
|
||||
activeClip(state): Clip | null {
|
||||
@@ -251,6 +257,12 @@ export const useSceneStore = defineStore('scene', {
|
||||
layer.offsetX = 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)
|
||||
if (!this.activeClipId) {
|
||||
@@ -472,6 +484,18 @@ export const useSceneStore = defineStore('scene', {
|
||||
if (!asset || asset.kind !== 'polygon') return
|
||||
if (pointIndex < 0 || pointIndex >= asset.points.length) return
|
||||
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 current = sampled.points ?? asset.points
|
||||
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) {
|
||||
const asset = this.scene.assets.find((a) => a.id === assetId)
|
||||
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) {
|
||||
const layer = this.activeLayers.find((l) => l.id === layerId)
|
||||
if (!layer) return
|
||||
|
||||
Reference in new issue
Block a user