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

+1 -1
View File
@@ -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
View File
@@ -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>
+16 -5
View File
@@ -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
} }
+219 -13
View File
@@ -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;
+7 -2
View File
@@ -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
} }
+17
View File
@@ -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 = []
}
}
+213
View File
@@ -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', () => {
+26
View File
@@ -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',
+53
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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'