0.14.0: multi-folder media drawer

- Clipart store holds an array of root folders instead of a single
  handle, plus an activeHandleIndex for the in-folder view. Getters:
  activeHandle, inOverview, hasAnyFolder, canGoBack, folderName.
- Actions: addFolder (appends a root), enterFolder, exitFolder,
  removeFolder. Single-folder installs auto-drill into the sole root
  and behave exactly like the pre-refactor drawer; adding a 2nd+
  folder pops back to the root overview.
- IDB persistence migrated from the 'clipartDir' single-handle key to
  a 'clipartDirs' array. Legacy records are migrated on first load.
  saveClipartFolders unwraps Pinia proxies (toRaw per element) so
  IDB's structured clone accepts the payload.
- ClipartItem union gained a 'folder' kind so root tiles render as
  first-class media items. listClipart emits nested directories too,
  but the drawer keeps drill-down one level deep for now.
- ClipartDrawer header: three equally-sized .header-btn chips —
  back (only when canGoBack), refresh (when viewing a folder), and a
  new `+` (when any folder is attached). No more big 'Choose folder'
  button — empty state is a centred 96px dashed + with a wrapped hint.
- Folder tiles carry a hover `×` to remove a root from the library.
- DocumentSettings media tab lists all configured folders with a per-
  folder remove action; primary button is now 'Add folder'.
- Pinia store now calls acceptHMRUpdate so Vite HMR actually swaps
  the store definition between reloads instead of leaving the first-
  load state shape behind.
- Tests: store actions (addFolder / enterFolder / exitFolder / remove
  / IDB round-trip), drawer UI (centred + empty state, overview with
  folder tiles + click-to-enter, back visibility, single-root hides
  back, header + button triggers addFolder). Tests isolated with
  try/finally so a failing store assertion can't leak error state
  into subsequent drawer tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-24 08:33:20 +02:00
1 parent 8aa91fc27b
commit 9fe86b62b9
9 files changed
+698 -142

No files matched your search

