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:
thandClaude Opus 4.7 committed 2026-04-20 09:53:01 +02:00
1 parent a05396e734
commit 276d621d20
13 files changed
+944 -64

No files matched your search

+203
View File
@@ -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