From b0813a1d5080c8b233f4f399bd857b197b8defa9 Mon Sep 17 00:00:00 2001
From: th
Date: Sat, 25 Apr 2026 09:42:36 +0200
Subject: [PATCH] 1.1.5: settings/media folder details + empty-click deselects
everything
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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.
---
package.json | 2 +-
src/App.vue | 14 ++
src/components/CanvasStage.vue | 21 ++-
src/components/DocumentSettings.vue | 232 ++++++++++++++++++++++++++--
src/components/Timeline.vue | 9 +-
src/composables/useDeselectAll.ts | 17 ++
src/devtools/tests.ts | 213 +++++++++++++++++++++++++
src/i18n/messages.ts | 26 ++++
src/lib/chromeClick.ts | 53 +++++++
src/lib/clipartLibrary.ts | 55 +++++--
src/stores/clipart.ts | 68 +++++++-
src/version.ts | 2 +-
12 files changed, 669 insertions(+), 43 deletions(-)
create mode 100644 src/composables/useDeselectAll.ts
create mode 100644 src/lib/chromeClick.ts
diff --git a/package.json b/package.json
index 4a3eff2..9fc5c67 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "ruwio",
"private": true,
- "version": "1.1.4",
+ "version": "1.1.5",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/src/App.vue b/src/App.vue
index ed9613f..abf23c5 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -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)
})
diff --git a/src/components/CanvasStage.vue b/src/components/CanvasStage.vue
index ba5afba..27fecc3 100644
--- a/src/components/CanvasStage.vue
+++ b/src/components/CanvasStage.vue
@@ -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(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) {
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) {
}
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
}
diff --git a/src/components/DocumentSettings.vue b/src/components/DocumentSettings.vue
index 6b1eba3..2fcf1f3 100644
--- a/src/components/DocumentSettings.vue
+++ b/src/components/DocumentSettings.vue
@@ -1,6 +1,8 @@
|