0.2.0: polygon tool, mask editor preview, collapsible properties

- Polygon asset type with per-vertex animation: new sidebar button inserts a
  hexagon; vertex handles in the editor write per-keyframe `points` arrays
  that interpolate between keyframes (requires matching point counts).
- Canvas mask preview: toggle in the zoom toolbar switches the stage
  between the editable ghost view and a rendered view that applies the
  actual masking via the shared canvas 2D path.
- Properties panel is grouped into collapsible sections (Transform, Text,
  Appearance, Shadow, Mask) with per-section open/closed state persisted
  to localStorage.
- Sidebar shape buttons are icon-only to reclaim width for the Polygon
  entry.

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 01:29:27 +02:00
1 parent 03b1147a93
commit a05396e734
12 files changed
+611 -173

No files matched your search

+30 -9
View File
@@ -8,6 +8,7 @@ import {
PhListBullets,
PhPencilSimple,
PhPlus,
PhPolygon,
PhSquare,
PhTextT,
PhTrash,
@@ -56,6 +57,23 @@ function addSampleCircle() {
store.addLayer(asset.id)
}
function addSamplePolygon() {
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
points.push({ x: Math.cos(a) * radius, y: Math.sin(a) * radius })
}
const asset = store.addAsset({
kind: 'polygon',
name: 'Polygon',
points,
fill: '#9b7ede',
})
store.addLayer(asset.id)
}
async function addSampleText() {
const input = await promptDialog({
title: t('sidebar.newTextDialog.title'),
@@ -169,6 +187,7 @@ const assetIconMap: Record<Asset['kind'], typeof PhSquare> = {
circle: PhCircle,
image: PhImage,
text: PhTextT,
polygon: PhPolygon,
}
const imageAssets = computed(() =>
@@ -218,17 +237,17 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
</button>
<div class="actions">
<button class="shape-btn" @click="addSampleRect">
<PhSquare :size="14" />
{{ t('sidebar.shape.rect') }}
<button class="shape-btn" :title="t('sidebar.shape.rect')" @click="addSampleRect">
<PhSquare :size="16" />
</button>
<button class="shape-btn" @click="addSampleCircle">
<PhCircle :size="14" />
{{ t('sidebar.shape.circle') }}
<button class="shape-btn" :title="t('sidebar.shape.circle')" @click="addSampleCircle">
<PhCircle :size="16" />
</button>
<button class="shape-btn" @click="addSampleText">
<PhTextT :size="14" />
{{ t('sidebar.shape.text') }}
<button class="shape-btn" :title="t('sidebar.shape.text')" @click="addSampleText">
<PhTextT :size="16" />
</button>
<button class="shape-btn" :title="t('sidebar.shape.polygon')" @click="addSamplePolygon">
<PhPolygon :size="16" />
</button>
</div>
@@ -375,6 +394,8 @@ h4 {
.shape-btn {
flex: 1;
justify-content: center;
align-items: center;
padding: 6px 0;
font-size: 12px;
}
+159
View File
@@ -284,6 +284,8 @@ type KonvaCtx = {
closePath: () => void
rect: (x: number, y: number, w: number, h: number) => void
arc: (x: number, y: number, r: number, s: number, e: number, cc?: boolean) => void
moveTo: (x: number, y: number) => void
lineTo: (x: number, y: number) => void
fillStrokeShape: (shape: unknown) => void
save: () => void
restore: () => void
@@ -360,6 +362,43 @@ function textConfig(layer: Layer, asset: Extract<Asset, { kind: 'text' }>, props
}
}
function polygonConfig(
layer: Layer,
asset: Extract<Asset, { kind: 'polygon' }>,
props: AnimatableProps,
) {
const fill = props.fill ?? asset.fill
const blur = props.blur ?? asset.blur ?? 0
const offX = layer.offsetX ?? 0
const offY = layer.offsetY ?? 0
const pts = props.points ?? asset.points
const flat: number[] = []
for (const p of pts) {
flat.push(p.x - offX, p.y - offY)
}
const cfg: Record<string, unknown> = {
...baseNodeConfig(layer, props),
points: flat,
closed: true,
fill,
}
if (blur > 0) {
cfg.sceneFunc = (ctx: KonvaCtx, shape: KonvaShape) => {
ctx.save()
ctx.setAttr('filter', `blur(${blur}px)`)
ctx.beginPath()
if (pts.length > 0) {
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()
ctx.fillStrokeShape(shape)
ctx.restore()
}
}
return cfg
}
function imageConfig(
layer: Layer,
asset: Extract<Asset, { kind: 'image' }>,
@@ -436,6 +475,23 @@ function clipTextConfig(item: RenderedClipLayer) {
}
}
function clipPolygonConfig(item: RenderedClipLayer) {
const asset = item.asset as Extract<Asset, { kind: 'polygon' }>
const offX = item.layer.offsetX ?? 0
const offY = item.layer.offsetY ?? 0
const pts = item.props.points ?? asset.points
const flat: number[] = []
for (const p of pts) {
flat.push(p.x - offX, p.y - offY)
}
return {
...clipBaseConfig(item.instanceId, item.layer, item.props),
points: flat,
closed: true,
fill: item.props.fill ?? asset.fill,
}
}
function clipImageConfig(item: RenderedClipLayer) {
const asset = item.asset as Extract<Asset, { kind: 'image' }>
const img = imageCache.value[asset.id]
@@ -515,6 +571,89 @@ const pivotDotConfig = computed(() => ({
listening: false,
}))
interface VertexHandle {
layerId: string
index: number
x: number
y: number
}
const polygonVertexHandles = computed<VertexHandle[]>(() => {
const id = store.selectedLayerId
if (!id) return []
const layer = store.activeLayers.find((l) => l.id === id)
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 live = liveLayerPose.value
const cx = live && live.layerId === id ? live.x : sampled.x
const cy = live && live.layerId === id ? live.y : sampled.y
const rot = live && live.layerId === id ? live.rotation : sampled.rotation
const scX = live && live.layerId === id ? live.scaleX : sampled.scaleX
const scY = live && live.layerId === id ? live.scaleY : sampled.scaleY
const offX = layer.offsetX ?? 0
const offY = layer.offsetY ?? 0
const pts = sampled.points ?? asset.points
const theta = (rot * Math.PI) / 180
const c = Math.cos(theta)
const s = Math.sin(theta)
return pts.map((p, index) => {
const lx = (p.x - offX) * scX
const ly = (p.y - offY) * scY
return {
layerId: id,
index,
x: cx + lx * c - ly * s,
y: cy + lx * s + ly * c,
}
})
})
function vertexHandleConfig(h: VertexHandle) {
return {
x: h.x,
y: h.y,
radius: 5 / zoom.value,
stroke: '#6ea8ff',
strokeWidth: 1.5 / zoom.value,
fill: '#ffffff',
draggable: true,
name: 'vertex-handle',
}
}
function worldToPolygonLocal(layer: Layer, wx: number, wy: number): { x: number; y: number } {
const sampled = sampleLayer(layer, store.playhead)
const dx = wx - sampled.x
const dy = wy - sampled.y
const theta = (sampled.rotation * Math.PI) / 180
const c = Math.cos(theta)
const s = Math.sin(theta)
const unrotX = dx * c + dy * s
const unrotY = -dx * s + dy * c
const invSX = sampled.scaleX !== 0 ? 1 / sampled.scaleX : 1
const invSY = sampled.scaleY !== 0 ? 1 / sampled.scaleY : 1
return {
x: unrotX * invSX + (layer.offsetX ?? 0),
y: unrotY * invSY + (layer.offsetY ?? 0),
}
}
function onVertexDragMove(e: Konva.KonvaEventObject<DragEvent>, index: number) {
const id = store.selectedLayerId
if (!id) return
const layer = store.activeLayers.find((l) => l.id === id)
if (!layer) return
const node = e.target
const local = worldToPolygonLocal(layer, node.x(), node.y())
store.movePolygonVertex(id, index, local.x, local.y)
}
function onVertexDragEnd(e: Konva.KonvaEventObject<DragEvent>, index: number) {
onVertexDragMove(e, index)
}
function onPivotDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
const info = pivotInfo.value
if (!info) return
@@ -615,6 +754,7 @@ function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
}
if (target.findAncestor?.('.pivot-handle')) return
if (target.findAncestor?.('.rotation-handle')) return
if (target.name?.() === 'vertex-handle' || target.findAncestor?.('.vertex-handle')) return
if (target === target.getStage()) {
store.selectLayer(null)
return
@@ -985,6 +1125,14 @@ onBeforeUnmount(() => {
@transform="handleTransform"
@transformend="handleTransformEnd"
/>
<v-line
v-else-if="entry.item.asset.kind === 'polygon'"
:config="polygonConfig(entry.item.layer, entry.item.asset, entry.item.props)"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@transformend="handleTransformEnd"
/>
<v-image
v-else-if="entry.item.asset.kind === 'image' && imageConfig(entry.item.layer, entry.item.asset, entry.item.props)"
:config="imageConfig(entry.item.layer, entry.item.asset, entry.item.props)!"
@@ -1007,6 +1155,10 @@ onBeforeUnmount(() => {
v-else-if="entry.item.asset.kind === 'text'"
:config="clipTextConfig(entry.item)"
/>
<v-line
v-else-if="entry.item.asset.kind === 'polygon'"
:config="clipPolygonConfig(entry.item)"
/>
<v-image
v-else-if="entry.item.asset.kind === 'image' && clipImageConfig(entry.item)"
:config="clipImageConfig(entry.item)!"
@@ -1032,6 +1184,13 @@ onBeforeUnmount(() => {
>
<v-circle :config="rotationKnobConfig" />
</v-group>
<v-circle
v-for="h in polygonVertexHandles"
:key="`vh-${h.layerId}-${h.index}`"
:config="vertexHandleConfig(h)"
@dragmove="(e: Konva.KonvaEventObject<DragEvent>) => onVertexDragMove(e, h.index)"
@dragend="(e: Konva.KonvaEventObject<DragEvent>) => onVertexDragEnd(e, h.index)"
/>
</v-group>
<v-transformer ref="transformerRef" :config="transformerConfig" />
</v-layer>
+228 -153
View File
@@ -1,12 +1,52 @@
<script setup lang="ts">
import { computed } from 'vue'
import { PhDiamond, PhTrash, PhX } from '@phosphor-icons/vue'
import { computed, ref, watch } from 'vue'
import { PhCaretDown, PhCaretRight, PhDiamond, PhTrash, PhX } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { sampleLayer } from '@/lib/interpolate'
import type { AnimatableProps } from '@/types/scene'
import RangeSlider from './RangeSlider.vue'
import { t } from '@/i18n'
const SECTION_STORAGE_KEY = 'animatr:propSections'
type SectionKey = 'transform' | 'text' | 'appearance' | 'shadow' | 'mask'
const defaultOpen: Record<SectionKey, boolean> = {
transform: true,
text: true,
appearance: true,
shadow: false,
mask: false,
}
function readStoredOpen(): Record<SectionKey, boolean> {
try {
const raw = localStorage.getItem(SECTION_STORAGE_KEY)
if (!raw) return { ...defaultOpen }
const parsed = JSON.parse(raw) as Partial<Record<SectionKey, boolean>>
return { ...defaultOpen, ...parsed }
} catch {
return { ...defaultOpen }
}
}
const sectionOpen = ref<Record<SectionKey, boolean>>(readStoredOpen())
watch(
sectionOpen,
(val) => {
try {
localStorage.setItem(SECTION_STORAGE_KEY, JSON.stringify(val))
} catch {
/* ignore */
}
},
{ deep: true },
)
function toggleSection(key: SectionKey) {
sectionOpen.value[key] = !sectionOpen.value[key]
}
const formatPercent = (v: number) => `${Math.round(v * 100)}%`
const store = useSceneStore()
@@ -36,12 +76,12 @@ const textAsset = computed(() => {
const blurAsset = computed(() => {
const a = selectedAsset.value
return a && (a.kind === 'rect' || a.kind === 'circle') ? a : null
return a && (a.kind === 'rect' || a.kind === 'circle' || a.kind === 'polygon') ? a : null
})
const canBeMask = computed(() => {
const a = selectedAsset.value
return !!a && (a.kind === 'rect' || a.kind === 'circle' || a.kind === 'text')
return !!a && (a.kind === 'rect' || a.kind === 'circle' || a.kind === 'text' || a.kind === 'polygon')
})
const maskInGroup = computed(() => {
@@ -117,7 +157,7 @@ const shadowColorValue = computed<string>({
const canFill = computed(() => {
const a = selectedAsset.value
return !!a && (a.kind === 'rect' || a.kind === 'circle' || a.kind === 'text')
return !!a && (a.kind === 'rect' || a.kind === 'circle' || a.kind === 'text' || a.kind === 'polygon')
})
const fillColor = computed<string>({
@@ -176,131 +216,160 @@ function removeKeyframeHere() {
</button>
</div>
<div class="row">
<label>X<input type="number" v-model.number="x" /></label>
<label>Y<input type="number" v-model.number="y" /></label>
</div>
<div class="row">
<label>Scale X<input type="number" step="0.1" v-model.number="scaleX" /></label>
<label>Scale Y<input type="number" step="0.1" v-model.number="scaleY" /></label>
</div>
<label>Rotation<input type="number" v-model.number="rotation" /></label>
<template v-if="textAsset">
<label>Text<input type="text" v-model="textAsset.text" /></label>
<label>Font Size<input type="number" min="1" v-model.number="textAsset.fontSize" /></label>
</template>
<label v-if="blurAsset" class="slider-label">
{{ t('properties.edgeBlur') }}
<RangeSlider
v-model="blurValue"
:min="0"
:max="64"
:step="1"
:format="(v: number) => `${Math.round(v)}px`"
/>
</label>
<section class="shadow-group">
<h4>{{ t('properties.shadow') }}</h4>
<label class="color-label">
{{ t('properties.shadowColor') }}
<div class="color-row">
<input
type="color"
class="color-swatch"
:value="shadowColorValue"
@input="shadowColorValue = ($event.target as HTMLInputElement).value"
/>
<span class="color-hex">{{ shadowColorValue.toUpperCase() }}</span>
<section class="prop-group">
<button class="group-header" @click="toggleSection('transform')">
<component :is="sectionOpen.transform ? PhCaretDown : PhCaretRight" :size="11" />
<span>{{ t('properties.sections.transform') }}</span>
</button>
<div v-if="sectionOpen.transform" class="group-body">
<div class="row">
<label>X<input type="number" v-model.number="x" /></label>
<label>Y<input type="number" v-model.number="y" /></label>
</div>
</label>
<label class="slider-label">
{{ t('properties.shadowOpacity') }}
<RangeSlider
v-model="shadowOpacity"
:min="0"
:max="1"
:step="0.01"
:format="formatPercent"
/>
</label>
<label class="slider-label">
{{ t('properties.shadowBlur') }}
<RangeSlider
v-model="shadowBlur"
:min="0"
:max="64"
:step="1"
:format="(v: number) => `${Math.round(v)}px`"
/>
</label>
<label class="slider-label">
{{ t('properties.shadowDistance') }}
<RangeSlider
v-model="shadowDistance"
:min="0"
:max="100"
:step="1"
:format="(v: number) => `${Math.round(v)}px`"
/>
</label>
<label class="slider-label">
{{ t('properties.shadowAngle') }}
<RangeSlider
v-model="shadowAngle"
:min="0"
:max="360"
:step="1"
:format="(v: number) => `${Math.round(v)}°`"
/>
</label>
<div class="row">
<label>Scale X<input type="number" step="0.1" v-model.number="scaleX" /></label>
<label>Scale Y<input type="number" step="0.1" v-model.number="scaleY" /></label>
</div>
<label>Rotation<input type="number" v-model.number="rotation" /></label>
</div>
</section>
<label v-if="canFill" class="color-label">
Color
<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>
</div>
</label>
<label class="slider-label">
Opacity
<RangeSlider v-model="opacity" :min="0" :max="1" :step="0.01" :format="formatPercent" />
</label>
<section v-if="canBeMask" class="mask-group">
<h4>{{ t('properties.mask') }}</h4>
<label class="mask-toggle">
<input
type="checkbox"
:checked="!!selected.isMask"
@change="store.setLayerMask(selected.id, ($event.target as HTMLInputElement).checked)"
/>
<span>{{ t('properties.maskUse') }}</span>
</label>
<div v-if="selected.isMask" class="mask-mode-row">
<label class="mask-radio">
<input
type="radio"
:checked="(selected.maskMode ?? 'positive') === 'positive'"
@change="store.setLayerMaskMode(selected.id, 'positive')"
/>
<span>{{ t('properties.maskPositive') }}</span>
<section v-if="textAsset" class="prop-group">
<button class="group-header" @click="toggleSection('text')">
<component :is="sectionOpen.text ? PhCaretDown : PhCaretRight" :size="11" />
<span>{{ t('properties.sections.text') }}</span>
</button>
<div v-if="sectionOpen.text" class="group-body">
<label>Text<input type="text" v-model="textAsset.text" /></label>
<label>Font Size<input type="number" min="1" v-model.number="textAsset.fontSize" /></label>
</div>
</section>
<section class="prop-group">
<button class="group-header" @click="toggleSection('appearance')">
<component :is="sectionOpen.appearance ? PhCaretDown : PhCaretRight" :size="11" />
<span>{{ t('properties.sections.appearance') }}</span>
</button>
<div v-if="sectionOpen.appearance" class="group-body">
<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>
</div>
</label>
<label class="mask-radio">
<input
type="radio"
:checked="selected.maskMode === 'negative'"
@change="store.setLayerMaskMode(selected.id, 'negative')"
<label class="slider-label">
{{ t('properties.opacity') }}
<RangeSlider v-model="opacity" :min="0" :max="1" :step="0.01" :format="formatPercent" />
</label>
<label v-if="blurAsset" class="slider-label">
{{ t('properties.edgeBlur') }}
<RangeSlider
v-model="blurValue"
:min="0"
:max="64"
:step="1"
:format="(v: number) => `${Math.round(v)}px`"
/>
<span>{{ t('properties.maskNegative') }}</span>
</label>
</div>
<p v-if="selected.isMask && maskInGroup" class="status-active">
{{ t('properties.maskActive') }}
</p>
<p v-if="selected.isMask && !maskInGroup" class="status-warn">
{{ t('properties.maskNeedsGroup') }}
</p>
<p v-if="selected.isMask" class="hint">{{ t('properties.maskHint') }}</p>
</section>
<section class="prop-group">
<button class="group-header" @click="toggleSection('shadow')">
<component :is="sectionOpen.shadow ? PhCaretDown : PhCaretRight" :size="11" />
<span>{{ t('properties.shadow') }}</span>
</button>
<div v-if="sectionOpen.shadow" class="group-body">
<label class="color-label">
{{ t('properties.shadowColor') }}
<div class="color-row">
<input
type="color"
class="color-swatch"
:value="shadowColorValue"
@input="shadowColorValue = ($event.target as HTMLInputElement).value"
/>
<span class="color-hex">{{ shadowColorValue.toUpperCase() }}</span>
</div>
</label>
<label class="slider-label">
{{ t('properties.shadowOpacity') }}
<RangeSlider v-model="shadowOpacity" :min="0" :max="1" :step="0.01" :format="formatPercent" />
</label>
<label class="slider-label">
{{ t('properties.shadowBlur') }}
<RangeSlider
v-model="shadowBlur"
:min="0"
:max="64"
:step="1"
:format="(v: number) => `${Math.round(v)}px`"
/>
</label>
<label class="slider-label">
{{ t('properties.shadowDistance') }}
<RangeSlider
v-model="shadowDistance"
:min="0"
:max="100"
:step="1"
:format="(v: number) => `${Math.round(v)}px`"
/>
</label>
<label class="slider-label">
{{ t('properties.shadowAngle') }}
<RangeSlider
v-model="shadowAngle"
:min="0"
:max="360"
:step="1"
:format="(v: number) => `${Math.round(v)}°`"
/>
</label>
</div>
</section>
<section v-if="canBeMask" class="prop-group">
<button class="group-header" @click="toggleSection('mask')">
<component :is="sectionOpen.mask ? PhCaretDown : PhCaretRight" :size="11" />
<span>{{ t('properties.mask') }}</span>
</button>
<div v-if="sectionOpen.mask" class="group-body">
<label class="mask-toggle">
<input
type="checkbox"
:checked="!!selected.isMask"
@change="store.setLayerMask(selected.id, ($event.target as HTMLInputElement).checked)"
/>
<span>{{ t('properties.maskUse') }}</span>
</label>
<div v-if="selected.isMask" class="mask-mode-row">
<label class="mask-radio">
<input
type="radio"
:checked="(selected.maskMode ?? 'positive') === 'positive'"
@change="store.setLayerMaskMode(selected.id, 'positive')"
/>
<span>{{ t('properties.maskPositive') }}</span>
</label>
<label class="mask-radio">
<input
type="radio"
:checked="selected.maskMode === 'negative'"
@change="store.setLayerMaskMode(selected.id, 'negative')"
/>
<span>{{ t('properties.maskNegative') }}</span>
</label>
</div>
<p v-if="selected.isMask && maskInGroup" class="status-active">
{{ t('properties.maskActive') }}
</p>
<p v-if="selected.isMask && !maskInGroup" class="status-warn">
{{ t('properties.maskNeedsGroup') }}
</p>
<p v-if="selected.isMask" class="hint">{{ t('properties.maskHint') }}</p>
</div>
</section>
<button class="danger-btn" @click="store.removeLayer(selected.id)">
@@ -317,7 +386,7 @@ function removeKeyframeHere() {
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
gap: 8px;
}
h3 {
@@ -363,38 +432,44 @@ input {
padding: 8px 0;
}
.shadow-group {
.prop-group {
display: flex;
flex-direction: column;
border-top: 1px solid var(--border);
margin: 0;
padding: 0;
}
.group-header {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 8px 4px;
background: transparent;
border: none;
color: var(--text-dim);
font-size: 10px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.08em;
cursor: pointer;
text-align: left;
}
.group-header:hover {
color: var(--text);
}
.group-header svg {
flex-shrink: 0;
}
.group-body {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 8px;
border-top: 1px solid var(--border);
}
.shadow-group h4 {
margin: 0;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
font-weight: 500;
}
.mask-group {
display: flex;
flex-direction: column;
gap: 6px;
padding-top: 8px;
border-top: 1px solid var(--border);
}
.mask-group h4 {
margin: 0;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
font-weight: 500;
padding: 0 2px 10px;
}
.mask-toggle,