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 @@ + +
+ () +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: ` +
+
a.png
+
+ ` + }) + 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.