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:
th committed 2026-04-25 09:42:36 +02:00
1 parent 3005d9310d
commit b0813a1d50
12 files changed
+669 -43

No files matched your search

+213
View File
@@ -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', () => {