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
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user