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:
1 parent
8aa91fc27b
commit
9fe86b62b9
9 files changed
+698
-142
No files matched your search
+239
-22
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user