+1 -1
View File
@@ -2809,7 +2809,7 @@ function onContainerDrop(e: DragEvent) {
e.preventDefault()
const idx = Number.parseInt(clipartIdx, 10)
const item = Number.isFinite(idx) ? clipart.items[idx] : null
if (item) {
if (item && item.kind !== 'folder') {
void importClipartAt(item.file, worldX, worldY)
}
return
+184 -40
View File
@@ -1,6 +1,16 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, watch } from 'vue'
import { PhArrowClockwise, PhCaretLeft, PhCaretRight, PhFolderOpen, PhImagesSquare, PhMusicNote } from '@phosphor-icons/vue'
import {
PhArrowClockwise,
PhArrowLeft,
PhCaretLeft,
PhCaretRight,
PhFolder,
PhImagesSquare,
PhMusicNote,
PhPlus,
PhX,
} from '@phosphor-icons/vue'
import { useClipartStore } from '@/stores/clipart'
import { readAudioDuration, readAsDataUrl } from '@/lib/fileLoader'
import {
@@ -9,14 +19,12 @@ import {
setAudioDragSource,
} from '@/composables/useAudioDragSource'
import { useFullscreen } from '@/composables/useFullscreen'
import { dialogState } from '@/components/ui'
import { confirmDialog, dialogState } from '@/components/ui'
import { t } from '@/i18n'
const clipart = useClipartStore()
const { isFullscreen } = useFullscreen()
// In browser-fullscreen mode the drawer auto-opens on tab hover and
// auto-closes when the mouse leaves the drawer — no click-toggle.
function onTabClick() {
if (isFullscreen.value) return
clipart.toggle()
@@ -55,10 +63,24 @@ onBeforeUnmount(() => {
document.removeEventListener('keydown', onDocKeyDown)
})
function onTileClick(index: number) {
const item = clipart.items[index]
if (!item || item.kind !== 'folder') return
// Folder tiles in overview mode index into `handles` — find which
// root this folder represents by name match, since `items` is
// synthesized from the handles list in overview mode.
const rootIdx = clipart.handles.findIndex((h) => h === item.handle)
if (rootIdx >= 0) clipart.enterFolder(rootIdx)
}
function onItemDragStart(e: DragEvent, index: number) {
if (!e.dataTransfer) return
const item = clipart.items[index]
if (!item) return
if (!item || item.kind === 'folder') {
// Folders aren't draggable onto the stage or timeline.
e.preventDefault()
return
}
e.dataTransfer.effectAllowed = 'copy'
e.dataTransfer.setData('application/x-animatr-clipart', String(index))
e.dataTransfer.setData('text/plain', item.name)
@@ -99,6 +121,21 @@ function onItemDragStart(e: DragEvent, index: number) {
}
setTimeout(() => ghost.remove(), 0)
}
async function onRemoveFolderTile(e: MouseEvent, index: number) {
e.stopPropagation()
const item = clipart.items[index]
if (!item || item.kind !== 'folder') return
const ok = await confirmDialog({
title: t('clipart.removeFolderDialog.title'),
message: t('clipart.removeFolderDialog.message', { name: item.name }),
confirmLabel: t('clipart.removeFolderDialog.confirm'),
variant: 'danger',
})
if (!ok) return
const rootIdx = clipart.handles.findIndex((h) => h === item.handle)
if (rootIdx >= 0) await clipart.removeFolder(rootIdx)
}
</script>
<template>
@@ -117,35 +154,56 @@ function onItemDragStart(e: DragEvent, index: number) {
</button>
<div class="drawer-panel">
<header class="drawer-header">
<PhImagesSquare :size="14" class="title-icon" />
<button
v-if="clipart.canGoBack"
class="header-btn"
v-tooltip="t('clipart.backToIndex')"
@click="clipart.exitFolder()"
>
<PhArrowLeft :size="13" />
</button>
<PhImagesSquare v-else :size="14" class="title-icon" />
<h3>{{ t('clipart.title') }}</h3>
<button
v-if="clipart.handle"
class="refresh-btn"
v-if="clipart.activeHandle"
class="header-btn"
v-tooltip="t('clipart.refresh')"
:disabled="clipart.loading"
@click="clipart.refresh()"
>
<PhArrowClockwise :size="13" />
</button>
<button
v-if="clipart.hasAnyFolder && clipart.supported"
class="header-btn"
v-tooltip="t('clipart.addFolderTooltip')"
@click="clipart.addFolder()"
>
<PhPlus :size="13" weight="bold" />
</button>
</header>
<div v-if="clipart.folderName" class="folder-row">
<span class="folder-name">{{ clipart.folderName }}</span>
<span class="count">{{ clipart.items.length }}</span>
</div>
<p v-if="!clipart.supported" class="empty-state">
<p v-if="!clipart.supported" class="empty-state empty-state-center">
{{ t('clipart.unsupported') }}
</p>
<div v-else-if="!clipart.handle" class="empty-state empty-state-action">
<p>{{ t('clipart.empty') }}</p>
<button class="choose-folder-btn" @click="clipart.pickFolder()">
<PhFolderOpen :size="14" />
<span>{{ t('clipart.chooseFolder') }}</span>
<div v-else-if="!clipart.hasAnyFolder" class="empty-state-center">
<button
class="empty-add-btn"
v-tooltip="t('clipart.addFolder')"
@click="clipart.addFolder()"
>
<PhPlus :size="40" weight="bold" />
</button>
<p class="empty-hint">{{ t('clipart.empty') }}</p>
</div>
<p v-else-if="clipart.error" class="error-state">{{ clipart.error }}</p>
<p v-else-if="clipart.loading" class="empty-state">{{ t('clipart.loading') }}</p>
<p v-else-if="clipart.items.length === 0" class="empty-state">
<p v-else-if="clipart.loading" class="empty-state empty-state-center">
{{ t('clipart.loading') }}
</p>
<p v-else-if="clipart.items.length === 0" class="empty-state empty-state-center">
{{ t('clipart.noFiles') }}
</p>
<div v-else class="tile-grid">
@@ -153,15 +211,29 @@ function onItemDragStart(e: DragEvent, index: number) {
v-for="(item, i) in clipart.items"
:key="item.name + i"
class="tile"
:class="{ 'audio-tile': item.kind === 'audio' }"
:class="{
'audio-tile': item.kind === 'audio',
'folder-tile': item.kind === 'folder',
}"
v-tooltip="item.name"
draggable="true"
:draggable="item.kind !== 'folder'"
@click="onTileClick(i)"
@dragstart="onItemDragStart($event, i)"
>
<img v-if="item.kind === 'image'" :src="item.url" :alt="item.name" />
<div v-else class="audio-icon">
<div v-else-if="item.kind === 'audio'" class="audio-icon">
<PhMusicNote :size="28" weight="regular" />
</div>
<div v-else class="folder-icon">
<PhFolder :size="36" weight="fill" />
<button
class="folder-remove"
v-tooltip="t('clipart.removeFolder')"
@click="onRemoveFolderTile($event, i)"
>
<PhX :size="10" weight="bold" />
</button>
</div>
<span class="tile-label">{{ item.name }}</span>
</div>
</div>
@@ -286,7 +358,10 @@ function onItemDragStart(e: DragEvent, index: number) {
font-weight: 600;
}
.refresh-btn {
/* Unified header button — same 24×24 outlined chip used for refresh,
add-folder, and the back-to-index control. Keeps the header rhythm
consistent regardless of which controls are surfaced. */
.header-btn {
width: 24px;
height: 24px;
padding: 0;
@@ -298,14 +373,15 @@ function onItemDragStart(e: DragEvent, index: number) {
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
}
.refresh-btn:hover:not(:disabled) {
.header-btn:hover:not(:disabled) {
color: var(--text);
background: var(--panel-alt);
}
.refresh-btn:disabled {
.header-btn:disabled {
opacity: 0.5;
cursor: wait;
}
@@ -346,33 +422,49 @@ function onItemDragStart(e: DragEvent, index: number) {
font-style: italic;
}
.empty-state-action {
/* Centred empty / loading / unsupported slot. Fills whatever vertical
space the drawer panel has left below the header so the big `+`
feels anchored at the visual centre. */
.empty-state-center {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
align-items: center;
justify-content: center;
gap: 14px;
padding: 24px;
text-align: center;
}
.empty-state-action p {
margin: 0;
}
.choose-folder-btn {
.empty-add-btn {
width: 96px;
height: 96px;
padding: 0;
border: 2px dashed var(--border-strong, var(--border));
background: transparent;
border-radius: 50%;
color: var(--text-dim);
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 1px solid var(--accent);
background: color-mix(in srgb, var(--accent) 14%, transparent);
color: var(--text);
border-radius: var(--radius-sm);
font-size: 12px;
justify-content: center;
cursor: pointer;
font-style: normal;
transition: border-color 140ms var(--ease), color 140ms var(--ease),
background 140ms var(--ease);
}
.choose-folder-btn:hover {
background: color-mix(in srgb, var(--accent) 22%, transparent);
.empty-add-btn:hover {
border-color: var(--accent);
color: var(--accent);
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
.empty-hint {
margin: 0;
max-width: 280px;
color: var(--text-dim);
font-size: 12px;
line-height: 1.45;
font-style: normal;
}
.error-state {
@@ -414,6 +506,15 @@ function onItemDragStart(e: DragEvent, index: number) {
cursor: grabbing;
}
.tile.folder-tile {
cursor: pointer;
position: relative;
}
.tile.folder-tile:active {
cursor: pointer;
}
.tile img {
width: 72px;
height: 72px;
@@ -434,6 +535,49 @@ function onItemDragStart(e: DragEvent, index: number) {
pointer-events: none;
}
.folder-icon {
position: relative;
width: 72px;
height: 72px;
display: flex;
align-items: center;
justify-content: center;
color: var(--accent);
background: color-mix(in srgb, var(--accent) 10%, transparent);
border-radius: var(--radius-sm);
pointer-events: none;
}
.folder-remove {
position: absolute;
top: 2px;
right: 2px;
width: 18px;
height: 18px;
padding: 0;
border: 1px solid var(--border);
background: var(--panel);
color: var(--text-dim);
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
pointer-events: auto;
transition: opacity 140ms var(--ease), color 140ms var(--ease),
border-color 140ms var(--ease);
}
.folder-tile:hover .folder-remove {
opacity: 1;
}
.folder-remove:hover {
color: var(--danger);
border-color: var(--danger);
}
.tile.audio-tile {
/* Same 1fr grid slot but the content is smaller — keep the tile the
same size as an image tile so the grid stays on a clean rhythm. */
+36 -14
View File
@@ -93,28 +93,35 @@ function applyTheme(t: Theme) {
<h3>{{ t('settings.tab.clipart') }}</h3>
<div class="clipart-row">
<div class="clipart-label">{{ t('clipart.folderLabel') }}</div>
<div class="clipart-value">
<ul v-if="clipart.hasAnyFolder" class="clipart-folder-list">
<li
v-for="(h, i) in clipart.handles"
:key="h.name + i"
class="clipart-value"
>
<PhFolder :size="13" />
<span class="clipart-folder-name">{{ h.name }}</span>
<button
class="ghost-btn small"
v-tooltip="t('clipart.removeFolder')"
@click="clipart.removeFolder(i)"
>
{{ t('clipart.clearFolder') }}
</button>
</li>
</ul>
<div v-else 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>
<span class="clipart-folder-empty">{{ t('clipart.noFolder') }}</span>
</div>
</div>
<div class="clipart-actions">
<button
class="primary-btn"
:disabled="!clipart.supported"
@click="clipart.pickFolder()"
@click="clipart.addFolder()"
>
{{ t('clipart.chooseFolder') }}
</button>
<button
v-if="clipart.handle"
class="ghost-btn"
@click="clipart.clearFolder()"
>
{{ t('clipart.clearFolder') }}
{{ t('clipart.addFolder') }}
</button>
</div>
<p v-if="!clipart.supported" class="error-line">
@@ -551,6 +558,7 @@ input[type='number'] {
}
.clipart-folder-name {
flex: 1 1 auto;
font-variant-numeric: tabular-nums;
overflow: hidden;
text-overflow: ellipsis;
@@ -562,6 +570,20 @@ input[type='number'] {
font-style: italic;
}
.clipart-folder-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.ghost-btn.small {
padding: 2px 8px;
font-size: 11px;
}
.clipart-actions {
display: flex;
gap: 8px;