- 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>
238 lines
7.4 KiB
TypeScript
238 lines
7.4 KiB
TypeScript
import { acceptHMRUpdate, defineStore } from 'pinia'
|
|
import {
|
|
disposeClipartItems,
|
|
isFileSystemAccessSupported,
|
|
listClipart,
|
|
loadStoredClipartFolders,
|
|
pickClipartFolder,
|
|
saveClipartFolders,
|
|
type ClipartItem,
|
|
} from '@/lib/clipartLibrary'
|
|
|
|
interface State {
|
|
/** 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
|
|
error: string | null
|
|
supported: boolean
|
|
initialized: boolean
|
|
}
|
|
|
|
const DRAWER_OPEN_KEY = 'animatr:clipartDrawerOpen'
|
|
|
|
function readDrawerOpen(): boolean {
|
|
try {
|
|
return localStorage.getItem(DRAWER_OPEN_KEY) === '1'
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function writeDrawerOpen(open: boolean) {
|
|
try {
|
|
localStorage.setItem(DRAWER_OPEN_KEY, open ? '1' : '0')
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
}
|
|
|
|
export const useClipartStore = defineStore('clipart', {
|
|
state: (): State => ({
|
|
handles: [],
|
|
activeHandleIndex: null,
|
|
items: [],
|
|
open: readDrawerOpen(),
|
|
loading: false,
|
|
error: null,
|
|
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
|
|
}
|
|
if (this.handles.length > 0) {
|
|
this.initialized = true
|
|
return
|
|
}
|
|
const handles = await loadStoredClipartFolders()
|
|
this.initialized = true
|
|
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 */
|
|
}
|
|
},
|
|
/** 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
|
|
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'
|
|
}
|
|
},
|
|
/** 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.handles = []
|
|
this.activeHandleIndex = null
|
|
this.items = []
|
|
this.error = null
|
|
await saveClipartFolders([])
|
|
},
|
|
async refresh() {
|
|
if (this.handles.length === 0) {
|
|
this.disposeItems()
|
|
this.items = []
|
|
return
|
|
}
|
|
this.loading = true
|
|
this.error = null
|
|
try {
|
|
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 {
|
|
this.loading = false
|
|
}
|
|
},
|
|
disposeItems() {
|
|
disposeClipartItems(this.items)
|
|
},
|
|
setOpen(v: boolean) {
|
|
this.open = v
|
|
writeDrawerOpen(v)
|
|
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()
|
|
}
|
|
},
|
|
toggle() {
|
|
this.setOpen(!this.open)
|
|
},
|
|
},
|
|
})
|
|
|
|
// 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))
|
|
}
|