0.4.0: clipart drawer, animation paths, bezier keyframes

- Clipart library: new left-edge drawer (5px idle, expands on hover,
  400px when open). User picks a local folder via the File System Access
  API in settings; directory handle is persisted in IndexedDB so the
  library survives reloads. Dragging a thumbnail onto the stage creates
  an image asset + layer at the drop point.
- Animation paths on the stage: selecting a keyframe draws the tween
  path from the previous keyframe to the selected one as a solid
  polyline with per-frame draggable dots. All other segments on the
  same position-track render as dashed ghost lines so the full curve
  progression is visible.
- Path dots grow on hover (2.5 → 6 px), show a grab cursor, and have a
  wider hit area so they're easier to grab.
- Bezier-curved position interpolation: keyframes gain optional
  ctrlX/ctrlY fields. When set, x/y tween along a quadratic bezier
  through the control point instead of a straight line. Dragging a
  path dot solves for the control so the curve passes through the
  drag point at its frame progress. Mirrored across all four render
  pipelines.
- Tests: new suites for polygon actions, freeform drawing lifecycle,
  polygon + stroke rendering (15 new tests). Fixed the mask-render
  tests that relied on a default-transparent canvas background.

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 10:35:43 +02:00
1 parent 276d621d20
commit 869b67458a
16 files changed
+1379 -15

No files matched your search

+131 -2
View File
@@ -1,7 +1,15 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { PhClock, PhFrameCorners, PhMoon, PhPalette, PhSun } from '@phosphor-icons/vue'
import {
PhClock,
PhFolder,
PhFrameCorners,
PhMoon,
PhPalette,
PhSun,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { useClipartStore } from '@/stores/clipart'
import { useTheme } from '@/composables/useTheme'
import type { Theme } from '@/composables/useTheme'
import { Modal } from '@/components/ui'
@@ -11,13 +19,15 @@ defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const store = useSceneStore()
const clipart = useClipartStore()
const { theme, setTheme } = useTheme()
type TabId = 'document' | 'timeline' | 'theme'
type TabId = 'document' | 'timeline' | 'clipart' | 'theme'
const tabs: { id: TabId; label: string; icon: typeof PhFrameCorners }[] = [
{ id: 'document', label: t('settings.tab.document'), icon: PhFrameCorners },
{ id: 'timeline', label: t('settings.tab.timeline'), icon: PhClock },
{ id: 'clipart', label: t('settings.tab.clipart'), icon: PhFolder },
{ id: 'theme', label: t('settings.tab.theme'), icon: PhPalette },
]
@@ -145,6 +155,41 @@ function applyTheme(t: Theme) {
</p>
</section>
<section v-else-if="activeTab === 'clipart'">
<h3>{{ t('settings.tab.clipart') }}</h3>
<div class="clipart-row">
<div class="clipart-label">{{ t('clipart.folderLabel') }}</div>
<div class="clipart-value">
<PhFolder :size="13" />
<span v-if="clipart.folderName" class="clipart-folder-name">
{{ clipart.folderName }}
</span>
<span v-else class="clipart-folder-empty">{{ t('clipart.noFolder') }}</span>
</div>
</div>
<div class="clipart-actions">
<button
class="primary-btn"
:disabled="!clipart.supported"
@click="clipart.pickFolder()"
>
{{ t('clipart.chooseFolder') }}
</button>
<button
v-if="clipart.handle"
class="ghost-btn"
@click="clipart.clearFolder()"
>
{{ t('clipart.clearFolder') }}
</button>
</div>
<p v-if="!clipart.supported" class="error-line">
{{ t('clipart.unsupported') }}
</p>
<p v-if="clipart.error" class="error-line">{{ clipart.error }}</p>
<p class="hint">{{ t('clipart.settingsHint') }}</p>
</section>
<section v-else-if="activeTab === 'theme'">
<h3>{{ t('settings.tab.theme') }}</h3>
<div class="theme-grid">
@@ -350,6 +395,90 @@ input[type='number'] {
margin: 0;
}
.clipart-row {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
}
.clipart-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-dim);
}
.clipart-value {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
background: var(--panel-alt);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-size: 12px;
}
.clipart-folder-name {
font-variant-numeric: tabular-nums;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.clipart-folder-empty {
color: var(--text-dim);
font-style: italic;
}
.clipart-actions {
display: flex;
gap: 8px;
margin-bottom: 10px;
}
.primary-btn {
padding: 6px 12px;
border-radius: var(--radius-sm);
background: var(--accent);
color: var(--accent-text, #fff);
border: 1px solid transparent;
font-size: 12px;
font-weight: 500;
cursor: pointer;
}
.primary-btn:hover:not(:disabled) {
filter: brightness(1.1);
}
.primary-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ghost-btn {
padding: 6px 12px;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
border: 1px solid var(--border);
font-size: 12px;
cursor: pointer;
}
.ghost-btn:hover {
background: var(--panel-alt);
}
.error-line {
margin: 0 0 8px;
color: var(--danger);
font-size: 11px;
}
.theme-grid {
display: grid;
grid-template-columns: 1fr 1fr;