1.1.5: settings/media folder details + empty-click deselects everything
- Settings → Media tab now shows per-folder image/audio counts, skipped subfolders, skipped files, and a rescan button. listClipart hard-skips subdirectories and unsupported file extensions, returning a skip report the panel renders. - Empty-click anywhere — chrome, bare stage, empty timeline tracks — clears the full selection (layers, clip instances, keyframes) via a shared useDeselectAll helper. shouldChromeClickDeselect() splits exclusions into interactive elements, overlays, editing panels and timeline selection elements so empty timeline space deselects too.
This commit is contained in:
1 parent
3005d9310d
commit
b0813a1d50
12 files changed
+669
-43
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ruwio",
|
"name": "ruwio",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.4",
|
"version": "1.1.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+14
@@ -36,11 +36,13 @@ import TestOverlay from './devtools/TestOverlay.vue'
|
|||||||
import ruwioLogo from './assets/logo.png'
|
import ruwioLogo from './assets/logo.png'
|
||||||
import { confirmDialog, DialogsHost, IconButton, promptDialog, Toast } from './components/ui'
|
import { confirmDialog, DialogsHost, IconButton, promptDialog, Toast } from './components/ui'
|
||||||
import { t } from './i18n'
|
import { t } from './i18n'
|
||||||
|
import { useDeselectAll } from './composables/useDeselectAll'
|
||||||
import { useHistory } from './composables/useHistory'
|
import { useHistory } from './composables/useHistory'
|
||||||
import { useAudioRuntime } from './composables/useAudioRuntime'
|
import { useAudioRuntime } from './composables/useAudioRuntime'
|
||||||
import { useDocumentDrawer } from './composables/useDocumentDrawer'
|
import { useDocumentDrawer } from './composables/useDocumentDrawer'
|
||||||
import { usePlaybackLoop } from './composables/usePlaybackLoop'
|
import { usePlaybackLoop } from './composables/usePlaybackLoop'
|
||||||
import { useProjectName } from './composables/useProjectName'
|
import { useProjectName } from './composables/useProjectName'
|
||||||
|
import { shouldChromeClickDeselect } from './lib/chromeClick'
|
||||||
import { ProjectFileError, pickProjectFile } from './lib/projectFile'
|
import { ProjectFileError, pickProjectFile } from './lib/projectFile'
|
||||||
import { readAnyProjectFile } from './lib/ruwFile'
|
import { readAnyProjectFile } from './lib/ruwFile'
|
||||||
import {
|
import {
|
||||||
@@ -150,6 +152,7 @@ useAudioRuntime()
|
|||||||
const store = useSceneStore()
|
const store = useSceneStore()
|
||||||
const clipart = useClipartStore()
|
const clipart = useClipartStore()
|
||||||
const history = useHistory()
|
const history = useHistory()
|
||||||
|
const deselectAll = useDeselectAll()
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => store.scene,
|
() => store.scene,
|
||||||
@@ -465,10 +468,20 @@ async function onPaste(e: ClipboardEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Click anywhere outside the stage on inert chrome → clear the
|
||||||
|
// selection. The predicate lives in lib/chromeClick so the rules can
|
||||||
|
// be unit-tested without mounting App.vue.
|
||||||
|
function onChromeClick(e: MouseEvent) {
|
||||||
|
if (e.button !== 0) return
|
||||||
|
if (!shouldChromeClickDeselect(e.target as Element | null)) return
|
||||||
|
deselectAll()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('keydown', onKeyDown)
|
document.addEventListener('keydown', onKeyDown)
|
||||||
document.addEventListener('contextmenu', onDocumentContextMenu)
|
document.addEventListener('contextmenu', onDocumentContextMenu)
|
||||||
document.addEventListener('paste', onPaste)
|
document.addEventListener('paste', onPaste)
|
||||||
|
document.addEventListener('click', onChromeClick)
|
||||||
window.addEventListener('resize', onWindowResize)
|
window.addEventListener('resize', onWindowResize)
|
||||||
void clipart.initialize()
|
void clipart.initialize()
|
||||||
bootWelcome()
|
bootWelcome()
|
||||||
@@ -477,6 +490,7 @@ onBeforeUnmount(() => {
|
|||||||
document.removeEventListener('keydown', onKeyDown)
|
document.removeEventListener('keydown', onKeyDown)
|
||||||
document.removeEventListener('contextmenu', onDocumentContextMenu)
|
document.removeEventListener('contextmenu', onDocumentContextMenu)
|
||||||
document.removeEventListener('paste', onPaste)
|
document.removeEventListener('paste', onPaste)
|
||||||
|
document.removeEventListener('click', onChromeClick)
|
||||||
window.removeEventListener('resize', onWindowResize)
|
window.removeEventListener('resize', onWindowResize)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { BUTTON_STATE_TIMES } from '@/types/scene'
|
|||||||
import { useButtonRuntime } from '@/composables/useButtonRuntime'
|
import { useButtonRuntime } from '@/composables/useButtonRuntime'
|
||||||
import { evalAction } from '@/lib/actions'
|
import { evalAction } from '@/lib/actions'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
|
import { useDeselectAll } from '@/composables/useDeselectAll'
|
||||||
import { useKfSelection } from '@/composables/useKfSelection'
|
import { useKfSelection } from '@/composables/useKfSelection'
|
||||||
import { showLocked } from '@/composables/useShowLocked'
|
import { showLocked } from '@/composables/useShowLocked'
|
||||||
import { expandedTrackOrder } from '@/lib/sceneOrder'
|
import { expandedTrackOrder } from '@/lib/sceneOrder'
|
||||||
@@ -45,6 +46,7 @@ import { CLIP_FALLBACK_BOUNDS, computeClipBounds, type Rect } from '@/lib/clipBo
|
|||||||
const store = useSceneStore()
|
const store = useSceneStore()
|
||||||
const clipart = useClipartStore()
|
const clipart = useClipartStore()
|
||||||
const { selectedKfs } = useKfSelection()
|
const { selectedKfs } = useKfSelection()
|
||||||
|
const deselectAll = useDeselectAll()
|
||||||
|
|
||||||
const container = ref<HTMLDivElement | null>(null)
|
const container = ref<HTMLDivElement | null>(null)
|
||||||
const containerSize = ref({ width: 0, height: 0 })
|
const containerSize = ref({ width: 0, height: 0 })
|
||||||
@@ -2016,7 +2018,7 @@ function finishBoxSelect() {
|
|||||||
const dPxX = Math.abs(b.curX - b.startX) * zoom.value
|
const dPxX = Math.abs(b.curX - b.startX) * zoom.value
|
||||||
const dPxY = Math.abs(b.curY - b.startY) * zoom.value
|
const dPxY = Math.abs(b.curY - b.startY) * zoom.value
|
||||||
if (indices.size === 0 && dPxX < 3 && dPxY < 3) {
|
if (indices.size === 0 && dPxX < 3 && dPxY < 3) {
|
||||||
store.selectLayer(null)
|
deselectAll()
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -2030,12 +2032,12 @@ function finishBoxSelect() {
|
|||||||
const draggedX = Math.abs(b.curX - b.startX) * zoom.value
|
const draggedX = Math.abs(b.curX - b.startX) * zoom.value
|
||||||
const draggedY = Math.abs(b.curY - b.startY) * zoom.value
|
const draggedY = Math.abs(b.curY - b.startY) * zoom.value
|
||||||
if (draggedX < 3 && draggedY < 3) {
|
if (draggedX < 3 && draggedY < 3) {
|
||||||
if (!b.additive) store.selectLayer(null)
|
if (!b.additive) deselectAll()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const stage = stageRef.value?.getStage()
|
const stage = stageRef.value?.getStage()
|
||||||
if (!stage) {
|
if (!stage) {
|
||||||
if (!b.additive) store.selectLayer(null)
|
if (!b.additive) deselectAll()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const hits: string[] = []
|
const hits: string[] = []
|
||||||
@@ -2074,7 +2076,7 @@ function finishBoxSelect() {
|
|||||||
})()
|
})()
|
||||||
: hits
|
: hits
|
||||||
if (merged.length === 0) {
|
if (merged.length === 0) {
|
||||||
store.selectLayer(null)
|
deselectAll()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (merged.length === 1) {
|
if (merged.length === 1) {
|
||||||
@@ -2615,7 +2617,7 @@ function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
|
|||||||
if (target.name?.() === 'path-frame-dot') return
|
if (target.name?.() === 'path-frame-dot') return
|
||||||
if (vertexMenu.value) vertexMenu.value = null
|
if (vertexMenu.value) vertexMenu.value = null
|
||||||
if (target === target.getStage()) {
|
if (target === target.getStage()) {
|
||||||
store.selectLayer(null)
|
deselectAll()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const id = target.id?.()
|
const id = target.id?.()
|
||||||
@@ -2628,11 +2630,20 @@ function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Bubbled mousedown from a Konva node we don't recognise as a
|
||||||
|
// layer. Use selectLayer(null) — NOT deselectAll — so that any
|
||||||
|
// clip-instance selection just set by an inner @mousedown
|
||||||
|
// handler (e.g. the ci-group's own listener) survives this
|
||||||
|
// bubbled fall-through.
|
||||||
store.selectLayer(null)
|
store.selectLayer(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const match = store.activeLayers.find((l) => l.id === id)
|
const match = store.activeLayers.find((l) => l.id === id)
|
||||||
if (!match) {
|
if (!match) {
|
||||||
|
// Same rationale: id-bearing nodes that aren't active layers are
|
||||||
|
// typically ci-group / ci-frame / ci-child shapes whose own
|
||||||
|
// mousedown handler has already run and set the clip-instance
|
||||||
|
// selection. Don't trample that.
|
||||||
store.selectLayer(null)
|
store.selectLayer(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
import {
|
import {
|
||||||
|
PhCaretDown,
|
||||||
|
PhCaretRight,
|
||||||
PhFolder,
|
PhFolder,
|
||||||
PhMoon,
|
PhMoon,
|
||||||
PhSlidersHorizontal,
|
PhSlidersHorizontal,
|
||||||
@@ -29,14 +31,41 @@ const { locale } = useI18n()
|
|||||||
|
|
||||||
// Re-check the stored clipart folder whenever settings opens — covers the
|
// Re-check the stored clipart folder whenever settings opens — covers the
|
||||||
// case where initial App.vue init raced ahead of IDB or the user opens
|
// case where initial App.vue init raced ahead of IDB or the user opens
|
||||||
// settings before the drawer ever mounted.
|
// settings before the drawer ever mounted. We also kick off a stats scan
|
||||||
|
// for every configured root so the per-folder counts + skipped reports
|
||||||
|
// are populated before render.
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
(open) => {
|
async (open) => {
|
||||||
if (open) void clipart.initialize()
|
if (!open) return
|
||||||
|
await clipart.initialize()
|
||||||
|
for (let i = 0; i < clipart.handles.length; i++) {
|
||||||
|
void clipart.scanFolderStats(i)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Per-folder collapse state for the "skipped folders" / "skipped files"
|
||||||
|
// detail blocks. Keyed by handle index — reset on every dialog open
|
||||||
|
// would feel busy, so we just let it persist for the session.
|
||||||
|
const expanded = reactive<Record<number, { folders: boolean; files: boolean }>>(
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
function toggleExpanded(i: number, key: 'folders' | 'files') {
|
||||||
|
if (!expanded[i]) expanded[i] = { folders: false, files: false }
|
||||||
|
expanded[i][key] = !expanded[i][key]
|
||||||
|
}
|
||||||
|
|
||||||
|
function scannedAgo(ts: number | undefined): string {
|
||||||
|
if (!ts) return t('clipart.scanPending')
|
||||||
|
const seconds = Math.max(0, Math.floor((Date.now() - ts) / 1000))
|
||||||
|
if (seconds < 60) return t('clipart.scannedJustNow')
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
if (minutes < 60) return t('clipart.scannedMinutesAgo', { n: minutes })
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
return t('clipart.scannedHoursAgo', { n: hours })
|
||||||
|
}
|
||||||
|
|
||||||
type TabId = 'clipart' | 'interface'
|
type TabId = 'clipart' | 'interface'
|
||||||
|
|
||||||
const tabs: { id: TabId; labelKey: string; icon: typeof PhFolder }[] = [
|
const tabs: { id: TabId; labelKey: string; icon: typeof PhFolder }[] = [
|
||||||
@@ -99,17 +128,85 @@ function applyTheme(t: Theme) {
|
|||||||
<li
|
<li
|
||||||
v-for="(h, i) in clipart.handles"
|
v-for="(h, i) in clipart.handles"
|
||||||
:key="h.name + i"
|
:key="h.name + i"
|
||||||
class="clipart-value"
|
class="folder-card"
|
||||||
>
|
>
|
||||||
<PhFolder :size="13" />
|
<div class="folder-head">
|
||||||
<span class="clipart-folder-name">{{ h.name }}</span>
|
<PhFolder :size="14" />
|
||||||
<button
|
<span class="folder-name">{{ h.name }}</span>
|
||||||
class="ghost-btn small"
|
<button
|
||||||
v-tooltip="t('clipart.removeFolder')"
|
class="ghost-btn small"
|
||||||
@click="clipart.removeFolder(i)"
|
v-tooltip="t('clipart.rescan')"
|
||||||
|
@click="clipart.scanFolderStats(i)"
|
||||||
|
>
|
||||||
|
{{ t('clipart.rescan') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="ghost-btn small"
|
||||||
|
v-tooltip="t('clipart.removeFolder')"
|
||||||
|
@click="clipart.removeFolder(i)"
|
||||||
|
>
|
||||||
|
{{ t('clipart.clearFolder') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="folder-stats">
|
||||||
|
<span class="stat-pill">
|
||||||
|
{{ t('clipart.statImages', { n: clipart.folderStats[i]?.images ?? 0 }) }}
|
||||||
|
</span>
|
||||||
|
<span class="stat-pill">
|
||||||
|
{{ t('clipart.statAudio', { n: clipart.folderStats[i]?.audio ?? 0 }) }}
|
||||||
|
</span>
|
||||||
|
<span class="stat-time">
|
||||||
|
{{ scannedAgo(clipart.folderStats[i]?.scannedAt) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="(clipart.folderStats[i]?.skippedFolders.length ?? 0) > 0"
|
||||||
|
class="skip-block"
|
||||||
>
|
>
|
||||||
{{ t('clipart.clearFolder') }}
|
<button
|
||||||
</button>
|
type="button"
|
||||||
|
class="skip-toggle"
|
||||||
|
@click="toggleExpanded(i, 'folders')"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="expanded[i]?.folders ? PhCaretDown : PhCaretRight"
|
||||||
|
:size="12"
|
||||||
|
/>
|
||||||
|
{{ t('clipart.skippedFolders', { n: clipart.folderStats[i]!.skippedFolders.length }) }}
|
||||||
|
</button>
|
||||||
|
<ul v-if="expanded[i]?.folders" class="skip-list">
|
||||||
|
<li v-for="name in clipart.folderStats[i]!.skippedFolders" :key="name">
|
||||||
|
{{ name }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-if="expanded[i]?.folders" class="skip-hint">
|
||||||
|
{{ t('clipart.skippedFoldersHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="(clipart.folderStats[i]?.skippedFiles.length ?? 0) > 0"
|
||||||
|
class="skip-block"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="skip-toggle"
|
||||||
|
@click="toggleExpanded(i, 'files')"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="expanded[i]?.files ? PhCaretDown : PhCaretRight"
|
||||||
|
:size="12"
|
||||||
|
/>
|
||||||
|
{{ t('clipart.skippedFiles', { n: clipart.folderStats[i]!.skippedFiles.length }) }}
|
||||||
|
</button>
|
||||||
|
<ul v-if="expanded[i]?.files" class="skip-list">
|
||||||
|
<li v-for="name in clipart.folderStats[i]!.skippedFiles" :key="name">
|
||||||
|
{{ name }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-if="expanded[i]?.files" class="skip-hint">
|
||||||
|
{{ t('clipart.skippedFilesHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div v-else class="clipart-value">
|
<div v-else class="clipart-value">
|
||||||
@@ -586,11 +683,120 @@ input[type='number'] {
|
|||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--panel-alt);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-name {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-stats {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-pill {
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--text);
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-time {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-block {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.skip-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim-strong);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-toggle:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
max-height: 140px;
|
||||||
|
overflow: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-list li {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
.ghost-btn.small {
|
.ghost-btn.small {
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import { snapToCandidates } from '@/lib/timelineSnap'
|
|||||||
import { confirmDialog, promptDialog } from '@/components/ui'
|
import { confirmDialog, promptDialog } from '@/components/ui'
|
||||||
import ScriptFrameDialog from './ScriptFrameDialog.vue'
|
import ScriptFrameDialog from './ScriptFrameDialog.vue'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
|
import { useDeselectAll } from '@/composables/useDeselectAll'
|
||||||
import { kfSelectionKey, useKfSelection, type KfSelectionKey } from '@/composables/useKfSelection'
|
import { kfSelectionKey, useKfSelection, type KfSelectionKey } from '@/composables/useKfSelection'
|
||||||
import { showLocked } from '@/composables/useShowLocked'
|
import { showLocked } from '@/composables/useShowLocked'
|
||||||
import type {
|
import type {
|
||||||
@@ -1037,6 +1038,7 @@ function isDropAfter(track: TimelineTrack, nextTrack: TimelineTrack | undefined)
|
|||||||
type SelectionKey = KfSelectionKey
|
type SelectionKey = KfSelectionKey
|
||||||
const { selectedKfs, selectedKfSet } = useKfSelection()
|
const { selectedKfs, selectedKfSet } = useKfSelection()
|
||||||
const selectionKey = kfSelectionKey
|
const selectionKey = kfSelectionKey
|
||||||
|
const deselectAll = useDeselectAll()
|
||||||
|
|
||||||
function isKfSelected(layerId: string, kind: 'main' | 'position' | 'rotation', keyframeId: string): boolean {
|
function isKfSelected(layerId: string, kind: 'main' | 'position' | 'rotation', keyframeId: string): boolean {
|
||||||
return selectedKfSet.value.has(`${layerId}:${kind}:${keyframeId}`)
|
return selectedKfSet.value.has(`${layerId}:${kind}:${keyframeId}`)
|
||||||
@@ -1743,8 +1745,11 @@ function onTracksPointerUp(e: PointerEvent) {
|
|||||||
if (b.moved) {
|
if (b.moved) {
|
||||||
commitBoxSelection(b)
|
commitBoxSelection(b)
|
||||||
} else if (!b.additive) {
|
} else if (!b.additive) {
|
||||||
// bare click on empty space without Shift → clear selection
|
// Bare click on empty timeline space without Shift → clear ALL
|
||||||
clearKfSelection()
|
// selection. Empty-click anywhere in the app means the same
|
||||||
|
// thing now: drop layers, clip instances, and keyframe selection
|
||||||
|
// in one gesture.
|
||||||
|
deselectAll()
|
||||||
}
|
}
|
||||||
boxSelect.value = null
|
boxSelect.value = null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { useKfSelection } from '@/composables/useKfSelection'
|
||||||
|
import { useSceneStore } from '@/stores/scene'
|
||||||
|
|
||||||
|
/** Clear every selection-like piece of UI state in one shot — layers,
|
||||||
|
* clip instances, and timeline keyframes. Called from every "empty
|
||||||
|
* click" surface (chrome, empty stage, empty timeline) so the gesture
|
||||||
|
* carries the same meaning everywhere instead of each surface only
|
||||||
|
* clearing its own slice. */
|
||||||
|
export function useDeselectAll() {
|
||||||
|
const store = useSceneStore()
|
||||||
|
const { selectedKfs } = useKfSelection()
|
||||||
|
return function deselectAll() {
|
||||||
|
store.selectLayer(null)
|
||||||
|
store.selectClipInstance(null)
|
||||||
|
selectedKfs.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5953,6 +5953,219 @@ export function registerAllTests(): void {
|
|||||||
expect(loaded[0].name).toBe('A')
|
expect(loaded[0].name).toBe('A')
|
||||||
await saveClipartFolders([])
|
await saveClipartFolders([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('listClipart reports skipped subfolders and unsupported files', async () => {
|
||||||
|
// Settings → Media tab needs to surface *what* was ignored under
|
||||||
|
// a media root. listClipart is the single place that inspects a
|
||||||
|
// directory; its skip report is what the panel renders.
|
||||||
|
const { listClipart } = await import('@/lib/clipartLibrary')
|
||||||
|
const fakeFile = (name: string) => ({
|
||||||
|
kind: 'file' as const,
|
||||||
|
getFile: async () => new File([new Uint8Array([0])], name),
|
||||||
|
})
|
||||||
|
const handle = {
|
||||||
|
name: 'root',
|
||||||
|
// Permission API stubs — listClipart calls these before
|
||||||
|
// iterating, so they have to short-circuit to "granted".
|
||||||
|
queryPermission: async () => 'granted',
|
||||||
|
requestPermission: async () => 'granted',
|
||||||
|
async *entries() {
|
||||||
|
yield ['cat.png', fakeFile('cat.png')] as const
|
||||||
|
yield ['song.mp3', fakeFile('song.mp3')] as const
|
||||||
|
yield ['notes.txt', fakeFile('notes.txt')] as const
|
||||||
|
yield [
|
||||||
|
'nested',
|
||||||
|
{ kind: 'directory' as const, name: 'nested' },
|
||||||
|
] as const
|
||||||
|
},
|
||||||
|
} as unknown as FileSystemDirectoryHandle
|
||||||
|
const listing = await listClipart(handle)
|
||||||
|
expect(listing.items.length).toBe(2)
|
||||||
|
expect(listing.items[0].kind).toBe('image') // images first
|
||||||
|
expect(listing.items[1].kind).toBe('audio')
|
||||||
|
expect(listing.skippedFolders).toEqual(['nested'])
|
||||||
|
expect(listing.skippedFiles).toEqual(['notes.txt'])
|
||||||
|
// Revoke the URLs we created so the test doesn't leak.
|
||||||
|
for (const it of listing.items) URL.revokeObjectURL(it.url)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('scanFolderStats caches counts + skips by handle index', async () => {
|
||||||
|
// The Settings → Media tab calls scanFolderStats(i) on open so
|
||||||
|
// each card can render counts + skip lists without waiting for
|
||||||
|
// the user to enter that root in the drawer.
|
||||||
|
const { useClipartStore } = await import('@/stores/clipart')
|
||||||
|
const s = useClipartStore()
|
||||||
|
await s.clearFolder()
|
||||||
|
const fakeFile = (name: string) => ({
|
||||||
|
kind: 'file' as const,
|
||||||
|
getFile: async () => new File([new Uint8Array([0])], name),
|
||||||
|
})
|
||||||
|
const handle = {
|
||||||
|
name: 'media',
|
||||||
|
queryPermission: async () => 'granted',
|
||||||
|
requestPermission: async () => 'granted',
|
||||||
|
async *entries() {
|
||||||
|
yield ['a.png', fakeFile('a.png')] as const
|
||||||
|
yield ['b.png', fakeFile('b.png')] as const
|
||||||
|
yield ['c.mp3', fakeFile('c.mp3')] as const
|
||||||
|
yield ['readme.md', fakeFile('readme.md')] as const
|
||||||
|
yield ['sub', { kind: 'directory' as const, name: 'sub' }] as const
|
||||||
|
},
|
||||||
|
} as unknown as FileSystemDirectoryHandle
|
||||||
|
s.handles = [handle]
|
||||||
|
try {
|
||||||
|
await s.scanFolderStats(0)
|
||||||
|
const stats = s.folderStats[0]
|
||||||
|
expect(stats).toBeTruthy()
|
||||||
|
expect(stats!.images).toBe(2)
|
||||||
|
expect(stats!.audio).toBe(1)
|
||||||
|
expect(stats!.skippedFolders).toEqual(['sub'])
|
||||||
|
expect(stats!.skippedFiles).toEqual(['readme.md'])
|
||||||
|
expect(stats!.scannedAt).toBeGreaterThan(0)
|
||||||
|
} finally {
|
||||||
|
await s.clearFolder()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
suite('chrome click → deselect predicate', () => {
|
||||||
|
// Backs the document-level click handler in App.vue that clears
|
||||||
|
// the selection when the user clicks on inert chrome (header,
|
||||||
|
// gaps around the stage). The predicate has to skip clicks on
|
||||||
|
// the Konva canvas, on interactive elements, and inside panels
|
||||||
|
// that own their own selection semantics.
|
||||||
|
test('plain DIV background click → deselect', async () => {
|
||||||
|
const { shouldChromeClickDeselect } = await import('@/lib/chromeClick')
|
||||||
|
const host = document.createElement('div')
|
||||||
|
host.className = 'topbar' // header chrome — not in exclusion list
|
||||||
|
document.body.appendChild(host)
|
||||||
|
try {
|
||||||
|
expect(shouldChromeClickDeselect(host)).toBe(true)
|
||||||
|
} finally {
|
||||||
|
host.remove()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clicks inside a button → keep selection', async () => {
|
||||||
|
const { shouldChromeClickDeselect } = await import('@/lib/chromeClick')
|
||||||
|
const host = document.createElement('div')
|
||||||
|
const btn = document.createElement('button')
|
||||||
|
const inner = document.createElement('span')
|
||||||
|
btn.appendChild(inner)
|
||||||
|
host.appendChild(btn)
|
||||||
|
document.body.appendChild(host)
|
||||||
|
try {
|
||||||
|
// Click on the inner span still bubbles to the button.
|
||||||
|
expect(shouldChromeClickDeselect(inner)).toBe(false)
|
||||||
|
expect(shouldChromeClickDeselect(btn)).toBe(false)
|
||||||
|
} finally {
|
||||||
|
host.remove()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clicks inside the sidebar / properties → keep selection', async () => {
|
||||||
|
// These two are editing-adjacent surfaces. Empty clicks here
|
||||||
|
// would be confusing (e.g. yanking the selection out from
|
||||||
|
// under an in-progress edit), so we leave the panel as a
|
||||||
|
// whole excluded.
|
||||||
|
const { shouldChromeClickDeselect } = await import('@/lib/chromeClick')
|
||||||
|
for (const cls of ['sidebar', 'properties']) {
|
||||||
|
const wrap = document.createElement('div')
|
||||||
|
wrap.className = cls
|
||||||
|
const child = document.createElement('div')
|
||||||
|
wrap.appendChild(child)
|
||||||
|
document.body.appendChild(wrap)
|
||||||
|
try {
|
||||||
|
expect(shouldChromeClickDeselect(child)).toBe(false)
|
||||||
|
} finally {
|
||||||
|
wrap.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clicks on empty timeline tracks → deselect, on timeline elements → keep', async () => {
|
||||||
|
// Empty timeline space (gaps below tracks, the .tracks
|
||||||
|
// background) should clear the selection. Timeline elements
|
||||||
|
// that own their own selection semantics (.kf, .clip-block,
|
||||||
|
// .track-name) must NOT trigger deselect — letting them would
|
||||||
|
// immediately undo the click that just selected something.
|
||||||
|
const { shouldChromeClickDeselect } = await import('@/lib/chromeClick')
|
||||||
|
const tl = document.createElement('div')
|
||||||
|
tl.className = 'timeline'
|
||||||
|
const tracks = document.createElement('div')
|
||||||
|
tracks.className = 'tracks'
|
||||||
|
const kf = document.createElement('div')
|
||||||
|
kf.className = 'kf'
|
||||||
|
const trackName = document.createElement('div')
|
||||||
|
trackName.className = 'track-name'
|
||||||
|
const trackLane = document.createElement('div')
|
||||||
|
trackLane.className = 'track-lane'
|
||||||
|
tracks.append(kf, trackName, trackLane)
|
||||||
|
tl.appendChild(tracks)
|
||||||
|
document.body.appendChild(tl)
|
||||||
|
try {
|
||||||
|
// Background within the timeline → deselect.
|
||||||
|
expect(shouldChromeClickDeselect(tracks)).toBe(true)
|
||||||
|
// Selection-managing children → keep.
|
||||||
|
expect(shouldChromeClickDeselect(kf)).toBe(false)
|
||||||
|
expect(shouldChromeClickDeselect(trackName)).toBe(false)
|
||||||
|
expect(shouldChromeClickDeselect(trackLane)).toBe(false)
|
||||||
|
} finally {
|
||||||
|
tl.remove()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clicks on a canvas → keep selection (Konva owns it)', async () => {
|
||||||
|
const { shouldChromeClickDeselect } = await import('@/lib/chromeClick')
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
document.body.appendChild(canvas)
|
||||||
|
try {
|
||||||
|
expect(shouldChromeClickDeselect(canvas)).toBe(false)
|
||||||
|
} finally {
|
||||||
|
canvas.remove()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clicks inside a modal dialog → keep selection', async () => {
|
||||||
|
const { shouldChromeClickDeselect } = await import('@/lib/chromeClick')
|
||||||
|
const dlg = document.createElement('div')
|
||||||
|
dlg.setAttribute('role', 'dialog')
|
||||||
|
const inner = document.createElement('div')
|
||||||
|
dlg.appendChild(inner)
|
||||||
|
document.body.appendChild(dlg)
|
||||||
|
try {
|
||||||
|
expect(shouldChromeClickDeselect(inner)).toBe(false)
|
||||||
|
} finally {
|
||||||
|
dlg.remove()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('null target → no deselect', async () => {
|
||||||
|
const { shouldChromeClickDeselect } = await import('@/lib/chromeClick')
|
||||||
|
expect(shouldChromeClickDeselect(null)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('useDeselectAll clears layers, clip instances and keyframes', async () => {
|
||||||
|
// Single helper used from every "empty click" surface — chrome
|
||||||
|
// (App.vue), empty stage (CanvasStage), empty tracks (Timeline)
|
||||||
|
// — to keep the gesture's meaning consistent.
|
||||||
|
const { useDeselectAll } = await import('@/composables/useDeselectAll')
|
||||||
|
const { useKfSelection } = await import('@/composables/useKfSelection')
|
||||||
|
const { useSceneStore } = await import('@/stores/scene')
|
||||||
|
const store = useSceneStore()
|
||||||
|
const { selectedKfs } = useKfSelection()
|
||||||
|
// Seed selection state.
|
||||||
|
store.selectedLayerIds = ['l1', 'l2']
|
||||||
|
store.selectedLayerId = 'l2'
|
||||||
|
store.selectedClipInstanceId = 'ci1'
|
||||||
|
selectedKfs.value = [{ layerId: 'l1', kind: 'main', keyframeId: 'k1' }]
|
||||||
|
const deselectAll = useDeselectAll()
|
||||||
|
deselectAll()
|
||||||
|
expect(store.selectedLayerIds.length).toBe(0)
|
||||||
|
expect(store.selectedLayerId).toBeNull()
|
||||||
|
expect(store.selectedClipInstanceId).toBeNull()
|
||||||
|
expect(selectedKfs.value.length).toBe(0)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
suite('interface prefs — shared composables', () => {
|
suite('interface prefs — shared composables', () => {
|
||||||
|
|||||||
@@ -522,6 +522,19 @@ export const en = {
|
|||||||
},
|
},
|
||||||
settingsHint:
|
settingsHint:
|
||||||
'Add one or more local folders with your images and audio files. The app remembers them; 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.',
|
||||||
|
rescan: 'Rescan',
|
||||||
|
statImages: '{n} images',
|
||||||
|
statAudio: '{n} audio',
|
||||||
|
scanPending: 'Not scanned yet',
|
||||||
|
scannedJustNow: 'Scanned just now',
|
||||||
|
scannedMinutesAgo: 'Scanned {n} min ago',
|
||||||
|
scannedHoursAgo: 'Scanned {n} h ago',
|
||||||
|
skippedFolders: '{n} subfolder(s) skipped',
|
||||||
|
skippedFoldersHint:
|
||||||
|
'Subfolders are not scanned. Add them as separate folders if you need their content.',
|
||||||
|
skippedFiles: '{n} unsupported file(s) skipped',
|
||||||
|
skippedFilesHint:
|
||||||
|
'Only .svg, .png, .jpg, .webp, .gif, .avif images and .mp3, .wav, .ogg, .m4a audio files are loaded.',
|
||||||
},
|
},
|
||||||
|
|
||||||
documentDrawer: {
|
documentDrawer: {
|
||||||
@@ -1312,6 +1325,19 @@ export const de: DeepPartial<Messages> = {
|
|||||||
},
|
},
|
||||||
settingsHint:
|
settingsHint:
|
||||||
'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.',
|
'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.',
|
||||||
|
rescan: 'Neu scannen',
|
||||||
|
statImages: '{n} Bilder',
|
||||||
|
statAudio: '{n} Audio',
|
||||||
|
scanPending: 'Noch nicht gescannt',
|
||||||
|
scannedJustNow: 'Gerade gescannt',
|
||||||
|
scannedMinutesAgo: 'Vor {n} Min. gescannt',
|
||||||
|
scannedHoursAgo: 'Vor {n} Std. gescannt',
|
||||||
|
skippedFolders: '{n} Unterordner ignoriert',
|
||||||
|
skippedFoldersHint:
|
||||||
|
'Unterordner werden nicht gescannt. Füge sie als eigenen Ordner hinzu, falls du ihren Inhalt brauchst.',
|
||||||
|
skippedFiles: '{n} nicht unterstützte Datei(en) ignoriert',
|
||||||
|
skippedFilesHint:
|
||||||
|
'Es werden nur .svg, .png, .jpg, .webp, .gif, .avif Bilder und .mp3, .wav, .ogg, .m4a Audiodateien geladen.',
|
||||||
},
|
},
|
||||||
documentDrawer: {
|
documentDrawer: {
|
||||||
open: 'Dokument-Einstellungen öffnen',
|
open: 'Dokument-Einstellungen öffnen',
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/** Selectors for interactive elements — clicks here should not be
|
||||||
|
* re-interpreted as "empty-click" gestures because the user is
|
||||||
|
* trying to fire a specific action (button), edit a value (input),
|
||||||
|
* or interact with a control (select etc.). */
|
||||||
|
const SELECTOR_INTERACTIVE =
|
||||||
|
'button, input, select, textarea, label, a, [contenteditable], ' +
|
||||||
|
'[role="button"], [role="textbox"], [role="combobox"], [role="slider"], ' +
|
||||||
|
'[role="menuitem"]'
|
||||||
|
|
||||||
|
/** Modal / drawer / preview overlays that live in their own visual
|
||||||
|
* context. We never want a click inside one of these to reach back
|
||||||
|
* out and deselect canvas content. */
|
||||||
|
const SELECTOR_OVERLAYS =
|
||||||
|
'[role="dialog"], .dialog, .backdrop, ' +
|
||||||
|
'.scene-drawer, .clipart-drawer, .document-drawer, ' +
|
||||||
|
'.fs-root, .toast'
|
||||||
|
|
||||||
|
/** Properties panel is an editing surface — clicking around inputs
|
||||||
|
* while editing must not yank the selection out from under the user.
|
||||||
|
* The sidebar's interactive items are buttons (already excluded),
|
||||||
|
* but the panel as a whole is editing-adjacent so we treat it the
|
||||||
|
* same way. */
|
||||||
|
const SELECTOR_EDITING_PANELS = '.properties, .sidebar'
|
||||||
|
|
||||||
|
/** Timeline elements that own their own selection / scrub semantics.
|
||||||
|
* Click on a keyframe → that kf becomes selected; click on a clip →
|
||||||
|
* that clip becomes the active instance; click on a track-name →
|
||||||
|
* the layer is selected. Letting the global handler fire on top of
|
||||||
|
* any of these would immediately undo the selection the user just
|
||||||
|
* made. The timeline's own background (.tracks gaps, header gutter)
|
||||||
|
* is *not* in this list — those clicks SHOULD deselect. */
|
||||||
|
const SELECTOR_TIMELINE_ELEMENTS =
|
||||||
|
'.kf, .kf-add, .clip-block, .clip-handle, .track-name, .track-lane, ' +
|
||||||
|
'.script-frame, .script-lane, .timeline-marker, .scrub-handle, ' +
|
||||||
|
'.playhead, .add-clip-btn, .drag-handle, .in-bracket, .out-bracket'
|
||||||
|
|
||||||
|
/** Should a click on `target` cause the global "deselect everything"
|
||||||
|
* behaviour to fire? Returns false for clicks on the Konva canvas
|
||||||
|
* (the stage handles its own logic), interactive elements, overlays,
|
||||||
|
* editing panels (sidebar / properties), and timeline elements with
|
||||||
|
* their own selection semantics. Anything else — topbar chrome,
|
||||||
|
* gaps around the canvas, empty timeline tracks, the app shell —
|
||||||
|
* counts as inert background and clicks there should clear the
|
||||||
|
* selection. */
|
||||||
|
export function shouldChromeClickDeselect(target: Element | null): boolean {
|
||||||
|
if (!target) return false
|
||||||
|
if (target.closest('canvas')) return false
|
||||||
|
if (target.closest(SELECTOR_INTERACTIVE)) return false
|
||||||
|
if (target.closest(SELECTOR_OVERLAYS)) return false
|
||||||
|
if (target.closest(SELECTOR_EDITING_PANELS)) return false
|
||||||
|
if (target.closest(SELECTOR_TIMELINE_ELEMENTS)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
+39
-16
@@ -137,44 +137,67 @@ async function ensurePermission(
|
|||||||
return next === 'granted'
|
return next === 'granted'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** List files inside a directory. Subdirectories surface as folder
|
/** Result of `listClipart` — file items plus a skip report so the
|
||||||
* items so the drawer can render them inline; the caller decides
|
* Settings panel can tell the user *why* something didn't show up.
|
||||||
* whether to drill in (root overview) or skip them (flat view). */
|
* Subdirectories are explicitly NOT recursed; the user can pick them
|
||||||
|
* individually as additional roots if they want their content too. */
|
||||||
|
export interface ClipartListing {
|
||||||
|
items: ClipartFileItem[]
|
||||||
|
/** Subdirectory names ignored under this root. */
|
||||||
|
skippedFolders: string[]
|
||||||
|
/** File names whose extension doesn't match the supported image /
|
||||||
|
* audio set. */
|
||||||
|
skippedFiles: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List files inside a directory. Subdirectories are skipped (not
|
||||||
|
* recursed) and reported via `skippedFolders`; unsupported files
|
||||||
|
* show up under `skippedFiles`. The drawer renders the items list;
|
||||||
|
* the settings panel uses the skip report to surface what was
|
||||||
|
* ignored. */
|
||||||
export async function listClipart(
|
export async function listClipart(
|
||||||
handle: FileSystemDirectoryHandle,
|
handle: FileSystemDirectoryHandle,
|
||||||
): Promise<ClipartItem[]> {
|
): Promise<ClipartListing> {
|
||||||
const granted = await ensurePermission(handle)
|
const granted = await ensurePermission(handle)
|
||||||
if (!granted) return []
|
if (!granted) return { items: [], skippedFolders: [], skippedFiles: [] }
|
||||||
const out: ClipartItem[] = []
|
const items: ClipartFileItem[] = []
|
||||||
|
const skippedFolders: string[] = []
|
||||||
|
const skippedFiles: string[] = []
|
||||||
// @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') {
|
if (entry.kind === 'directory') {
|
||||||
out.push({ kind: 'folder', name, handle: entry as FileSystemDirectoryHandle })
|
skippedFolders.push(name)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (entry.kind !== 'file') continue
|
if (entry.kind !== 'file') continue
|
||||||
let kind: 'image' | 'audio' | 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) {
|
||||||
|
skippedFiles.push(name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const file = await (entry as FileSystemFileHandle).getFile()
|
const file = await (entry as FileSystemFileHandle).getFile()
|
||||||
out.push({ kind, name, file, url: URL.createObjectURL(file) })
|
items.push({ kind, name, file, url: URL.createObjectURL(file) })
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore unreadable entries */
|
/* unreadable entries get reported as skips so the user gets a
|
||||||
|
hint rather than silently missing content. */
|
||||||
|
skippedFiles.push(name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Folders first, then images, then audio. Inside each group sort by
|
// Images first, then audio. Inside each group sort by name so
|
||||||
// name so navigation order stays stable across drawer re-opens.
|
// navigation order stays stable across drawer re-opens.
|
||||||
out.sort((a, b) => {
|
items.sort((a, b) => {
|
||||||
const rank = (k: ClipartItem['kind']) =>
|
const rank = (k: ClipartFileItem['kind']) => (k === 'image' ? 0 : 1)
|
||||||
k === 'folder' ? 0 : k === 'image' ? 1 : 2
|
|
||||||
const ra = rank(a.kind)
|
const ra = rank(a.kind)
|
||||||
const rb = rank(b.kind)
|
const rb = rank(b.kind)
|
||||||
if (ra !== rb) return ra - rb
|
if (ra !== rb) return ra - rb
|
||||||
return a.name.localeCompare(b.name)
|
return a.name.localeCompare(b.name)
|
||||||
})
|
})
|
||||||
return out
|
skippedFolders.sort((a, b) => a.localeCompare(b))
|
||||||
|
skippedFiles.sort((a, b) => a.localeCompare(b))
|
||||||
|
return { items, skippedFolders, skippedFiles }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function disposeClipartItems(items: ClipartItem[]): void {
|
export function disposeClipartItems(items: ClipartItem[]): void {
|
||||||
|
|||||||
+63
-5
@@ -9,6 +9,19 @@ import {
|
|||||||
type ClipartItem,
|
type ClipartItem,
|
||||||
} from '@/lib/clipartLibrary'
|
} from '@/lib/clipartLibrary'
|
||||||
|
|
||||||
|
/** Cached per-root stats so the Settings → Media tab can render
|
||||||
|
* details without forcing a re-scan of every handle. Populated as
|
||||||
|
* the user enters each folder; reset on folder removal. */
|
||||||
|
export interface ClipartFolderStats {
|
||||||
|
images: number
|
||||||
|
audio: number
|
||||||
|
skippedFolders: string[]
|
||||||
|
skippedFiles: string[]
|
||||||
|
/** Wall-clock timestamp of the last refresh — surfaced as "scanned
|
||||||
|
* X minutes ago" in the settings UI. */
|
||||||
|
scannedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
/** Root folders the user has added. Order matches display order. */
|
/** Root folders the user has added. Order matches display order. */
|
||||||
handles: FileSystemDirectoryHandle[]
|
handles: FileSystemDirectoryHandle[]
|
||||||
@@ -20,6 +33,8 @@ interface State {
|
|||||||
/** Current view's items — folder tiles in overview mode, files in
|
/** Current view's items — folder tiles in overview mode, files in
|
||||||
* in-folder mode. Consumers iterate this directly. */
|
* in-folder mode. Consumers iterate this directly. */
|
||||||
items: ClipartItem[]
|
items: ClipartItem[]
|
||||||
|
/** Stats keyed by handle index, populated by refresh() / scanFolderStats. */
|
||||||
|
folderStats: Record<number, ClipartFolderStats>
|
||||||
open: boolean
|
open: boolean
|
||||||
loading: boolean
|
loading: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
@@ -50,6 +65,7 @@ export const useClipartStore = defineStore('clipart', {
|
|||||||
handles: [],
|
handles: [],
|
||||||
activeHandleIndex: null,
|
activeHandleIndex: null,
|
||||||
items: [],
|
items: [],
|
||||||
|
folderStats: {},
|
||||||
open: readDrawerOpen(),
|
open: readDrawerOpen(),
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -184,12 +200,25 @@ export const useClipartStore = defineStore('clipart', {
|
|||||||
try {
|
try {
|
||||||
const active = this.activeHandle
|
const active = this.activeHandle
|
||||||
if (active) {
|
if (active) {
|
||||||
// In-folder view: list files; strip subdirectory entries so
|
const listing = await listClipart(active)
|
||||||
// drilling stays one-level-deep (matches the pre-multi-root
|
|
||||||
// behaviour).
|
|
||||||
const next = await listClipart(active)
|
|
||||||
this.disposeItems()
|
this.disposeItems()
|
||||||
this.items = next.filter((it) => it.kind !== 'folder')
|
this.items = listing.items
|
||||||
|
// Cache the active root's stats by index so the settings
|
||||||
|
// panel can show counts + skipped report without forcing
|
||||||
|
// its own scan.
|
||||||
|
const idx = this.handles.indexOf(active)
|
||||||
|
if (idx >= 0) {
|
||||||
|
this.folderStats = {
|
||||||
|
...this.folderStats,
|
||||||
|
[idx]: {
|
||||||
|
images: listing.items.filter((i) => i.kind === 'image').length,
|
||||||
|
audio: listing.items.filter((i) => i.kind === 'audio').length,
|
||||||
|
skippedFolders: listing.skippedFolders,
|
||||||
|
skippedFiles: listing.skippedFiles,
|
||||||
|
scannedAt: Date.now(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Overview: synthesize a folder tile per root. These items
|
// Overview: synthesize a folder tile per root. These items
|
||||||
// have no URLs to revoke, so disposeItems is cheap.
|
// have no URLs to revoke, so disposeItems is cheap.
|
||||||
@@ -206,6 +235,35 @@ export const useClipartStore = defineStore('clipart', {
|
|||||||
this.loading = false
|
this.loading = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
/** Scan a root by index without touching the active view —
|
||||||
|
* Settings → Media uses this to gather stats for every
|
||||||
|
* configured root regardless of which one the drawer is
|
||||||
|
* currently on. Reuses the same listClipart code path so the
|
||||||
|
* skip rules + counts stay identical. */
|
||||||
|
async scanFolderStats(index: number): Promise<void> {
|
||||||
|
const handle = this.handles[index]
|
||||||
|
if (!handle) return
|
||||||
|
try {
|
||||||
|
const listing = await listClipart(handle)
|
||||||
|
// The off-screen scan creates object URLs we won't render —
|
||||||
|
// revoke them so we don't leak.
|
||||||
|
for (const it of listing.items) {
|
||||||
|
try { URL.revokeObjectURL(it.url) } catch { /* noop */ }
|
||||||
|
}
|
||||||
|
this.folderStats = {
|
||||||
|
...this.folderStats,
|
||||||
|
[index]: {
|
||||||
|
images: listing.items.filter((i) => i.kind === 'image').length,
|
||||||
|
audio: listing.items.filter((i) => i.kind === 'audio').length,
|
||||||
|
skippedFolders: listing.skippedFolders,
|
||||||
|
skippedFiles: listing.skippedFiles,
|
||||||
|
scannedAt: Date.now(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* permission likely denied — leave stats untouched. */
|
||||||
|
}
|
||||||
|
},
|
||||||
disposeItems() {
|
disposeItems() {
|
||||||
disposeClipartItems(this.items)
|
disposeClipartItems(this.items)
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
export const APP_VERSION = '1.1.4'
|
export const APP_VERSION = '1.1.5'
|
||||||
export const APP_NAME = 'RUWIO'
|
export const APP_NAME = 'RUWIO'
|
||||||
Reference in new issue
Block a user