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 @@