- 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>
596 lines
15 KiB
Vue
596 lines
15 KiB
Vue
<script setup lang="ts">
|
||
import { onBeforeUnmount, onMounted, watch } from 'vue'
|
||
import {
|
||
PhArrowClockwise,
|
||
PhArrowLeft,
|
||
PhCaretLeft,
|
||
PhCaretRight,
|
||
PhFolder,
|
||
PhImagesSquare,
|
||
PhMusicNote,
|
||
PhPlus,
|
||
PhX,
|
||
} from '@phosphor-icons/vue'
|
||
import { useClipartStore } from '@/stores/clipart'
|
||
import { readAudioDuration, readAsDataUrl } from '@/lib/fileLoader'
|
||
import {
|
||
clearAudioDragSource,
|
||
installAudioClipDragImage,
|
||
setAudioDragSource,
|
||
} from '@/composables/useAudioDragSource'
|
||
import { useFullscreen } from '@/composables/useFullscreen'
|
||
import { confirmDialog, dialogState } from '@/components/ui'
|
||
import { t } from '@/i18n'
|
||
|
||
const clipart = useClipartStore()
|
||
const { isFullscreen } = useFullscreen()
|
||
|
||
function onTabClick() {
|
||
if (isFullscreen.value) return
|
||
clipart.toggle()
|
||
}
|
||
function onTabEnter() {
|
||
if (isFullscreen.value) clipart.setOpen(true)
|
||
}
|
||
function onDrawerLeave() {
|
||
if (isFullscreen.value) clipart.setOpen(false)
|
||
}
|
||
|
||
// ESC closes the drawer when opened via click. Skipped in fullscreen
|
||
// (hover-leave handles it) and while a dialog is on top.
|
||
function onDocKeyDown(e: KeyboardEvent) {
|
||
if (!clipart.open || isFullscreen.value) return
|
||
if (e.key !== 'Escape') return
|
||
if (dialogState.value) return
|
||
e.preventDefault()
|
||
clipart.setOpen(false)
|
||
}
|
||
|
||
watch(
|
||
() => clipart.open,
|
||
(v) => {
|
||
if (v) document.addEventListener('keydown', onDocKeyDown)
|
||
else document.removeEventListener('keydown', onDocKeyDown)
|
||
},
|
||
)
|
||
|
||
onMounted(() => {
|
||
clipart.initialize()
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
clipart.disposeItems()
|
||
document.removeEventListener('keydown', onDocKeyDown)
|
||
})
|
||
|
||
function onTileClick(index: number) {
|
||
const item = clipart.items[index]
|
||
if (!item || item.kind !== 'folder') return
|
||
// Folder tiles in overview mode index into `handles` — find which
|
||
// root this folder represents by name match, since `items` is
|
||
// synthesized from the handles list in overview mode.
|
||
const rootIdx = clipart.handles.findIndex((h) => h === item.handle)
|
||
if (rootIdx >= 0) clipart.enterFolder(rootIdx)
|
||
}
|
||
|
||
function onItemDragStart(e: DragEvent, index: number) {
|
||
if (!e.dataTransfer) return
|
||
const item = clipart.items[index]
|
||
if (!item || item.kind === 'folder') {
|
||
// Folders aren't draggable onto the stage or timeline.
|
||
e.preventDefault()
|
||
return
|
||
}
|
||
e.dataTransfer.effectAllowed = 'copy'
|
||
e.dataTransfer.setData('application/x-animatr-clipart', String(index))
|
||
e.dataTransfer.setData('text/plain', item.name)
|
||
if (item.kind === 'audio') {
|
||
// Populate the shared drag-source so Timeline can render a ghost
|
||
// preview on hover. We don't know the audio duration until the
|
||
// file decodes, so seed with 0 (ghost renders at minimum width)
|
||
// and upgrade asynchronously. dragend clears both.
|
||
setAudioDragSource({ duration: 0, name: item.name })
|
||
installAudioClipDragImage(e, { name: item.name })
|
||
const el = e.currentTarget as HTMLElement
|
||
el.addEventListener('dragend', clearAudioDragSource, { once: true })
|
||
void (async () => {
|
||
try {
|
||
const dataUrl = await readAsDataUrl(item.file)
|
||
const d = await readAudioDuration(dataUrl)
|
||
setAudioDragSource({ duration: d, name: item.name })
|
||
} catch {
|
||
/* leave the placeholder width — still a visible hint */
|
||
}
|
||
})()
|
||
return
|
||
}
|
||
// Use the actual image as drag ghost
|
||
const ghost = document.createElement('img')
|
||
ghost.src = item.url
|
||
ghost.style.position = 'absolute'
|
||
ghost.style.top = '-1000px'
|
||
ghost.style.left = '-1000px'
|
||
ghost.style.width = '96px'
|
||
ghost.style.height = '96px'
|
||
ghost.style.objectFit = 'contain'
|
||
document.body.appendChild(ghost)
|
||
try {
|
||
e.dataTransfer.setDragImage(ghost, 48, 48)
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
setTimeout(() => ghost.remove(), 0)
|
||
}
|
||
|
||
async function onRemoveFolderTile(e: MouseEvent, index: number) {
|
||
e.stopPropagation()
|
||
const item = clipart.items[index]
|
||
if (!item || item.kind !== 'folder') return
|
||
const ok = await confirmDialog({
|
||
title: t('clipart.removeFolderDialog.title'),
|
||
message: t('clipart.removeFolderDialog.message', { name: item.name }),
|
||
confirmLabel: t('clipart.removeFolderDialog.confirm'),
|
||
variant: 'danger',
|
||
})
|
||
if (!ok) return
|
||
const rootIdx = clipart.handles.findIndex((h) => h === item.handle)
|
||
if (rootIdx >= 0) await clipart.removeFolder(rootIdx)
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<aside
|
||
class="clipart-drawer"
|
||
:class="{ open: clipart.open, fullscreen: isFullscreen }"
|
||
@mouseleave="onDrawerLeave"
|
||
>
|
||
<button
|
||
class="drawer-tab"
|
||
v-tooltip="clipart.open ? t('clipart.close') : t('clipart.open')"
|
||
@click="onTabClick"
|
||
@mouseenter="onTabEnter"
|
||
>
|
||
<component :is="clipart.open ? PhCaretLeft : PhCaretRight" :size="14" />
|
||
</button>
|
||
<div class="drawer-panel">
|
||
<header class="drawer-header">
|
||
<button
|
||
v-if="clipart.canGoBack"
|
||
class="header-btn"
|
||
v-tooltip="t('clipart.backToIndex')"
|
||
@click="clipart.exitFolder()"
|
||
>
|
||
<PhArrowLeft :size="13" />
|
||
</button>
|
||
<PhImagesSquare v-else :size="14" class="title-icon" />
|
||
<h3>{{ t('clipart.title') }}</h3>
|
||
<button
|
||
v-if="clipart.activeHandle"
|
||
class="header-btn"
|
||
v-tooltip="t('clipart.refresh')"
|
||
:disabled="clipart.loading"
|
||
@click="clipart.refresh()"
|
||
>
|
||
<PhArrowClockwise :size="13" />
|
||
</button>
|
||
<button
|
||
v-if="clipart.hasAnyFolder && clipart.supported"
|
||
class="header-btn"
|
||
v-tooltip="t('clipart.addFolderTooltip')"
|
||
@click="clipart.addFolder()"
|
||
>
|
||
<PhPlus :size="13" weight="bold" />
|
||
</button>
|
||
</header>
|
||
<div v-if="clipart.folderName" class="folder-row">
|
||
<span class="folder-name">{{ clipart.folderName }}</span>
|
||
<span class="count">{{ clipart.items.length }}</span>
|
||
</div>
|
||
<p v-if="!clipart.supported" class="empty-state empty-state-center">
|
||
{{ t('clipart.unsupported') }}
|
||
</p>
|
||
<div v-else-if="!clipart.hasAnyFolder" class="empty-state-center">
|
||
<button
|
||
class="empty-add-btn"
|
||
v-tooltip="t('clipart.addFolder')"
|
||
@click="clipart.addFolder()"
|
||
>
|
||
<PhPlus :size="40" weight="bold" />
|
||
</button>
|
||
<p class="empty-hint">{{ t('clipart.empty') }}</p>
|
||
</div>
|
||
<p v-else-if="clipart.error" class="error-state">{{ clipart.error }}</p>
|
||
<p v-else-if="clipart.loading" class="empty-state empty-state-center">
|
||
{{ t('clipart.loading') }}
|
||
</p>
|
||
<p v-else-if="clipart.items.length === 0" class="empty-state empty-state-center">
|
||
{{ t('clipart.noFiles') }}
|
||
</p>
|
||
<div v-else class="tile-grid">
|
||
<div
|
||
v-for="(item, i) in clipart.items"
|
||
:key="item.name + i"
|
||
class="tile"
|
||
:class="{
|
||
'audio-tile': item.kind === 'audio',
|
||
'folder-tile': item.kind === 'folder',
|
||
}"
|
||
v-tooltip="item.name"
|
||
:draggable="item.kind !== 'folder'"
|
||
@click="onTileClick(i)"
|
||
@dragstart="onItemDragStart($event, i)"
|
||
>
|
||
<img v-if="item.kind === 'image'" :src="item.url" :alt="item.name" />
|
||
<div v-else-if="item.kind === 'audio'" class="audio-icon">
|
||
<PhMusicNote :size="28" weight="regular" />
|
||
</div>
|
||
<div v-else class="folder-icon">
|
||
<PhFolder :size="36" weight="fill" />
|
||
<button
|
||
class="folder-remove"
|
||
v-tooltip="t('clipart.removeFolder')"
|
||
@click="onRemoveFolderTile($event, i)"
|
||
>
|
||
<PhX :size="10" weight="bold" />
|
||
</button>
|
||
</div>
|
||
<span class="tile-label">{{ item.name }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.clipart-drawer {
|
||
display: flex;
|
||
align-items: stretch;
|
||
flex-shrink: 0;
|
||
height: 100%;
|
||
width: 5px;
|
||
transition: width 220ms var(--ease);
|
||
background: var(--border);
|
||
border-right: 1px solid var(--border);
|
||
position: relative;
|
||
/* The drawer-panel is always mounted at its full 400px width so the
|
||
thumbnail grid's columns don't reflow mid-animation. `overflow:hidden`
|
||
clips it to the drawer's animated width so while closed/hovering the
|
||
panel is rendered but invisible. */
|
||
overflow: hidden;
|
||
}
|
||
|
||
.clipart-drawer:hover {
|
||
width: 24px;
|
||
background: var(--panel);
|
||
}
|
||
|
||
.clipart-drawer.open {
|
||
width: 424px;
|
||
background: var(--panel);
|
||
}
|
||
|
||
.drawer-tab {
|
||
flex: 0 0 100%;
|
||
width: 100%;
|
||
min-width: 0;
|
||
padding: 0;
|
||
border: none;
|
||
background: transparent;
|
||
color: var(--text-dim);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
writing-mode: vertical-rl;
|
||
overflow: hidden;
|
||
transition: background 140ms var(--ease);
|
||
}
|
||
|
||
.drawer-tab :deep(svg) {
|
||
opacity: 0;
|
||
transition: opacity 140ms var(--ease);
|
||
}
|
||
|
||
.clipart-drawer:hover:not(.fullscreen) .drawer-tab :deep(svg),
|
||
.clipart-drawer.open:not(.fullscreen) .drawer-tab :deep(svg) {
|
||
opacity: 1;
|
||
}
|
||
|
||
.clipart-drawer:hover:not(.fullscreen) .drawer-tab {
|
||
background: var(--panel-alt);
|
||
color: var(--text);
|
||
}
|
||
|
||
.clipart-drawer.fullscreen .drawer-tab {
|
||
cursor: default;
|
||
background: transparent !important;
|
||
}
|
||
|
||
/* Once the fullscreen hover opens the drawer, the tab column has
|
||
served its purpose (hover-detect) — reclaim its 24px so the panel
|
||
uses the full width. The aside itself still catches mouseleave. */
|
||
.clipart-drawer.fullscreen.open .drawer-tab {
|
||
display: none;
|
||
}
|
||
|
||
/* Tab column is gone in fullscreen — reclaim its 24 px so the stage
|
||
area grows by the same amount instead of leaving a blank strip. */
|
||
.clipart-drawer.fullscreen.open {
|
||
width: 400px;
|
||
}
|
||
|
||
.clipart-drawer.open .drawer-tab {
|
||
flex: 0 0 24px;
|
||
width: 24px;
|
||
background: var(--panel);
|
||
}
|
||
|
||
.drawer-panel {
|
||
/* Rigid 400px width — never flex-shrinks. Combined with the drawer's
|
||
`overflow: hidden`, this lets the grid be laid out correctly the
|
||
moment it mounts and stay that way regardless of the drawer's
|
||
current animated width. */
|
||
flex: 0 0 400px;
|
||
width: 400px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
border-left: 1px solid var(--border);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.drawer-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 10px 12px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.title-icon {
|
||
color: var(--text-dim);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.drawer-header h3 {
|
||
flex: 1;
|
||
margin: 0;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* 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;
|
||
border: 1px solid var(--border);
|
||
background: transparent;
|
||
border-radius: var(--radius-sm);
|
||
color: var(--text-dim);
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.header-btn:hover:not(:disabled) {
|
||
color: var(--text);
|
||
background: var(--panel-alt);
|
||
}
|
||
|
||
.header-btn:disabled {
|
||
opacity: 0.5;
|
||
cursor: wait;
|
||
}
|
||
|
||
.folder-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 6px 12px;
|
||
font-size: 11px;
|
||
color: var(--text-dim);
|
||
border-bottom: 1px solid var(--border);
|
||
background: var(--panel-alt);
|
||
}
|
||
|
||
.folder-name {
|
||
flex: 1;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.count {
|
||
font-variant-numeric: tabular-nums;
|
||
padding: 1px 6px;
|
||
border-radius: 999px;
|
||
background: var(--border);
|
||
color: var(--text-dim-strong);
|
||
font-size: 10px;
|
||
}
|
||
|
||
.empty-state {
|
||
margin: 0;
|
||
padding: 16px 14px;
|
||
color: var(--text-dim);
|
||
font-size: 12px;
|
||
font-style: italic;
|
||
}
|
||
|
||
/* 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: center;
|
||
justify-content: center;
|
||
gap: 14px;
|
||
padding: 24px;
|
||
text-align: center;
|
||
}
|
||
|
||
.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;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
transition: border-color 140ms var(--ease), color 140ms var(--ease),
|
||
background 140ms var(--ease);
|
||
}
|
||
|
||
.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 {
|
||
margin: 0;
|
||
padding: 16px 14px;
|
||
color: var(--danger);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.tile-grid {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 10px;
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
|
||
gap: 8px;
|
||
align-content: start;
|
||
}
|
||
|
||
.tile {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 6px;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
background: var(--bg);
|
||
cursor: grab;
|
||
transition: border-color 140ms var(--ease), background 140ms var(--ease);
|
||
}
|
||
|
||
.tile:hover {
|
||
border-color: var(--accent);
|
||
background: var(--panel-alt);
|
||
}
|
||
|
||
.tile:active {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
.tile.folder-tile {
|
||
cursor: pointer;
|
||
position: relative;
|
||
}
|
||
|
||
.tile.folder-tile:active {
|
||
cursor: pointer;
|
||
}
|
||
|
||
.tile img {
|
||
width: 72px;
|
||
height: 72px;
|
||
object-fit: contain;
|
||
pointer-events: none;
|
||
user-select: none;
|
||
}
|
||
|
||
.audio-icon {
|
||
width: 72px;
|
||
height: 72px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #0ea5e9;
|
||
background: color-mix(in srgb, #0ea5e9 12%, transparent);
|
||
border-radius: var(--radius-sm);
|
||
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. */
|
||
background: var(--bg);
|
||
}
|
||
|
||
.tile-label {
|
||
font-size: 10px;
|
||
color: var(--text-dim);
|
||
max-width: 100%;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
</style>
|