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:
1 parent
03b1147a93
commit
a05396e734
12 files changed
+611
-173
No files matched your search
+228
-153
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user