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",
|
||||
"private": true,
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+14
@@ -36,11 +36,13 @@ import TestOverlay from './devtools/TestOverlay.vue'
|
||||
import ruwioLogo from './assets/logo.png'
|
||||
import { confirmDialog, DialogsHost, IconButton, promptDialog, Toast } from './components/ui'
|
||||
import { t } from './i18n'
|
||||
import { useDeselectAll } from './composables/useDeselectAll'
|
||||
import { useHistory } from './composables/useHistory'
|
||||
import { useAudioRuntime } from './composables/useAudioRuntime'
|
||||
import { useDocumentDrawer } from './composables/useDocumentDrawer'
|
||||
import { usePlaybackLoop } from './composables/usePlaybackLoop'
|
||||
import { useProjectName } from './composables/useProjectName'
|
||||
import { shouldChromeClickDeselect } from './lib/chromeClick'
|
||||
import { ProjectFileError, pickProjectFile } from './lib/projectFile'
|
||||
import { readAnyProjectFile } from './lib/ruwFile'
|
||||
import {
|
||||
@@ -150,6 +152,7 @@ useAudioRuntime()
|
||||
const store = useSceneStore()
|
||||
const clipart = useClipartStore()
|
||||
const history = useHistory()
|
||||
const deselectAll = useDeselectAll()
|
||||
|
||||
watch(
|
||||
() => 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(() => {
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
document.addEventListener('contextmenu', onDocumentContextMenu)
|
||||
document.addEventListener('paste', onPaste)
|
||||
document.addEventListener('click', onChromeClick)
|
||||
window.addEventListener('resize', onWindowResize)
|
||||
void clipart.initialize()
|
||||
bootWelcome()
|
||||
@@ -477,6 +490,7 @@ onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
document.removeEventListener('contextmenu', onDocumentContextMenu)
|
||||
document.removeEventListener('paste', onPaste)
|
||||
document.removeEventListener('click', onChromeClick)
|
||||
window.removeEventListener('resize', onWindowResize)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -37,6 +37,7 @@ import { BUTTON_STATE_TIMES } from '@/types/scene'
|
||||
import { useButtonRuntime } from '@/composables/useButtonRuntime'
|
||||
import { evalAction } from '@/lib/actions'
|
||||
import { t } from '@/i18n'
|
||||
import { useDeselectAll } from '@/composables/useDeselectAll'
|
||||
import { useKfSelection } from '@/composables/useKfSelection'
|
||||
import { showLocked } from '@/composables/useShowLocked'
|
||||
import { expandedTrackOrder } from '@/lib/sceneOrder'
|
||||
@@ -45,6 +46,7 @@ import { CLIP_FALLBACK_BOUNDS, computeClipBounds, type Rect } from '@/lib/clipBo
|
||||
const store = useSceneStore()
|
||||
const clipart = useClipartStore()
|
||||
const { selectedKfs } = useKfSelection()
|
||||
const deselectAll = useDeselectAll()
|
||||
|
||||
const container = ref<HTMLDivElement | null>(null)
|
||||
const containerSize = ref({ width: 0, height: 0 })
|
||||
@@ -2016,7 +2018,7 @@ function finishBoxSelect() {
|
||||
const dPxX = Math.abs(b.curX - b.startX) * zoom.value
|
||||
const dPxY = Math.abs(b.curY - b.startY) * zoom.value
|
||||
if (indices.size === 0 && dPxX < 3 && dPxY < 3) {
|
||||
store.selectLayer(null)
|
||||
deselectAll()
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -2030,12 +2032,12 @@ function finishBoxSelect() {
|
||||
const draggedX = Math.abs(b.curX - b.startX) * zoom.value
|
||||
const draggedY = Math.abs(b.curY - b.startY) * zoom.value
|
||||
if (draggedX < 3 && draggedY < 3) {
|
||||
if (!b.additive) store.selectLayer(null)
|
||||
if (!b.additive) deselectAll()
|
||||
return
|
||||
}
|
||||
const stage = stageRef.value?.getStage()
|
||||
if (!stage) {
|
||||
if (!b.additive) store.selectLayer(null)
|
||||
if (!b.additive) deselectAll()
|
||||
return
|
||||
}
|
||||
const hits: string[] = []
|
||||
@@ -2074,7 +2076,7 @@ function finishBoxSelect() {
|
||||
})()
|
||||
: hits
|
||||
if (merged.length === 0) {
|
||||
store.selectLayer(null)
|
||||
deselectAll()
|
||||
return
|
||||
}
|
||||
if (merged.length === 1) {
|
||||
@@ -2615,7 +2617,7 @@ function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
|
||||
if (target.name?.() === 'path-frame-dot') return
|
||||
if (vertexMenu.value) vertexMenu.value = null
|
||||
if (target === target.getStage()) {
|
||||
store.selectLayer(null)
|
||||
deselectAll()
|
||||
return
|
||||
}
|
||||
const id = target.id?.()
|
||||
@@ -2628,11 +2630,20 @@ function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
|
||||
}
|
||||
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)
|
||||
return
|
||||
}
|
||||
const match = store.activeLayers.find((l) => l.id === id)
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import {
|
||||
PhCaretDown,
|
||||
PhCaretRight,
|
||||
PhFolder,
|
||||
PhMoon,
|
||||
PhSlidersHorizontal,
|
||||
@@ -29,14 +31,41 @@ const { locale } = useI18n()
|
||||
|
||||
// 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
|
||||
// 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(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (open) void clipart.initialize()
|
||||
async (open) => {
|
||||
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'
|
||||
|
||||
const tabs: { id: TabId; labelKey: string; icon: typeof PhFolder }[] = [
|
||||
@@ -99,17 +128,85 @@ function applyTheme(t: Theme) {
|
||||
<li
|
||||
v-for="(h, i) in clipart.handles"
|
||||
:key="h.name + i"
|
||||
class="clipart-value"
|
||||
class="folder-card"
|
||||
>
|
||||
<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)"
|
||||
<div class="folder-head">
|
||||
<PhFolder :size="14" />
|
||||
<span class="folder-name">{{ h.name }}</span>
|
||||
<button
|
||||
class="ghost-btn small"
|
||||
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>
|
||||
</ul>
|
||||
<div v-else class="clipart-value">
|
||||
@@ -586,11 +683,120 @@ input[type='number'] {
|
||||
list-style: none;
|
||||
margin: 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;
|
||||
flex-direction: column;
|
||||
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 {
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
|
||||
@@ -42,6 +42,7 @@ import { snapToCandidates } from '@/lib/timelineSnap'
|
||||
import { confirmDialog, promptDialog } from '@/components/ui'
|
||||
import ScriptFrameDialog from './ScriptFrameDialog.vue'
|
||||
import { t } from '@/i18n'
|
||||
import { useDeselectAll } from '@/composables/useDeselectAll'
|
||||
import { kfSelectionKey, useKfSelection, type KfSelectionKey } from '@/composables/useKfSelection'
|
||||
import { showLocked } from '@/composables/useShowLocked'
|
||||
import type {
|
||||
@@ -1037,6 +1038,7 @@ function isDropAfter(track: TimelineTrack, nextTrack: TimelineTrack | undefined)
|
||||
type SelectionKey = KfSelectionKey
|
||||
const { selectedKfs, selectedKfSet } = useKfSelection()
|
||||
const selectionKey = kfSelectionKey
|
||||
const deselectAll = useDeselectAll()
|
||||
|
||||
function isKfSelected(layerId: string, kind: 'main' | 'position' | 'rotation', keyframeId: string): boolean {
|
||||
return selectedKfSet.value.has(`${layerId}:${kind}:${keyframeId}`)
|
||||
@@ -1743,8 +1745,11 @@ function onTracksPointerUp(e: PointerEvent) {
|
||||
if (b.moved) {
|
||||
commitBoxSelection(b)
|
||||
} else if (!b.additive) {
|
||||
// bare click on empty space without Shift → clear selection
|
||||
clearKfSelection()
|
||||
// Bare click on empty timeline space without Shift → clear ALL
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
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', () => {
|
||||
|
||||
@@ -522,6 +522,19 @@ export const en = {
|
||||
},
|
||||
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.',
|
||||
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: {
|
||||
@@ -1312,6 +1325,19 @@ export const de: DeepPartial<Messages> = {
|
||||
},
|
||||
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.',
|
||||
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: {
|
||||
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'
|
||||
}
|
||||
|
||||
/** 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). */
|
||||
/** Result of `listClipart` — file items plus a skip report so the
|
||||
* Settings panel can tell the user *why* something didn't show up.
|
||||
* 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(
|
||||
handle: FileSystemDirectoryHandle,
|
||||
): Promise<ClipartItem[]> {
|
||||
): Promise<ClipartListing> {
|
||||
const granted = await ensurePermission(handle)
|
||||
if (!granted) return []
|
||||
const out: ClipartItem[] = []
|
||||
if (!granted) return { items: [], skippedFolders: [], skippedFiles: [] }
|
||||
const items: ClipartFileItem[] = []
|
||||
const skippedFolders: string[] = []
|
||||
const skippedFiles: string[] = []
|
||||
// @ts-expect-error async iterator not in DOM lib
|
||||
for await (const [name, entry] of handle.entries()) {
|
||||
if (entry.kind === 'directory') {
|
||||
out.push({ kind: 'folder', name, handle: entry as FileSystemDirectoryHandle })
|
||||
skippedFolders.push(name)
|
||||
continue
|
||||
}
|
||||
if (entry.kind !== 'file') continue
|
||||
let kind: 'image' | 'audio' | null = null
|
||||
if (IMAGE_EXT.test(name)) kind = 'image'
|
||||
else if (AUDIO_EXT.test(name)) kind = 'audio'
|
||||
if (!kind) continue
|
||||
if (!kind) {
|
||||
skippedFiles.push(name)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
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 {
|
||||
/* 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
|
||||
// name so navigation order stays stable across drawer re-opens.
|
||||
out.sort((a, b) => {
|
||||
const rank = (k: ClipartItem['kind']) =>
|
||||
k === 'folder' ? 0 : k === 'image' ? 1 : 2
|
||||
// Images first, then audio. Inside each group sort by name so
|
||||
// navigation order stays stable across drawer re-opens.
|
||||
items.sort((a, b) => {
|
||||
const rank = (k: ClipartFileItem['kind']) => (k === 'image' ? 0 : 1)
|
||||
const ra = rank(a.kind)
|
||||
const rb = rank(b.kind)
|
||||
if (ra !== rb) return ra - rb
|
||||
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 {
|
||||
|
||||
+63
-5
@@ -9,6 +9,19 @@ import {
|
||||
type ClipartItem,
|
||||
} 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 {
|
||||
/** Root folders the user has added. Order matches display order. */
|
||||
handles: FileSystemDirectoryHandle[]
|
||||
@@ -20,6 +33,8 @@ interface State {
|
||||
/** Current view's items — folder tiles in overview mode, files in
|
||||
* in-folder mode. Consumers iterate this directly. */
|
||||
items: ClipartItem[]
|
||||
/** Stats keyed by handle index, populated by refresh() / scanFolderStats. */
|
||||
folderStats: Record<number, ClipartFolderStats>
|
||||
open: boolean
|
||||
loading: boolean
|
||||
error: string | null
|
||||
@@ -50,6 +65,7 @@ export const useClipartStore = defineStore('clipart', {
|
||||
handles: [],
|
||||
activeHandleIndex: null,
|
||||
items: [],
|
||||
folderStats: {},
|
||||
open: readDrawerOpen(),
|
||||
loading: false,
|
||||
error: null,
|
||||
@@ -184,12 +200,25 @@ export const useClipartStore = defineStore('clipart', {
|
||||
try {
|
||||
const active = this.activeHandle
|
||||
if (active) {
|
||||
// In-folder view: list files; strip subdirectory entries so
|
||||
// drilling stays one-level-deep (matches the pre-multi-root
|
||||
// behaviour).
|
||||
const next = await listClipart(active)
|
||||
const listing = await listClipart(active)
|
||||
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 {
|
||||
// Overview: synthesize a folder tile per root. These items
|
||||
// have no URLs to revoke, so disposeItems is cheap.
|
||||
@@ -206,6 +235,35 @@ export const useClipartStore = defineStore('clipart', {
|
||||
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() {
|
||||
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'
|
||||
Reference in new issue
Block a user