mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-07 07:38:05 +00:00
feat: add marquee select and Ctrl/Cmd+A to the Media Assets panel
Rubber-band select from an empty grid spot, or hold Ctrl/Cmd to marquee from a card; the drag can begin anywhere in the panel (title, search, tabs) since the grid gaps were too thin. The overlay is clamped to the panel and the pointer is captured so a release over the canvas still ends the drag. Ctrl/Cmd+A selects all assets while the panel is hovered and yields to the canvas otherwise, bailing while a text field is focused. Native card drag, trailing-click suppression, and the shift-range anchor are preserved. Covered by unit and e2e tests.
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<SidebarTabTemplate
|
||||
ref="panelRef"
|
||||
:title="isInFolderView ? '' : $t('sideToolbar.mediaAssets.title')"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
@@ -100,18 +101,19 @@
|
||||
@context-menu="handleAssetContextMenu"
|
||||
@approach-end="handleApproachEnd"
|
||||
/>
|
||||
<AssetsSidebarGridView
|
||||
v-else
|
||||
:assets="displayAssets"
|
||||
:is-selected="isSelected"
|
||||
:show-output-count="shouldShowOutputCount"
|
||||
:get-output-count="getOutputCount"
|
||||
@select-asset="handleAssetSelect"
|
||||
@context-menu="handleAssetContextMenu"
|
||||
@approach-end="handleApproachEnd"
|
||||
@zoom="handleZoomClick"
|
||||
@output-count-click="enterFolderView"
|
||||
/>
|
||||
<div v-else class="size-full">
|
||||
<AssetsSidebarGridView
|
||||
:assets="displayAssets"
|
||||
:is-selected="isSelected"
|
||||
:show-output-count="shouldShowOutputCount"
|
||||
:get-output-count="getOutputCount"
|
||||
@select-asset="handleAssetSelect"
|
||||
@context-menu="handleAssetContextMenu"
|
||||
@approach-end="handleApproachEnd"
|
||||
@zoom="handleZoomClick"
|
||||
@output-count-click="enterFolderView"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
@@ -125,6 +127,13 @@
|
||||
/>
|
||||
</template>
|
||||
</SidebarTabTemplate>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="marqueeStyle"
|
||||
class="pointer-events-none fixed z-9999 border border-blue-400 bg-blue-500/20"
|
||||
:style="marqueeStyle"
|
||||
/>
|
||||
</Teleport>
|
||||
<MediaLightbox
|
||||
v-model:active-index="galleryActiveIndex"
|
||||
:all-gallery-items="galleryItems"
|
||||
@@ -164,8 +173,10 @@ import {
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
watch
|
||||
watch,
|
||||
watchEffect
|
||||
} from 'vue'
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
@@ -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'
|
||||
@@ -259,7 +271,10 @@ const outputAssets = useAssetsApi('output')
|
||||
// Asset selection
|
||||
const {
|
||||
isSelected,
|
||||
selectedIds,
|
||||
handleAssetClick,
|
||||
selectAll,
|
||||
setSelectedIds,
|
||||
hasSelection,
|
||||
clearSelection,
|
||||
getSelectedAssets,
|
||||
@@ -270,6 +285,13 @@ const {
|
||||
deactivate: deactivateSelection
|
||||
} = useAssetSelection()
|
||||
|
||||
const panelRef = ref<ComponentPublicInstance>()
|
||||
const marqueePanelRef = ref<HTMLElement>()
|
||||
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))
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
)
|
||||
"
|
||||
:data-selected="selected"
|
||||
:data-asset-id="asset?.id"
|
||||
:draggable="true"
|
||||
@click.stop="$emit('click')"
|
||||
@contextmenu.prevent.stop="
|
||||
|
||||
480
src/platform/assets/composables/useAssetGridSelection.test.ts
Normal file
480
src/platform/assets/composables/useAssetGridSelection.test.ts
Normal file
@@ -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<string, { left: number; right: number }> = {
|
||||
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<string, unknown> = {}) {
|
||||
return {
|
||||
getAssets: () => assets,
|
||||
getSelectedIds: vi.fn(() => [] as string[]),
|
||||
setSelectedIds: vi.fn(),
|
||||
selectAll: vi.fn(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
async function renderHarness(callbacks: ReturnType<typeof createCallbacks>) {
|
||||
const Harness = defineComponent({
|
||||
setup() {
|
||||
const gridContainerRef = ref<HTMLElement>()
|
||||
const hoverTargetRef = ref<HTMLElement>()
|
||||
const { marqueeStyle } = useAssetGridSelection({
|
||||
marqueeContainerRef: gridContainerRef,
|
||||
hoverTargetRef,
|
||||
getAssets: callbacks.getAssets,
|
||||
getSelectedIds: callbacks.getSelectedIds,
|
||||
setSelectedIds: callbacks.setSelectedIds,
|
||||
selectAll: callbacks.selectAll
|
||||
})
|
||||
return { gridContainerRef, hoverTargetRef, marqueeStyle }
|
||||
},
|
||||
template: `
|
||||
<div ref="hoverTargetRef" data-testid="panel">
|
||||
<input data-testid="search" />
|
||||
<textarea data-testid="textarea"></textarea>
|
||||
<div contenteditable="true" data-testid="editable"></div>
|
||||
<div ref="gridContainerRef" data-testid="grid">
|
||||
<button data-testid="grid-button">x</button>
|
||||
<div data-asset-id="a" data-testid="card-a"></div>
|
||||
<div data-asset-id="b" data-testid="card-b"></div>
|
||||
<div data-asset-id="c" data-testid="card-c"></div>
|
||||
</div>
|
||||
<div
|
||||
v-if="marqueeStyle"
|
||||
data-testid="marquee"
|
||||
:style="marqueeStyle"
|
||||
></div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
|
||||
render(Harness)
|
||||
await nextTick()
|
||||
vi.spyOn(screen.getByTestId('grid'), 'getBoundingClientRect').mockReturnValue(
|
||||
fromPartial<DOMRect>({ left: 0, top: 0, right: 1000, bottom: 1000 })
|
||||
)
|
||||
for (const id of Object.keys(cardBoxes)) {
|
||||
vi.spyOn(
|
||||
screen.getByTestId(`card-${id}`),
|
||||
'getBoundingClientRect'
|
||||
).mockReturnValue(
|
||||
fromPartial<DOMRect>({
|
||||
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<HTMLElement>()
|
||||
useAssetGridSelection({
|
||||
marqueeContainerRef: containerRef,
|
||||
hoverTargetRef: containerRef,
|
||||
getAssets: () => assets,
|
||||
getSelectedIds: () => ['a'],
|
||||
setSelectedIds,
|
||||
selectAll: vi.fn()
|
||||
})
|
||||
return { containerRef }
|
||||
},
|
||||
template: `
|
||||
<div ref="containerRef" data-testid="list">
|
||||
<div data-testid="row">a.png</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
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<HTMLInputElement>('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<HTMLTextAreaElement>('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()
|
||||
})
|
||||
})
|
||||
})
|
||||
173
src/platform/assets/composables/useAssetGridSelection.ts
Normal file
173
src/platform/assets/composables/useAssetGridSelection.ts
Normal file
@@ -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<HTMLElement | undefined>
|
||||
hoverTargetRef: Ref<HTMLElement | undefined>
|
||||
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<Box | null>(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<HTMLElement>(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 }
|
||||
}
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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,
|
||||
|
||||
86
src/platform/assets/utils/marqueeSelectionUtil.test.ts
Normal file
86
src/platform/assets/utils/marqueeSelectionUtil.test.ts
Normal file
@@ -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([])
|
||||
})
|
||||
})
|
||||
51
src/platform/assets/utils/marqueeSelectionUtil.ts
Normal file
51
src/platform/assets/utils/marqueeSelectionUtil.ts
Normal file
@@ -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<string> = []
|
||||
): Set<string> {
|
||||
const result = new Set(baseIds)
|
||||
for (const { id, rect } of cards) {
|
||||
if (rectsIntersect(rect, marquee)) {
|
||||
result.add(id)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user