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

+30 -3
View File
@@ -7,6 +7,7 @@ import {
PhImageSquare,
PhListBullets,
PhPencilSimple,
PhPencilSimpleLine,
PhPlus,
PhPolygon,
PhSquare,
@@ -57,9 +58,21 @@ function addSampleCircle() {
store.addLayer(asset.id)
}
function addSamplePolygon() {
function startFreeformDrawing() {
store.startFreeformDrawing()
}
async function addSamplePolygon() {
const input = await promptDialog({
title: t('sidebar.newPolygonDialog.title'),
message: t('sidebar.newPolygonDialog.message'),
defaultValue: '6',
confirmLabel: t('sidebar.newPolygonDialog.confirm'),
})
if (input === null) return
const parsed = parseInt(input.trim(), 10)
const sides = Number.isFinite(parsed) ? Math.max(3, Math.min(24, parsed)) : 6
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
@@ -67,7 +80,7 @@ function addSamplePolygon() {
}
const asset = store.addAsset({
kind: 'polygon',
name: 'Polygon',
name: `Polygon ${sides}`,
points,
fill: '#9b7ede',
})
@@ -249,6 +262,14 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
<button class="shape-btn" :title="t('sidebar.shape.polygon')" @click="addSamplePolygon">
<PhPolygon :size="16" />
</button>
<button
class="shape-btn"
:class="{ active: store.drawingMode === 'freeform' }"
:title="store.drawingMode === 'freeform' ? t('sidebar.shape.freeformActive') : t('sidebar.shape.freeform')"
@click="startFreeformDrawing"
>
<PhPencilSimpleLine :size="16" />
</button>
</div>
<input
@@ -399,6 +420,12 @@ h4 {
font-size: 12px;
}
.shape-btn.active {
background: var(--accent-soft);
color: var(--accent);
border-color: var(--accent);
}
.error {
color: var(--danger);
font-size: 11px;
+333 -19
View File
@@ -318,6 +318,17 @@ function makeCircleSceneFunc(blur: number) {
}
}
function strokeCfg(
props: AnimatableProps,
asset: { stroke?: string; strokeWidth?: number },
): Record<string, unknown> {
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<Asset, { kind: 'rect' }>, 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<Asset, { kind: 'rect' }>, 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<Asset, { kind: 'text' }>, props: AnimatableProps) {
return {
const fill = props.fill ?? asset.fill
const cfg: Record<string, unknown> = {
...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<string, unknown> = {
...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<Asset, { kind: 'text' }>
return {
const fill = item.props.fill ?? asset.fill
const cfg: Record<string, unknown> = {
...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<string, unknown> = {
...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<DragEvent>, index: number) {
onVertexDragMove(e, index)
}
const draggingPivot = ref(false)
function onPivotDragStart() {
draggingPivot.value = true
}
function onPivotDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
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<typeof setTimeout> | 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<Konva.Node>(`#${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<Event>) {
updateLivePose(node, id)
}
const drawingPreview = ref<{ x: number; y: number } | null>(null)
function pointerToWorld(e: Konva.KonvaEventObject<MouseEvent>) {
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<MouseEvent>) {
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<MouseEvent>) {
store.selectLayer(match ? match.id : null)
}
function handleStageMouseMove(e: Konva.KonvaEventObject<MouseEvent>) {
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<DragEvent>) {
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)
})
</script>
<template>
<div ref="container" class="stage-container">
<v-stage ref="stageRef" :config="stageConfig" @mousedown="handleStageMouseDown">
<div ref="container" class="stage-container" :class="{ drawing: store.drawingMode === 'freeform' }">
<v-stage
ref="stageRef"
:config="stageConfig"
@mousedown="handleStageMouseDown"
@mousemove="handleStageMouseMove"
>
<v-layer>
<v-group :config="viewConfig">
<v-group :config="clipGroupConfig">
@@ -1171,6 +1474,7 @@ onBeforeUnmount(() => {
<v-group
v-if="pivotHandleConfig"
:config="pivotHandleConfig"
@dragstart="onPivotDragStart"
@dragend="onPivotDragEnd"
>
<v-circle :config="pivotRingConfig" />
@@ -1191,6 +1495,12 @@ onBeforeUnmount(() => {
@dragmove="(e: Konva.KonvaEventObject<DragEvent>) => onVertexDragMove(e, h.index)"
@dragend="(e: Konva.KonvaEventObject<DragEvent>) => onVertexDragEnd(e, h.index)"
/>
<v-line v-if="drawingPreviewConfig" :config="drawingPreviewConfig" />
<v-circle
v-for="d in drawingDotsConfig"
:key="d.key"
:config="d"
/>
</v-group>
<v-transformer ref="transformerRef" :config="transformerConfig" />
</v-layer>
@@ -1244,6 +1554,10 @@ onBeforeUnmount(() => {
overflow: hidden;
}
.stage-container.drawing {
cursor: crosshair;
}
.mask-preview-canvas {
position: absolute;
inset: 0;
+200 -5
View File
@@ -9,11 +9,12 @@ import { t } from '@/i18n'
const SECTION_STORAGE_KEY = 'animatr:propSections'
type SectionKey = 'transform' | 'text' | 'appearance' | 'shadow' | 'mask'
type SectionKey = 'transform' | 'text' | 'polygon' | 'appearance' | 'shadow' | 'mask'
const defaultOpen: Record<SectionKey, boolean> = {
transform: true,
text: true,
polygon: true,
appearance: true,
shadow: false,
mask: false,
@@ -74,6 +75,68 @@ const textAsset = computed(() => {
return a && a.kind === 'text' ? a : null
})
const polygonAsset = computed(() => {
const a = selectedAsset.value
return a && a.kind === 'polygon' ? a : null
})
const polygonPointCount = computed<number>({
get: () => polygonAsset.value?.points.length ?? 0,
set: (v: number) => {
const a = polygonAsset.value
if (!a) return
store.setPolygonPointCount(a.id, v)
},
})
const polygonClosed = computed<boolean>({
get: () => polygonAsset.value?.closed !== false,
set: (v: boolean) => {
const a = polygonAsset.value
if (!a) return
store.setPolygonClosed(a.id, v)
},
})
const canStroke = computed(() => {
const k = selectedAsset.value?.kind
return k === 'polygon' || k === 'rect' || k === 'circle' || k === 'text'
})
const strokeColor = computed<string>({
get: () => {
if (sampled.value?.stroke) return sampled.value.stroke
const a = selectedAsset.value
if (a && 'stroke' in a && a.stroke) return a.stroke
return '#000000'
},
set: (v: string) => {
if (!selected.value) return
store.upsertKeyframe(selected.value.id, store.playhead, { stroke: v })
},
})
const strokeWidth = computed<number>({
get: () => {
if (sampled.value?.strokeWidth !== undefined) return sampled.value.strokeWidth
const a = selectedAsset.value
if (a && 'strokeWidth' in a && a.strokeWidth !== undefined) return a.strokeWidth
return 0
},
set: (v: number) => {
if (!selected.value) return
const patch: Partial<AnimatableProps> = { strokeWidth: v }
// Always co-write a stroke color alongside a non-zero width so the
// outline stays visible regardless of prior width=0 states.
if (v > 0) {
const a = selectedAsset.value
const assetStroke = a && 'stroke' in a ? a.stroke : undefined
patch.stroke = sampled.value?.stroke ?? assetStroke ?? '#000000'
}
store.upsertKeyframe(selected.value.id, store.playhead, patch)
},
})
const blurAsset = computed(() => {
const a = selectedAsset.value
return a && (a.kind === 'rect' || a.kind === 'circle' || a.kind === 'polygon') ? a : null
@@ -162,9 +225,10 @@ const canFill = computed(() => {
const fillColor = computed<string>({
get: () => {
if (sampled.value?.fill) return sampled.value.fill
const samp = sampled.value?.fill
if (samp && samp !== 'transparent') return samp
const a = selectedAsset.value
if (a && 'fill' in a) return a.fill
if (a && 'fill' in a && a.fill && a.fill !== 'transparent') return a.fill
return '#000000'
},
set: (v: string) => {
@@ -173,6 +237,42 @@ const fillColor = computed<string>({
},
})
const fillTransparent = computed<boolean>({
get: () => {
const samp = sampled.value?.fill
if (samp !== undefined) return samp === 'transparent'
const a = selectedAsset.value
if (a && 'fill' in a) return a.fill === 'transparent'
return false
},
set: (v: boolean) => {
if (!selected.value) return
if (v) {
store.upsertKeyframe(selected.value.id, store.playhead, { fill: 'transparent' })
} else {
store.upsertKeyframe(selected.value.id, store.playhead, { fill: fillColor.value })
}
},
})
const strokeTransparent = computed<boolean>({
get: () => {
const s = sampled.value?.stroke
if (s !== undefined) return s === 'transparent'
const a = selectedAsset.value
if (a && 'stroke' in a) return a.stroke === 'transparent'
return false
},
set: (v: boolean) => {
if (!selected.value) return
if (v) {
store.upsertKeyframe(selected.value.id, store.playhead, { stroke: 'transparent' })
} else {
store.upsertKeyframe(selected.value.id, store.playhead, { stroke: strokeColor.value })
}
},
})
type NumericKey = 'x' | 'y' | 'scaleX' | 'scaleY' | 'rotation' | 'opacity'
function bind(key: NumericKey, decimals = 0) {
@@ -245,6 +345,24 @@ function removeKeyframeHere() {
</div>
</section>
<section v-if="polygonAsset" class="prop-group">
<button class="group-header" @click="toggleSection('polygon')">
<component :is="sectionOpen.polygon ? PhCaretDown : PhCaretRight" :size="11" />
<span>{{ t('properties.sections.polygon') }}</span>
</button>
<div v-if="sectionOpen.polygon" class="group-body">
<label class="inline-toggle">
<input type="checkbox" v-model="polygonClosed" />
<span>{{ t('properties.polygonClosed') }}</span>
</label>
<label>
{{ t('properties.polygonPoints') }}
<input type="number" min="3" max="64" v-model.number="polygonPointCount" />
</label>
<p class="hint">{{ t('properties.polygonPointsHint') }}</p>
</div>
</section>
<section class="prop-group">
<button class="group-header" @click="toggleSection('appearance')">
<component :is="sectionOpen.appearance ? PhCaretDown : PhCaretRight" :size="11" />
@@ -254,10 +372,47 @@ function removeKeyframeHere() {
<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>
<input
type="color"
class="color-swatch"
:value="fillColor"
:disabled="fillTransparent"
@input="fillColor = ($event.target as HTMLInputElement).value"
/>
<span class="color-hex">{{ fillTransparent ? '—' : fillColor.toUpperCase() }}</span>
<label class="alpha-toggle">
<input type="checkbox" v-model="fillTransparent" />
<span>{{ t('properties.transparent') }}</span>
</label>
</div>
</label>
<label v-if="canStroke" class="color-label">
{{ t('properties.stroke') }}
<div class="color-row">
<input
type="color"
class="color-swatch"
:value="strokeColor"
:disabled="strokeTransparent"
@input="strokeColor = ($event.target as HTMLInputElement).value"
/>
<span class="color-hex">{{ strokeTransparent ? '—' : strokeColor.toUpperCase() }}</span>
<label class="alpha-toggle">
<input type="checkbox" v-model="strokeTransparent" />
<span>{{ t('properties.transparent') }}</span>
</label>
</div>
</label>
<label v-if="canStroke" class="slider-label">
{{ t('properties.strokeWidth') }}
<RangeSlider
v-model="strokeWidth"
:min="0"
:max="32"
:step="0.5"
:format="(v: number) => `${v}px`"
/>
</label>
<label class="slider-label">
{{ t('properties.opacity') }}
<RangeSlider v-model="opacity" :min="0" :max="1" :step="0.01" :format="formatPercent" />
@@ -472,6 +627,23 @@ input {
padding: 0 2px 10px;
}
.inline-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text);
text-transform: none;
letter-spacing: 0;
cursor: pointer;
flex: 0 0 auto;
}
.inline-toggle input {
width: auto;
margin: 0;
}
.mask-toggle,
.mask-radio {
display: inline-flex;
@@ -617,6 +789,29 @@ input {
letter-spacing: 0.03em;
}
.alpha-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: auto;
font-size: 10px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.05em;
cursor: pointer;
flex: 0 0 auto;
}
.alpha-toggle input {
width: auto;
margin: 0;
}
.color-swatch:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.danger-btn {
justify-content: center;
margin-top: 6px;
+2 -2
View File
@@ -2254,12 +2254,12 @@ onBeforeUnmount(() => {
bottom: -1px;
}
.track.inactive .track-lane {
.track.inactive .track-lane > :not(.playhead) {
opacity: 0.4;
transition: opacity 140ms var(--ease);
}
.track.inactive:hover .track-lane {
.track.inactive:hover .track-lane > :not(.playhead) {
opacity: 0.75;
}