0.10.2: audio waveforms + sidebar section polish

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-22 13:45:34 +02:00
1 parent c5b1dd566a
commit 87de1344c4
6 files changed
+587 -39

No files matched your search

+234 -36
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { computed, ref, watchEffect } from 'vue'
import {
PhCaretDown,
PhCaretRight,
PhCircle,
PhCursorClick,
PhFilmStrip,
@@ -33,6 +35,39 @@ const uploadError = ref<string | null>(null)
const { enabled: showThumbs } = useAssetThumbsPref()
/** Per-section collapsed state, persisted so the user's layout
* survives reloads. Each key is false (expanded) by default —
* that's the desired first-run behaviour per product brief. */
type AssetSection = 'images' | 'clips' | 'buttons' | 'audio'
const ASSET_SECTION_KEY = 'animatr:assetSectionsCollapsed'
function readSectionState(): Record<AssetSection, boolean> {
try {
const raw = localStorage.getItem(ASSET_SECTION_KEY)
if (!raw) return { images: false, clips: false, buttons: false, audio: false }
const parsed = JSON.parse(raw) as Partial<Record<AssetSection, boolean>>
return {
images: !!parsed.images,
clips: !!parsed.clips,
buttons: !!parsed.buttons,
audio: !!parsed.audio,
}
} catch {
return { images: false, clips: false, buttons: false, audio: false }
}
}
const collapsedSections = ref<Record<AssetSection, boolean>>(readSectionState())
function toggleSection(key: AssetSection) {
collapsedSections.value[key] = !collapsedSections.value[key]
try {
localStorage.setItem(ASSET_SECTION_KEY, JSON.stringify(collapsedSections.value))
} catch {
/* noop */
}
}
function addSampleRect() {
const asset = store.addAsset({
kind: 'rect',
@@ -389,7 +424,24 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
@dragleave="onPanelDragLeave"
@drop="onPanelDrop"
>
<h3>Assets</h3>
<div class="assets-header">
<h3>Assets</h3>
<label
class="thumbs-switch"
v-tooltip="showThumbs ? t('sidebar.thumbnailsHide') : t('sidebar.thumbnailsShow')"
>
<span class="thumbs-switch-label">{{ t('sidebar.thumbsToggle') }}</span>
<input
type="checkbox"
class="thumbs-switch-input"
:checked="showThumbs"
@change="showThumbs = ($event.target as HTMLInputElement).checked"
/>
<span class="thumbs-switch-track">
<span class="thumbs-switch-knob"></span>
</span>
</label>
</div>
<button class="upload-btn" @click="triggerUpload">
<PhUploadSimple :size="16" />
@@ -432,18 +484,15 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
/>
<p v-if="uploadError" class="error">{{ uploadError }}</p>
<div class="lib-header">
<h4>{{ t('sidebar.library') }}</h4>
<button
class="thumb-toggle"
v-tooltip="showThumbs ? t('sidebar.thumbnailsHide') : t('sidebar.thumbnailsShow')"
@click="showThumbs = !showThumbs"
>
<PhImageSquare v-if="showThumbs" :size="13" />
<PhListBullets v-else :size="13" />
<!-- Images -->
<div class="section-head-row" :class="{ collapsed: collapsedSections.images }">
<button class="section-head" @click="toggleSection('images')">
<component :is="collapsedSections.images ? PhCaretRight : PhCaretDown" :size="11" class="section-caret" />
<span class="section-title">{{ t('sidebar.images') }}</span>
</button>
<span class="section-count">{{ imageAssets.length }}</span>
</div>
<ul class="assets" :class="{ compact: !showThumbs }">
<ul v-show="!collapsedSections.images" class="assets" :class="{ compact: !showThumbs }">
<li
v-for="asset in imageAssets"
:key="asset.id"
@@ -473,13 +522,18 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
<li v-if="!imageAssets.length" class="empty">{{ t('sidebar.noImages') }}</li>
</ul>
<div class="lib-header clips-header">
<h4>{{ t('sidebar.clips') }}</h4>
<button class="thumb-toggle" v-tooltip="t('sidebar.createClipTooltip')" @click="addClip">
<!-- Clips -->
<div class="section-head-row" :class="{ collapsed: collapsedSections.clips }">
<button class="section-head" @click="toggleSection('clips')">
<component :is="collapsedSections.clips ? PhCaretRight : PhCaretDown" :size="11" class="section-caret" />
<span class="section-title">{{ t('sidebar.clips') }}</span>
</button>
<button class="section-action" v-tooltip="t('sidebar.createClipTooltip')" @click.stop="addClip">
<PhPlus :size="12" weight="bold" />
</button>
<span class="section-count">{{ clips.length }}</span>
</div>
<ul class="clips">
<ul v-show="!collapsedSections.clips" class="clips">
<li
v-for="clip in clips"
:key="clip.id"
@@ -514,13 +568,18 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
<li v-if="!clips.length" class="empty">{{ t('sidebar.noClips') }}</li>
</ul>
<div class="lib-header clips-header">
<h4>{{ t('sidebar.buttons') }}</h4>
<button class="thumb-toggle" v-tooltip="t('sidebar.createButtonTooltip')" @click="addButton">
<!-- Buttons -->
<div class="section-head-row" :class="{ collapsed: collapsedSections.buttons }">
<button class="section-head" @click="toggleSection('buttons')">
<component :is="collapsedSections.buttons ? PhCaretRight : PhCaretDown" :size="11" class="section-caret" />
<span class="section-title">{{ t('sidebar.buttons') }}</span>
</button>
<button class="section-action" v-tooltip="t('sidebar.createButtonTooltip')" @click.stop="addButton">
<PhPlus :size="12" weight="bold" />
</button>
<span class="section-count">{{ buttonAssets.length }}</span>
</div>
<ul class="clips" :class="{ compact: !showThumbs }">
<ul v-show="!collapsedSections.buttons" class="clips" :class="{ compact: !showThumbs }">
<li
v-for="ba in buttonAssets"
:key="ba.id"
@@ -569,10 +628,15 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
<li v-if="!buttonAssets.length" class="empty">{{ t('sidebar.noButtons') }}</li>
</ul>
<div class="lib-header clips-header">
<h4>{{ t('sidebar.audio') }}</h4>
<!-- Audio -->
<div class="section-head-row" :class="{ collapsed: collapsedSections.audio }">
<button class="section-head" @click="toggleSection('audio')">
<component :is="collapsedSections.audio ? PhCaretRight : PhCaretDown" :size="11" class="section-caret" />
<span class="section-title">{{ t('sidebar.audio') }}</span>
</button>
<span class="section-count">{{ audioAssets.length }}</span>
</div>
<ul class="clips">
<ul v-show="!collapsedSections.audio" class="clips">
<li
v-for="asset in audioAssets"
:key="asset.id"
@@ -682,27 +746,159 @@ h4 {
border-radius: var(--radius-sm);
}
.lib-header {
.assets-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 6px;
gap: 10px;
}
.thumb-toggle {
/* Compact slide switch labelled "Thumbs" — native checkbox stays hidden
* for a11y (keyboard + screen readers), the visible track + knob is
* driven purely by the `:checked` state. */
.thumbs-switch {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
user-select: none;
font-size: 11px;
color: var(--text-dim);
}
.thumbs-switch-label {
font-weight: 500;
letter-spacing: 0.01em;
}
.thumbs-switch-input {
/* Keep focus behaviour via the native input; the visual sits next to it. */
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
.thumbs-switch-track {
position: relative;
width: 26px;
height: 14px;
border-radius: 999px;
background: var(--border);
transition: background 140ms var(--ease);
flex-shrink: 0;
}
.thumbs-switch-knob {
position: absolute;
top: 1.5px;
left: 1.5px;
width: 11px;
height: 11px;
border-radius: 50%;
background: var(--panel);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
transition: left 140ms var(--ease), background 140ms var(--ease);
}
.thumbs-switch-input:checked + .thumbs-switch-track {
background: var(--accent);
}
.thumbs-switch-input:checked + .thumbs-switch-track .thumbs-switch-knob {
left: 13.5px;
background: #fff;
}
.thumbs-switch-input:focus-visible + .thumbs-switch-track {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.thumbs-switch:hover .thumbs-switch-track {
filter: brightness(1.08);
}
/* Section rows: unified layout — [caret + title][action?][count]. The
* count always sits at the far right so the right column is visually
* stable regardless of whether a row has a create-action (+) beside it. */
.section-head-row {
display: flex;
align-items: center;
gap: 4px;
margin-top: 4px;
border-bottom: 1px solid var(--border);
}
.section-head-row.collapsed {
border-bottom-color: transparent;
}
.section-head {
flex: 1;
display: flex;
align-items: center;
gap: 6px;
padding: 6px 4px;
background: transparent;
border: none;
color: var(--text);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
cursor: pointer;
text-align: left;
}
.section-head:hover {
background: color-mix(in srgb, var(--panel-alt) 60%, transparent);
}
.section-caret {
color: var(--text-dim);
flex-shrink: 0;
}
.section-title {
flex: 1;
}
.section-action {
width: 22px;
height: 22px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid var(--border);
border: none;
color: var(--text-dim);
border-radius: var(--radius-sm);
border-radius: 50%;
cursor: pointer;
flex-shrink: 0;
transition: background 140ms var(--ease), color 140ms var(--ease);
}
.thumb-toggle:hover:not(:disabled) {
background: var(--panel-alt);
color: var(--text);
.section-action:hover {
background: var(--accent);
color: #fff;
}
.section-count {
font-variant-numeric: tabular-nums;
font-weight: 500;
font-size: 10px;
text-transform: none;
letter-spacing: 0;
color: var(--text-dim);
padding: 1px 6px;
border-radius: 999px;
background: var(--border);
flex-shrink: 0;
min-width: 20px;
text-align: center;
}
.assets {
@@ -799,16 +995,21 @@ h4 {
width: 22px;
height: 22px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid var(--border);
border: none;
color: var(--text-dim);
border-radius: 50%;
flex-shrink: 0;
cursor: pointer;
transition: background 140ms var(--ease), color 140ms var(--ease);
}
.add-layer:hover:not(:disabled) {
color: var(--accent);
border-color: var(--accent);
background: var(--accent);
color: #fff;
}
.empty {
@@ -818,9 +1019,6 @@ h4 {
justify-content: center;
}
.clips-header {
margin-top: 14px;
}
.clips {
list-style: none;
+88 -2
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch, type ComponentPublicInstance } from 'vue'
import {
PhArrowsSplit,
PhCaretDown,
@@ -33,6 +33,7 @@ import { useSceneStore } from '@/stores/scene'
import { useClipartStore } from '@/stores/clipart'
import { importAudioFile } from '@/lib/fileLoader'
import { audioDragSource, clearAudioDragSource } from '@/composables/useAudioDragSource'
import { drawWaveform, getWaveformSlot, slicePeaksForClip } from '@/composables/useAudioWaveform'
import { sampleLayer } from '@/lib/interpolate'
import { snapToCandidates } from '@/lib/timelineSnap'
import { confirmDialog, promptDialog } from '@/components/ui'
@@ -1760,6 +1761,42 @@ function audioClipTooltip(clip: AudioClip): string {
return `${asset.name} · ${audibleDurationOf(clip).toFixed(2)}s`
}
/** Resolved peaks for a clip's asset — undefined while the decoder
* is still working (first render shows an empty block; waveform
* fades in once computation finishes). */
function clipPeaks(clip: AudioClip): Float32Array | undefined {
const asset = audioAssetById.value.get(clip.audioAssetId)
if (!asset || !asset.src) return undefined
const slot = getWaveformSlot(asset.id, asset.src)
if (!slot || slot.state !== 'ready') return undefined
return slicePeaksForClip(slot.peaks, asset.duration, clip.trimStart ?? 0, clip.trimEnd ?? 0)
}
/** Canvas ref-callback: redraw on every Vue update so the bars track
* the clip's current width (resizes on zoom + trim changes). Cheap
* because peaks are cached and the draw itself is a few hundred
* fillRects. */
function renderWaveformCanvas(clip: AudioClip) {
return (raw: Element | ComponentPublicInstance | null) => {
if (!raw || !(raw instanceof HTMLCanvasElement)) return
const el = raw
const peaks = clipPeaks(clip)
if (!peaks) return
const parent = el.parentElement
if (!parent) return
const pr = window.devicePixelRatio || 1
const cssW = Math.max(1, parent.clientWidth - 2)
const cssH = Math.max(1, parent.clientHeight - 4)
const pxW = Math.max(1, Math.round(cssW * pr))
const pxH = Math.max(1, Math.round(cssH * pr))
if (el.width !== pxW) el.width = pxW
if (el.height !== pxH) el.height = pxH
el.style.width = cssW + 'px'
el.style.height = cssH + 'px'
drawWaveform(el, peaks, { pixelRatio: pr })
}
}
/** Would a hypothetical audible block spanning [start, start+length] on
* `trackId` collide with an existing clip? Clips on the same track
* must not overlap — the user has to pick a second track for parallel
@@ -3211,6 +3248,11 @@ onBeforeUnmount(() => {
@pointercancel="onAudioClipPointerUp"
@contextmenu="onAudioClipContextMenu($event, clip)"
>
<canvas
v-if="clipPeaks(clip)"
class="audio-clip-wave"
:ref="renderWaveformCanvas(clip)"
/>
<span class="audio-clip-label">
{{ audioAssetById.get(clip.audioAssetId)?.name ?? '—' }}
</span>
@@ -3824,6 +3866,29 @@ onBeforeUnmount(() => {
.snap-btn.add-kf-btn {
margin-left: auto;
/* Softer-than-the-other-toolbar-buttons treatment: no border, round
* hit-area, accent-filled on hover — matches the sidebar's section
* `+` buttons so the two "create new thing" affordances read the
* same visually. */
border: none;
border-radius: 50%;
background: transparent;
}
.snap-btn.add-kf-btn:hover:not(:disabled) {
background: var(--accent);
color: #fff;
border-color: transparent;
}
.snap-btn.add-kf-btn.active {
background: var(--accent);
color: #fff;
border-color: transparent;
}
.snap-btn.add-kf-btn:disabled {
background: transparent;
}
.mode-toggle {
@@ -3835,7 +3900,15 @@ onBeforeUnmount(() => {
}
.mode-btn {
padding: 3px 8px;
/* Match the height of the sibling .snap-btn icons so the timeline
* toolbar sits on one visual baseline — the wrapper .mode-toggle's
* 1px border is already accounted for by `box-sizing: border-box`
* higher up, so the inner 20px hit area stays flush with neighbours. */
display: inline-flex;
align-items: center;
justify-content: center;
height: 20px;
padding: 0 8px;
min-width: 22px;
background: transparent;
border: none;
@@ -4729,6 +4802,15 @@ onBeforeUnmount(() => {
user-select: none;
}
.audio-clip-wave {
position: absolute;
left: 1px;
right: 1px;
top: 2px;
pointer-events: none;
opacity: 0.85;
}
.audio-clip-block:active,
.audio-clip-block.moving {
cursor: grabbing;
@@ -4758,12 +4840,16 @@ onBeforeUnmount(() => {
}
.audio-clip-label {
position: relative;
font-size: 10px;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
/* Tiny shadow keeps the text legible over the waveform fill. */
text-shadow: 0 0 2px rgba(0, 0, 0, 0.4);
z-index: 1;
}
.clip-block {