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

+239 -22
View File
@@ -4114,15 +4114,16 @@ export function registerAllTests(): void {
expect(localStorage.getItem('animatr:clipartDrawerOpen')).toBe('0')
})
test('clearFolder resets handle, folderName and items', async () => {
test('clearFolder resets handles, active index and items', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
// simulate a loaded state
s.folderName = 'mock'
s.handle = {} as FileSystemDirectoryHandle
s.handles = [{ name: 'mock' } as unknown as FileSystemDirectoryHandle]
s.activeHandleIndex = 0
s.items = []
await s.clearFolder()
expect(s.handle).toBeNull()
expect(s.handles.length).toBe(0)
expect(s.activeHandleIndex).toBeNull()
expect(s.folderName).toBeNull()
expect(s.items.length).toBe(0)
})
@@ -4133,6 +4134,109 @@ export function registerAllTests(): void {
const s = useClipartStore()
expect(s.supported).toBe(isFileSystemAccessSupported())
})
test('single-handle auto-drill: activeHandle points at the sole root', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
await s.clearFolder()
const h = { name: 'only' } as unknown as FileSystemDirectoryHandle
s.handles = [h]
s.activeHandleIndex = null
// Pinia wraps the handle in a reactive proxy, so reference-equal
// fails — compare by the stable `name` field instead.
expect(s.activeHandle?.name).toBe('only')
expect(s.inOverview).toBe(false)
expect(s.canGoBack).toBe(false)
expect(s.folderName).toBe('only')
await s.clearFolder()
})
test('two handles with no active index sits in overview mode', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
await s.clearFolder()
s.handles = [
{ name: 'a' } as unknown as FileSystemDirectoryHandle,
{ name: 'b' } as unknown as FileSystemDirectoryHandle,
]
s.activeHandleIndex = null
expect(s.inOverview).toBe(true)
expect(s.activeHandle).toBeNull()
expect(s.canGoBack).toBe(false)
expect(s.folderName).toBeNull()
await s.clearFolder()
})
test('enterFolder activates; exitFolder returns to overview when multi-root', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
await s.clearFolder()
s.handles = [
{ name: 'a' } as unknown as FileSystemDirectoryHandle,
{ name: 'b' } as unknown as FileSystemDirectoryHandle,
]
s.enterFolder(1)
expect(s.activeHandleIndex).toBe(1)
expect(s.canGoBack).toBe(true)
s.exitFolder()
expect(s.activeHandleIndex).toBeNull()
expect(s.inOverview).toBe(true)
await s.clearFolder()
})
test('exitFolder is a no-op when only one root is configured', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
await s.clearFolder()
s.handles = [{ name: 'only' } as unknown as FileSystemDirectoryHandle]
s.activeHandleIndex = 0
s.exitFolder()
// Single-folder installs always auto-drill — back has no meaning.
expect(s.activeHandleIndex).toBe(0)
expect(s.canGoBack).toBe(false)
await s.clearFolder()
})
test('removeFolder shifts the active index and compacts handles', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
await s.clearFolder()
s.handles = [
{ name: 'a' } as unknown as FileSystemDirectoryHandle,
{ name: 'b' } as unknown as FileSystemDirectoryHandle,
{ name: 'c' } as unknown as FileSystemDirectoryHandle,
]
s.activeHandleIndex = 2
try {
await s.removeFolder(0) // a removed; active 2 → 1
expect(s.handles.map((h) => h.name)).toEqual(['b', 'c'])
expect(s.activeHandleIndex).toBe(1)
// Removing the active handle drops to overview.
await s.removeFolder(1)
expect(s.handles.map((h) => h.name)).toEqual(['b'])
expect(s.activeHandleIndex).toBeNull()
} finally {
// Ensure state is wiped even if assertions throw — otherwise
// the dangling `error`/`handles` leak into the next clipart test
// and the drawer renders the error branch instead of the grid.
await s.clearFolder()
}
})
test('saveClipartFolders + loadStoredClipartFolders round-trip (array format)', async () => {
// Smoke test for the new IDB schema. The picker returns a
// DirectoryHandle; we fake one with any object since we only
// care about serialisation behaviour, not the permission API.
const { saveClipartFolders, loadStoredClipartFolders } = await import(
'@/lib/clipartLibrary'
)
const fake = { name: 'A' } as unknown as FileSystemDirectoryHandle
await saveClipartFolders([fake])
const loaded = await loadStoredClipartFolders()
expect(loaded.length).toBe(1)
expect(loaded[0].name).toBe('A')
await saveClipartFolders([])
})
})
suite('interface prefs — shared composables', () => {
@@ -4253,31 +4357,30 @@ export function registerAllTests(): void {
handle.unmount()
})
test('empty state shows a Choose-Folder button that calls pickFolder', async () => {
test('empty state shows a centred + button that calls addFolder', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
s.setOpen(true)
s.supported = true
// No handle → empty-state with action.
s.handle = null
s.folderName = null
// No handles → empty-state with the big centred `+` action.
s.handles = []
s.activeHandleIndex = null
s.items = []
// Spy on pickFolder so we don't actually open a folder picker.
const calls: number[] = []
const orig = s.pickFolder
s.pickFolder = async () => {
const orig = s.addFolder
s.addFolder = async () => {
calls.push(1)
}
try {
const handle = mountComponent(ClipartDrawer, {})
await nextTick()
const btn = handle.host.querySelector('.choose-folder-btn') as HTMLButtonElement | null
const btn = handle.host.querySelector('.empty-add-btn') as HTMLButtonElement | null
expect(btn).toBeTruthy()
btn!.click()
expect(calls.length).toBe(1)
handle.unmount()
} finally {
s.pickFolder = orig
s.addFolder = orig
s.setOpen(false)
}
})
@@ -4285,17 +4388,20 @@ export function registerAllTests(): void {
test('audio clipart items render as .audio-tile with the music-note icon, not an img', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
// Wipe first so prior tests' dangling error / loading / handles
// can't match the drawer's earlier `v-else-if` branches.
s.error = null
s.loading = false
s.setOpen(true)
// The tile grid is gated on `clipart.handle` (real folder-picker
// handle in prod). A truthy stub is enough for the template to
// skip past the empty-state branches.
// Tile grid is gated on an active folder — seed a stub handle so
// the template skips past the empty-state branch.
s.supported = true
s.handle = {} as unknown as FileSystemDirectoryHandle
s.folderName = 'stub'
s.handles = [{ name: 'stub' } as unknown as FileSystemDirectoryHandle]
s.activeHandleIndex = 0
const mockFile = new File(['x'], 'x.mp3', { type: 'audio/mpeg' })
s.items = [
{ name: 'ding.mp3', file: mockFile, url: 'blob:dummy-audio', kind: 'audio' },
{ name: 'img.png', file: mockFile, url: 'blob:dummy-img', kind: 'image' },
{ kind: 'audio', name: 'ding.mp3', file: mockFile, url: 'blob:dummy-audio' },
{ kind: 'image', name: 'img.png', file: mockFile, url: 'blob:dummy-img' },
]
const handle = mountComponent(ClipartDrawer, {})
await nextTick()
@@ -4314,11 +4420,122 @@ export function registerAllTests(): void {
// ClipartDrawer stays visually expanded underneath subsequent
// mountComponent calls, which can lead to confusing stale UI.
s.items = []
s.handle = null
s.folderName = null
s.handles = []
s.activeHandleIndex = null
s.setOpen(false)
handle.unmount()
})
test('overview mode renders folder tiles; clicking one enters that root', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
s.error = null
s.loading = false
s.setOpen(true)
s.supported = true
const h1 = { name: 'folderA' } as unknown as FileSystemDirectoryHandle
const h2 = { name: 'folderB' } as unknown as FileSystemDirectoryHandle
s.handles = [h1, h2]
s.activeHandleIndex = null
// Overview items = one folder tile per root (the store would
// normally do this via refresh; we set it directly to avoid
// permission calls in the test environment).
s.items = [
{ kind: 'folder', name: 'folderA', handle: h1 },
{ kind: 'folder', name: 'folderB', handle: h2 },
]
const handle = mountComponent(ClipartDrawer, {})
await nextTick()
const tiles = handle.host.querySelectorAll('.tile.folder-tile')
expect(tiles.length).toBe(2)
// Clicking the second folder enters it.
;(tiles[1] as HTMLElement).click()
await nextTick()
expect(s.activeHandleIndex).toBe(1)
s.items = []
s.handles = []
s.activeHandleIndex = null
s.setOpen(false)
handle.unmount()
})
test('back button is shown inside a folder when more than one root is configured', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
s.setOpen(true)
s.supported = true
s.handles = [
{ name: 'a' } as unknown as FileSystemDirectoryHandle,
{ name: 'b' } as unknown as FileSystemDirectoryHandle,
]
s.activeHandleIndex = 0
s.items = []
const handle = mountComponent(ClipartDrawer, {})
await nextTick()
// The back button carries the exitFolder tooltip as aria-label
// via the v-tooltip directive.
const back = handle.host.querySelector(
`button[aria-label="${i18nT('clipart.backToIndex')}"]`,
) as HTMLButtonElement | null
expect(back).toBeTruthy()
back!.click()
await nextTick()
expect(s.activeHandleIndex).toBeNull()
s.items = []
s.handles = []
s.activeHandleIndex = null
s.setOpen(false)
handle.unmount()
})
test('single-root install hides the back button (auto-drill)', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
s.setOpen(true)
s.supported = true
s.handles = [{ name: 'only' } as unknown as FileSystemDirectoryHandle]
s.activeHandleIndex = null
s.items = []
const handle = mountComponent(ClipartDrawer, {})
await nextTick()
const back = handle.host.querySelector(
`button[aria-label="${i18nT('clipart.backToIndex')}"]`,
) as HTMLButtonElement | null
expect(back).toBeNull()
s.handles = []
s.setOpen(false)
handle.unmount()
})
test('header + button calls addFolder', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
s.setOpen(true)
s.supported = true
s.handles = [{ name: 'only' } as unknown as FileSystemDirectoryHandle]
s.activeHandleIndex = null
s.items = []
const calls: number[] = []
const orig = s.addFolder
s.addFolder = async () => {
calls.push(1)
}
try {
const handle = mountComponent(ClipartDrawer, {})
await nextTick()
const btn = handle.host.querySelector(
`button[aria-label="${i18nT('clipart.addFolderTooltip')}"]`,
) as HTMLButtonElement | null
expect(btn).toBeTruthy()
btn!.click()
expect(calls.length).toBe(1)
handle.unmount()
} finally {
s.addFolder = orig
s.handles = []
s.setOpen(false)
}
})
})
suite('ui — SceneDrawer', () => {