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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user