0.14.0: multi-folder media drawer
- Clipart store holds an array of root folders instead of a single handle, plus an activeHandleIndex for the in-folder view. Getters: activeHandle, inOverview, hasAnyFolder, canGoBack, folderName. - Actions: addFolder (appends a root), enterFolder, exitFolder, removeFolder. Single-folder installs auto-drill into the sole root and behave exactly like the pre-refactor drawer; adding a 2nd+ folder pops back to the root overview. - IDB persistence migrated from the 'clipartDir' single-handle key to a 'clipartDirs' array. Legacy records are migrated on first load. saveClipartFolders unwraps Pinia proxies (toRaw per element) so IDB's structured clone accepts the payload. - ClipartItem union gained a 'folder' kind so root tiles render as first-class media items. listClipart emits nested directories too, but the drawer keeps drill-down one level deep for now. - ClipartDrawer header: three equally-sized .header-btn chips — back (only when canGoBack), refresh (when viewing a folder), and a new `+` (when any folder is attached). No more big 'Choose folder' button — empty state is a centred 96px dashed + with a wrapped hint. - Folder tiles carry a hover `×` to remove a root from the library. - DocumentSettings media tab lists all configured folders with a per- folder remove action; primary button is now 'Add folder'. - Pinia store now calls acceptHMRUpdate so Vite HMR actually swaps the store definition between reloads instead of leaving the first- load state shape behind. - Tests: store actions (addFolder / enterFolder / exitFolder / remove / IDB round-trip), drawer UI (centred + empty state, overview with folder tiles + click-to-enter, back visibility, single-root hides back, header + button triggers addFolder). Tests isolated with try/finally so a failing store assertion can't leak error state into subsequent drawer tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
8aa91fc27b
commit
9fe86b62b9
9 files changed
+698
-142
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ruwio",
|
"name": "ruwio",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.13.1",
|
"version": "0.14.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -2809,7 +2809,7 @@ function onContainerDrop(e: DragEvent) {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const idx = Number.parseInt(clipartIdx, 10)
|
const idx = Number.parseInt(clipartIdx, 10)
|
||||||
const item = Number.isFinite(idx) ? clipart.items[idx] : null
|
const item = Number.isFinite(idx) ? clipart.items[idx] : null
|
||||||
if (item) {
|
if (item && item.kind !== 'folder') {
|
||||||
void importClipartAt(item.file, worldX, worldY)
|
void importClipartAt(item.file, worldX, worldY)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onBeforeUnmount, onMounted, watch } from 'vue'
|
import { onBeforeUnmount, onMounted, watch } from 'vue'
|
||||||
import { PhArrowClockwise, PhCaretLeft, PhCaretRight, PhFolderOpen, PhImagesSquare, PhMusicNote } from '@phosphor-icons/vue'
|
import {
|
||||||
|
PhArrowClockwise,
|
||||||
|
PhArrowLeft,
|
||||||
|
PhCaretLeft,
|
||||||
|
PhCaretRight,
|
||||||
|
PhFolder,
|
||||||
|
PhImagesSquare,
|
||||||
|
PhMusicNote,
|
||||||
|
PhPlus,
|
||||||
|
PhX,
|
||||||
|
} from '@phosphor-icons/vue'
|
||||||
import { useClipartStore } from '@/stores/clipart'
|
import { useClipartStore } from '@/stores/clipart'
|
||||||
import { readAudioDuration, readAsDataUrl } from '@/lib/fileLoader'
|
import { readAudioDuration, readAsDataUrl } from '@/lib/fileLoader'
|
||||||
import {
|
import {
|
||||||
@@ -9,14 +19,12 @@ import {
|
|||||||
setAudioDragSource,
|
setAudioDragSource,
|
||||||
} from '@/composables/useAudioDragSource'
|
} from '@/composables/useAudioDragSource'
|
||||||
import { useFullscreen } from '@/composables/useFullscreen'
|
import { useFullscreen } from '@/composables/useFullscreen'
|
||||||
import { dialogState } from '@/components/ui'
|
import { confirmDialog, dialogState } from '@/components/ui'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
const clipart = useClipartStore()
|
const clipart = useClipartStore()
|
||||||
const { isFullscreen } = useFullscreen()
|
const { isFullscreen } = useFullscreen()
|
||||||
|
|
||||||
// In browser-fullscreen mode the drawer auto-opens on tab hover and
|
|
||||||
// auto-closes when the mouse leaves the drawer — no click-toggle.
|
|
||||||
function onTabClick() {
|
function onTabClick() {
|
||||||
if (isFullscreen.value) return
|
if (isFullscreen.value) return
|
||||||
clipart.toggle()
|
clipart.toggle()
|
||||||
@@ -55,10 +63,24 @@ onBeforeUnmount(() => {
|
|||||||
document.removeEventListener('keydown', onDocKeyDown)
|
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) {
|
function onItemDragStart(e: DragEvent, index: number) {
|
||||||
if (!e.dataTransfer) return
|
if (!e.dataTransfer) return
|
||||||
const item = clipart.items[index]
|
const item = clipart.items[index]
|
||||||
if (!item) return
|
if (!item || item.kind === 'folder') {
|
||||||
|
// Folders aren't draggable onto the stage or timeline.
|
||||||
|
e.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
e.dataTransfer.effectAllowed = 'copy'
|
e.dataTransfer.effectAllowed = 'copy'
|
||||||
e.dataTransfer.setData('application/x-animatr-clipart', String(index))
|
e.dataTransfer.setData('application/x-animatr-clipart', String(index))
|
||||||
e.dataTransfer.setData('text/plain', item.name)
|
e.dataTransfer.setData('text/plain', item.name)
|
||||||
@@ -99,6 +121,21 @@ function onItemDragStart(e: DragEvent, index: number) {
|
|||||||
}
|
}
|
||||||
setTimeout(() => ghost.remove(), 0)
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -117,35 +154,56 @@ function onItemDragStart(e: DragEvent, index: number) {
|
|||||||
</button>
|
</button>
|
||||||
<div class="drawer-panel">
|
<div class="drawer-panel">
|
||||||
<header class="drawer-header">
|
<header class="drawer-header">
|
||||||
<PhImagesSquare :size="14" class="title-icon" />
|
<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>
|
<h3>{{ t('clipart.title') }}</h3>
|
||||||
<button
|
<button
|
||||||
v-if="clipart.handle"
|
v-if="clipart.activeHandle"
|
||||||
class="refresh-btn"
|
class="header-btn"
|
||||||
v-tooltip="t('clipart.refresh')"
|
v-tooltip="t('clipart.refresh')"
|
||||||
:disabled="clipart.loading"
|
:disabled="clipart.loading"
|
||||||
@click="clipart.refresh()"
|
@click="clipart.refresh()"
|
||||||
>
|
>
|
||||||
<PhArrowClockwise :size="13" />
|
<PhArrowClockwise :size="13" />
|
||||||
</button>
|
</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>
|
</header>
|
||||||
<div v-if="clipart.folderName" class="folder-row">
|
<div v-if="clipart.folderName" class="folder-row">
|
||||||
<span class="folder-name">{{ clipart.folderName }}</span>
|
<span class="folder-name">{{ clipart.folderName }}</span>
|
||||||
<span class="count">{{ clipart.items.length }}</span>
|
<span class="count">{{ clipart.items.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!clipart.supported" class="empty-state">
|
<p v-if="!clipart.supported" class="empty-state empty-state-center">
|
||||||
{{ t('clipart.unsupported') }}
|
{{ t('clipart.unsupported') }}
|
||||||
</p>
|
</p>
|
||||||
<div v-else-if="!clipart.handle" class="empty-state empty-state-action">
|
<div v-else-if="!clipart.hasAnyFolder" class="empty-state-center">
|
||||||
<p>{{ t('clipart.empty') }}</p>
|
<button
|
||||||
<button class="choose-folder-btn" @click="clipart.pickFolder()">
|
class="empty-add-btn"
|
||||||
<PhFolderOpen :size="14" />
|
v-tooltip="t('clipart.addFolder')"
|
||||||
<span>{{ t('clipart.chooseFolder') }}</span>
|
@click="clipart.addFolder()"
|
||||||
|
>
|
||||||
|
<PhPlus :size="40" weight="bold" />
|
||||||
</button>
|
</button>
|
||||||
|
<p class="empty-hint">{{ t('clipart.empty') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<p v-else-if="clipart.error" class="error-state">{{ clipart.error }}</p>
|
<p v-else-if="clipart.error" class="error-state">{{ clipart.error }}</p>
|
||||||
<p v-else-if="clipart.loading" class="empty-state">{{ t('clipart.loading') }}</p>
|
<p v-else-if="clipart.loading" class="empty-state empty-state-center">
|
||||||
<p v-else-if="clipart.items.length === 0" class="empty-state">
|
{{ t('clipart.loading') }}
|
||||||
|
</p>
|
||||||
|
<p v-else-if="clipart.items.length === 0" class="empty-state empty-state-center">
|
||||||
{{ t('clipart.noFiles') }}
|
{{ t('clipart.noFiles') }}
|
||||||
</p>
|
</p>
|
||||||
<div v-else class="tile-grid">
|
<div v-else class="tile-grid">
|
||||||
@@ -153,15 +211,29 @@ function onItemDragStart(e: DragEvent, index: number) {
|
|||||||
v-for="(item, i) in clipart.items"
|
v-for="(item, i) in clipart.items"
|
||||||
:key="item.name + i"
|
:key="item.name + i"
|
||||||
class="tile"
|
class="tile"
|
||||||
:class="{ 'audio-tile': item.kind === 'audio' }"
|
:class="{
|
||||||
|
'audio-tile': item.kind === 'audio',
|
||||||
|
'folder-tile': item.kind === 'folder',
|
||||||
|
}"
|
||||||
v-tooltip="item.name"
|
v-tooltip="item.name"
|
||||||
draggable="true"
|
:draggable="item.kind !== 'folder'"
|
||||||
|
@click="onTileClick(i)"
|
||||||
@dragstart="onItemDragStart($event, i)"
|
@dragstart="onItemDragStart($event, i)"
|
||||||
>
|
>
|
||||||
<img v-if="item.kind === 'image'" :src="item.url" :alt="item.name" />
|
<img v-if="item.kind === 'image'" :src="item.url" :alt="item.name" />
|
||||||
<div v-else class="audio-icon">
|
<div v-else-if="item.kind === 'audio'" class="audio-icon">
|
||||||
<PhMusicNote :size="28" weight="regular" />
|
<PhMusicNote :size="28" weight="regular" />
|
||||||
</div>
|
</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>
|
<span class="tile-label">{{ item.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -286,7 +358,10 @@ function onItemDragStart(e: DragEvent, index: number) {
|
|||||||
font-weight: 600;
|
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;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -298,14 +373,15 @@ function onItemDragStart(e: DragEvent, index: number) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.refresh-btn:hover:not(:disabled) {
|
.header-btn:hover:not(:disabled) {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: var(--panel-alt);
|
background: var(--panel-alt);
|
||||||
}
|
}
|
||||||
|
|
||||||
.refresh-btn:disabled {
|
.header-btn:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: wait;
|
cursor: wait;
|
||||||
}
|
}
|
||||||
@@ -346,33 +422,49 @@ function onItemDragStart(e: DragEvent, index: number) {
|
|||||||
font-style: italic;
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
gap: 10px;
|
justify-content: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state-action p {
|
.empty-add-btn {
|
||||||
margin: 0;
|
width: 96px;
|
||||||
}
|
height: 96px;
|
||||||
|
padding: 0;
|
||||||
.choose-folder-btn {
|
border: 2px dashed var(--border-strong, var(--border));
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--text-dim);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
justify-content: center;
|
||||||
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;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-style: normal;
|
transition: border-color 140ms var(--ease), color 140ms var(--ease),
|
||||||
|
background 140ms var(--ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
.choose-folder-btn:hover {
|
.empty-add-btn:hover {
|
||||||
background: color-mix(in srgb, var(--accent) 22%, transparent);
|
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 {
|
.error-state {
|
||||||
@@ -414,6 +506,15 @@ function onItemDragStart(e: DragEvent, index: number) {
|
|||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tile.folder-tile {
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile.folder-tile:active {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.tile img {
|
.tile img {
|
||||||
width: 72px;
|
width: 72px;
|
||||||
height: 72px;
|
height: 72px;
|
||||||
@@ -434,6 +535,49 @@ function onItemDragStart(e: DragEvent, index: number) {
|
|||||||
pointer-events: none;
|
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 {
|
.tile.audio-tile {
|
||||||
/* Same 1fr grid slot but the content is smaller — keep the tile the
|
/* 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. */
|
same size as an image tile so the grid stays on a clean rhythm. */
|
||||||
|
|||||||
@@ -93,28 +93,35 @@ function applyTheme(t: Theme) {
|
|||||||
<h3>{{ t('settings.tab.clipart') }}</h3>
|
<h3>{{ t('settings.tab.clipart') }}</h3>
|
||||||
<div class="clipart-row">
|
<div class="clipart-row">
|
||||||
<div class="clipart-label">{{ t('clipart.folderLabel') }}</div>
|
<div class="clipart-label">{{ t('clipart.folderLabel') }}</div>
|
||||||
<div class="clipart-value">
|
<ul v-if="clipart.hasAnyFolder" class="clipart-folder-list">
|
||||||
|
<li
|
||||||
|
v-for="(h, i) in clipart.handles"
|
||||||
|
:key="h.name + i"
|
||||||
|
class="clipart-value"
|
||||||
|
>
|
||||||
|
<PhFolder :size="13" />
|
||||||
|
<span class="clipart-folder-name">{{ h.name }}</span>
|
||||||
|
<button
|
||||||
|
class="ghost-btn small"
|
||||||
|
v-tooltip="t('clipart.removeFolder')"
|
||||||
|
@click="clipart.removeFolder(i)"
|
||||||
|
>
|
||||||
|
{{ t('clipart.clearFolder') }}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div v-else class="clipart-value">
|
||||||
<PhFolder :size="13" />
|
<PhFolder :size="13" />
|
||||||
<span v-if="clipart.folderName" class="clipart-folder-name">
|
<span class="clipart-folder-empty">{{ t('clipart.noFolder') }}</span>
|
||||||
{{ clipart.folderName }}
|
|
||||||
</span>
|
|
||||||
<span v-else class="clipart-folder-empty">{{ t('clipart.noFolder') }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="clipart-actions">
|
<div class="clipart-actions">
|
||||||
<button
|
<button
|
||||||
class="primary-btn"
|
class="primary-btn"
|
||||||
:disabled="!clipart.supported"
|
:disabled="!clipart.supported"
|
||||||
@click="clipart.pickFolder()"
|
@click="clipart.addFolder()"
|
||||||
>
|
>
|
||||||
{{ t('clipart.chooseFolder') }}
|
{{ t('clipart.addFolder') }}
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="clipart.handle"
|
|
||||||
class="ghost-btn"
|
|
||||||
@click="clipart.clearFolder()"
|
|
||||||
>
|
|
||||||
{{ t('clipart.clearFolder') }}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!clipart.supported" class="error-line">
|
<p v-if="!clipart.supported" class="error-line">
|
||||||
@@ -551,6 +558,7 @@ input[type='number'] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.clipart-folder-name {
|
.clipart-folder-name {
|
||||||
|
flex: 1 1 auto;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -562,6 +570,20 @@ input[type='number'] {
|
|||||||
font-style: italic;
|
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 {
|
.clipart-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
+239
-22
@@ -4114,15 +4114,16 @@ export function registerAllTests(): void {
|
|||||||
expect(localStorage.getItem('animatr:clipartDrawerOpen')).toBe('0')
|
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 { useClipartStore } = await import('@/stores/clipart')
|
||||||
const s = useClipartStore()
|
const s = useClipartStore()
|
||||||
// simulate a loaded state
|
// simulate a loaded state
|
||||||
s.folderName = 'mock'
|
s.handles = [{ name: 'mock' } as unknown as FileSystemDirectoryHandle]
|
||||||
s.handle = {} as FileSystemDirectoryHandle
|
s.activeHandleIndex = 0
|
||||||
s.items = []
|
s.items = []
|
||||||
await s.clearFolder()
|
await s.clearFolder()
|
||||||
expect(s.handle).toBeNull()
|
expect(s.handles.length).toBe(0)
|
||||||
|
expect(s.activeHandleIndex).toBeNull()
|
||||||
expect(s.folderName).toBeNull()
|
expect(s.folderName).toBeNull()
|
||||||
expect(s.items.length).toBe(0)
|
expect(s.items.length).toBe(0)
|
||||||
})
|
})
|
||||||
@@ -4133,6 +4134,109 @@ export function registerAllTests(): void {
|
|||||||
const s = useClipartStore()
|
const s = useClipartStore()
|
||||||
expect(s.supported).toBe(isFileSystemAccessSupported())
|
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', () => {
|
suite('interface prefs — shared composables', () => {
|
||||||
@@ -4253,31 +4357,30 @@ export function registerAllTests(): void {
|
|||||||
handle.unmount()
|
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 { useClipartStore } = await import('@/stores/clipart')
|
||||||
const s = useClipartStore()
|
const s = useClipartStore()
|
||||||
s.setOpen(true)
|
s.setOpen(true)
|
||||||
s.supported = true
|
s.supported = true
|
||||||
// No handle → empty-state with action.
|
// No handles → empty-state with the big centred `+` action.
|
||||||
s.handle = null
|
s.handles = []
|
||||||
s.folderName = null
|
s.activeHandleIndex = null
|
||||||
s.items = []
|
s.items = []
|
||||||
// Spy on pickFolder so we don't actually open a folder picker.
|
|
||||||
const calls: number[] = []
|
const calls: number[] = []
|
||||||
const orig = s.pickFolder
|
const orig = s.addFolder
|
||||||
s.pickFolder = async () => {
|
s.addFolder = async () => {
|
||||||
calls.push(1)
|
calls.push(1)
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const handle = mountComponent(ClipartDrawer, {})
|
const handle = mountComponent(ClipartDrawer, {})
|
||||||
await nextTick()
|
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()
|
expect(btn).toBeTruthy()
|
||||||
btn!.click()
|
btn!.click()
|
||||||
expect(calls.length).toBe(1)
|
expect(calls.length).toBe(1)
|
||||||
handle.unmount()
|
handle.unmount()
|
||||||
} finally {
|
} finally {
|
||||||
s.pickFolder = orig
|
s.addFolder = orig
|
||||||
s.setOpen(false)
|
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 () => {
|
test('audio clipart items render as .audio-tile with the music-note icon, not an img', async () => {
|
||||||
const { useClipartStore } = await import('@/stores/clipart')
|
const { useClipartStore } = await import('@/stores/clipart')
|
||||||
const s = useClipartStore()
|
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)
|
s.setOpen(true)
|
||||||
// The tile grid is gated on `clipart.handle` (real folder-picker
|
// Tile grid is gated on an active folder — seed a stub handle so
|
||||||
// handle in prod). A truthy stub is enough for the template to
|
// the template skips past the empty-state branch.
|
||||||
// skip past the empty-state branches.
|
|
||||||
s.supported = true
|
s.supported = true
|
||||||
s.handle = {} as unknown as FileSystemDirectoryHandle
|
s.handles = [{ name: 'stub' } as unknown as FileSystemDirectoryHandle]
|
||||||
s.folderName = 'stub'
|
s.activeHandleIndex = 0
|
||||||
const mockFile = new File(['x'], 'x.mp3', { type: 'audio/mpeg' })
|
const mockFile = new File(['x'], 'x.mp3', { type: 'audio/mpeg' })
|
||||||
s.items = [
|
s.items = [
|
||||||
{ name: 'ding.mp3', file: mockFile, url: 'blob:dummy-audio', kind: 'audio' },
|
{ kind: 'audio', name: 'ding.mp3', file: mockFile, url: 'blob:dummy-audio' },
|
||||||
{ name: 'img.png', file: mockFile, url: 'blob:dummy-img', kind: 'image' },
|
{ kind: 'image', name: 'img.png', file: mockFile, url: 'blob:dummy-img' },
|
||||||
]
|
]
|
||||||
const handle = mountComponent(ClipartDrawer, {})
|
const handle = mountComponent(ClipartDrawer, {})
|
||||||
await nextTick()
|
await nextTick()
|
||||||
@@ -4314,11 +4420,122 @@ export function registerAllTests(): void {
|
|||||||
// ClipartDrawer stays visually expanded underneath subsequent
|
// ClipartDrawer stays visually expanded underneath subsequent
|
||||||
// mountComponent calls, which can lead to confusing stale UI.
|
// mountComponent calls, which can lead to confusing stale UI.
|
||||||
s.items = []
|
s.items = []
|
||||||
s.handle = null
|
s.handles = []
|
||||||
s.folderName = null
|
s.activeHandleIndex = null
|
||||||
s.setOpen(false)
|
s.setOpen(false)
|
||||||
handle.unmount()
|
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', () => {
|
suite('ui — SceneDrawer', () => {
|
||||||
|
|||||||
+22
-4
@@ -438,7 +438,7 @@ export const en = {
|
|||||||
open: 'Open media',
|
open: 'Open media',
|
||||||
close: 'Close media',
|
close: 'Close media',
|
||||||
refresh: 'Reload folder',
|
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.',
|
noFiles: 'No images or audio files in this folder.',
|
||||||
loading: 'Loading…',
|
loading: 'Loading…',
|
||||||
unsupported: 'Your browser does not support folder access. Try Chrome or Edge.',
|
unsupported: 'Your browser does not support folder access. Try Chrome or Edge.',
|
||||||
@@ -446,8 +446,17 @@ export const en = {
|
|||||||
clearFolder: 'Clear',
|
clearFolder: 'Clear',
|
||||||
folderLabel: 'Folder',
|
folderLabel: 'Folder',
|
||||||
noFolder: 'No folder selected',
|
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:
|
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: {
|
documentDrawer: {
|
||||||
@@ -1206,7 +1215,7 @@ export const de: DeepPartial<Messages> = {
|
|||||||
open: 'Medien öffnen',
|
open: 'Medien öffnen',
|
||||||
close: 'Medien schließen',
|
close: 'Medien schließen',
|
||||||
refresh: 'Ordner neu laden',
|
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.',
|
noFiles: 'In diesem Ordner gibt es keine Bilder oder Audiodateien.',
|
||||||
loading: 'Lädt…',
|
loading: 'Lädt…',
|
||||||
unsupported:
|
unsupported:
|
||||||
@@ -1215,8 +1224,17 @@ export const de: DeepPartial<Messages> = {
|
|||||||
clearFolder: 'Leeren',
|
clearFolder: 'Leeren',
|
||||||
folderLabel: 'Ordner',
|
folderLabel: 'Ordner',
|
||||||
noFolder: 'Kein Ordner ausgewählt',
|
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:
|
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: {
|
documentDrawer: {
|
||||||
open: 'Dokument-Einstellungen öffnen',
|
open: 'Dokument-Einstellungen öffnen',
|
||||||
|
|||||||
+75
-27
@@ -1,3 +1,5 @@
|
|||||||
|
import { toRaw } from 'vue'
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
showDirectoryPicker?: (options?: {
|
showDirectoryPicker?: (options?: {
|
||||||
@@ -10,20 +12,35 @@ declare global {
|
|||||||
const DB_NAME = 'animatr-handles'
|
const DB_NAME = 'animatr-handles'
|
||||||
const DB_VERSION = 1
|
const DB_VERSION = 1
|
||||||
const STORE = 'handles'
|
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 IMAGE_EXT = /\.(svg|png|jpe?g|webp|gif|avif)$/i
|
||||||
const AUDIO_EXT = /\.(mp3|wav|ogg|m4a)$/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
|
name: string
|
||||||
file: File
|
file: File
|
||||||
url: string
|
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<IDBDatabase> {
|
function openDb(): Promise<IDBDatabase> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const req = indexedDB.open(DB_NAME, DB_VERSION)
|
const req = indexedDB.open(DB_NAME, DB_VERSION)
|
||||||
@@ -36,27 +53,46 @@ function openDb(): Promise<IDBDatabase> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function putHandle(handle: FileSystemDirectoryHandle | null): Promise<void> {
|
async function putHandles(handles: FileSystemDirectoryHandle[]): Promise<void> {
|
||||||
const db = await openDb()
|
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<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const tx = db.transaction(STORE, 'readwrite')
|
const tx = db.transaction(STORE, 'readwrite')
|
||||||
const store = tx.objectStore(STORE)
|
const store = tx.objectStore(STORE)
|
||||||
if (handle) store.put(handle, HANDLE_KEY)
|
if (raw.length > 0) store.put(raw, HANDLES_KEY)
|
||||||
else store.delete(HANDLE_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.oncomplete = () => resolve()
|
||||||
tx.onerror = () => reject(tx.error)
|
tx.onerror = () => reject(tx.error)
|
||||||
})
|
})
|
||||||
db.close()
|
db.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getHandle(): Promise<FileSystemDirectoryHandle | null> {
|
async function getStoredHandles(): Promise<FileSystemDirectoryHandle[]> {
|
||||||
const db = await openDb()
|
const db = await openDb()
|
||||||
const result = await new Promise<FileSystemDirectoryHandle | null>((resolve, reject) => {
|
const result = await new Promise<FileSystemDirectoryHandle[]>(
|
||||||
const tx = db.transaction(STORE, 'readonly')
|
(resolve, reject) => {
|
||||||
const req = tx.objectStore(STORE).get(HANDLE_KEY)
|
const tx = db.transaction(STORE, 'readonly')
|
||||||
req.onsuccess = () => resolve((req.result as FileSystemDirectoryHandle | undefined) ?? null)
|
const s = tx.objectStore(STORE)
|
||||||
req.onerror = () => reject(req.error)
|
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()
|
db.close()
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -68,25 +104,25 @@ export function isFileSystemAccessSupported(): boolean {
|
|||||||
export async function pickClipartFolder(): Promise<FileSystemDirectoryHandle | null> {
|
export async function pickClipartFolder(): Promise<FileSystemDirectoryHandle | null> {
|
||||||
if (!isFileSystemAccessSupported()) return null
|
if (!isFileSystemAccessSupported()) return null
|
||||||
try {
|
try {
|
||||||
const handle = await window.showDirectoryPicker!({ id: 'animatr-clipart', mode: 'read' })
|
return await window.showDirectoryPicker!({ id: 'animatr-clipart', mode: 'read' })
|
||||||
await putHandle(handle)
|
|
||||||
return handle
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if ((err as Error).name === 'AbortError') return null
|
if ((err as Error).name === 'AbortError') return null
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadStoredClipartFolder(): Promise<FileSystemDirectoryHandle | null> {
|
export async function loadStoredClipartFolders(): Promise<FileSystemDirectoryHandle[]> {
|
||||||
try {
|
try {
|
||||||
return await getHandle()
|
return await getStoredHandles()
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearStoredClipartFolder(): Promise<void> {
|
export async function saveClipartFolders(
|
||||||
await putHandle(null)
|
handles: FileSystemDirectoryHandle[],
|
||||||
|
): Promise<void> {
|
||||||
|
await putHandles(handles)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensurePermission(
|
async function ensurePermission(
|
||||||
@@ -101,6 +137,9 @@ async function ensurePermission(
|
|||||||
return next === 'granted'
|
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(
|
export async function listClipart(
|
||||||
handle: FileSystemDirectoryHandle,
|
handle: FileSystemDirectoryHandle,
|
||||||
): Promise<ClipartItem[]> {
|
): Promise<ClipartItem[]> {
|
||||||
@@ -109,22 +148,30 @@ export async function listClipart(
|
|||||||
const out: ClipartItem[] = []
|
const out: ClipartItem[] = []
|
||||||
// @ts-expect-error async iterator not in DOM lib
|
// @ts-expect-error async iterator not in DOM lib
|
||||||
for await (const [name, entry] of handle.entries()) {
|
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
|
if (entry.kind !== 'file') continue
|
||||||
let kind: ClipartKind | null = null
|
let kind: 'image' | 'audio' | null = null
|
||||||
if (IMAGE_EXT.test(name)) kind = 'image'
|
if (IMAGE_EXT.test(name)) kind = 'image'
|
||||||
else if (AUDIO_EXT.test(name)) kind = 'audio'
|
else if (AUDIO_EXT.test(name)) kind = 'audio'
|
||||||
if (!kind) continue
|
if (!kind) continue
|
||||||
try {
|
try {
|
||||||
const file = await (entry as FileSystemFileHandle).getFile()
|
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 {
|
} catch {
|
||||||
/* ignore unreadable entries */
|
/* ignore unreadable entries */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Sort by kind first (images before audio, so the thumbnail grid stays
|
// Folders first, then images, then audio. Inside each group sort by
|
||||||
// visually homogeneous), then by name inside each kind.
|
// name so navigation order stays stable across drawer re-opens.
|
||||||
out.sort((a, b) => {
|
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 a.name.localeCompare(b.name)
|
||||||
})
|
})
|
||||||
return out
|
return out
|
||||||
@@ -132,6 +179,7 @@ export async function listClipart(
|
|||||||
|
|
||||||
export function disposeClipartItems(items: ClipartItem[]): void {
|
export function disposeClipartItems(items: ClipartItem[]): void {
|
||||||
for (const it of items) {
|
for (const it of items) {
|
||||||
|
if (it.kind === 'folder') continue
|
||||||
try {
|
try {
|
||||||
URL.revokeObjectURL(it.url)
|
URL.revokeObjectURL(it.url)
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
+139
-32
@@ -1,17 +1,24 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { acceptHMRUpdate, defineStore } from 'pinia'
|
||||||
import {
|
import {
|
||||||
clearStoredClipartFolder,
|
|
||||||
disposeClipartItems,
|
disposeClipartItems,
|
||||||
isFileSystemAccessSupported,
|
isFileSystemAccessSupported,
|
||||||
listClipart,
|
listClipart,
|
||||||
loadStoredClipartFolder,
|
loadStoredClipartFolders,
|
||||||
pickClipartFolder,
|
pickClipartFolder,
|
||||||
|
saveClipartFolders,
|
||||||
type ClipartItem,
|
type ClipartItem,
|
||||||
} from '@/lib/clipartLibrary'
|
} from '@/lib/clipartLibrary'
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
handle: FileSystemDirectoryHandle | null
|
/** Root folders the user has added. Order matches display order. */
|
||||||
folderName: string | null
|
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[]
|
items: ClipartItem[]
|
||||||
open: boolean
|
open: boolean
|
||||||
loading: boolean
|
loading: boolean
|
||||||
@@ -40,8 +47,8 @@ function writeDrawerOpen(open: boolean) {
|
|||||||
|
|
||||||
export const useClipartStore = defineStore('clipart', {
|
export const useClipartStore = defineStore('clipart', {
|
||||||
state: (): State => ({
|
state: (): State => ({
|
||||||
handle: null,
|
handles: [],
|
||||||
folderName: null,
|
activeHandleIndex: null,
|
||||||
items: [],
|
items: [],
|
||||||
open: readDrawerOpen(),
|
open: readDrawerOpen(),
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -49,63 +56,150 @@ export const useClipartStore = defineStore('clipart', {
|
|||||||
supported: isFileSystemAccessSupported(),
|
supported: isFileSystemAccessSupported(),
|
||||||
initialized: false,
|
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: {
|
actions: {
|
||||||
async initialize() {
|
async initialize() {
|
||||||
if (!this.supported) {
|
if (!this.supported) {
|
||||||
this.initialized = true
|
this.initialized = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Safe to call multiple times; a second call will no-op once a handle
|
if (this.handles.length > 0) {
|
||||||
// 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) {
|
|
||||||
this.initialized = true
|
this.initialized = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const handle = await loadStoredClipartFolder()
|
const handles = await loadStoredClipartFolders()
|
||||||
this.initialized = true
|
this.initialized = true
|
||||||
if (!handle) return
|
if (handles.length === 0) return
|
||||||
this.handle = handle
|
this.handles = handles
|
||||||
this.folderName = handle.name
|
|
||||||
try {
|
try {
|
||||||
await this.refresh()
|
await this.refresh()
|
||||||
} catch {
|
} 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
|
this.error = null
|
||||||
try {
|
try {
|
||||||
const handle = await pickClipartFolder()
|
const handle = await pickClipartFolder()
|
||||||
if (!handle) return
|
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()
|
await this.refresh()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.error = (err as Error).message ?? 'Failed to pick folder'
|
this.error = (err as Error).message ?? 'Failed to pick folder'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setHandle(handle: FileSystemDirectoryHandle) {
|
/** Legacy alias — early callers (DocumentSettings, tests) still
|
||||||
this.disposeItems()
|
* invoke pickFolder. Keeps the same semantics as addFolder. */
|
||||||
this.handle = handle
|
async pickFolder() {
|
||||||
this.folderName = handle.name
|
await this.addFolder()
|
||||||
this.items = []
|
},
|
||||||
|
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() {
|
async clearFolder() {
|
||||||
this.disposeItems()
|
this.disposeItems()
|
||||||
this.handle = null
|
this.handles = []
|
||||||
this.folderName = null
|
this.activeHandleIndex = null
|
||||||
this.items = []
|
this.items = []
|
||||||
this.error = null
|
this.error = null
|
||||||
await clearStoredClipartFolder()
|
await saveClipartFolders([])
|
||||||
},
|
},
|
||||||
async refresh() {
|
async refresh() {
|
||||||
if (!this.handle) return
|
if (this.handles.length === 0) {
|
||||||
|
this.disposeItems()
|
||||||
|
this.items = []
|
||||||
|
return
|
||||||
|
}
|
||||||
this.loading = true
|
this.loading = true
|
||||||
this.error = null
|
this.error = null
|
||||||
try {
|
try {
|
||||||
const next = await listClipart(this.handle)
|
const active = this.activeHandle
|
||||||
this.disposeItems()
|
if (active) {
|
||||||
this.items = next
|
// 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) {
|
} catch (err) {
|
||||||
this.error = (err as Error).message ?? 'Failed to read folder'
|
this.error = (err as Error).message ?? 'Failed to read folder'
|
||||||
} finally {
|
} finally {
|
||||||
@@ -118,8 +212,14 @@ export const useClipartStore = defineStore('clipart', {
|
|||||||
setOpen(v: boolean) {
|
setOpen(v: boolean) {
|
||||||
this.open = v
|
this.open = v
|
||||||
writeDrawerOpen(v)
|
writeDrawerOpen(v)
|
||||||
if (v && this.handle && this.items.length === 0 && !this.loading) {
|
if (
|
||||||
// User gesture opens the drawer — good moment to (re-)request access
|
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()
|
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))
|
||||||
|
}
|
||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
export const APP_VERSION = '0.13.1'
|
export const APP_VERSION = '0.14.0'
|
||||||
export const APP_NAME = 'RUWIO'
|
export const APP_NAME = 'RUWIO'
|
||||||
Reference in new issue
Block a user