diff --git a/browser_tests/tests/sidebar/assetsSidebarTab.spec.ts b/browser_tests/tests/sidebar/assetsSidebarTab.spec.ts
index becfffc4cb..b0d611a9e2 100644
--- a/browser_tests/tests/sidebar/assetsSidebarTab.spec.ts
+++ b/browser_tests/tests/sidebar/assetsSidebarTab.spec.ts
@@ -276,3 +276,136 @@ test.describe('FE-130 assets sidebar route mocks', () => {
)
})
})
+
+test.describe('FE-910 marquee selection and select all', () => {
+ test.beforeEach(async ({ jobsRoutes, page }) => {
+ await jobsRoutes.mockJobsQueue([])
+ await jobsRoutes.mockJobsHistory(generatedJobs)
+ await mockInputFiles(page, ['imported.png'])
+ await mockViewFiles(page, viewFiles)
+ })
+
+ test('marquee-drag from empty space selects the covered cards', async ({
+ comfyPage
+ }) => {
+ const tab = comfyPage.menu.assetsTab
+ const { page } = comfyPage
+
+ await comfyPage.setup()
+ await tab.open()
+ 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')
+
+ const left = Math.min(alpha.x, beta.x)
+ const top = Math.min(alpha.y, beta.y)
+ const right = Math.max(alpha.x + alpha.width, beta.x + beta.width)
+ const bottom = Math.max(alpha.y + alpha.height, beta.y + beta.height)
+
+ // Press in empty space below the cards, then rubber-band up over both.
+ await page.mouse.move((left + right) / 2, bottom + 40)
+ await page.mouse.down()
+ await page.mouse.move(left + 4, top + 4, { steps: 12 })
+ await page.mouse.up()
+
+ await expect(tab.selectedCards).toHaveCount(2)
+ await expect(tab.selectionFooter).toBeVisible()
+ })
+
+ test('Ctrl/Cmd+A selects every asset while the panel is hovered', async ({
+ comfyPage
+ }) => {
+ const tab = comfyPage.menu.assetsTab
+
+ await comfyPage.setup()
+ await tab.open()
+ 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 comfyPage.setup()
+ await tab.open()
+ await expect(tab.assetCards).toHaveCount(2)
+ await expect(tab.selectedCards).toHaveCount(0)
+
+ const header = await page
+ .locator('.comfy-vue-side-bar-header')
+ .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 comfyPage.setup()
+ await tab.open()
+ 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 comfyPage.setup()
+ await tab.open()
+ 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)
+ })
+})
diff --git a/src/components/sidebar/tabs/AssetsSidebarTab.vue b/src/components/sidebar/tabs/AssetsSidebarTab.vue
index e30e8b7725..3fb3527f86 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 @@
/>
+
+
+
()
+const marqueePanelRef = ref()
+watchEffect(() => {
+ const el = panelRef.value?.$el
+ marqueePanelRef.value = el instanceof HTMLElement ? el : undefined
+})
+
const {
downloadAssets,
deleteAssets,
@@ -337,6 +359,15 @@ const visibleAssets = computed(() => {
return listViewSelectableAssets.value
})
+const { marqueeStyle } = useAssetGridSelection({
+ marqueeContainerRef: marqueePanelRef,
+ hoverTargetRef: marqueePanelRef,
+ getAssets: () => visibleAssets.value,
+ getSelectedIds: () => [...selectedIds.value],
+ setSelectedIds,
+ selectAll
+})
+
const previewableVisibleAssets = computed(() =>
visibleAssets.value.filter((asset) =>
isPreviewableMediaType(getMediaTypeFromFilename(asset.name))
diff --git a/src/platform/assets/components/MediaAssetCard.vue b/src/platform/assets/components/MediaAssetCard.vue
index 37c8dbaedf..6b3ee6b5f3 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="
diff --git a/src/platform/assets/composables/useAssetGridSelection.test.ts b/src/platform/assets/composables/useAssetGridSelection.test.ts
new file mode 100644
index 0000000000..6f0d02b694
--- /dev/null
+++ b/src/platform/assets/composables/useAssetGridSelection.test.ts
@@ -0,0 +1,480 @@
+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('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('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('clears the overlay and restores body user-select 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()
+ expect(document.body.style.userSelect).toBe('none')
+
+ window.dispatchEvent(
+ pointer('pointercancel', { clientX: 30, clientY: 40 })
+ )
+ await nextTick()
+ expect(screen.queryByTestId('marquee')).toBeNull()
+ expect(document.body.style.userSelect).toBe('')
+ })
+
+ 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('ignores a reentrant pointer-down and does not leak body user-select', async () => {
+ const callbacks = createCallbacks()
+ await renderHarness(callbacks)
+
+ expect(document.body.style.userSelect).toBe('')
+ grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
+ grid().dispatchEvent(pointer('pointerdown', { clientX: 5, clientY: 5 }))
+ window.dispatchEvent(pointer('pointerup', { clientX: 5, clientY: 5 }))
+
+ expect(document.body.style.userSelect).toBe('')
+ })
+
+ it('captures the pointer on a real marquee start, not on a plain card 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).toHaveBeenCalledWith(1)
+ })
+ })
+
+ 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('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()
+ })
+ })
+})
diff --git a/src/platform/assets/composables/useAssetGridSelection.ts b/src/platform/assets/composables/useAssetGridSelection.ts
new file mode 100644
index 0000000000..2ff4741308
--- /dev/null
+++ b/src/platform/assets/composables/useAssetGridSelection.ts
@@ -0,0 +1,173 @@
+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 type { Box } from '@/platform/assets/utils/marqueeSelectionUtil'
+import {
+ normalizeMarqueeRect,
+ selectMarqueeIds
+} from '@/platform/assets/utils/marqueeSelectionUtil'
+import { clampRectToBounds } 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
+}
+
+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
+ } = options
+
+ const marqueeRect = ref(null)
+ const isHoveringPanel = useElementHover(hoverTargetRef)
+
+ let startX = 0
+ let startY = 0
+ let baseIds: string[] = []
+ let isTracking = false
+ let isDragging = false
+ let suppressNextClick = false
+ let priorBodyUserSelect = ''
+
+ function collectCards(container: HTMLElement) {
+ return [...container.querySelectorAll(CARD_SELECTOR)].flatMap(
+ (el) => {
+ const id = el.dataset.assetId
+ return id ? [{ id, rect: el.getBoundingClientRect() }] : []
+ }
+ )
+ }
+
+ function applyMarquee(clientX: number, clientY: number) {
+ const container = marqueeContainerRef.value
+ if (!container) return
+ const rect = clampRectToBounds(
+ normalizeMarqueeRect(
+ { x: startX, y: startY },
+ { x: clientX, y: clientY }
+ ),
+ container.getBoundingClientRect()
+ )
+ marqueeRect.value = rect
+ setSelectedIds(
+ [...selectMarqueeIds(collectCards(container), rect, baseIds)],
+ getAssets()
+ )
+ }
+
+ function onPointerMove(e: PointerEvent) {
+ if (!isTracking) return
+ if (
+ !isDragging &&
+ Math.hypot(e.clientX - startX, e.clientY - startY) < DRAG_THRESHOLD_PX
+ ) {
+ return
+ }
+ isDragging = true
+ applyMarquee(e.clientX, e.clientY)
+ }
+
+ function endDrag() {
+ if (!isTracking) return
+ isTracking = false
+ document.body.style.userSelect = priorBodyUserSelect
+ marqueeRect.value = null
+ if (isDragging) suppressNextClick = true
+ isDragging = false
+ }
+
+ function preventDragStart(e: Event) {
+ if (isTracking) e.preventDefault()
+ }
+
+ function onPointerDown(e: PointerEvent) {
+ if (e.button !== 0) return
+ if (isTracking) return
+ suppressNextClick = false
+ 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
+ baseIds = e.shiftKey || e.ctrlKey || e.metaKey ? getSelectedIds() : []
+ isDragging = false
+ isTracking = true
+ container.setPointerCapture(e.pointerId)
+ priorBodyUserSelect = document.body.style.userSelect
+ document.body.style.userSelect = 'none'
+ }
+
+ function onClickCapture(e: MouseEvent) {
+ if (!suppressNextClick) return
+ suppressNextClick = false
+ e.stopImmediatePropagation()
+ e.preventDefault()
+ }
+
+ function onKeydown(e: KeyboardEvent) {
+ if (!(e.ctrlKey || e.metaKey) || (e.key !== 'a' && e.key !== 'A')) return
+ if (!isHoveringPanel.value || isTextEntryTarget(document.activeElement)) {
+ 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 })
+
+ onScopeDispose(() => {
+ if (isTracking) document.body.style.userSelect = priorBodyUserSelect
+ })
+
+ 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..8465ffdc1d 100644
--- a/src/platform/assets/composables/useAssetSelection.ts
+++ b/src/platform/assets/composables/useAssetSelection.ts
@@ -1,4 +1,5 @@
import { useKeyModifier } from '@vueuse/core'
+import { findLastIndex } from 'es-toolkit/compat'
import { computed, ref } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
@@ -101,6 +102,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 = findLastIndex(allAssets, (asset) =>
+ selected.has(asset.id)
+ )
+ setAnchor(anchorIndex, anchorIndex >= 0 ? allAssets[anchorIndex].id : null)
+ }
+
/**
* Get the actual asset objects for selected IDs
*/
@@ -182,6 +196,7 @@ export function useAssetSelection() {
// Selection actions
handleAssetClick,
selectAll,
+ setSelectedIds,
clearSelection: () => selectionStore.clearSelection(),
getSelectedAssets,
reconcileSelection,
diff --git a/src/platform/assets/utils/marqueeSelectionUtil.test.ts b/src/platform/assets/utils/marqueeSelectionUtil.test.ts
new file mode 100644
index 0000000000..2711f89df0
--- /dev/null
+++ b/src/platform/assets/utils/marqueeSelectionUtil.test.ts
@@ -0,0 +1,86 @@
+import { describe, expect, it } from 'vitest'
+
+import type { Box, MarqueeCard } from './marqueeSelectionUtil'
+import { normalizeMarqueeRect, selectMarqueeIds } from './marqueeSelectionUtil'
+
+const box = (
+ left: number,
+ top: number,
+ right: number,
+ bottom: number
+): Box => ({
+ 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('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..8073cb5cbd
--- /dev/null
+++ b/src/platform/assets/utils/marqueeSelectionUtil.ts
@@ -0,0 +1,51 @@
+export interface Box {
+ left: number
+ top: number
+ right: number
+ bottom: number
+}
+
+export interface MarqueeCard {
+ id: string
+ rect: Box
+}
+
+export function normalizeMarqueeRect(
+ start: { x: number; y: number },
+ end: { x: number; y: number }
+): Box {
+ 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: Box, b: Box): 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). A fresh Set is returned;
+ * `baseIds` is never mutated.
+ */
+export function selectMarqueeIds(
+ cards: readonly MarqueeCard[],
+ marquee: Box,
+ baseIds: Iterable = []
+): Set {
+ const result = new Set(baseIds)
+ for (const { id, rect } of cards) {
+ if (rectsIntersect(rect, marquee)) {
+ result.add(id)
+ }
+ }
+ return result
+}
diff --git a/src/utils/mathUtil.test.ts b/src/utils/mathUtil.test.ts
index 3d0d6021a7..61c05affac 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 {
+ clampRectToBounds,
computeUnionBounds,
denormalize,
gcd,
@@ -137,4 +138,29 @@ describe('mathUtil', () => {
expect(result!.height).toBe(242)
})
})
+
+ describe('clampRectToBounds', () => {
+ const bounds = { left: 0, top: 0, right: 100, bottom: 100 }
+
+ it('returns the rect unchanged when fully inside the bounds', () => {
+ expect(
+ clampRectToBounds({ 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(
+ clampRectToBounds(
+ { 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(
+ clampRectToBounds({ 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..6491728e71 100644
--- a/src/utils/mathUtil.ts
+++ b/src/utils/mathUtil.ts
@@ -1,6 +1,31 @@
+import { clamp } from 'es-toolkit/math'
+
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
import type { Bounds } from '@/renderer/core/layout/types'
+export interface RectEdges {
+ left: number
+ top: number
+ right: number
+ bottom: number
+}
+
+/**
+ * Clamps a rectangle so every edge stays within `bounds`. Both the rect and the
+ * bounds use viewport-style edges (left/top/right/bottom), e.g. a DOMRect.
+ */
+export function clampRectToBounds(
+ 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.