diff --git a/package.json b/package.json index 4d593c2..aeb293b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ruwio", "private": true, - "version": "0.13.1", + "version": "0.14.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src/components/CanvasStage.vue b/src/components/CanvasStage.vue index 05f42d3..461d8ae 100644 --- a/src/components/CanvasStage.vue +++ b/src/components/CanvasStage.vue @@ -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 diff --git a/src/components/ClipartDrawer.vue b/src/components/ClipartDrawer.vue index 415388b..c189665 100644 --- a/src/components/ClipartDrawer.vue +++ b/src/components/ClipartDrawer.vue @@ -1,6 +1,16 @@ @@ -117,35 +154,56 @@ function onItemDragStart(e: DragEvent, index: number) { - + + + + {{ t('clipart.title') }} + + + {{ clipart.folderName }} {{ clipart.items.length }} - + {{ t('clipart.unsupported') }} - - {{ t('clipart.empty') }} - - - {{ t('clipart.chooseFolder') }} + + + + {{ t('clipart.empty') }} {{ clipart.error }} - {{ t('clipart.loading') }} - + + {{ t('clipart.loading') }} + + {{ t('clipart.noFiles') }} @@ -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)" > - + + + + + + + {{ item.name }} @@ -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. */ diff --git a/src/components/DocumentSettings.vue b/src/components/DocumentSettings.vue index 9377978..6b0ecb1 100644 --- a/src/components/DocumentSettings.vue +++ b/src/components/DocumentSettings.vue @@ -93,28 +93,35 @@ function applyTheme(t: Theme) { {{ t('settings.tab.clipart') }} {{ t('clipart.folderLabel') }} - + + + + {{ h.name }} + + {{ t('clipart.clearFolder') }} + + + + - - {{ clipart.folderName }} - - {{ t('clipart.noFolder') }} + {{ t('clipart.noFolder') }} - {{ t('clipart.chooseFolder') }} - - - {{ t('clipart.clearFolder') }} + {{ t('clipart.addFolder') }} @@ -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; diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts index 4be670d..e7d6550 100644 --- a/src/devtools/tests.ts +++ b/src/devtools/tests.ts @@ -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', () => { diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index 037cf14..143413d 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -438,7 +438,7 @@ export const en = { open: 'Open media', close: 'Close media', refresh: 'Reload folder', - empty: 'Choose a folder with images and audio to populate the library.', + empty: 'Add a folder with images and audio to populate the library.', noFiles: 'No images or audio files in this folder.', loading: 'Loading…', unsupported: 'Your browser does not support folder access. Try Chrome or Edge.', @@ -446,8 +446,17 @@ export const en = { clearFolder: 'Clear', folderLabel: 'Folder', noFolder: 'No folder selected', + addFolder: 'Add folder', + addFolderTooltip: 'Add another media folder', + backToIndex: 'Back to folder index', + removeFolder: 'Remove folder from library', + removeFolderDialog: { + title: 'Remove folder from library?', + message: '"{name}" will no longer show up in the Media drawer. The files themselves stay untouched on disk.', + confirm: 'Remove', + }, settingsHint: - 'Point to a local folder with your images and audio files. The app remembers the folder; permissions may be re-asked after browser restart.', + 'Add one or more local folders with your images and audio files. The app remembers them; permissions may be re-asked after browser restart.', }, documentDrawer: { @@ -1206,7 +1215,7 @@ export const de: DeepPartial = { open: 'Medien öffnen', close: 'Medien schließen', refresh: 'Ordner neu laden', - empty: 'Wähle einen Ordner mit Bildern und Audiodateien, um die Bibliothek zu füllen.', + empty: 'Füge einen Ordner mit Bildern und Audiodateien hinzu, um die Bibliothek zu füllen.', noFiles: 'In diesem Ordner gibt es keine Bilder oder Audiodateien.', loading: 'Lädt…', unsupported: @@ -1215,8 +1224,17 @@ export const de: DeepPartial = { clearFolder: 'Leeren', folderLabel: 'Ordner', noFolder: 'Kein Ordner ausgewählt', + addFolder: 'Ordner hinzufügen', + addFolderTooltip: 'Weiteren Medien-Ordner hinzufügen', + backToIndex: 'Zur Ordnerübersicht', + removeFolder: 'Ordner aus der Bibliothek entfernen', + removeFolderDialog: { + title: 'Ordner entfernen?', + message: '„{name}" erscheint nicht mehr im Medien-Drawer. Die Dateien auf der Festplatte bleiben unangetastet.', + confirm: 'Entfernen', + }, settingsHint: - 'Verweise auf einen lokalen Ordner mit Bildern und Audio. Die App merkt sich den Ordner. Nach einem Browser-Neustart kann die Berechtigung erneut abgefragt werden.', + 'Binde einen oder mehrere lokale Ordner mit Bildern und Audio an. Die App merkt sich die Auswahl. Nach einem Browser-Neustart kann die Berechtigung erneut abgefragt werden.', }, documentDrawer: { open: 'Dokument-Einstellungen öffnen', diff --git a/src/lib/clipartLibrary.ts b/src/lib/clipartLibrary.ts index c27d269..e6cc851 100644 --- a/src/lib/clipartLibrary.ts +++ b/src/lib/clipartLibrary.ts @@ -1,3 +1,5 @@ +import { toRaw } from 'vue' + declare global { interface Window { showDirectoryPicker?: (options?: { @@ -10,20 +12,35 @@ declare global { const DB_NAME = 'animatr-handles' const DB_VERSION = 1 const STORE = 'handles' -const HANDLE_KEY = 'clipartDir' +// Legacy single-handle key. Kept so we can read and migrate pre-0.14 +// installs into the array-keyed format the multi-folder drawer uses. +const LEGACY_HANDLE_KEY = 'clipartDir' +const HANDLES_KEY = 'clipartDirs' const IMAGE_EXT = /\.(svg|png|jpe?g|webp|gif|avif)$/i const AUDIO_EXT = /\.(mp3|wav|ogg|m4a)$/i -export type ClipartKind = 'image' | 'audio' +export type ClipartKind = 'image' | 'audio' | 'folder' -export interface ClipartItem { +/** File-backed entry (image / audio). */ +export interface ClipartFileItem { + kind: 'image' | 'audio' name: string file: File url: string - kind: ClipartKind } +/** Folder entry shown either at the root overview (one per picked root) + * or for nested subdirectories. Carries the handle so the drawer can + * drill into it on click. */ +export interface ClipartFolderItem { + kind: 'folder' + name: string + handle: FileSystemDirectoryHandle +} + +export type ClipartItem = ClipartFileItem | ClipartFolderItem + function openDb(): Promise { return new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, DB_VERSION) @@ -36,27 +53,46 @@ function openDb(): Promise { }) } -async function putHandle(handle: FileSystemDirectoryHandle | null): Promise { +async function putHandles(handles: FileSystemDirectoryHandle[]): Promise { const db = await openDb() + // Strip Vue/Pinia reactive wrapping — IDB's structured clone can't + // serialize proxies. Real FileSystemDirectoryHandle objects are + // opaque browser-owned values that Vue leaves alone; the `toRaw` on + // each element makes this robust for test stubs (plain objects) too. + const raw = handles.map((h) => toRaw(h)) await new Promise((resolve, reject) => { const tx = db.transaction(STORE, 'readwrite') const store = tx.objectStore(STORE) - if (handle) store.put(handle, HANDLE_KEY) - else store.delete(HANDLE_KEY) + if (raw.length > 0) store.put(raw, HANDLES_KEY) + else store.delete(HANDLES_KEY) + // Legacy slot is cleared on any write — we've taken over. + store.delete(LEGACY_HANDLE_KEY) tx.oncomplete = () => resolve() tx.onerror = () => reject(tx.error) }) db.close() } -async function getHandle(): Promise { +async function getStoredHandles(): Promise { const db = await openDb() - const result = await new Promise((resolve, reject) => { - const tx = db.transaction(STORE, 'readonly') - const req = tx.objectStore(STORE).get(HANDLE_KEY) - req.onsuccess = () => resolve((req.result as FileSystemDirectoryHandle | undefined) ?? null) - req.onerror = () => reject(req.error) - }) + const result = await new Promise( + (resolve, reject) => { + const tx = db.transaction(STORE, 'readonly') + const s = tx.objectStore(STORE) + const reqNew = s.get(HANDLES_KEY) + const reqLegacy = s.get(LEGACY_HANDLE_KEY) + tx.oncomplete = () => { + const next = reqNew.result as FileSystemDirectoryHandle[] | undefined + if (Array.isArray(next) && next.length > 0) { + resolve(next) + return + } + const legacy = reqLegacy.result as FileSystemDirectoryHandle | undefined + resolve(legacy ? [legacy] : []) + } + tx.onerror = () => reject(tx.error) + }, + ) db.close() return result } @@ -68,25 +104,25 @@ export function isFileSystemAccessSupported(): boolean { export async function pickClipartFolder(): Promise { if (!isFileSystemAccessSupported()) return null try { - const handle = await window.showDirectoryPicker!({ id: 'animatr-clipart', mode: 'read' }) - await putHandle(handle) - return handle + return await window.showDirectoryPicker!({ id: 'animatr-clipart', mode: 'read' }) } catch (err) { if ((err as Error).name === 'AbortError') return null throw err } } -export async function loadStoredClipartFolder(): Promise { +export async function loadStoredClipartFolders(): Promise { try { - return await getHandle() + return await getStoredHandles() } catch { - return null + return [] } } -export async function clearStoredClipartFolder(): Promise { - await putHandle(null) +export async function saveClipartFolders( + handles: FileSystemDirectoryHandle[], +): Promise { + await putHandles(handles) } async function ensurePermission( @@ -101,6 +137,9 @@ async function ensurePermission( return next === 'granted' } +/** List files inside a directory. Subdirectories surface as folder + * items so the drawer can render them inline; the caller decides + * whether to drill in (root overview) or skip them (flat view). */ export async function listClipart( handle: FileSystemDirectoryHandle, ): Promise { @@ -109,22 +148,30 @@ export async function listClipart( const out: ClipartItem[] = [] // @ts-expect-error async iterator not in DOM lib for await (const [name, entry] of handle.entries()) { + if (entry.kind === 'directory') { + out.push({ kind: 'folder', name, handle: entry as FileSystemDirectoryHandle }) + continue + } if (entry.kind !== 'file') continue - let kind: ClipartKind | null = null + let kind: 'image' | 'audio' | null = null if (IMAGE_EXT.test(name)) kind = 'image' else if (AUDIO_EXT.test(name)) kind = 'audio' if (!kind) continue try { const file = await (entry as FileSystemFileHandle).getFile() - out.push({ name, file, url: URL.createObjectURL(file), kind }) + out.push({ kind, name, file, url: URL.createObjectURL(file) }) } catch { /* ignore unreadable entries */ } } - // Sort by kind first (images before audio, so the thumbnail grid stays - // visually homogeneous), then by name inside each kind. + // Folders first, then images, then audio. Inside each group sort by + // name so navigation order stays stable across drawer re-opens. out.sort((a, b) => { - if (a.kind !== b.kind) return a.kind === 'image' ? -1 : 1 + const rank = (k: ClipartItem['kind']) => + k === 'folder' ? 0 : k === 'image' ? 1 : 2 + const ra = rank(a.kind) + const rb = rank(b.kind) + if (ra !== rb) return ra - rb return a.name.localeCompare(b.name) }) return out @@ -132,6 +179,7 @@ export async function listClipart( export function disposeClipartItems(items: ClipartItem[]): void { for (const it of items) { + if (it.kind === 'folder') continue try { URL.revokeObjectURL(it.url) } catch { diff --git a/src/stores/clipart.ts b/src/stores/clipart.ts index 023b8bf..5e9d0d2 100644 --- a/src/stores/clipart.ts +++ b/src/stores/clipart.ts @@ -1,17 +1,24 @@ -import { defineStore } from 'pinia' +import { acceptHMRUpdate, defineStore } from 'pinia' import { - clearStoredClipartFolder, disposeClipartItems, isFileSystemAccessSupported, listClipart, - loadStoredClipartFolder, + loadStoredClipartFolders, pickClipartFolder, + saveClipartFolders, type ClipartItem, } from '@/lib/clipartLibrary' interface State { - handle: FileSystemDirectoryHandle | null - folderName: string | null + /** Root folders the user has added. Order matches display order. */ + handles: FileSystemDirectoryHandle[] + /** Which root folder is being viewed. `null` means "root overview" + * (folder tiles, only used when `handles.length > 1`). With a single + * root the drawer auto-drills into it — callers should check + * `inOverview` rather than this field directly. */ + activeHandleIndex: number | null + /** Current view's items — folder tiles in overview mode, files in + * in-folder mode. Consumers iterate this directly. */ items: ClipartItem[] open: boolean loading: boolean @@ -40,8 +47,8 @@ function writeDrawerOpen(open: boolean) { export const useClipartStore = defineStore('clipart', { state: (): State => ({ - handle: null, - folderName: null, + handles: [], + activeHandleIndex: null, items: [], open: readDrawerOpen(), loading: false, @@ -49,63 +56,150 @@ export const useClipartStore = defineStore('clipart', { supported: isFileSystemAccessSupported(), initialized: false, }), + getters: { + /** Active root handle (either the explicitly-entered one or the + * sole-folder auto-drill). `null` when the drawer sits on the + * overview. */ + activeHandle(state): FileSystemDirectoryHandle | null { + if (state.activeHandleIndex !== null) { + return state.handles[state.activeHandleIndex] ?? null + } + if (state.handles.length === 1) return state.handles[0] + return null + }, + /** True when the drawer should render folder tiles instead of + * files. Only possible with 2+ roots. */ + inOverview(state): boolean { + return state.handles.length > 1 && state.activeHandleIndex === null + }, + /** Current folder's display name, or null in overview / empty. */ + folderName(): string | null { + return this.activeHandle?.name ?? null + }, + /** True when there's any folder configured at all. Drives the + * header-controls visibility in the drawer. */ + hasAnyFolder(state): boolean { + return state.handles.length > 0 + }, + /** Back button is shown whenever the user can exit to the overview + * (requires an explicit active index AND multiple roots). */ + canGoBack(state): boolean { + return state.activeHandleIndex !== null && state.handles.length > 1 + }, + }, actions: { async initialize() { if (!this.supported) { this.initialized = true return } - // Safe to call multiple times; a second call will no-op once a handle - // has been loaded. If the previous attempt failed silently (e.g. IDB - // race on app start), a later call from Settings/drawer can retry. - if (this.handle) { + if (this.handles.length > 0) { this.initialized = true return } - const handle = await loadStoredClipartFolder() + const handles = await loadStoredClipartFolders() this.initialized = true - if (!handle) return - this.handle = handle - this.folderName = handle.name + if (handles.length === 0) return + this.handles = handles try { await this.refresh() } catch { - /* permission may require a user gesture; items will populate on next open */ + /* permission may require a user gesture; items will populate + on next open */ } }, - async pickFolder() { + /** Prompt the user for a folder and append it to the root list. + * Single-folder installs keep the auto-drill behaviour; adding a + * second root pops the drawer back to the overview so both roots + * are visible. */ + async addFolder() { this.error = null try { const handle = await pickClipartFolder() if (!handle) return - this.setHandle(handle) + const wasEmpty = this.handles.length === 0 + this.handles = [...this.handles, handle] + await saveClipartFolders(this.handles) + if (wasEmpty) { + // First folder — auto-drill via the getter. Nothing to do. + } else { + // Adding a 2nd+ folder: show the overview so the user sees + // every root at once. + this.activeHandleIndex = null + } await this.refresh() } catch (err) { this.error = (err as Error).message ?? 'Failed to pick folder' } }, - setHandle(handle: FileSystemDirectoryHandle) { - this.disposeItems() - this.handle = handle - this.folderName = handle.name - this.items = [] + /** Legacy alias — early callers (DocumentSettings, tests) still + * invoke pickFolder. Keeps the same semantics as addFolder. */ + async pickFolder() { + await this.addFolder() + }, + enterFolder(index: number) { + if (index < 0 || index >= this.handles.length) return + this.activeHandleIndex = index + void this.refresh() + }, + /** Pop to the overview. No-op with only one root (the drawer stays + * auto-drilled). */ + exitFolder() { + if (this.handles.length <= 1) return + this.activeHandleIndex = null + void this.refresh() + }, + async removeFolder(index: number) { + if (index < 0 || index >= this.handles.length) return + this.handles = this.handles.filter((_, i) => i !== index) + // Adjust the active pointer so it still points at the intended + // folder (or drops to overview when the active one was removed). + if (this.activeHandleIndex === index) { + this.activeHandleIndex = null + } else if ( + this.activeHandleIndex !== null && + this.activeHandleIndex > index + ) { + this.activeHandleIndex -= 1 + } + await saveClipartFolders(this.handles) + await this.refresh() }, async clearFolder() { this.disposeItems() - this.handle = null - this.folderName = null + this.handles = [] + this.activeHandleIndex = null this.items = [] this.error = null - await clearStoredClipartFolder() + await saveClipartFolders([]) }, async refresh() { - if (!this.handle) return + if (this.handles.length === 0) { + this.disposeItems() + this.items = [] + return + } this.loading = true this.error = null try { - const next = await listClipart(this.handle) - this.disposeItems() - this.items = next + const active = this.activeHandle + if (active) { + // In-folder view: list files; strip subdirectory entries so + // drilling stays one-level-deep (matches the pre-multi-root + // behaviour). + const next = await listClipart(active) + this.disposeItems() + this.items = next.filter((it) => it.kind !== 'folder') + } else { + // Overview: synthesize a folder tile per root. These items + // have no URLs to revoke, so disposeItems is cheap. + this.disposeItems() + this.items = this.handles.map((h) => ({ + kind: 'folder' as const, + name: h.name, + handle: h, + })) + } } catch (err) { this.error = (err as Error).message ?? 'Failed to read folder' } finally { @@ -118,8 +212,14 @@ export const useClipartStore = defineStore('clipart', { setOpen(v: boolean) { this.open = v writeDrawerOpen(v) - if (v && this.handle && this.items.length === 0 && !this.loading) { - // User gesture opens the drawer — good moment to (re-)request access + if ( + v && + this.handles.length > 0 && + this.items.length === 0 && + !this.loading + ) { + // User gesture opens the drawer — good moment to (re-)request + // directory permission or recover from a cold-boot skip. void this.refresh() } }, @@ -128,3 +228,10 @@ export const useClipartStore = defineStore('clipart', { }, }, }) + +// Let Vite's HMR swap the store definition in place instead of leaving +// the initial (stale) state + getters behind — without this, edits to +// `State` or the getter surface only land after a full page reload. +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useClipartStore, import.meta.hot)) +} diff --git a/src/version.ts b/src/version.ts index e80b2a7..030e6b9 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ -export const APP_VERSION = '0.13.1' +export const APP_VERSION = '0.14.0' export const APP_NAME = 'RUWIO'
+
{{ t('clipart.unsupported') }}
{{ t('clipart.empty') }}
{{ clipart.error }}
{{ t('clipart.loading') }}
+ {{ t('clipart.loading') }} +
{{ t('clipart.noFiles') }}
@@ -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; diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts index 4be670d..e7d6550 100644 --- a/src/devtools/tests.ts +++ b/src/devtools/tests.ts @@ -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', () => { diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index 037cf14..143413d 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -438,7 +438,7 @@ export const en = { open: 'Open media', close: 'Close media', refresh: 'Reload folder', - empty: 'Choose a folder with images and audio to populate the library.', + empty: 'Add a folder with images and audio to populate the library.', noFiles: 'No images or audio files in this folder.', loading: 'Loading…', unsupported: 'Your browser does not support folder access. Try Chrome or Edge.', @@ -446,8 +446,17 @@ export const en = { clearFolder: 'Clear', folderLabel: 'Folder', noFolder: 'No folder selected', + addFolder: 'Add folder', + addFolderTooltip: 'Add another media folder', + backToIndex: 'Back to folder index', + removeFolder: 'Remove folder from library', + removeFolderDialog: { + title: 'Remove folder from library?', + message: '"{name}" will no longer show up in the Media drawer. The files themselves stay untouched on disk.', + confirm: 'Remove', + }, settingsHint: - 'Point to a local folder with your images and audio files. The app remembers the folder; permissions may be re-asked after browser restart.', + 'Add one or more local folders with your images and audio files. The app remembers them; permissions may be re-asked after browser restart.', }, documentDrawer: { @@ -1206,7 +1215,7 @@ export const de: DeepPartial = { open: 'Medien öffnen', close: 'Medien schließen', refresh: 'Ordner neu laden', - empty: 'Wähle einen Ordner mit Bildern und Audiodateien, um die Bibliothek zu füllen.', + empty: 'Füge einen Ordner mit Bildern und Audiodateien hinzu, um die Bibliothek zu füllen.', noFiles: 'In diesem Ordner gibt es keine Bilder oder Audiodateien.', loading: 'Lädt…', unsupported: @@ -1215,8 +1224,17 @@ export const de: DeepPartial = { clearFolder: 'Leeren', folderLabel: 'Ordner', noFolder: 'Kein Ordner ausgewählt', + addFolder: 'Ordner hinzufügen', + addFolderTooltip: 'Weiteren Medien-Ordner hinzufügen', + backToIndex: 'Zur Ordnerübersicht', + removeFolder: 'Ordner aus der Bibliothek entfernen', + removeFolderDialog: { + title: 'Ordner entfernen?', + message: '„{name}" erscheint nicht mehr im Medien-Drawer. Die Dateien auf der Festplatte bleiben unangetastet.', + confirm: 'Entfernen', + }, settingsHint: - 'Verweise auf einen lokalen Ordner mit Bildern und Audio. Die App merkt sich den Ordner. Nach einem Browser-Neustart kann die Berechtigung erneut abgefragt werden.', + 'Binde einen oder mehrere lokale Ordner mit Bildern und Audio an. Die App merkt sich die Auswahl. Nach einem Browser-Neustart kann die Berechtigung erneut abgefragt werden.', }, documentDrawer: { open: 'Dokument-Einstellungen öffnen', diff --git a/src/lib/clipartLibrary.ts b/src/lib/clipartLibrary.ts index c27d269..e6cc851 100644 --- a/src/lib/clipartLibrary.ts +++ b/src/lib/clipartLibrary.ts @@ -1,3 +1,5 @@ +import { toRaw } from 'vue' + declare global { interface Window { showDirectoryPicker?: (options?: { @@ -10,20 +12,35 @@ declare global { const DB_NAME = 'animatr-handles' const DB_VERSION = 1 const STORE = 'handles' -const HANDLE_KEY = 'clipartDir' +// Legacy single-handle key. Kept so we can read and migrate pre-0.14 +// installs into the array-keyed format the multi-folder drawer uses. +const LEGACY_HANDLE_KEY = 'clipartDir' +const HANDLES_KEY = 'clipartDirs' const IMAGE_EXT = /\.(svg|png|jpe?g|webp|gif|avif)$/i const AUDIO_EXT = /\.(mp3|wav|ogg|m4a)$/i -export type ClipartKind = 'image' | 'audio' +export type ClipartKind = 'image' | 'audio' | 'folder' -export interface ClipartItem { +/** File-backed entry (image / audio). */ +export interface ClipartFileItem { + kind: 'image' | 'audio' name: string file: File url: string - kind: ClipartKind } +/** Folder entry shown either at the root overview (one per picked root) + * or for nested subdirectories. Carries the handle so the drawer can + * drill into it on click. */ +export interface ClipartFolderItem { + kind: 'folder' + name: string + handle: FileSystemDirectoryHandle +} + +export type ClipartItem = ClipartFileItem | ClipartFolderItem + function openDb(): Promise { return new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, DB_VERSION) @@ -36,27 +53,46 @@ function openDb(): Promise { }) } -async function putHandle(handle: FileSystemDirectoryHandle | null): Promise { +async function putHandles(handles: FileSystemDirectoryHandle[]): Promise { const db = await openDb() + // Strip Vue/Pinia reactive wrapping — IDB's structured clone can't + // serialize proxies. Real FileSystemDirectoryHandle objects are + // opaque browser-owned values that Vue leaves alone; the `toRaw` on + // each element makes this robust for test stubs (plain objects) too. + const raw = handles.map((h) => toRaw(h)) await new Promise((resolve, reject) => { const tx = db.transaction(STORE, 'readwrite') const store = tx.objectStore(STORE) - if (handle) store.put(handle, HANDLE_KEY) - else store.delete(HANDLE_KEY) + if (raw.length > 0) store.put(raw, HANDLES_KEY) + else store.delete(HANDLES_KEY) + // Legacy slot is cleared on any write — we've taken over. + store.delete(LEGACY_HANDLE_KEY) tx.oncomplete = () => resolve() tx.onerror = () => reject(tx.error) }) db.close() } -async function getHandle(): Promise { +async function getStoredHandles(): Promise { const db = await openDb() - const result = await new Promise((resolve, reject) => { - const tx = db.transaction(STORE, 'readonly') - const req = tx.objectStore(STORE).get(HANDLE_KEY) - req.onsuccess = () => resolve((req.result as FileSystemDirectoryHandle | undefined) ?? null) - req.onerror = () => reject(req.error) - }) + const result = await new Promise( + (resolve, reject) => { + const tx = db.transaction(STORE, 'readonly') + const s = tx.objectStore(STORE) + const reqNew = s.get(HANDLES_KEY) + const reqLegacy = s.get(LEGACY_HANDLE_KEY) + tx.oncomplete = () => { + const next = reqNew.result as FileSystemDirectoryHandle[] | undefined + if (Array.isArray(next) && next.length > 0) { + resolve(next) + return + } + const legacy = reqLegacy.result as FileSystemDirectoryHandle | undefined + resolve(legacy ? [legacy] : []) + } + tx.onerror = () => reject(tx.error) + }, + ) db.close() return result } @@ -68,25 +104,25 @@ export function isFileSystemAccessSupported(): boolean { export async function pickClipartFolder(): Promise { if (!isFileSystemAccessSupported()) return null try { - const handle = await window.showDirectoryPicker!({ id: 'animatr-clipart', mode: 'read' }) - await putHandle(handle) - return handle + return await window.showDirectoryPicker!({ id: 'animatr-clipart', mode: 'read' }) } catch (err) { if ((err as Error).name === 'AbortError') return null throw err } } -export async function loadStoredClipartFolder(): Promise { +export async function loadStoredClipartFolders(): Promise { try { - return await getHandle() + return await getStoredHandles() } catch { - return null + return [] } } -export async function clearStoredClipartFolder(): Promise { - await putHandle(null) +export async function saveClipartFolders( + handles: FileSystemDirectoryHandle[], +): Promise { + await putHandles(handles) } async function ensurePermission( @@ -101,6 +137,9 @@ async function ensurePermission( return next === 'granted' } +/** List files inside a directory. Subdirectories surface as folder + * items so the drawer can render them inline; the caller decides + * whether to drill in (root overview) or skip them (flat view). */ export async function listClipart( handle: FileSystemDirectoryHandle, ): Promise { @@ -109,22 +148,30 @@ export async function listClipart( const out: ClipartItem[] = [] // @ts-expect-error async iterator not in DOM lib for await (const [name, entry] of handle.entries()) { + if (entry.kind === 'directory') { + out.push({ kind: 'folder', name, handle: entry as FileSystemDirectoryHandle }) + continue + } if (entry.kind !== 'file') continue - let kind: ClipartKind | null = null + let kind: 'image' | 'audio' | null = null if (IMAGE_EXT.test(name)) kind = 'image' else if (AUDIO_EXT.test(name)) kind = 'audio' if (!kind) continue try { const file = await (entry as FileSystemFileHandle).getFile() - out.push({ name, file, url: URL.createObjectURL(file), kind }) + out.push({ kind, name, file, url: URL.createObjectURL(file) }) } catch { /* ignore unreadable entries */ } } - // Sort by kind first (images before audio, so the thumbnail grid stays - // visually homogeneous), then by name inside each kind. + // Folders first, then images, then audio. Inside each group sort by + // name so navigation order stays stable across drawer re-opens. out.sort((a, b) => { - if (a.kind !== b.kind) return a.kind === 'image' ? -1 : 1 + const rank = (k: ClipartItem['kind']) => + k === 'folder' ? 0 : k === 'image' ? 1 : 2 + const ra = rank(a.kind) + const rb = rank(b.kind) + if (ra !== rb) return ra - rb return a.name.localeCompare(b.name) }) return out @@ -132,6 +179,7 @@ export async function listClipart( export function disposeClipartItems(items: ClipartItem[]): void { for (const it of items) { + if (it.kind === 'folder') continue try { URL.revokeObjectURL(it.url) } catch { diff --git a/src/stores/clipart.ts b/src/stores/clipart.ts index 023b8bf..5e9d0d2 100644 --- a/src/stores/clipart.ts +++ b/src/stores/clipart.ts @@ -1,17 +1,24 @@ -import { defineStore } from 'pinia' +import { acceptHMRUpdate, defineStore } from 'pinia' import { - clearStoredClipartFolder, disposeClipartItems, isFileSystemAccessSupported, listClipart, - loadStoredClipartFolder, + loadStoredClipartFolders, pickClipartFolder, + saveClipartFolders, type ClipartItem, } from '@/lib/clipartLibrary' interface State { - handle: FileSystemDirectoryHandle | null - folderName: string | null + /** Root folders the user has added. Order matches display order. */ + handles: FileSystemDirectoryHandle[] + /** Which root folder is being viewed. `null` means "root overview" + * (folder tiles, only used when `handles.length > 1`). With a single + * root the drawer auto-drills into it — callers should check + * `inOverview` rather than this field directly. */ + activeHandleIndex: number | null + /** Current view's items — folder tiles in overview mode, files in + * in-folder mode. Consumers iterate this directly. */ items: ClipartItem[] open: boolean loading: boolean @@ -40,8 +47,8 @@ function writeDrawerOpen(open: boolean) { export const useClipartStore = defineStore('clipart', { state: (): State => ({ - handle: null, - folderName: null, + handles: [], + activeHandleIndex: null, items: [], open: readDrawerOpen(), loading: false, @@ -49,63 +56,150 @@ export const useClipartStore = defineStore('clipart', { supported: isFileSystemAccessSupported(), initialized: false, }), + getters: { + /** Active root handle (either the explicitly-entered one or the + * sole-folder auto-drill). `null` when the drawer sits on the + * overview. */ + activeHandle(state): FileSystemDirectoryHandle | null { + if (state.activeHandleIndex !== null) { + return state.handles[state.activeHandleIndex] ?? null + } + if (state.handles.length === 1) return state.handles[0] + return null + }, + /** True when the drawer should render folder tiles instead of + * files. Only possible with 2+ roots. */ + inOverview(state): boolean { + return state.handles.length > 1 && state.activeHandleIndex === null + }, + /** Current folder's display name, or null in overview / empty. */ + folderName(): string | null { + return this.activeHandle?.name ?? null + }, + /** True when there's any folder configured at all. Drives the + * header-controls visibility in the drawer. */ + hasAnyFolder(state): boolean { + return state.handles.length > 0 + }, + /** Back button is shown whenever the user can exit to the overview + * (requires an explicit active index AND multiple roots). */ + canGoBack(state): boolean { + return state.activeHandleIndex !== null && state.handles.length > 1 + }, + }, actions: { async initialize() { if (!this.supported) { this.initialized = true return } - // Safe to call multiple times; a second call will no-op once a handle - // has been loaded. If the previous attempt failed silently (e.g. IDB - // race on app start), a later call from Settings/drawer can retry. - if (this.handle) { + if (this.handles.length > 0) { this.initialized = true return } - const handle = await loadStoredClipartFolder() + const handles = await loadStoredClipartFolders() this.initialized = true - if (!handle) return - this.handle = handle - this.folderName = handle.name + if (handles.length === 0) return + this.handles = handles try { await this.refresh() } catch { - /* permission may require a user gesture; items will populate on next open */ + /* permission may require a user gesture; items will populate + on next open */ } }, - async pickFolder() { + /** Prompt the user for a folder and append it to the root list. + * Single-folder installs keep the auto-drill behaviour; adding a + * second root pops the drawer back to the overview so both roots + * are visible. */ + async addFolder() { this.error = null try { const handle = await pickClipartFolder() if (!handle) return - this.setHandle(handle) + const wasEmpty = this.handles.length === 0 + this.handles = [...this.handles, handle] + await saveClipartFolders(this.handles) + if (wasEmpty) { + // First folder — auto-drill via the getter. Nothing to do. + } else { + // Adding a 2nd+ folder: show the overview so the user sees + // every root at once. + this.activeHandleIndex = null + } await this.refresh() } catch (err) { this.error = (err as Error).message ?? 'Failed to pick folder' } }, - setHandle(handle: FileSystemDirectoryHandle) { - this.disposeItems() - this.handle = handle - this.folderName = handle.name - this.items = [] + /** Legacy alias — early callers (DocumentSettings, tests) still + * invoke pickFolder. Keeps the same semantics as addFolder. */ + async pickFolder() { + await this.addFolder() + }, + enterFolder(index: number) { + if (index < 0 || index >= this.handles.length) return + this.activeHandleIndex = index + void this.refresh() + }, + /** Pop to the overview. No-op with only one root (the drawer stays + * auto-drilled). */ + exitFolder() { + if (this.handles.length <= 1) return + this.activeHandleIndex = null + void this.refresh() + }, + async removeFolder(index: number) { + if (index < 0 || index >= this.handles.length) return + this.handles = this.handles.filter((_, i) => i !== index) + // Adjust the active pointer so it still points at the intended + // folder (or drops to overview when the active one was removed). + if (this.activeHandleIndex === index) { + this.activeHandleIndex = null + } else if ( + this.activeHandleIndex !== null && + this.activeHandleIndex > index + ) { + this.activeHandleIndex -= 1 + } + await saveClipartFolders(this.handles) + await this.refresh() }, async clearFolder() { this.disposeItems() - this.handle = null - this.folderName = null + this.handles = [] + this.activeHandleIndex = null this.items = [] this.error = null - await clearStoredClipartFolder() + await saveClipartFolders([]) }, async refresh() { - if (!this.handle) return + if (this.handles.length === 0) { + this.disposeItems() + this.items = [] + return + } this.loading = true this.error = null try { - const next = await listClipart(this.handle) - this.disposeItems() - this.items = next + const active = this.activeHandle + if (active) { + // In-folder view: list files; strip subdirectory entries so + // drilling stays one-level-deep (matches the pre-multi-root + // behaviour). + const next = await listClipart(active) + this.disposeItems() + this.items = next.filter((it) => it.kind !== 'folder') + } else { + // Overview: synthesize a folder tile per root. These items + // have no URLs to revoke, so disposeItems is cheap. + this.disposeItems() + this.items = this.handles.map((h) => ({ + kind: 'folder' as const, + name: h.name, + handle: h, + })) + } } catch (err) { this.error = (err as Error).message ?? 'Failed to read folder' } finally { @@ -118,8 +212,14 @@ export const useClipartStore = defineStore('clipart', { setOpen(v: boolean) { this.open = v writeDrawerOpen(v) - if (v && this.handle && this.items.length === 0 && !this.loading) { - // User gesture opens the drawer — good moment to (re-)request access + if ( + v && + this.handles.length > 0 && + this.items.length === 0 && + !this.loading + ) { + // User gesture opens the drawer — good moment to (re-)request + // directory permission or recover from a cold-boot skip. void this.refresh() } }, @@ -128,3 +228,10 @@ export const useClipartStore = defineStore('clipart', { }, }, }) + +// Let Vite's HMR swap the store definition in place instead of leaving +// the initial (stale) state + getters behind — without this, edits to +// `State` or the getter surface only land after a full page reload. +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useClipartStore, import.meta.hot)) +} diff --git a/src/version.ts b/src/version.ts index e80b2a7..030e6b9 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ -export const APP_VERSION = '0.13.1' +export const APP_VERSION = '0.14.0' export const APP_NAME = 'RUWIO'