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

+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;