Files
RUWIO/src/components/ClipartDrawer.vue
T
thandClaude Opus 4.7 9fe86b62b9 0.14.0: multi-folder media drawer
- Clipart store holds an array of root folders instead of a single
  handle, plus an activeHandleIndex for the in-folder view. Getters:
  activeHandle, inOverview, hasAnyFolder, canGoBack, folderName.
- Actions: addFolder (appends a root), enterFolder, exitFolder,
  removeFolder. Single-folder installs auto-drill into the sole root
  and behave exactly like the pre-refactor drawer; adding a 2nd+
  folder pops back to the root overview.
- IDB persistence migrated from the 'clipartDir' single-handle key to
  a 'clipartDirs' array. Legacy records are migrated on first load.
  saveClipartFolders unwraps Pinia proxies (toRaw per element) so
  IDB's structured clone accepts the payload.
- ClipartItem union gained a 'folder' kind so root tiles render as
  first-class media items. listClipart emits nested directories too,
  but the drawer keeps drill-down one level deep for now.
- ClipartDrawer header: three equally-sized .header-btn chips —
  back (only when canGoBack), refresh (when viewing a folder), and a
  new `+` (when any folder is attached). No more big 'Choose folder'
  button — empty state is a centred 96px dashed + with a wrapped hint.
- Folder tiles carry a hover `×` to remove a root from the library.
- DocumentSettings media tab lists all configured folders with a per-
  folder remove action; primary button is now 'Add folder'.
- Pinia store now calls acceptHMRUpdate so Vite HMR actually swaps
  the store definition between reloads instead of leaving the first-
  load state shape behind.
- Tests: store actions (addFolder / enterFolder / exitFolder / remove
  / IDB round-trip), drawer UI (centred + empty state, overview with
  folder tiles + click-to-enter, back visibility, single-root hides
  back, header + button triggers addFolder). Tests isolated with
  try/finally so a failing store assertion can't leak error state
  into subsequent drawer tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 08:33:20 +02:00

596 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>