diff --git a/browser_tests/fixtures/components/SidebarTab.ts b/browser_tests/fixtures/components/SidebarTab.ts
index 3db6d04bc1..bcece779a3 100644
--- a/browser_tests/fixtures/components/SidebarTab.ts
+++ b/browser_tests/fixtures/components/SidebarTab.ts
@@ -322,6 +322,9 @@ export class AssetsSidebarTab extends SidebarTab {
// --- Folder view ---
public readonly backToAssetsButton: Locator
+ // --- Panel chrome ---
+ public readonly panelHeader: Locator
+
// --- Loading ---
public readonly skeletonLoaders: Locator
@@ -358,6 +361,7 @@ export class AssetsSidebarTab extends SidebarTab {
this.deleteSelectedButton = page.getByTestId('assets-delete-selected')
this.downloadSelectedButton = page.getByTestId('assets-download-selected')
this.backToAssetsButton = page.getByText('Back to all assets')
+ this.panelHeader = page.locator('.comfy-vue-side-bar-header')
this.skeletonLoaders = page.locator(
'.sidebar-content-container .animate-pulse'
)
diff --git a/browser_tests/tests/sidebar/assetsSidebarTab.spec.ts b/browser_tests/tests/sidebar/assetsSidebarTab.spec.ts
index becfffc4cb..30fb7ec8af 100644
--- a/browser_tests/tests/sidebar/assetsSidebarTab.spec.ts
+++ b/browser_tests/tests/sidebar/assetsSidebarTab.spec.ts
@@ -276,3 +276,255 @@ test.describe('FE-130 assets sidebar route mocks', () => {
)
})
})
+
+test.describe('FE-910 marquee selection and select all', () => {
+ test.beforeEach(async ({ jobsRoutes, page, comfyPage }) => {
+ await jobsRoutes.mockJobsQueue([])
+ await jobsRoutes.mockJobsHistory(generatedJobs)
+ await mockInputFiles(page, ['imported.png'])
+ await mockViewFiles(page, viewFiles)
+ await comfyPage.setup()
+ await comfyPage.menu.assetsTab.open()
+ })
+
+ test('Ctrl/Cmd+A selects every asset while the panel is hovered', async ({
+ comfyPage
+ }) => {
+ const tab = comfyPage.menu.assetsTab
+
+ await expect(tab.assetCards).toHaveCount(2)
+
+ await tab.getAssetCardByName('alpha').hover()
+ await comfyPage.page.keyboard.press('ControlOrMeta+a')
+
+ await expect(tab.selectedCards).toHaveCount(2)
+ })
+
+ test('a marquee that begins in the panel header selects the cards', async ({
+ comfyPage
+ }) => {
+ const tab = comfyPage.menu.assetsTab
+ const { page } = comfyPage
+
+ await expect(tab.assetCards).toHaveCount(2)
+ await expect(tab.selectedCards).toHaveCount(0)
+
+ const header = await tab.panelHeader.boundingBox()
+ const beta = await tab.getAssetCardByName('beta').boundingBox()
+ if (!header || !beta) {
+ throw new Error('panel header or asset card has no layout box')
+ }
+
+ // Begin the rubber-band in the header (above the grid), then drag down
+ // across both cards.
+ await page.mouse.move(header.x + 24, header.y + 20)
+ await page.mouse.down()
+ await page.mouse.move(beta.x + 8, beta.y + beta.height - 8, { steps: 14 })
+ await page.mouse.up()
+
+ await expect(tab.selectedCards).toHaveCount(2)
+ await expect(tab.selectionFooter).toBeVisible()
+ })
+
+ test('Ctrl/Cmd+A leaves assets unselected while the canvas is hovered', async ({
+ comfyPage
+ }) => {
+ const tab = comfyPage.menu.assetsTab
+ const { page } = comfyPage
+
+ await expect(tab.assetCards).toHaveCount(2)
+
+ const viewport = page.viewportSize()
+ if (!viewport) throw new Error('viewport size is unavailable')
+
+ // Hover the canvas (not the panel); Ctrl/Cmd+A must yield to the canvas.
+ await page.mouse.move(viewport.width - 100, viewport.height / 2)
+ await page.keyboard.press('ControlOrMeta+a')
+
+ await expect(tab.selectedCards).toHaveCount(0)
+ })
+
+ test('a modifier-held marquee adds to the existing selection', async ({
+ comfyPage
+ }) => {
+ const tab = comfyPage.menu.assetsTab
+ const { page } = comfyPage
+
+ await expect(tab.assetCards).toHaveCount(2)
+
+ await tab.getAssetCardByName('alpha').click()
+ await expect(tab.selectedCards).toHaveCount(1)
+
+ const beta = await tab.getAssetCardByName('beta').boundingBox()
+ if (!beta) throw new Error('beta card has no layout box')
+
+ // Hold a modifier so the marquee is additive, then rubber-band over beta.
+ await page.keyboard.down('Control')
+ await page.mouse.move(beta.x + 12, beta.y + 12)
+ await page.mouse.down()
+ await page.mouse.move(beta.x + beta.width - 12, beta.y + beta.height - 12, {
+ steps: 12
+ })
+ await page.mouse.up()
+ await page.keyboard.up('Control')
+
+ await expect(tab.selectedCards).toHaveCount(2)
+ })
+
+ test('a Ctrl/Cmd+Shift marquee removes the covered cards from the selection', async ({
+ comfyPage
+ }) => {
+ const tab = comfyPage.menu.assetsTab
+ const { page } = comfyPage
+
+ await expect(tab.assetCards).toHaveCount(2)
+
+ await tab.getAssetCardByName('alpha').hover()
+ await page.keyboard.press('ControlOrMeta+a')
+ await expect(tab.selectedCards).toHaveCount(2)
+
+ const beta = await tab.getAssetCardByName('beta').boundingBox()
+ if (!beta) throw new Error('beta card has no layout box')
+
+ // Ctrl+Shift makes the marquee subtractive: rubber-band over beta only.
+ await page.keyboard.down('Control')
+ await page.keyboard.down('Shift')
+ await page.mouse.move(beta.x + 12, beta.y + 12)
+ await page.mouse.down()
+ await page.mouse.move(beta.x + beta.width - 12, beta.y + beta.height - 12, {
+ steps: 12
+ })
+ await page.mouse.up()
+ await page.keyboard.up('Shift')
+ await page.keyboard.up('Control')
+
+ await expect(tab.selectedCards).toHaveCount(1)
+ await expect(tab.getAssetCardByName('alpha')).toHaveAttribute(
+ 'data-selected',
+ 'true'
+ )
+ })
+
+ test('Ctrl/Cmd-dragging from an asset card starts a marquee selection', async ({
+ comfyPage
+ }) => {
+ const tab = comfyPage.menu.assetsTab
+ const { page } = comfyPage
+
+ await expect(tab.assetCards).toHaveCount(2)
+ await expect(tab.selectedCards).toHaveCount(0)
+
+ const alpha = await tab.getAssetCardByName('alpha').boundingBox()
+ const beta = await tab.getAssetCardByName('beta').boundingBox()
+ if (!alpha || !beta) throw new Error('asset cards have no layout box')
+
+ // Ctrl bypasses card drag, so a press that begins on a card rubber-bands.
+ await page.keyboard.down('Control')
+ await page.mouse.move(alpha.x + alpha.width / 2, alpha.y + alpha.height / 2)
+ await page.mouse.down()
+ await page.mouse.move(beta.x + beta.width - 6, beta.y + beta.height - 6, {
+ steps: 12
+ })
+ await page.mouse.up()
+ await page.keyboard.up('Control')
+
+ await expect(tab.selectedCards).toHaveCount(2)
+ await expect(tab.selectionFooter).toBeVisible()
+ })
+
+ test('Ctrl/Cmd-dragging within a single card selects only that card', async ({
+ comfyPage
+ }) => {
+ const tab = comfyPage.menu.assetsTab
+ const { page } = comfyPage
+
+ await expect(tab.assetCards).toHaveCount(2)
+
+ const alpha = tab.getAssetCardByName('alpha')
+ const box = await alpha.boundingBox()
+ if (!box) throw new Error('alpha card has no layout box')
+
+ const start = { x: box.x + box.width / 2, y: box.y + box.height / 2 }
+ await page.keyboard.down('Control')
+ await page.mouse.move(start.x, start.y)
+ await page.mouse.down()
+ await page.mouse.move(start.x + 12, start.y + 12, { steps: 4 })
+ await page.mouse.up()
+ await page.keyboard.up('Control')
+
+ await expect(tab.selectedCards).toHaveCount(1)
+ await expect(alpha).toHaveAttribute('data-selected', 'true')
+ })
+
+ test('Ctrl/Cmd+A in the focused search input does not select assets', async ({
+ comfyPage
+ }) => {
+ const tab = comfyPage.menu.assetsTab
+ const query = 'alpha'
+
+ await tab.searchInput.fill(query)
+ await expect(tab.assetCards).toHaveCount(1)
+
+ await tab.searchInput.focus()
+ await comfyPage.page.keyboard.press('ControlOrMeta+a')
+
+ await expect(tab.selectedCards).toHaveCount(0)
+ await expect
+ .poll(() =>
+ tab.searchInput.evaluate((el: HTMLInputElement) => {
+ return { start: el.selectionStart, end: el.selectionEnd }
+ })
+ )
+ .toEqual({ start: 0, end: query.length })
+ })
+
+ test('a drag starting in the search input does not marquee-select assets', async ({
+ comfyPage
+ }) => {
+ const tab = comfyPage.menu.assetsTab
+ const { page } = comfyPage
+
+ await expect(tab.assetCards).toHaveCount(2)
+
+ const search = await tab.searchInput.boundingBox()
+ const beta = await tab.getAssetCardByName('beta').boundingBox()
+ if (!search || !beta)
+ throw new Error('search box or card has no layout box')
+
+ await page.mouse.move(
+ search.x + search.width / 2,
+ search.y + search.height / 2
+ )
+ await page.mouse.down()
+ await page.mouse.move(beta.x + beta.width / 2, beta.y + beta.height / 2, {
+ steps: 12
+ })
+ await page.mouse.up()
+
+ await expect(tab.selectedCards).toHaveCount(0)
+ })
+
+ test('Ctrl/Cmd+A does not select assets while an aria-modal dialog is open', async ({
+ comfyPage
+ }) => {
+ const tab = comfyPage.menu.assetsTab
+ await expect(tab.assetCards).toHaveCount(2)
+
+ await comfyPage.page.evaluate(() => {
+ const dialog = document.createElement('div')
+ dialog.id = 'test-modal'
+ dialog.setAttribute('role', 'dialog')
+ dialog.setAttribute('aria-modal', 'true')
+ document.body.appendChild(dialog)
+ })
+
+ await tab.getAssetCardByName('alpha').hover()
+ await comfyPage.page.keyboard.press('ControlOrMeta+a')
+
+ await expect(tab.selectedCards).toHaveCount(0)
+
+ await comfyPage.page.evaluate(() => {
+ document.getElementById('test-modal')?.remove()
+ })
+ })
+})
diff --git a/src/components/graph/GraphCanvas.vue b/src/components/graph/GraphCanvas.vue
index 0d46429c1f..426e6a44fb 100644
--- a/src/components/graph/GraphCanvas.vue
+++ b/src/components/graph/GraphCanvas.vue
@@ -39,6 +39,10 @@
+
-
+
@@ -116,6 +123,7 @@ import {
onUnmounted,
ref,
shallowRef,
+ useTemplateRef,
watch,
watchEffect
} from 'vue'
@@ -202,6 +210,7 @@ const emit = defineEmits<{
ready: []
}>()
const canvasRef = ref(null)
+const canvasPanelBoundsRef = useTemplateRef('canvasPanelBoundsRef')
const nodeSearchboxPopoverRef = shallowRef | null>(null)
diff --git a/src/components/graph/SelectionRectangle.test.ts b/src/components/graph/SelectionRectangle.test.ts
new file mode 100644
index 0000000000..8bfc4f9798
--- /dev/null
+++ b/src/components/graph/SelectionRectangle.test.ts
@@ -0,0 +1,106 @@
+import { fromPartial } from '@total-typescript/shoehorn'
+import { render, screen } from '@testing-library/vue'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { nextTick, ref } from 'vue'
+
+import SelectionRectangle from './SelectionRectangle.vue'
+
+const rafCallbacks: Array<() => void> = []
+vi.mock('@vueuse/core', () => ({
+ useRafFn: (cb: () => void) => {
+ rafCallbacks.push(cb)
+ return { pause: vi.fn(), resume: vi.fn() }
+ }
+}))
+
+const mockCanvas = ref(null)
+vi.mock('@/renderer/core/canvas/canvasStore', () => ({
+ useCanvasStore: () => ({
+ get canvas() {
+ return mockCanvas.value
+ }
+ })
+}))
+
+function createPanelEl() {
+ const panel = document.createElement('div')
+ vi.spyOn(panel, 'getBoundingClientRect').mockReturnValue(
+ fromPartial({ left: 300, top: 0, right: 1000, bottom: 800 })
+ )
+ return panel
+}
+
+function dragRectangle(eDown: [number, number], eMove: [number, number]) {
+ const canvasEl = document.createElement('canvas')
+ vi.spyOn(canvasEl, 'getBoundingClientRect').mockReturnValue(
+ fromPartial({ left: 0, top: 0, right: 1000, bottom: 800 })
+ )
+ mockCanvas.value = {
+ canvas: canvasEl,
+ dragging_rectangle: true,
+ pointer: {
+ eDown: { safeOffsetX: eDown[0], safeOffsetY: eDown[1] },
+ eMove: { safeOffsetX: eMove[0], safeOffsetY: eMove[1] }
+ }
+ }
+ rafCallbacks[rafCallbacks.length - 1]()
+}
+
+describe('SelectionRectangle', () => {
+ afterEach(() => {
+ rafCallbacks.length = 0
+ mockCanvas.value = null
+ document.body.replaceChildren()
+ vi.restoreAllMocks()
+ })
+
+ it('clips the rectangle to the canvas panel when dragged over the sidebar', async () => {
+ render(SelectionRectangle, { props: { panelEl: createPanelEl() } })
+
+ dragRectangle([100, 100], [800, 400])
+ await nextTick()
+
+ const rect = screen.getByTestId('selection-rectangle')
+ expect(rect.style.left).toBe('300px')
+ expect(rect.style.top).toBe('100px')
+ expect(rect.style.width).toBe('500px')
+ expect(rect.style.height).toBe('300px')
+ })
+
+ it('leaves a rectangle within the panel unchanged', async () => {
+ render(SelectionRectangle, { props: { panelEl: createPanelEl() } })
+
+ dragRectangle([400, 100], [600, 300])
+ await nextTick()
+
+ const rect = screen.getByTestId('selection-rectangle')
+ expect(rect.style.left).toBe('400px')
+ expect(rect.style.top).toBe('100px')
+ expect(rect.style.width).toBe('200px')
+ expect(rect.style.height).toBe('200px')
+ })
+
+ it('normalizes and clips a rectangle dragged up-and-left', async () => {
+ render(SelectionRectangle, { props: { panelEl: createPanelEl() } })
+
+ dragRectangle([800, 400], [100, 100])
+ await nextTick()
+
+ const rect = screen.getByTestId('selection-rectangle')
+ expect(rect.style.left).toBe('300px')
+ expect(rect.style.top).toBe('100px')
+ expect(rect.style.width).toBe('500px')
+ expect(rect.style.height).toBe('300px')
+ })
+
+ it('renders unclamped edges when the canvas panel is absent', async () => {
+ render(SelectionRectangle)
+
+ dragRectangle([100, 100], [800, 400])
+ await nextTick()
+
+ const rect = screen.getByTestId('selection-rectangle')
+ expect(rect.style.left).toBe('100px')
+ expect(rect.style.width).toBe('700px')
+ })
+})
diff --git a/src/components/graph/SelectionRectangle.vue b/src/components/graph/SelectionRectangle.vue
index 7fc886d586..c971996286 100644
--- a/src/components/graph/SelectionRectangle.vue
+++ b/src/components/graph/SelectionRectangle.vue
@@ -1,6 +1,7 @@
@@ -11,6 +12,13 @@ import { useRafFn } from '@vueuse/core'
import { computed, ref } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
+import { clipRectToBounds } from '@/utils/mathUtil'
+import type { RectEdges } from '@/utils/mathUtil'
+
+const { panelEl } = defineProps<{
+ /** Clip surface owned by the caller; the rectangle renders unclipped when absent. */
+ panelEl?: HTMLElement
+}>()
const canvasStore = useCanvasStore()
@@ -20,17 +28,18 @@ const selectionRect = ref<{
w: number
h: number
} | null>(null)
+const panelBounds = ref()
useRafFn(() => {
const canvas = canvasStore.canvas
- if (!canvas) {
- selectionRect.value = null
- return
- }
+ if (!canvas) return
const { pointer, dragging_rectangle } = canvas
if (dragging_rectangle && pointer.eDown && pointer.eMove) {
+ if (!selectionRect.value) {
+ panelBounds.value = getCanvasPanelBounds(canvas.canvas)
+ }
const x = pointer.eDown.safeOffsetX
const y = pointer.eDown.safeOffsetY
const w = pointer.eMove.safeOffsetX - x
@@ -39,25 +48,47 @@ useRafFn(() => {
selectionRect.value = { x, y, w, h }
} else {
selectionRect.value = null
+ panelBounds.value = undefined
}
})
const isVisible = computed(() => selectionRect.value !== null)
+function getCanvasPanelBounds(
+ canvasEl: HTMLCanvasElement
+): RectEdges | undefined {
+ if (!panelEl) return undefined
+
+ const panel = panelEl.getBoundingClientRect()
+ const canvas = canvasEl.getBoundingClientRect()
+ return {
+ left: panel.left - canvas.left,
+ top: panel.top - canvas.top,
+ right: panel.right - canvas.left,
+ bottom: panel.bottom - canvas.top
+ }
+}
+
const rectangleStyle = computed(() => {
const rect = selectionRect.value
if (!rect) return {}
- const left = rect.w >= 0 ? rect.x : rect.x + rect.w
- const top = rect.h >= 0 ? rect.y : rect.y + rect.h
- const width = Math.abs(rect.w)
- const height = Math.abs(rect.h)
+ const edges: RectEdges = {
+ left: rect.w >= 0 ? rect.x : rect.x + rect.w,
+ top: rect.h >= 0 ? rect.y : rect.y + rect.h,
+ right: rect.w >= 0 ? rect.x + rect.w : rect.x,
+ bottom: rect.h >= 0 ? rect.y + rect.h : rect.y
+ }
+ const bounds = panelBounds.value
+ const { left, top, right, bottom } = bounds
+ ? clipRectToBounds(edges, bounds)
+ : edges
return {
left: `${left}px`,
top: `${top}px`,
- width: `${width}px`,
- height: `${height}px`
+ width: `${right - left}px`,
+ height: `${bottom - top}px`
}
})
diff --git a/src/components/sidebar/tabs/AssetsSidebarTab.vue b/src/components/sidebar/tabs/AssetsSidebarTab.vue
index e30e8b7725..d406406fe8 100644
--- a/src/components/sidebar/tabs/AssetsSidebarTab.vue
+++ b/src/components/sidebar/tabs/AssetsSidebarTab.vue
@@ -1,5 +1,6 @@
@@ -100,18 +101,19 @@
@context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd"
/>
-
+
@@ -125,6 +127,13 @@
/>
+
+
+
import {
+ unrefElement,
useAsyncState,
useDebounceFn,
useStorage,
@@ -164,6 +174,7 @@ import {
onMounted,
onUnmounted,
ref,
+ useTemplateRef,
watch
} from 'vue'
import { useI18n } from 'vue-i18n'
@@ -182,6 +193,7 @@ import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBa
import MediaAssetSelectionBar from '@/platform/assets/components/MediaAssetSelectionBar.vue'
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
import { useAssetsApi } from '@/platform/assets/composables/media/useAssetsApi'
+import { useAssetGridSelection } from '@/platform/assets/composables/useAssetGridSelection'
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
@@ -239,7 +251,7 @@ const contextMenuFileKind = computed(() =>
getMediaTypeFromFilename(contextMenuAsset.value?.name ?? '')
)
-const shouldShowOutputCount = (item: AssetItem): boolean => {
+const showOutputCount = (item: AssetItem): boolean => {
if (activeTab.value !== 'output' || isInFolderView.value) {
return false
}
@@ -259,7 +271,10 @@ const outputAssets = useAssetsApi('output')
// Asset selection
const {
isSelected,
+ selectedIds,
handleAssetClick,
+ selectAll,
+ setSelectedIds,
hasSelection,
clearSelection,
getSelectedAssets,
@@ -270,6 +285,12 @@ const {
deactivate: deactivateSelection
} = useAssetSelection()
+const panelRef = useTemplateRef('panelRef')
+const marqueePanelRef = computed(() => {
+ const el = unrefElement(panelRef)
+ return el instanceof HTMLElement ? el : undefined
+})
+
const {
downloadAssets,
deleteAssets,
@@ -337,6 +358,16 @@ const visibleAssets = computed(() => {
return listViewSelectableAssets.value
})
+const { marqueeStyle } = useAssetGridSelection({
+ marqueeContainerRef: marqueePanelRef,
+ hoverTargetRef: marqueePanelRef,
+ getAssets: () => visibleAssets.value,
+ getSelectedIds: () => [...selectedIds.value],
+ setSelectedIds,
+ selectAll,
+ isEnabled: () => !isListView.value
+})
+
const previewableVisibleAssets = computed(() =>
visibleAssets.value.filter((asset) =>
isPreviewableMediaType(getMediaTypeFromFilename(asset.name))
@@ -575,7 +606,7 @@ const handleDeselectAll = () => {
}
const handleEmptySpaceClick = () => {
- if (hasSelection) {
+ if (hasSelection.value) {
clearSelection()
}
}
diff --git a/src/platform/assets/components/MediaAssetCard.test.ts b/src/platform/assets/components/MediaAssetCard.test.ts
new file mode 100644
index 0000000000..b41ef2429a
--- /dev/null
+++ b/src/platform/assets/components/MediaAssetCard.test.ts
@@ -0,0 +1,118 @@
+import { createTestingPinia } from '@pinia/testing'
+import { render, screen } from '@testing-library/vue'
+import { setActivePinia } from 'pinia'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { createI18n } from 'vue-i18n'
+
+import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
+import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
+
+vi.mock('@/stores/assetsStore', () => ({
+ useAssetsStore: () => ({ isAssetDeleting: () => false })
+}))
+
+vi.mock('../composables/useMediaAssetActions', () => ({
+ useMediaAssetActions: () => ({ downloadAssets: vi.fn() })
+}))
+
+vi.mock('@/platform/assets/schemas/assetMetadataSchema', () => ({
+ getOutputAssetMetadata: () => ({
+ allOutputs: [
+ {
+ filename: 'a.png',
+ subfolder: '',
+ type: 'output',
+ display_name: 'Display A'
+ }
+ ]
+ })
+}))
+
+const asset: AssetItem = {
+ id: 'a',
+ name: 'a.png',
+ tags: [],
+ preview_url: '/preview.png'
+}
+
+function renderCard() {
+ setActivePinia(createTestingPinia({ stubActions: false }))
+ const i18n = createI18n({
+ legacy: false,
+ locale: 'en',
+ messages: { en: {} },
+ missingWarn: false,
+ fallbackWarn: false
+ })
+ return render(MediaAssetCard, {
+ props: { asset, loading: true },
+ global: {
+ plugins: [i18n],
+ stubs: {
+ IconGroup: true,
+ LoadingOverlay: true,
+ Button: true,
+ MediaTitle: true
+ },
+ directives: { tooltip: {} }
+ }
+ })
+}
+
+function dispatchDragStart(
+ init: { ctrlKey?: boolean; metaKey?: boolean } = {}
+) {
+ const dataTransfer = new DataTransfer()
+ const add = vi.spyOn(dataTransfer.items, 'add').mockImplementation(() => null)
+ const event = new DragEvent('dragstart', { bubbles: true, cancelable: true })
+ // happy-dom's DragEvent ignores dataTransfer/modifier init, so set them here.
+ Object.defineProperties(event, {
+ dataTransfer: { value: dataTransfer, configurable: true },
+ ctrlKey: { value: init.ctrlKey ?? false, configurable: true },
+ metaKey: { value: init.metaKey ?? false, configurable: true }
+ })
+ screen.getByRole('button').dispatchEvent(event)
+ return { event, add }
+}
+
+describe('MediaAssetCard', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('dragStart', () => {
+ it('cancels the native drag when Ctrl is held so a marquee can start over the card', () => {
+ renderCard()
+
+ const { event, add } = dispatchDragStart({ ctrlKey: true })
+
+ expect(event.defaultPrevented).toBe(true)
+ expect(add).not.toHaveBeenCalled()
+ })
+
+ it('cancels the native drag when Meta is held', () => {
+ renderCard()
+
+ const { event } = dispatchDragStart({ metaKey: true })
+
+ expect(event.defaultPrevented).toBe(true)
+ })
+
+ it('includes the asset metadata with display_name in the drag payload', () => {
+ renderCard()
+
+ const { event, add } = dispatchDragStart()
+
+ expect(event.defaultPrevented).toBe(false)
+ expect(add).toHaveBeenCalledWith(
+ JSON.stringify({
+ filename: 'a.png',
+ subfolder: '',
+ type: 'output',
+ display_name: 'Display A'
+ }),
+ expect.any(String)
+ )
+ })
+ })
+})
diff --git a/src/platform/assets/components/MediaAssetCard.vue b/src/platform/assets/components/MediaAssetCard.vue
index 63d58207eb..4fdde80ca8 100644
--- a/src/platform/assets/components/MediaAssetCard.vue
+++ b/src/platform/assets/components/MediaAssetCard.vue
@@ -21,6 +21,7 @@
)
"
:data-selected="selected"
+ :data-asset-id="asset?.id"
:draggable="true"
@click.stop="$emit('click')"
@contextmenu.prevent.stop="
@@ -316,6 +317,11 @@ const handleOutputCountClick = () => {
emit('output-count-click')
}
function dragStart(e: DragEvent) {
+ if (e.ctrlKey || e.metaKey) {
+ e.preventDefault()
+ return
+ }
+
if (!asset?.preview_url) return
const { dataTransfer } = e
diff --git a/src/platform/assets/composables/useAssetGridSelection.test.ts b/src/platform/assets/composables/useAssetGridSelection.test.ts
new file mode 100644
index 0000000000..757309db99
--- /dev/null
+++ b/src/platform/assets/composables/useAssetGridSelection.test.ts
@@ -0,0 +1,800 @@
+import { render, screen } from '@testing-library/vue'
+import { fromPartial } from '@total-typescript/shoehorn'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { defineComponent, nextTick, ref } from 'vue'
+
+import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
+
+import { useAssetGridSelection } from './useAssetGridSelection'
+
+const assets: AssetItem[] = [
+ { id: 'a', name: 'a.png', tags: [] },
+ { id: 'b', name: 'b.png', tags: [] },
+ { id: 'c', name: 'c.png', tags: [] }
+]
+
+const cardBoxes: Record = {
+ a: { left: 0, right: 50 },
+ b: { left: 60, right: 110 },
+ c: { left: 120, right: 170 }
+}
+
+function pointer(type: string, init: PointerEventInit = {}) {
+ return new PointerEvent(type, {
+ bubbles: true,
+ cancelable: true,
+ button: 0,
+ pointerId: 1,
+ isPrimary: true,
+ ...init
+ })
+}
+
+function createCallbacks(overrides: Record = {}) {
+ return {
+ getAssets: () => assets,
+ getSelectedIds: vi.fn(() => [] as string[]),
+ setSelectedIds: vi.fn(),
+ selectAll: vi.fn(),
+ ...overrides
+ }
+}
+
+async function renderHarness(callbacks: ReturnType) {
+ const Harness = defineComponent({
+ setup() {
+ const gridContainerRef = ref()
+ const hoverTargetRef = ref()
+ const { marqueeStyle } = useAssetGridSelection({
+ marqueeContainerRef: gridContainerRef,
+ hoverTargetRef,
+ getAssets: callbacks.getAssets,
+ getSelectedIds: callbacks.getSelectedIds,
+ setSelectedIds: callbacks.setSelectedIds,
+ selectAll: callbacks.selectAll
+ })
+ return { gridContainerRef, hoverTargetRef, marqueeStyle }
+ },
+ template: `
+
+ `
+ })
+
+ render(Harness)
+ await nextTick()
+ vi.spyOn(screen.getByTestId('grid'), 'getBoundingClientRect').mockReturnValue(
+ fromPartial({ left: 0, top: 0, right: 1000, bottom: 1000 })
+ )
+ for (const id of Object.keys(cardBoxes)) {
+ vi.spyOn(
+ screen.getByTestId(`card-${id}`),
+ 'getBoundingClientRect'
+ ).mockReturnValue(
+ fromPartial({
+ left: cardBoxes[id].left,
+ right: cardBoxes[id].right,
+ top: 0,
+ bottom: 50
+ })
+ )
+ }
+}
+
+const grid = () => screen.getByTestId('grid')
+const panel = () => screen.getByTestId('panel')
+const card = (id: string) => screen.getByTestId(`card-${id}`)
+
+describe('useAssetGridSelection', () => {
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ describe('marquee', () => {
+ it('selects intersecting cards when dragging from empty space', async () => {
+ const callbacks = createCallbacks()
+ await renderHarness(callbacks)
+
+ grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
+ window.dispatchEvent(
+ pointer('pointermove', { clientX: 110, clientY: 50 })
+ )
+
+ expect(callbacks.setSelectedIds).toHaveBeenLastCalledWith(
+ ['a', 'b'],
+ assets
+ )
+ })
+
+ it('unions with the current selection when a modifier is held', async () => {
+ const callbacks = createCallbacks({ getSelectedIds: vi.fn(() => ['c']) })
+ await renderHarness(callbacks)
+
+ grid().dispatchEvent(
+ pointer('pointerdown', { clientX: 0, clientY: 0, shiftKey: true })
+ )
+ window.dispatchEvent(pointer('pointermove', { clientX: 55, clientY: 50 }))
+
+ expect(
+ [...callbacks.setSelectedIds.mock.lastCall![0]].sort(
+ (a: string, b: string) => a.localeCompare(b)
+ )
+ ).toEqual(['a', 'c'])
+ })
+
+ it('removes covered cards from the selection when Ctrl+Shift is held (subtractive)', async () => {
+ const callbacks = createCallbacks({
+ getSelectedIds: vi.fn(() => ['a', 'b'])
+ })
+ await renderHarness(callbacks)
+
+ grid().dispatchEvent(
+ pointer('pointerdown', {
+ clientX: 0,
+ clientY: 0,
+ ctrlKey: true,
+ shiftKey: true
+ })
+ )
+ window.dispatchEvent(pointer('pointermove', { clientX: 55, clientY: 50 }))
+
+ expect(callbacks.setSelectedIds.mock.lastCall![0]).toEqual(['b'])
+ })
+
+ it('removes covered cards when Cmd+Shift is held (macOS subtractive)', async () => {
+ const callbacks = createCallbacks({
+ getSelectedIds: vi.fn(() => ['a', 'b'])
+ })
+ await renderHarness(callbacks)
+
+ grid().dispatchEvent(
+ pointer('pointerdown', {
+ clientX: 0,
+ clientY: 0,
+ metaKey: true,
+ shiftKey: true
+ })
+ )
+ window.dispatchEvent(pointer('pointermove', { clientX: 55, clientY: 50 }))
+
+ expect(callbacks.setSelectedIds.mock.lastCall![0]).toEqual(['b'])
+ })
+
+ it('restores cards when the subtractive marquee shrinks back off them', async () => {
+ const callbacks = createCallbacks({
+ getSelectedIds: vi.fn(() => ['a', 'b'])
+ })
+ await renderHarness(callbacks)
+
+ grid().dispatchEvent(
+ pointer('pointerdown', {
+ clientX: 55,
+ clientY: 55,
+ ctrlKey: true,
+ shiftKey: true
+ })
+ )
+ window.dispatchEvent(pointer('pointermove', { clientX: 5, clientY: 45 }))
+ expect(callbacks.setSelectedIds.mock.lastCall![0]).toEqual(['b'])
+
+ window.dispatchEvent(pointer('pointermove', { clientX: 54, clientY: 54 }))
+ expect(callbacks.setSelectedIds.mock.lastCall![0]).toEqual(['a', 'b'])
+ })
+
+ it('ignores movement below the drag threshold', async () => {
+ const callbacks = createCallbacks()
+ await renderHarness(callbacks)
+
+ grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
+ window.dispatchEvent(pointer('pointermove', { clientX: 2, clientY: 2 }))
+
+ expect(callbacks.setSelectedIds).not.toHaveBeenCalled()
+ })
+
+ it('does not start a marquee on a plain pointer-down on a card', async () => {
+ const callbacks = createCallbacks()
+ await renderHarness(callbacks)
+
+ card('a').dispatchEvent(
+ pointer('pointerdown', { clientX: 5, clientY: 5 })
+ )
+ window.dispatchEvent(pointer('pointermove', { clientX: 80, clientY: 40 }))
+
+ expect(callbacks.setSelectedIds).not.toHaveBeenCalled()
+ })
+
+ it('starts a marquee on a card when Ctrl is held and blocks native drag', async () => {
+ const callbacks = createCallbacks()
+ await renderHarness(callbacks)
+
+ card('a').dispatchEvent(
+ pointer('pointerdown', { clientX: 5, clientY: 5, ctrlKey: true })
+ )
+ const dragEvent = new DragEvent('dragstart', {
+ bubbles: true,
+ cancelable: true
+ })
+ card('a').dispatchEvent(dragEvent)
+ window.dispatchEvent(pointer('pointermove', { clientX: 65, clientY: 40 }))
+
+ expect(dragEvent.defaultPrevented).toBe(true)
+ expect(callbacks.setSelectedIds).toHaveBeenCalled()
+ })
+
+ it('does not block native drag when no marquee is tracking', async () => {
+ const callbacks = createCallbacks()
+ await renderHarness(callbacks)
+
+ const dragEvent = new DragEvent('dragstart', {
+ bubbles: true,
+ cancelable: true
+ })
+ card('a').dispatchEvent(dragEvent)
+
+ expect(dragEvent.defaultPrevented).toBe(false)
+ })
+
+ it('shows a marquee overlay while dragging and removes it on release', async () => {
+ const callbacks = createCallbacks()
+ await renderHarness(callbacks)
+
+ grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
+ window.dispatchEvent(pointer('pointermove', { clientX: 30, clientY: 40 }))
+ await nextTick()
+
+ const overlay = screen.getByTestId('marquee')
+ expect(overlay.style.width).toBe('30px')
+ expect(overlay.style.height).toBe('40px')
+
+ window.dispatchEvent(pointer('pointerup', { clientX: 30, clientY: 40 }))
+ await nextTick()
+ expect(screen.queryByTestId('marquee')).toBeNull()
+ })
+
+ it('clips the overlay to the grid container when dragging past its edge', async () => {
+ const callbacks = createCallbacks()
+ await renderHarness(callbacks)
+
+ grid().dispatchEvent(pointer('pointerdown', { clientX: 10, clientY: 10 }))
+ window.dispatchEvent(
+ pointer('pointermove', { clientX: 2000, clientY: 2000 })
+ )
+ await nextTick()
+
+ const overlay = screen.getByTestId('marquee')
+ expect(overlay.style.left).toBe('10px')
+ expect(overlay.style.top).toBe('10px')
+ expect(overlay.style.width).toBe('990px')
+ expect(overlay.style.height).toBe('990px')
+ })
+
+ it('clears the overlay on dragend when a native drag swallows pointerup', async () => {
+ const callbacks = createCallbacks()
+ await renderHarness(callbacks)
+
+ grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
+ window.dispatchEvent(pointer('pointermove', { clientX: 30, clientY: 40 }))
+ await nextTick()
+ expect(screen.getByTestId('marquee')).toBeTruthy()
+
+ window.dispatchEvent(new DragEvent('dragend', { bubbles: true }))
+ await nextTick()
+ expect(screen.queryByTestId('marquee')).toBeNull()
+ })
+
+ it('suppresses the click that trails a drag, but not a plain click', async () => {
+ const callbacks = createCallbacks()
+ await renderHarness(callbacks)
+
+ grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
+ window.dispatchEvent(pointer('pointermove', { clientX: 80, clientY: 40 }))
+ window.dispatchEvent(pointer('pointerup', { clientX: 80, clientY: 40 }))
+
+ const trailing = new MouseEvent('click', {
+ bubbles: true,
+ cancelable: true
+ })
+ window.dispatchEvent(trailing)
+ expect(trailing.defaultPrevented).toBe(true)
+
+ const next = new MouseEvent('click', { bubbles: true, cancelable: true })
+ window.dispatchEvent(next)
+ expect(next.defaultPrevented).toBe(false)
+ })
+
+ it('does not start a marquee for a non-primary mouse button', async () => {
+ const callbacks = createCallbacks()
+ await renderHarness(callbacks)
+
+ grid().dispatchEvent(
+ pointer('pointerdown', { clientX: 0, clientY: 0, button: 2 })
+ )
+ window.dispatchEvent(
+ pointer('pointermove', { clientX: 110, clientY: 50 })
+ )
+
+ expect(callbacks.setSelectedIds).not.toHaveBeenCalled()
+ })
+
+ it('does not start a marquee when the press lands on an interactive control', async () => {
+ const callbacks = createCallbacks()
+ await renderHarness(callbacks)
+
+ screen
+ .getByTestId('grid-button')
+ .dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
+ window.dispatchEvent(
+ pointer('pointermove', { clientX: 110, clientY: 50 })
+ )
+
+ expect(callbacks.setSelectedIds).not.toHaveBeenCalled()
+ })
+
+ it('starts a marquee on a card when Cmd/Meta is held', async () => {
+ const callbacks = createCallbacks()
+ await renderHarness(callbacks)
+
+ card('a').dispatchEvent(
+ pointer('pointerdown', { clientX: 5, clientY: 5, metaKey: true })
+ )
+ window.dispatchEvent(pointer('pointermove', { clientX: 65, clientY: 40 }))
+
+ expect(callbacks.setSelectedIds).toHaveBeenCalled()
+ })
+
+ it('clears the selection when a no-modifier marquee covers no card', async () => {
+ const callbacks = createCallbacks({ getSelectedIds: vi.fn(() => ['a']) })
+ await renderHarness(callbacks)
+
+ grid().dispatchEvent(
+ pointer('pointerdown', { clientX: 300, clientY: 300 })
+ )
+ window.dispatchEvent(
+ pointer('pointermove', { clientX: 400, clientY: 400 })
+ )
+
+ expect(callbacks.setSelectedIds).toHaveBeenLastCalledWith([], assets)
+ })
+
+ it('prevents text selection during a marquee and releases it on pointercancel', async () => {
+ const callbacks = createCallbacks()
+ await renderHarness(callbacks)
+
+ grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
+ window.dispatchEvent(pointer('pointermove', { clientX: 30, clientY: 40 }))
+ await nextTick()
+ expect(screen.getByTestId('marquee')).toBeTruthy()
+
+ const duringDrag = new Event('selectstart', {
+ bubbles: true,
+ cancelable: true
+ })
+ grid().dispatchEvent(duringDrag)
+ expect(duringDrag.defaultPrevented).toBe(true)
+
+ window.dispatchEvent(
+ pointer('pointercancel', { clientX: 30, clientY: 40 })
+ )
+ await nextTick()
+ expect(screen.queryByTestId('marquee')).toBeNull()
+
+ const afterEnd = new Event('selectstart', {
+ bubbles: true,
+ cancelable: true
+ })
+ grid().dispatchEvent(afterEnd)
+ expect(afterEnd.defaultPrevented).toBe(false)
+ })
+
+ it('auto-resets click suppression when a drag ends without a trailing click', async () => {
+ const callbacks = createCallbacks()
+ await renderHarness(callbacks)
+
+ grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
+ window.dispatchEvent(pointer('pointermove', { clientX: 80, clientY: 40 }))
+ window.dispatchEvent(
+ pointer('pointercancel', { clientX: 80, clientY: 40 })
+ )
+
+ await new Promise((resolve) => setTimeout(resolve))
+
+ const laterClick = new MouseEvent('click', {
+ bubbles: true,
+ cancelable: true
+ })
+ window.dispatchEvent(laterClick)
+ expect(laterClick.defaultPrevented).toBe(false)
+ })
+
+ it('only blocks text selection inside the grid container during a marquee', async () => {
+ const callbacks = createCallbacks()
+ await renderHarness(callbacks)
+
+ grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
+ window.dispatchEvent(pointer('pointermove', { clientX: 30, clientY: 40 }))
+ await nextTick()
+
+ const insideGrid = new Event('selectstart', {
+ bubbles: true,
+ cancelable: true
+ })
+ card('a').dispatchEvent(insideGrid)
+ expect(insideGrid.defaultPrevented).toBe(true)
+
+ const outsideGrid = new Event('selectstart', {
+ bubbles: true,
+ cancelable: true
+ })
+ screen.getByTestId('search').dispatchEvent(outsideGrid)
+ expect(outsideGrid.defaultPrevented).toBe(false)
+ })
+
+ it('stops blocking text selection after a normal marquee release', async () => {
+ const callbacks = createCallbacks()
+ await renderHarness(callbacks)
+
+ grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
+ window.dispatchEvent(pointer('pointermove', { clientX: 30, clientY: 40 }))
+
+ const duringDrag = new Event('selectstart', {
+ bubbles: true,
+ cancelable: true
+ })
+ card('a').dispatchEvent(duringDrag)
+ expect(duringDrag.defaultPrevented).toBe(true)
+
+ window.dispatchEvent(pointer('pointerup', { clientX: 30, clientY: 40 }))
+
+ const afterRelease = new Event('selectstart', {
+ bubbles: true,
+ cancelable: true
+ })
+ card('a').dispatchEvent(afterRelease)
+ expect(afterRelease.defaultPrevented).toBe(false)
+ })
+
+ it('does not suppress the click after a sub-threshold press', async () => {
+ const callbacks = createCallbacks()
+ await renderHarness(callbacks)
+
+ grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
+ window.dispatchEvent(pointer('pointermove', { clientX: 2, clientY: 2 }))
+ window.dispatchEvent(pointer('pointerup', { clientX: 2, clientY: 2 }))
+
+ const click = new MouseEvent('click', { bubbles: true, cancelable: true })
+ window.dispatchEvent(click)
+ expect(click.defaultPrevented).toBe(false)
+ })
+
+ it('does not marquee or clear selection when the container has no cards', async () => {
+ const setSelectedIds = vi.fn()
+ const Harness = defineComponent({
+ setup() {
+ const containerRef = ref()
+ useAssetGridSelection({
+ marqueeContainerRef: containerRef,
+ hoverTargetRef: containerRef,
+ getAssets: () => assets,
+ getSelectedIds: () => ['a'],
+ setSelectedIds,
+ selectAll: vi.fn()
+ })
+ return { containerRef }
+ },
+ template: `
+
+ `
+ })
+ render(Harness)
+ await nextTick()
+
+ screen
+ .getByTestId('list')
+ .dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
+ window.dispatchEvent(
+ pointer('pointermove', { clientX: 110, clientY: 50 })
+ )
+
+ expect(setSelectedIds).not.toHaveBeenCalled()
+ })
+
+ it('does not start a marquee when disabled (e.g. list view)', async () => {
+ const setSelectedIds = vi.fn()
+ const Harness = defineComponent({
+ setup() {
+ const containerRef = ref()
+ useAssetGridSelection({
+ marqueeContainerRef: containerRef,
+ hoverTargetRef: containerRef,
+ getAssets: () => assets,
+ getSelectedIds: () => [],
+ setSelectedIds,
+ selectAll: vi.fn(),
+ isEnabled: () => false
+ })
+ return { containerRef }
+ },
+ template: `
+
+ `
+ })
+ render(Harness)
+ await nextTick()
+
+ screen
+ .getByTestId('disabled-grid')
+ .dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
+ window.dispatchEvent(
+ pointer('pointermove', { clientX: 110, clientY: 50 })
+ )
+
+ expect(setSelectedIds).not.toHaveBeenCalled()
+ })
+
+ it('ignores a reentrant pointer-down and does not leave text selection blocked', async () => {
+ const callbacks = createCallbacks()
+ await renderHarness(callbacks)
+
+ grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
+ grid().dispatchEvent(pointer('pointerdown', { clientX: 5, clientY: 5 }))
+ window.dispatchEvent(pointer('pointerup', { clientX: 5, clientY: 5 }))
+
+ const selection = new Event('selectstart', {
+ bubbles: true,
+ cancelable: true
+ })
+ grid().dispatchEvent(selection)
+ expect(selection.defaultPrevented).toBe(false)
+ })
+
+ it('captures the pointer once a marquee drag starts, not on press', async () => {
+ const callbacks = createCallbacks()
+ await renderHarness(callbacks)
+ const capture = vi.spyOn(grid(), 'setPointerCapture')
+
+ card('a').dispatchEvent(
+ pointer('pointerdown', { clientX: 5, clientY: 5 })
+ )
+ expect(capture).not.toHaveBeenCalled()
+
+ grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
+ expect(capture).not.toHaveBeenCalled()
+
+ window.dispatchEvent(
+ pointer('pointermove', { clientX: 110, clientY: 50 })
+ )
+ expect(capture).toHaveBeenCalledWith(1)
+ })
+
+ it('does not capture the pointer on a Ctrl/Cmd-click of a card', async () => {
+ const callbacks = createCallbacks()
+ await renderHarness(callbacks)
+ const capture = vi.spyOn(grid(), 'setPointerCapture')
+
+ card('a').dispatchEvent(
+ pointer('pointerdown', { clientX: 5, clientY: 5, ctrlKey: true })
+ )
+ window.dispatchEvent(pointer('pointerup', { clientX: 5, clientY: 5 }))
+
+ expect(capture).not.toHaveBeenCalled()
+ })
+
+ it('still tracks a marquee when setPointerCapture throws', async () => {
+ const callbacks = createCallbacks()
+ await renderHarness(callbacks)
+ vi.spyOn(grid(), 'setPointerCapture').mockImplementation(() => {
+ throw new Error('stale pointer id')
+ })
+
+ grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
+ window.dispatchEvent(
+ pointer('pointermove', { clientX: 110, clientY: 50 })
+ )
+ expect(callbacks.setSelectedIds).toHaveBeenLastCalledWith(
+ ['a', 'b'],
+ assets
+ )
+
+ window.dispatchEvent(pointer('pointerup', { clientX: 110, clientY: 50 }))
+ await nextTick()
+ expect(screen.queryByTestId('marquee')).toBeNull()
+ })
+ })
+
+ describe('ctrl/cmd + A', () => {
+ function pressSelectAll(init: KeyboardEventInit = {}) {
+ const event = new KeyboardEvent('keydown', {
+ key: 'a',
+ ctrlKey: true,
+ bubbles: true,
+ cancelable: true,
+ ...init
+ })
+ window.dispatchEvent(event)
+ return event
+ }
+
+ it('selects all visible assets and blocks the event when hovered', async () => {
+ const callbacks = createCallbacks()
+ await renderHarness(callbacks)
+
+ panel().dispatchEvent(new MouseEvent('mouseenter'))
+ const event = pressSelectAll()
+
+ expect(callbacks.selectAll).toHaveBeenCalledWith(assets)
+ expect(event.defaultPrevented).toBe(true)
+ })
+
+ it('selects all with the Cmd/Meta key while hovered', async () => {
+ const callbacks = createCallbacks()
+ await renderHarness(callbacks)
+
+ panel().dispatchEvent(new MouseEvent('mouseenter'))
+ pressSelectAll({ ctrlKey: false, metaKey: true })
+
+ expect(callbacks.selectAll).toHaveBeenCalledWith(assets)
+ })
+
+ it('does nothing when the panel is not hovered', async () => {
+ const callbacks = createCallbacks()
+ await renderHarness(callbacks)
+
+ pressSelectAll()
+
+ expect(callbacks.selectAll).not.toHaveBeenCalled()
+ })
+
+ it('still selects all when hover desyncs but the pointer stays inside the panel', async () => {
+ const callbacks = createCallbacks()
+ await renderHarness(callbacks)
+ vi.spyOn(panel(), 'getBoundingClientRect').mockReturnValue(
+ fromPartial({
+ left: 0,
+ top: 0,
+ right: 500,
+ bottom: 500,
+ width: 500,
+ height: 500
+ })
+ )
+
+ panel().dispatchEvent(new MouseEvent('mouseenter'))
+ window.dispatchEvent(
+ pointer('pointermove', { clientX: 100, clientY: 100 })
+ )
+ // The selection bar under the cursor unmounts on "deselect all", which
+ // latches useElementHover false while the pointer is still inside.
+ panel().dispatchEvent(new MouseEvent('mouseleave'))
+
+ const event = pressSelectAll()
+
+ expect(callbacks.selectAll).toHaveBeenCalledWith(assets)
+ expect(event.defaultPrevented).toBe(true)
+ })
+
+ it('does not select all when the live pointer is outside the panel rect', async () => {
+ const callbacks = createCallbacks()
+ await renderHarness(callbacks)
+ vi.spyOn(panel(), 'getBoundingClientRect').mockReturnValue(
+ fromPartial({
+ left: 0,
+ top: 0,
+ right: 500,
+ bottom: 500,
+ width: 500,
+ height: 500
+ })
+ )
+
+ window.dispatchEvent(
+ pointer('pointermove', { clientX: 900, clientY: 900 })
+ )
+ panel().dispatchEvent(new MouseEvent('mouseleave'))
+
+ const event = pressSelectAll()
+
+ expect(callbacks.selectAll).not.toHaveBeenCalled()
+ expect(event.defaultPrevented).toBe(false)
+ })
+
+ it('ignores other keys and the unmodified A while hovered', async () => {
+ const callbacks = createCallbacks()
+ await renderHarness(callbacks)
+
+ panel().dispatchEvent(new MouseEvent('mouseenter'))
+ pressSelectAll({ ctrlKey: false })
+ pressSelectAll({ key: 'b' })
+
+ expect(callbacks.selectAll).not.toHaveBeenCalled()
+ })
+
+ it('does not hijack select-all while typing in a field', async () => {
+ const callbacks = createCallbacks()
+ await renderHarness(callbacks)
+
+ panel().dispatchEvent(new MouseEvent('mouseenter'))
+ screen.getByTestId('search').focus()
+ pressSelectAll()
+
+ expect(callbacks.selectAll).not.toHaveBeenCalled()
+ })
+
+ it('does not hijack select-all while focused in a textarea', async () => {
+ const callbacks = createCallbacks()
+ await renderHarness(callbacks)
+
+ panel().dispatchEvent(new MouseEvent('mouseenter'))
+ screen.getByTestId('textarea').focus()
+ pressSelectAll()
+
+ expect(callbacks.selectAll).not.toHaveBeenCalled()
+ })
+
+ it('does not hijack select-all while focused in a contenteditable element', async () => {
+ const callbacks = createCallbacks()
+ await renderHarness(callbacks)
+
+ panel().dispatchEvent(new MouseEvent('mouseenter'))
+ screen.getByTestId('editable').focus()
+ pressSelectAll()
+
+ expect(callbacks.selectAll).not.toHaveBeenCalled()
+ })
+
+ it('does not hijack select-all while an aria-modal dialog is open', async () => {
+ const callbacks = createCallbacks()
+ await renderHarness(callbacks)
+
+ panel().dispatchEvent(new MouseEvent('mouseenter'))
+ const dialog = document.createElement('div')
+ dialog.setAttribute('role', 'dialog')
+ dialog.setAttribute('aria-modal', 'true')
+ document.body.appendChild(dialog)
+
+ const event = pressSelectAll()
+
+ expect(callbacks.selectAll).not.toHaveBeenCalled()
+ expect(event.defaultPrevented).toBe(false)
+
+ dialog.remove()
+ })
+
+ it('stops the select-all keystroke from reaching other handlers when hovered', async () => {
+ const callbacks = createCallbacks()
+ await renderHarness(callbacks)
+ const downstream = vi.fn()
+ window.addEventListener('keydown', downstream)
+
+ panel().dispatchEvent(new MouseEvent('mouseenter'))
+ pressSelectAll()
+ expect(callbacks.selectAll).toHaveBeenCalledTimes(1)
+ expect(downstream).not.toHaveBeenCalled()
+
+ panel().dispatchEvent(new MouseEvent('mouseleave'))
+ pressSelectAll()
+ expect(callbacks.selectAll).toHaveBeenCalledTimes(1)
+ expect(downstream).toHaveBeenCalledTimes(1)
+
+ window.removeEventListener('keydown', downstream)
+ })
+ })
+})
diff --git a/src/platform/assets/composables/useAssetGridSelection.ts b/src/platform/assets/composables/useAssetGridSelection.ts
new file mode 100644
index 0000000000..3e782fbfa0
--- /dev/null
+++ b/src/platform/assets/composables/useAssetGridSelection.ts
@@ -0,0 +1,250 @@
+import { useElementHover, useEventListener } from '@vueuse/core'
+import type { Ref } from 'vue'
+import { computed, onScopeDispose, ref } from 'vue'
+
+import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
+import {
+ normalizeMarqueeRect,
+ selectMarqueeIds
+} from '@/platform/assets/utils/marqueeSelectionUtil'
+import { clipRectToBounds } from '@/utils/mathUtil'
+import type { RectEdges } from '@/utils/mathUtil'
+
+const DRAG_THRESHOLD_PX = 4
+const CARD_SELECTOR = '[data-asset-id]'
+const INTERACTIVE_SELECTOR =
+ 'button, input, textarea, select, a[href], [role="slider"], [role="tab"], [contenteditable]'
+
+interface AssetGridSelectionOptions {
+ marqueeContainerRef: Ref
+ hoverTargetRef: Ref
+ getAssets: () => AssetItem[]
+ getSelectedIds: () => string[]
+ setSelectedIds: (ids: string[], allAssets: AssetItem[]) => void
+ selectAll: (assets: AssetItem[]) => void
+ isEnabled?: () => boolean
+}
+
+function isTextEntryTarget(element: Element | null): boolean {
+ return (
+ element instanceof HTMLInputElement ||
+ element instanceof HTMLTextAreaElement ||
+ (element instanceof HTMLElement && element.isContentEditable)
+ )
+}
+
+export function useAssetGridSelection(options: AssetGridSelectionOptions) {
+ const {
+ marqueeContainerRef,
+ hoverTargetRef,
+ getAssets,
+ getSelectedIds,
+ setSelectedIds,
+ selectAll,
+ isEnabled = () => true
+ } = options
+
+ const marqueeRect = ref(null)
+ const isHoveringPanel = useElementHover(hoverTargetRef)
+
+ let startX = 0
+ let startY = 0
+ let pointerId = 0
+ let baseIds: string[] = []
+ let isSubtractive = false
+ let isTracking = false
+ let isDragging = false
+ let suppressNextClick = false
+ let suppressClickResetTimeout: ReturnType | null = null
+ let dragCards: { id: string; rect: DOMRect }[] = []
+ let dragBounds: DOMRect | null = null
+ let pointerClientX = 0
+ let pointerClientY = 0
+ let hasPointerSample = false
+
+ function collectCards(container: HTMLElement) {
+ return [...container.querySelectorAll(CARD_SELECTOR)].flatMap(
+ (el) => {
+ const id = el.dataset.assetId
+ return id ? [{ id, rect: el.getBoundingClientRect() }] : []
+ }
+ )
+ }
+
+ function snapshotDrag() {
+ const container = marqueeContainerRef.value
+ if (!container) return
+ dragCards = collectCards(container)
+ dragBounds = container.getBoundingClientRect()
+ }
+
+ function applyMarquee(clientX: number, clientY: number) {
+ if (!dragBounds) return
+ const rect = clipRectToBounds(
+ normalizeMarqueeRect(
+ { x: startX, y: startY },
+ { x: clientX, y: clientY }
+ ),
+ dragBounds
+ )
+ marqueeRect.value = rect
+ setSelectedIds(
+ [...selectMarqueeIds(dragCards, rect, baseIds, isSubtractive)],
+ getAssets()
+ )
+ }
+
+ function onPointerMove(e: PointerEvent) {
+ pointerClientX = e.clientX
+ pointerClientY = e.clientY
+ hasPointerSample = true
+ if (!isTracking) return
+ if (
+ !isDragging &&
+ Math.hypot(e.clientX - startX, e.clientY - startY) < DRAG_THRESHOLD_PX
+ ) {
+ return
+ }
+ if (!isDragging) {
+ isDragging = true
+ snapshotDrag()
+ capturePointer()
+ }
+ applyMarquee(e.clientX, e.clientY)
+ }
+
+ function capturePointer() {
+ try {
+ marqueeContainerRef.value?.setPointerCapture(pointerId)
+ } catch {
+ // Stale/invalid pointerId: window listeners still end the drag.
+ }
+ }
+
+ function endDrag() {
+ if (!isTracking) return
+ isTracking = false
+ marqueeRect.value = null
+ dragCards = []
+ dragBounds = null
+ if (isDragging) scheduleSuppressNextClick()
+ isDragging = false
+ }
+
+ function scheduleSuppressNextClick() {
+ suppressNextClick = true
+ clearSuppressTimer()
+ suppressClickResetTimeout = setTimeout(() => {
+ suppressNextClick = false
+ suppressClickResetTimeout = null
+ }, 0)
+ }
+
+ function clearSuppressTimer() {
+ if (suppressClickResetTimeout !== null) {
+ clearTimeout(suppressClickResetTimeout)
+ suppressClickResetTimeout = null
+ }
+ }
+
+ function preventDragStart(e: Event) {
+ if (isTracking) e.preventDefault()
+ }
+
+ function preventTextSelection(e: Event) {
+ if (!isTracking) return
+ const container = marqueeContainerRef.value
+ if (container && e.target instanceof Node && container.contains(e.target)) {
+ e.preventDefault()
+ }
+ }
+
+ function onPointerDown(e: PointerEvent) {
+ if (e.button !== 0) return
+ if (isTracking) return
+ if (!isEnabled()) return
+ suppressNextClick = false
+ clearSuppressTimer()
+ const container = marqueeContainerRef.value
+ if (!container) return
+ if (!container.querySelector(CARD_SELECTOR)) return
+ const target = e.target
+ if (!(target instanceof HTMLElement)) return
+ if (target.closest(INTERACTIVE_SELECTOR)) return
+
+ const onCard = target.closest(CARD_SELECTOR)
+ if (onCard && !e.ctrlKey && !e.metaKey) return
+
+ startX = e.clientX
+ startY = e.clientY
+ pointerId = e.pointerId
+ baseIds = e.shiftKey || e.ctrlKey || e.metaKey ? getSelectedIds() : []
+ isSubtractive = (e.ctrlKey || e.metaKey) && e.shiftKey
+ isDragging = false
+ isTracking = true
+ }
+
+ function onClickCapture(e: MouseEvent) {
+ if (!suppressNextClick) return
+ suppressNextClick = false
+ clearSuppressTimer()
+ e.stopImmediatePropagation()
+ e.preventDefault()
+ }
+
+ // useElementHover latches stale-false when an overlay under the cursor (the
+ // selection bar) unmounts on "deselect all"; recheck the live pointer against
+ // the panel rect so Ctrl/Cmd+A still resolves against the panel it is over.
+ function isPointerInsidePanel(): boolean {
+ const el = hoverTargetRef.value
+ if (!el || !hasPointerSample) return false
+ const { left, top, right, bottom } = el.getBoundingClientRect()
+ return (
+ pointerClientX >= left &&
+ pointerClientX <= right &&
+ pointerClientY >= top &&
+ pointerClientY <= bottom
+ )
+ }
+
+ function onKeydown(e: KeyboardEvent) {
+ if (!(e.ctrlKey || e.metaKey) || (e.key !== 'a' && e.key !== 'A')) return
+ if (
+ !(isHoveringPanel.value || isPointerInsidePanel()) ||
+ isTextEntryTarget(document.activeElement) ||
+ document.querySelector('[role="dialog"][aria-modal="true"]')
+ ) {
+ return
+ }
+ e.preventDefault()
+ e.stopImmediatePropagation()
+ selectAll(getAssets())
+ }
+
+ useEventListener(marqueeContainerRef, 'pointerdown', onPointerDown)
+ useEventListener(marqueeContainerRef, 'dragstart', preventDragStart, {
+ capture: true
+ })
+ useEventListener(window, 'pointermove', onPointerMove)
+ useEventListener(window, ['pointerup', 'pointercancel', 'dragend'], endDrag)
+ useEventListener(window, 'click', onClickCapture, { capture: true })
+ useEventListener(window, 'keydown', onKeydown, { capture: true })
+ useEventListener(window, 'selectstart', preventTextSelection, {
+ capture: true
+ })
+
+ onScopeDispose(clearSuppressTimer)
+
+ const marqueeStyle = computed(() => {
+ const rect = marqueeRect.value
+ if (!rect) return null
+ return {
+ left: `${rect.left}px`,
+ top: `${rect.top}px`,
+ width: `${rect.right - rect.left}px`,
+ height: `${rect.bottom - rect.top}px`
+ }
+ })
+
+ return { marqueeStyle }
+}
diff --git a/src/platform/assets/composables/useAssetSelection.test.ts b/src/platform/assets/composables/useAssetSelection.test.ts
index 4ac0f09910..ab789e16f8 100644
--- a/src/platform/assets/composables/useAssetSelection.test.ts
+++ b/src/platform/assets/composables/useAssetSelection.test.ts
@@ -248,6 +248,36 @@ describe('useAssetSelection', () => {
})
})
+ describe('setSelectedIds', () => {
+ it('replaces selection and anchors on the last selected asset', () => {
+ const selection = useAssetSelection()
+ const store = useAssetSelectionStore()
+ const assets = createMockAssets(5)
+
+ selection.setSelectedIds(['asset-1', 'asset-3'], assets)
+
+ expect(Array.from(store.selectedAssetIds).sort()).toEqual([
+ 'asset-1',
+ 'asset-3'
+ ])
+ expect(store.lastSelectedIndex).toBe(3)
+ expect(store.lastSelectedAssetId).toBe('asset-3')
+ })
+
+ it('clears the anchor when the selection is empty', () => {
+ const selection = useAssetSelection()
+ const store = useAssetSelectionStore()
+ const assets = createMockAssets(3)
+ store.setLastSelectedIndex(2)
+ store.setLastSelectedAssetId('asset-2')
+
+ selection.setSelectedIds([], assets)
+
+ expect(store.lastSelectedIndex).toBe(-1)
+ expect(store.lastSelectedAssetId).toBeNull()
+ })
+ })
+
describe('clearSelection', () => {
it('clears all selections', () => {
const { handleAssetClick, clearSelection, selectedCount } =
diff --git a/src/platform/assets/composables/useAssetSelection.ts b/src/platform/assets/composables/useAssetSelection.ts
index fd2512152f..5a31642e01 100644
--- a/src/platform/assets/composables/useAssetSelection.ts
+++ b/src/platform/assets/composables/useAssetSelection.ts
@@ -101,6 +101,19 @@ export function useAssetSelection() {
}
}
+ /**
+ * Replace the selection (e.g. from a marquee) and keep the shift-range anchor
+ * on the last selected asset, the same way selectAll maintains it.
+ */
+ function setSelectedIds(ids: string[], allAssets: AssetItem[]) {
+ selectionStore.setSelection(ids)
+ const selected = new Set(ids)
+ const anchorIndex = allAssets.findLastIndex((asset) =>
+ selected.has(asset.id)
+ )
+ setAnchor(anchorIndex, anchorIndex >= 0 ? allAssets[anchorIndex].id : null)
+ }
+
/**
* Get the actual asset objects for selected IDs
*/
@@ -182,6 +195,7 @@ export function useAssetSelection() {
// Selection actions
handleAssetClick,
selectAll,
+ setSelectedIds,
clearSelection: () => selectionStore.clearSelection(),
getSelectedAssets,
reconcileSelection,
diff --git a/src/platform/assets/utils/marqueeSelectionUtil.property.test.ts b/src/platform/assets/utils/marqueeSelectionUtil.property.test.ts
new file mode 100644
index 0000000000..b194eed947
--- /dev/null
+++ b/src/platform/assets/utils/marqueeSelectionUtil.property.test.ts
@@ -0,0 +1,93 @@
+import * as fc from 'fast-check'
+import { describe, expect, it } from 'vitest'
+
+import type { MarqueeCard } from './marqueeSelectionUtil'
+import { normalizeMarqueeRect, selectMarqueeIds } from './marqueeSelectionUtil'
+
+const ID_POOL = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'] as const
+
+const arbPoint = fc.record({
+ x: fc.integer({ min: -100, max: 100 }),
+ y: fc.integer({ min: -100, max: 100 })
+})
+
+const arbRect = fc
+ .tuple(arbPoint, arbPoint)
+ .map(([start, end]) => normalizeMarqueeRect(start, end))
+
+const arbCards: fc.Arbitrary = fc.uniqueArray(
+ fc.record({ id: fc.constantFrom(...ID_POOL), rect: arbRect }),
+ { selector: (card) => card.id, maxLength: ID_POOL.length }
+)
+
+const arbBaseIds = fc.uniqueArray(fc.constantFrom(...ID_POOL), {
+ maxLength: ID_POOL.length
+})
+
+describe('marqueeSelectionUtil properties', () => {
+ it('additive result is the union of the base and the covered ids', () => {
+ fc.assert(
+ fc.property(arbCards, arbRect, arbBaseIds, (cards, marquee, base) => {
+ const covered = selectMarqueeIds(cards, marquee)
+ const result = selectMarqueeIds(cards, marquee, base)
+ expect([...result].sort()).toEqual(
+ [...new Set([...base, ...covered])].sort()
+ )
+ })
+ )
+ })
+
+ it('subtractive result is the base minus the covered ids', () => {
+ fc.assert(
+ fc.property(arbCards, arbRect, arbBaseIds, (cards, marquee, base) => {
+ const covered = selectMarqueeIds(cards, marquee)
+ const result = selectMarqueeIds(cards, marquee, base, true)
+ expect([...result].sort()).toEqual(
+ base.filter((id) => !covered.has(id)).sort()
+ )
+ })
+ )
+ })
+
+ it('subtractive mode preserves the base order of surviving ids', () => {
+ fc.assert(
+ fc.property(arbCards, arbRect, arbBaseIds, (cards, marquee, base) => {
+ const covered = selectMarqueeIds(cards, marquee)
+ const result = selectMarqueeIds(cards, marquee, base, true)
+ expect([...result]).toEqual(base.filter((id) => !covered.has(id)))
+ })
+ )
+ })
+
+ it('never mutates baseIds in either mode', () => {
+ fc.assert(
+ fc.property(
+ arbCards,
+ arbRect,
+ arbBaseIds,
+ fc.boolean(),
+ (cards, marquee, base, subtract) => {
+ const original = [...base]
+ selectMarqueeIds(cards, marquee, base, subtract)
+ expect(base).toEqual(original)
+ }
+ )
+ )
+ })
+
+ it('normalizeMarqueeRect always yields ordered edges containing both points', () => {
+ fc.assert(
+ fc.property(arbPoint, arbPoint, (start, end) => {
+ const rect = normalizeMarqueeRect(start, end)
+ expect(rect.left).toBeLessThanOrEqual(rect.right)
+ expect(rect.top).toBeLessThanOrEqual(rect.bottom)
+ for (const point of [start, end]) {
+ expect(point.x).toBeGreaterThanOrEqual(rect.left)
+ expect(point.x).toBeLessThanOrEqual(rect.right)
+ expect(point.y).toBeGreaterThanOrEqual(rect.top)
+ expect(point.y).toBeLessThanOrEqual(rect.bottom)
+ }
+ })
+ )
+ })
+})
diff --git a/src/platform/assets/utils/marqueeSelectionUtil.test.ts b/src/platform/assets/utils/marqueeSelectionUtil.test.ts
new file mode 100644
index 0000000000..124e253064
--- /dev/null
+++ b/src/platform/assets/utils/marqueeSelectionUtil.test.ts
@@ -0,0 +1,93 @@
+import { describe, expect, it } from 'vitest'
+
+import type { RectEdges } from '@/utils/mathUtil'
+
+import type { MarqueeCard } from './marqueeSelectionUtil'
+import { normalizeMarqueeRect, selectMarqueeIds } from './marqueeSelectionUtil'
+
+const box = (
+ left: number,
+ top: number,
+ right: number,
+ bottom: number
+): RectEdges => ({
+ left,
+ top,
+ right,
+ bottom
+})
+
+describe('normalizeMarqueeRect', () => {
+ it('orders corners when dragging down-right', () => {
+ expect(normalizeMarqueeRect({ x: 10, y: 20 }, { x: 50, y: 80 })).toEqual(
+ box(10, 20, 50, 80)
+ )
+ })
+
+ it('orders corners when dragging up-left', () => {
+ expect(normalizeMarqueeRect({ x: 50, y: 80 }, { x: 10, y: 20 })).toEqual(
+ box(10, 20, 50, 80)
+ )
+ })
+
+ it('orders corners when dragging across axes', () => {
+ expect(normalizeMarqueeRect({ x: 50, y: 20 }, { x: 10, y: 80 })).toEqual(
+ box(10, 20, 50, 80)
+ )
+ })
+})
+
+describe('selectMarqueeIds', () => {
+ const cards: MarqueeCard[] = [
+ { id: 'a', rect: box(0, 0, 10, 10) },
+ { id: 'b', rect: box(20, 0, 30, 10) },
+ { id: 'c', rect: box(40, 0, 50, 10) }
+ ]
+
+ it('selects only intersecting cards when base is empty (replace)', () => {
+ const result = selectMarqueeIds(cards, box(15, 0, 35, 10))
+ expect([...result]).toEqual(['b'])
+ })
+
+ it('unions intersecting cards with the base selection (additive)', () => {
+ const result = selectMarqueeIds(cards, box(35, 0, 55, 10), ['a'])
+ expect([...result].sort()).toEqual(['a', 'c'])
+ })
+
+ it('removes intersecting cards from the base selection (subtractive)', () => {
+ const result = selectMarqueeIds(cards, box(15, 0, 35, 10), ['a', 'b'], true)
+ expect([...result]).toEqual(['a'])
+ })
+
+ it('returns a copy of the base when nothing intersects', () => {
+ const base = new Set(['a'])
+ const result = selectMarqueeIds(cards, box(100, 100, 110, 110), base)
+ expect([...result]).toEqual(['a'])
+ expect(result).not.toBe(base)
+ })
+
+ it('does not mutate the provided base set', () => {
+ const base = new Set(['a'])
+ selectMarqueeIds(cards, box(15, 0, 35, 10), base)
+ expect([...base]).toEqual(['a'])
+ })
+
+ it('includes a card whose edge merely touches the marquee', () => {
+ const touching = [{ id: 'edge', rect: box(0, 0, 10, 10) }]
+ expect([...selectMarqueeIds(touching, box(10, 0, 20, 10))]).toEqual([
+ 'edge'
+ ])
+ })
+
+ it('includes a card that contains the marquee and vice versa', () => {
+ const around = [{ id: 'around', rect: box(40, 40, 60, 60) }]
+ expect([...selectMarqueeIds(around, box(0, 0, 100, 100))]).toEqual([
+ 'around'
+ ])
+ })
+
+ it('excludes a card separated on a single axis', () => {
+ const below = [{ id: 'below', rect: box(0, 20, 10, 30) }]
+ expect([...selectMarqueeIds(below, box(0, 0, 10, 10))]).toEqual([])
+ })
+})
diff --git a/src/platform/assets/utils/marqueeSelectionUtil.ts b/src/platform/assets/utils/marqueeSelectionUtil.ts
new file mode 100644
index 0000000000..288f7b234e
--- /dev/null
+++ b/src/platform/assets/utils/marqueeSelectionUtil.ts
@@ -0,0 +1,48 @@
+import type { RectEdges } from '@/utils/mathUtil'
+
+export interface MarqueeCard {
+ id: string
+ rect: RectEdges
+}
+
+export function normalizeMarqueeRect(
+ start: { x: number; y: number },
+ end: { x: number; y: number }
+): RectEdges {
+ return {
+ left: Math.min(start.x, end.x),
+ top: Math.min(start.y, end.y),
+ right: Math.max(start.x, end.x),
+ bottom: Math.max(start.y, end.y)
+ }
+}
+
+function rectsIntersect(a: RectEdges, b: RectEdges): boolean {
+ return !(
+ a.right < b.left ||
+ a.left > b.right ||
+ a.bottom < b.top ||
+ a.top > b.bottom
+ )
+}
+
+/**
+ * Resolve the asset ids a marquee covers, starting from `baseIds` (the selection
+ * to preserve when a modifier makes the drag additive). With `subtract`, covered
+ * ids are removed from `baseIds` instead of added. A fresh Set is returned;
+ * `baseIds` is never mutated.
+ */
+export function selectMarqueeIds(
+ cards: readonly MarqueeCard[],
+ marquee: RectEdges,
+ baseIds: Iterable = [],
+ subtract = false
+): Set {
+ const result = new Set(baseIds)
+ for (const { id, rect } of cards) {
+ if (!rectsIntersect(rect, marquee)) continue
+ if (subtract) result.delete(id)
+ else result.add(id)
+ }
+ return result
+}
diff --git a/src/platform/settings/constants/coreSettings.ts b/src/platform/settings/constants/coreSettings.ts
index 98bbaf3268..d59802d81d 100644
--- a/src/platform/settings/constants/coreSettings.ts
+++ b/src/platform/settings/constants/coreSettings.ts
@@ -779,7 +779,7 @@ export const CORE_SETTINGS: SettingParams[] = [
tooltip:
'When enabled, nodes are selected/deselected in real-time as you drag the selection rectangle, similar to other design tools.',
type: 'boolean',
- defaultValue: false,
+ defaultValue: true,
versionAdded: '1.36.1'
},
{
diff --git a/src/utils/mathUtil.test.ts b/src/utils/mathUtil.test.ts
index 3d0d6021a7..1521b46269 100644
--- a/src/utils/mathUtil.test.ts
+++ b/src/utils/mathUtil.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
import {
+ clipRectToBounds,
computeUnionBounds,
denormalize,
gcd,
@@ -137,4 +138,29 @@ describe('mathUtil', () => {
expect(result!.height).toBe(242)
})
})
+
+ describe('clipRectToBounds', () => {
+ const bounds = { left: 0, top: 0, right: 100, bottom: 100 }
+
+ it('returns the rect unchanged when fully inside the bounds', () => {
+ expect(
+ clipRectToBounds({ left: 10, top: 10, right: 40, bottom: 40 }, bounds)
+ ).toEqual({ left: 10, top: 10, right: 40, bottom: 40 })
+ })
+
+ it('clamps every edge that extends past the bounds', () => {
+ expect(
+ clipRectToBounds(
+ { left: -20, top: -10, right: 150, bottom: 130 },
+ bounds
+ )
+ ).toEqual({ left: 0, top: 0, right: 100, bottom: 100 })
+ })
+
+ it('clamps only the overflowing side', () => {
+ expect(
+ clipRectToBounds({ left: 10, top: 10, right: 200, bottom: 40 }, bounds)
+ ).toEqual({ left: 10, top: 10, right: 100, bottom: 40 })
+ })
+ })
})
diff --git a/src/utils/mathUtil.ts b/src/utils/mathUtil.ts
index fd840abb9d..347a516dfa 100644
--- a/src/utils/mathUtil.ts
+++ b/src/utils/mathUtil.ts
@@ -1,6 +1,30 @@
+import { clamp } from 'es-toolkit/math'
+
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
import type { Bounds } from '@/renderer/core/layout/types'
+/** A rectangle's viewport edges: the DOMRect subset, so a DOMRect is directly assignable. */
+export type RectEdges = Pick
+
+/**
+ * Clips a rectangle to `bounds`: each edge is limited independently, so the
+ * rect shrinks to the overlapping region rather than being moved to fit.
+ * Keeps an interaction rectangle (the canvas selection box, the assets
+ * marquee) inside its own surface. Both the rect and the bounds use
+ * viewport-style edges (left/top/right/bottom), e.g. a DOMRect.
+ */
+export function clipRectToBounds(
+ rect: RectEdges,
+ bounds: RectEdges
+): RectEdges {
+ return {
+ left: clamp(rect.left, bounds.left, bounds.right),
+ top: clamp(rect.top, bounds.top, bounds.bottom),
+ right: clamp(rect.right, bounds.left, bounds.right),
+ bottom: clamp(rect.bottom, bounds.top, bounds.bottom)
+ }
+}
+
/**
* Linearly maps a value from [min, max] to [0, 1].
* Returns 0 when min equals max to avoid division by zero.