Compare commits

...

3 Commits

Author SHA1 Message Date
Nathaniel Parson Koroso
59858dc843 fix: harden asset marquee interactions 2026-06-26 18:29:09 -07:00
Nathaniel Parson Koroso
caa5568a3e test: cover asset marquee selection 2026-06-26 12:37:08 -07:00
Nathaniel Parson Koroso
f8335dc0af feat: add asset marquee selection 2026-06-26 12:36:25 -07:00
12 changed files with 1884 additions and 31 deletions

View File

@@ -308,6 +308,8 @@ export class AssetsSidebarTab extends SidebarTab {
// --- Asset cards ---
public readonly assetCards: Locator
public readonly selectedCards: Locator
public readonly marqueeSurface: Locator
public readonly marqueeSelection: Locator
// --- List view items ---
public readonly listViewItems: Locator
@@ -349,6 +351,8 @@ export class AssetsSidebarTab extends SidebarTab {
.getByRole('button')
.and(page.locator('[data-selected]'))
this.selectedCards = page.locator('[data-selected="true"]')
this.marqueeSurface = page.getByTestId('assets-marquee-surface')
this.marqueeSelection = page.getByTestId('assets-marquee-selection')
this.listViewItems = page.locator(
'.sidebar-content-container [role="button"][tabindex="0"]'
)

View File

@@ -1,5 +1,5 @@
import { expect, mergeTests } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import {
@@ -99,6 +99,20 @@ const viewFiles = {
'multi-output-b.png': {}
}
function createGeneratedJobsFixture(count: number) {
const files: Record<string, ViewFile> = {}
const jobs = Array.from({ length: count }, (_, index) => {
const id = `virtual-${String(index).padStart(2, '0')}`
files[`output_${id}.png`] = {}
return createRouteMockJob({
id,
create_time: routeMockJobTimestamp - 10_000 - index
})
})
return { files, jobs }
}
async function mockInputFiles(page: Page, files: readonly string[]) {
await page.route('**/internal/files/input**', async (route) => {
if (route.request().method().toUpperCase() !== 'GET') {
@@ -145,6 +159,38 @@ async function mockViewFiles(page: Page, filesByName: ViewFilesByName) {
})
}
async function boundingBox(locator: Locator, label: string) {
const box = await locator.boundingBox()
if (!box) {
throw new Error(`${label} must have a bounding box`)
}
return box
}
async function installSelectAllBubbleCounter(page: Page) {
await page.evaluate(() => {
const win = window as Window & { assetsCtrlAPropagationCount?: number }
win.assetsCtrlAPropagationCount = 0
window.addEventListener('keydown', (event) => {
const isSelectAllShortcut =
(event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a'
if (isSelectAllShortcut) {
win.assetsCtrlAPropagationCount =
(win.assetsCtrlAPropagationCount ?? 0) + 1
}
})
})
}
async function selectAllBubbleCount(page: Page) {
return await page.evaluate(() => {
const win = window as Window & { assetsCtrlAPropagationCount?: number }
return win.assetsCtrlAPropagationCount ?? 0
})
}
test.describe('FE-130 assets sidebar route mocks', () => {
test.beforeEach(async ({ jobsRoutes, page }) => {
await jobsRoutes.mockJobsQueue([])
@@ -227,6 +273,319 @@ test.describe('FE-130 assets sidebar route mocks', () => {
await expect(tab.downloadSelectedButton).toBeVisible()
})
test('marquee dragging from empty grid space selects intersected assets', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await comfyPage.setup()
await tab.open()
await expect.poll(() => tab.assetCards.count()).toBeGreaterThanOrEqual(2)
const surfaceBox = await boundingBox(tab.marqueeSurface, 'marquee surface')
const secondCardBox = await boundingBox(
tab.assetCards.nth(1),
'second card'
)
await comfyPage.page.mouse.move(surfaceBox.x + 4, surfaceBox.y + 4)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(
secondCardBox.x + secondCardBox.width - 4,
secondCardBox.y + secondCardBox.height - 4,
{ steps: 8 }
)
await expect(tab.marqueeSelection).toBeVisible()
await comfyPage.page.mouse.up()
await expect(tab.selectedCards).toHaveCount(2)
})
test('plain dragging from an asset card does not marquee select', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await comfyPage.setup()
await tab.open()
await expect.poll(() => tab.assetCards.count()).toBeGreaterThanOrEqual(1)
const firstCardBox = await boundingBox(tab.assetCards.first(), 'first card')
await comfyPage.page.mouse.move(
firstCardBox.x + firstCardBox.width / 2,
firstCardBox.y + firstCardBox.height / 2
)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(
firstCardBox.x + firstCardBox.width / 2 + 40,
firstCardBox.y + firstCardBox.height / 2 + 40,
{ steps: 8 }
)
await expect(tab.marqueeSelection).toBeHidden()
await comfyPage.page.mouse.up()
await expect(tab.selectedCards).toHaveCount(0)
})
test('marquee hit testing ignores virtualized buffer rows outside the panel', async ({
comfyPage,
jobsRoutes,
page
}) => {
const tab = comfyPage.menu.assetsTab
const virtualizedAssets = createGeneratedJobsFixture(30)
await jobsRoutes.mockJobsHistory(virtualizedAssets.jobs)
await mockViewFiles(page, virtualizedAssets.files)
await comfyPage.setup()
await tab.open()
await expect.poll(() => tab.assetCards.count()).toBeGreaterThanOrEqual(1)
const gridScroller = page.locator(
'.sidebar-content-container .p-scrollpanel-content'
)
await gridScroller.evaluate((element) => {
element.scrollTop = 500
element.dispatchEvent(new Event('scroll'))
})
await expect
.poll(() => gridScroller.evaluate((element) => element.scrollTop))
.toBeGreaterThan(0)
const surfaceBox = await boundingBox(tab.marqueeSurface, 'marquee surface')
await comfyPage.page.mouse.move(surfaceBox.x + 4, surfaceBox.y + 4)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(
surfaceBox.x + surfaceBox.width - 4,
Math.max(surfaceBox.y - 80, 4),
{ steps: 8 }
)
await expect(tab.marqueeSelection).toBeVisible()
await comfyPage.page.mouse.up()
const selectedCardsIntersectPanel = await page.evaluate(() => {
const surface = document.querySelector(
'[data-testid="assets-marquee-surface"]'
)
if (!(surface instanceof HTMLElement)) {
return []
}
const surfaceRect = surface.getBoundingClientRect()
return Array.from(
document.querySelectorAll('[data-selected="true"]')
).map((element) => {
if (!(element instanceof HTMLElement)) {
return false
}
const rect = element.getBoundingClientRect()
return (
rect.left < surfaceRect.right &&
rect.right > surfaceRect.left &&
rect.top < surfaceRect.bottom &&
rect.bottom > surfaceRect.top
)
})
})
expect(selectedCardsIntersectPanel.length).toBeGreaterThan(0)
expect(selectedCardsIntersectPanel).not.toContain(false)
})
test('Ctrl+dragging from an asset card starts marquee selection', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await comfyPage.setup()
await tab.open()
await expect.poll(() => tab.assetCards.count()).toBeGreaterThanOrEqual(2)
const firstCardBox = await boundingBox(tab.assetCards.first(), 'first card')
const secondCardBox = await boundingBox(
tab.assetCards.nth(1),
'second card'
)
await comfyPage.page.keyboard.down('Control')
try {
await comfyPage.page.mouse.move(
firstCardBox.x + firstCardBox.width / 2,
firstCardBox.y + firstCardBox.height / 2
)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(
secondCardBox.x + secondCardBox.width - 4,
secondCardBox.y + secondCardBox.height - 4,
{ steps: 8 }
)
await expect(tab.marqueeSelection).toBeVisible()
await comfyPage.page.mouse.up()
} finally {
await comfyPage.page.keyboard.up('Control')
}
await expect(tab.selectedCards).toHaveCount(2)
})
test('Ctrl+dragging within one asset card keeps marquee selection', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await comfyPage.setup()
await tab.open()
const firstCard = tab.assetCards.first()
const firstCardBox = await boundingBox(firstCard, 'first card')
const start = {
x: firstCardBox.x + firstCardBox.width / 2,
y: firstCardBox.y + firstCardBox.height / 2
}
await comfyPage.page.keyboard.down('Control')
try {
await comfyPage.page.mouse.move(start.x, start.y)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(start.x + 12, start.y + 12, {
steps: 4
})
await expect(tab.marqueeSelection).toBeVisible()
await comfyPage.page.mouse.up()
} finally {
await comfyPage.page.keyboard.up('Control')
}
await expect(tab.selectedCards).toHaveCount(1)
await expect(firstCard).toHaveAttribute('data-selected', 'true')
})
test('Ctrl+A selects assets while hovered', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await comfyPage.setup()
await tab.open()
await expect.poll(() => tab.assetCards.count()).toBeGreaterThanOrEqual(1)
const cardCount = await tab.assetCards.count()
await tab.marqueeSurface.hover()
await comfyPage.keyboard.selectAll(null)
await expect(tab.selectedCards).toHaveCount(cardCount)
})
test('Ctrl+A in the focused search input does not select assets', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await comfyPage.setup()
await tab.open()
await tab.searchInput.fill('alpha')
await expect(tab.assetCards).toHaveCount(1)
await tab.marqueeSurface.hover()
await tab.searchInput.focus()
await comfyPage.keyboard.selectAll(null)
await expect(tab.selectedCards).toHaveCount(0)
await expect
.poll(() =>
tab.searchInput.evaluate((element) => {
if (!(element instanceof HTMLInputElement)) {
throw new Error('Expected asset search input')
}
return {
selectionEnd: element.selectionEnd,
selectionStart: element.selectionStart,
valueLength: element.value.length
}
})
)
.toEqual({
selectionEnd: 5,
selectionStart: 0,
valueLength: 5
})
})
test('marquee dragging from search through tabs does not select text', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await comfyPage.setup()
await tab.open()
await tab.searchInput.fill('a')
await expect(tab.assetCards).toHaveCount(2)
await tab.searchInput.evaluate((element) => {
if (!(element instanceof HTMLInputElement)) {
throw new Error('Expected asset search input')
}
element.setSelectionRange(0, element.value.length)
})
const searchBox = await boundingBox(tab.searchInput, 'asset search input')
const firstCardBox = await boundingBox(tab.assetCards.first(), 'first card')
await comfyPage.page.mouse.move(
searchBox.x + searchBox.width / 2,
searchBox.y + searchBox.height / 2
)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(
firstCardBox.x + firstCardBox.width - 8,
firstCardBox.y + firstCardBox.height - 8,
{ steps: 10 }
)
await expect(tab.marqueeSelection).toBeVisible()
await comfyPage.page.mouse.up()
await expect.poll(() => tab.selectedCards.count()).toBeGreaterThanOrEqual(1)
await expect
.poll(() =>
tab.searchInput.evaluate((element) => {
if (!(element instanceof HTMLInputElement)) {
throw new Error('Expected asset search input')
}
return {
documentSelection: window.getSelection()?.toString() ?? '',
selectionEnd: element.selectionEnd,
selectionStart: element.selectionStart,
valueLength: element.value.length
}
})
)
.toEqual({
documentSelection: '',
selectionEnd: 1,
selectionStart: 1,
valueLength: 1
})
})
test('Ctrl+A while hovered does not bubble to global keybindings', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await comfyPage.setup()
await tab.open()
await installSelectAllBubbleCounter(comfyPage.page)
await comfyPage.canvas.click()
await tab.marqueeSurface.hover()
await comfyPage.keyboard.selectAll(null)
await expect(tab.selectedCards).toHaveCount(await tab.assetCards.count())
await expect.poll(() => selectAllBubbleCount(comfyPage.page)).toBe(0)
})
test('loads full generated job outputs from job detail', async ({
comfyPage,
jobsRoutes

View File

@@ -0,0 +1,150 @@
import { render, screen } from '@testing-library/vue'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import type * as VueUseCore from '@vueuse/core'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import SelectionRectangle from './SelectionRectangle.vue'
const rafCallbacks = vi.hoisted(() => [] as Array<() => void>)
vi.mock('@vueuse/core', async (importOriginal) => {
const actual = await importOriginal<typeof VueUseCore>()
return {
...actual,
useRafFn: vi.fn((callback: () => void) => {
rafCallbacks.push(callback)
return {
pause: vi.fn(),
resume: vi.fn()
}
})
}
})
type StubCanvas = {
canvas: HTMLCanvasElement
dragging_rectangle: [number, number, number, number] | null
pointer: {
eDown?: PointerLike
eMove?: PointerLike
}
}
type PointerLike = {
clientX: number
clientY: number
}
function createCanvasElement(rect: DOMRect) {
const canvas = document.createElement('canvas')
Object.defineProperty(canvas, 'getBoundingClientRect', {
configurable: true,
value: () => rect
})
return canvas
}
function createPointer(clientX: number, clientY: number): PointerLike {
return { clientX, clientY }
}
async function runFrame() {
rafCallbacks.at(-1)?.()
await nextTick()
}
describe('SelectionRectangle', () => {
beforeEach(() => {
rafCallbacks.length = 0
document.body.replaceChildren()
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('clips the canvas selection rectangle to the graph canvas bounds', async () => {
const store = useCanvasStore()
const canvas = createCanvasElement(new DOMRect(100, 50, 300, 200))
store.canvas = {
canvas,
dragging_rectangle: [0, 0, 1, 1],
pointer: {
eDown: createPointer(50, 20),
eMove: createPointer(250, 180)
}
} as StubCanvas as unknown as LGraphCanvas
const { unmount } = render(SelectionRectangle)
await runFrame()
const element = screen.getByTestId('selection-rectangle')
expect(element.style.display).not.toBe('none')
expect(element.style.left).toBe('0px')
expect(element.style.top).toBe('0px')
expect(element.style.width).toBe('150px')
expect(element.style.height).toBe('130px')
unmount()
})
it('normalizes and clips an up-left drag to an offset canvas panel', async () => {
const store = useCanvasStore()
const canvas = createCanvasElement(new DOMRect(100, 50, 600, 400))
const panel = document.createElement('div')
panel.className = 'graph-canvas-panel'
Object.defineProperty(panel, 'getBoundingClientRect', {
configurable: true,
value: () => new DOMRect(250, 100, 350, 300)
})
document.body.append(panel)
store.canvas = {
canvas,
dragging_rectangle: [0, 0, 1, 1],
pointer: {
eDown: createPointer(650, 450),
eMove: createPointer(150, 80)
}
} as StubCanvas as unknown as LGraphCanvas
const { unmount } = render(SelectionRectangle)
await runFrame()
const element = screen.getByTestId('selection-rectangle')
expect(element.style.display).not.toBe('none')
expect(element.style.left).toBe('150px')
expect(element.style.top).toBe('50px')
expect(element.style.width).toBe('350px')
expect(element.style.height).toBe('300px')
unmount()
})
it('hides the canvas selection rectangle when the drag misses the canvas', async () => {
const store = useCanvasStore()
const canvas = createCanvasElement(new DOMRect(100, 50, 300, 200))
store.canvas = {
canvas,
dragging_rectangle: [0, 0, 1, 1],
pointer: {
eDown: createPointer(10, 10),
eMove: createPointer(50, 40)
}
} as StubCanvas as unknown as LGraphCanvas
const { unmount } = render(SelectionRectangle)
await runFrame()
expect(screen.getByTestId('selection-rectangle').style.display).toBe('none')
unmount()
})
})

View File

@@ -1,6 +1,7 @@
<template>
<div
v-show="isVisible"
v-show="selectionRect !== null"
data-testid="selection-rectangle"
class="pointer-events-none absolute z-9999 border border-blue-400 bg-blue-500/20"
:style="rectangleStyle"
/>
@@ -11,15 +12,16 @@ import { useRafFn } from '@vueuse/core'
import { computed, ref } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { RectEdges } from '@/utils/mathUtil'
import {
clampRectToBounds,
getRectFromPoints,
hasRectArea
} from '@/utils/mathUtil'
const canvasStore = useCanvasStore()
const selectionRect = ref<{
x: number
y: number
w: number
h: number
} | null>(null)
const selectionRect = ref<RectEdges | null>(null)
useRafFn(() => {
const canvas = canvasStore.canvas
@@ -31,33 +33,51 @@ useRafFn(() => {
const { pointer, dragging_rectangle } = canvas
if (dragging_rectangle && pointer.eDown && pointer.eMove) {
const x = pointer.eDown.safeOffsetX
const y = pointer.eDown.safeOffsetY
const w = pointer.eMove.safeOffsetX - x
const h = pointer.eMove.safeOffsetY - y
const canvasBounds = canvas.canvas.getBoundingClientRect()
const dragBounds = getRectFromPoints(
{
x: pointer.eDown.clientX - canvasBounds.left,
y: pointer.eDown.clientY - canvasBounds.top
},
{
x: pointer.eMove.clientX - canvasBounds.left,
y: pointer.eMove.clientY - canvasBounds.top
}
)
const clippedBounds = clampRectToBounds(
dragBounds,
getCanvasPanelBounds(canvas.canvas)
)
selectionRect.value = { x, y, w, h }
selectionRect.value = hasRectArea(clippedBounds) ? clippedBounds : null
} else {
selectionRect.value = null
}
})
const isVisible = computed(() => selectionRect.value !== null)
const rectangleStyle = computed(() => {
const rect = selectionRect.value
if (!rect) return {}
const left = rect.w >= 0 ? rect.x : rect.x + rect.w
const top = rect.h >= 0 ? rect.y : rect.y + rect.h
const width = Math.abs(rect.w)
const height = Math.abs(rect.h)
return {
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
height: `${height}px`
left: `${rect.left}px`,
top: `${rect.top}px`,
width: `${rect.right - rect.left}px`,
height: `${rect.bottom - rect.top}px`
}
})
function getCanvasPanelBounds(canvas: HTMLCanvasElement): RectEdges {
const canvasBounds = canvas.getBoundingClientRect()
const panel = document.querySelector('.graph-canvas-panel')
const panelBounds =
panel instanceof HTMLElement ? panel.getBoundingClientRect() : canvasBounds
return {
left: panelBounds.left - canvasBounds.left,
top: panelBounds.top - canvasBounds.top,
right: panelBounds.right - canvasBounds.left,
bottom: panelBounds.bottom - canvasBounds.top
}
}
</script>

View File

@@ -1,7 +1,14 @@
<template>
<SidebarTabTemplate
ref="assetPanelComponentRef"
data-testid="assets-marquee-surface"
tabindex="-1"
:title="isInFolderView ? '' : $t('sideToolbar.mediaAssets.title')"
class="relative focus:outline-none"
v-bind="$attrs"
@click.capture="handleAssetPanelClickCapture"
@dragstart.capture="handleAssetPanelDragStartCapture"
@pointerdown.capture="handleMarqueePointerDown"
>
<template #alt-title>
<div
@@ -125,6 +132,14 @@
/>
</template>
</SidebarTabTemplate>
<Teleport to="body">
<div
v-if="isMarqueeSelecting"
data-testid="assets-marquee-selection"
class="pointer-events-none fixed z-9999 rounded-lg border-2 border-primary-background bg-primary-background/30"
:style="marqueeStyle"
/>
</Teleport>
<MediaLightbox
v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems"
@@ -164,8 +179,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 +199,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 { useAssetMarqueeSelection } from '@/platform/assets/composables/useAssetMarqueeSelection'
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
@@ -259,7 +277,10 @@ const outputAssets = useAssetsApi('output')
// Asset selection
const {
isSelected,
selectedIds,
handleAssetClick,
selectAll,
setSelectedIds,
hasSelection,
clearSelection,
getSelectedAssets,
@@ -287,6 +308,13 @@ const mediaAssets = computed(() => currentAssets.value.media.value)
const galleryActiveIndex = ref(-1)
const currentGalleryAssetId = ref<string | null>(null)
const assetPanelComponentRef = ref<ComponentPublicInstance>()
const assetPanelRef = ref<HTMLElement | null>(null)
watchEffect(() => {
const element = assetPanelComponentRef.value?.$el
assetPanelRef.value = element instanceof HTMLElement ? element : null
})
const DEFAULT_SKELETON_COUNT = 6
const skeletonCount = computed(() =>
@@ -367,6 +395,23 @@ const showEmptyState = computed(
!loading.value && !isFolderLoading.value && displayAssets.value.length === 0
)
const {
isMarqueeSelecting,
marqueeStyle,
handleAssetPanelClickCapture,
handleAssetPanelDragStartCapture,
handleMarqueePointerDown
} = useAssetMarqueeSelection({
assetPanelRef,
isListView,
showLoadingState,
showEmptyState,
visibleAssets,
selectedIds,
selectAll,
setSelectedIds
})
watch(visibleAssets, (newAssets) => {
// Alternative: keep hidden selections and surface them in UI; for now prune
// so selection stays consistent with what this view can act on.
@@ -575,7 +620,7 @@ const handleDeselectAll = () => {
}
const handleEmptySpaceClick = () => {
if (hasSelection) {
if (hasSelection.value) {
clearSelection()
}
}

View File

@@ -20,6 +20,7 @@
: 'hover:bg-modal-card-background-hovered/20'
)
"
:data-asset-id="asset?.id"
:data-selected="selected"
:draggable="true"
@click.stop="$emit('click')"
@@ -316,6 +317,11 @@ const handleOutputCountClick = () => {
emit('output-count-click')
}
function dragStart(e: DragEvent) {
if (e.ctrlKey || e.metaKey) {
e.preventDefault()
return
}
if (!asset?.preview_url) return
const { dataTransfer } = e

View File

@@ -0,0 +1,778 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { EffectScope } from 'vue'
import { effectScope, ref } from 'vue'
import type { AssetId, AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useAssetMarqueeSelection } from './useAssetMarqueeSelection'
const activeScopes: EffectScope[] = []
type TestRect = {
left: number
top: number
width: number
height: number
}
type PointerOptions = MouseEventInit & {
pointerId?: number
target?: EventTarget
}
function createAsset(id: AssetId): AssetItem {
return {
id,
name: `${id}.png`,
tags: ['output']
}
}
function setRect(element: HTMLElement, rect: TestRect) {
Object.defineProperty(element, 'getBoundingClientRect', {
configurable: true,
value: () => new DOMRect(rect.left, rect.top, rect.width, rect.height)
})
}
function createPanel() {
const panel = document.createElement('div')
panel.tabIndex = -1
setRect(panel, { left: 0, top: 0, width: 240, height: 240 })
document.body.append(panel)
return panel
}
function createAssetElement(id: AssetId, rect: TestRect) {
const element = document.createElement('button')
element.dataset.assetId = id
setRect(element, rect)
return element
}
function installPointerCapture(element: HTMLElement) {
const setPointerCapture = vi.fn()
const releasePointerCapture = vi.fn()
const hasPointerCapture = vi.fn(() => true)
Object.defineProperties(element, {
setPointerCapture: {
configurable: true,
value: setPointerCapture
},
releasePointerCapture: {
configurable: true,
value: releasePointerCapture
},
hasPointerCapture: {
configurable: true,
value: hasPointerCapture
}
})
return { setPointerCapture, releasePointerCapture }
}
function createPointerEvent(type: string, options: PointerOptions = {}) {
const event = new MouseEvent(type, {
bubbles: true,
cancelable: true,
button: options.button ?? 0,
clientX: options.clientX ?? 0,
clientY: options.clientY ?? 0,
ctrlKey: options.ctrlKey ?? false,
metaKey: options.metaKey ?? false,
shiftKey: options.shiftKey ?? false
}) as PointerEvent
Object.defineProperty(event, 'pointerId', {
configurable: true,
value: options.pointerId ?? 1
})
if (options.target) {
Object.defineProperty(event, 'target', {
configurable: true,
value: options.target
})
}
return event
}
function createKeyboardEvent(options: KeyboardEventInit = {}) {
return new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: 'a',
ctrlKey: true,
...options
})
}
function hoverPanel(panel: HTMLElement) {
panel.dispatchEvent(new MouseEvent('mouseenter'))
}
function leavePanel(panel: HTMLElement) {
panel.dispatchEvent(new MouseEvent('mouseleave'))
}
function mountMarquee(assets = [createAsset('a'), createAsset('b')]) {
const scope = effectScope()
activeScopes.push(scope)
const panel = createPanel()
const assetPanelRef = ref<HTMLElement | null>(panel)
const selectedIds = ref<ReadonlySet<AssetId>>(new Set())
const visibleAssets = ref<AssetItem[]>(assets)
const isListView = ref(false)
const showLoadingState = ref(false)
const showEmptyState = ref(false)
const selectAll = vi.fn((allAssets: AssetItem[]) => {
selectedIds.value = new Set(allAssets.map((asset) => asset.id))
})
const setSelectedIds = vi.fn((ids: AssetId[]) => {
selectedIds.value = new Set(ids)
})
const marquee = scope.run(() =>
useAssetMarqueeSelection({
assetPanelRef,
isListView,
showLoadingState,
showEmptyState,
visibleAssets,
selectedIds,
selectAll,
setSelectedIds
})
)
if (!marquee) {
throw new Error('Expected marquee selection composable to mount')
}
return {
isListView,
marquee,
panel,
scope,
selectedIds,
selectAll,
setSelectedIds,
showEmptyState,
showLoadingState,
visibleAssets
}
}
function selectedIdsArray(selectedIds: ReadonlySet<AssetId>) {
return Array.from(selectedIds)
}
describe('useAssetMarqueeSelection', () => {
afterEach(() => {
activeScopes.splice(0).forEach((scope) => {
scope.stop()
})
document.body.replaceChildren()
vi.useRealTimers()
vi.restoreAllMocks()
})
it('selects intersected assets from panel chrome and clips to the panel', () => {
const { marquee, panel, selectedIds } = mountMarquee([createAsset('a')])
setRect(panel, { left: 100, top: 50, width: 240, height: 240 })
const header = document.createElement('div')
setRect(header, { left: 100, top: 50, width: 240, height: 64 })
panel.append(
header,
createAssetElement('a', { left: 140, top: 150, width: 80, height: 80 })
)
marquee.handleMarqueePointerDown(
createPointerEvent('pointerdown', {
target: header,
clientX: 180,
clientY: 80
})
)
window.dispatchEvent(
createPointerEvent('pointermove', { clientX: 400, clientY: 190 })
)
expect(marquee.isMarqueeSelecting.value).toBe(true)
expect(selectedIdsArray(selectedIds.value)).toEqual(['a'])
expect(marquee.marqueeStyle.value).toMatchObject({
left: '180px',
top: '80px',
width: '160px',
height: '110px'
})
})
it('ignores moves below the marquee threshold', () => {
const { marquee, panel, setSelectedIds } = mountMarquee([createAsset('a')])
panel.append(
createAssetElement('a', { left: 20, top: 20, width: 80, height: 80 })
)
expect(marquee.marqueeStyle.value).toEqual({})
marquee.handleMarqueePointerDown(
createPointerEvent('pointerdown', {
target: panel,
clientX: 10,
clientY: 10
})
)
window.dispatchEvent(
createPointerEvent('pointermove', {
clientX: 12,
clientY: 12
})
)
expect(marquee.isMarqueeSelecting.value).toBe(false)
expect(setSelectedIds).not.toHaveBeenCalled()
})
it('keeps the active pointer in control when another pointer presses', () => {
const { marquee, panel } = mountMarquee([createAsset('a')])
panel.append(
createAssetElement('a', { left: 20, top: 20, width: 80, height: 80 })
)
marquee.handleMarqueePointerDown(
createPointerEvent('pointerdown', {
target: panel,
clientX: 10,
clientY: 10,
pointerId: 1
})
)
window.dispatchEvent(
createPointerEvent('pointermove', {
clientX: 100,
clientY: 100,
pointerId: 1
})
)
expect(marquee.isMarqueeSelecting.value).toBe(true)
marquee.handleMarqueePointerDown(
createPointerEvent('pointerdown', {
target: panel,
clientX: 200,
clientY: 200,
pointerId: 2
})
)
window.dispatchEvent(
createPointerEvent('pointerup', {
clientX: 200,
clientY: 200,
pointerId: 2
})
)
expect(marquee.isMarqueeSelecting.value).toBe(true)
window.dispatchEvent(
createPointerEvent('pointerup', {
clientX: 100,
clientY: 100,
pointerId: 1
})
)
expect(marquee.isMarqueeSelecting.value).toBe(false)
})
it('clears the active marquee on pointer cancel', () => {
const { marquee, panel } = mountMarquee([createAsset('a')])
panel.append(
createAssetElement('a', { left: 20, top: 20, width: 80, height: 80 })
)
marquee.handleMarqueePointerDown(
createPointerEvent('pointerdown', {
target: panel,
clientX: 10,
clientY: 10,
pointerId: 7
})
)
window.dispatchEvent(
createPointerEvent('pointermove', {
clientX: 100,
clientY: 100,
pointerId: 7
})
)
expect(marquee.isMarqueeSelecting.value).toBe(true)
window.dispatchEvent(
createPointerEvent('pointercancel', {
pointerId: 7
})
)
expect(marquee.isMarqueeSelecting.value).toBe(false)
const click = new MouseEvent('click', {
bubbles: true,
cancelable: true
})
marquee.handleAssetPanelClickCapture(click)
expect(click.defaultPrevented).toBe(false)
})
it('prevents native drag while a marquee gesture is tracking', () => {
const { marquee, panel } = mountMarquee([createAsset('a')])
const asset = createAssetElement('a', {
left: 20,
top: 20,
width: 80,
height: 80
})
panel.append(asset)
marquee.handleMarqueePointerDown(
createPointerEvent('pointerdown', {
target: asset,
clientX: 30,
clientY: 30,
pointerId: 1,
ctrlKey: true
})
)
const dragStart = new DragEvent('dragstart', {
bubbles: true,
cancelable: true
})
marquee.handleAssetPanelDragStartCapture(dragStart)
expect(dragStart.defaultPrevented).toBe(true)
})
it('ignores plain card drags but allows modifier card drags to add', () => {
const { marquee, panel, selectedIds, selectAll } = mountMarquee()
selectedIds.value = new Set(['b'])
const first = createAssetElement('a', {
left: 20,
top: 20,
width: 80,
height: 80
})
const second = createAssetElement('b', {
left: 120,
top: 120,
width: 80,
height: 80
})
panel.append(first, second)
marquee.handleMarqueePointerDown(
createPointerEvent('pointerdown', {
target: first,
clientX: 30,
clientY: 30
})
)
window.dispatchEvent(
createPointerEvent('pointermove', { clientX: 190, clientY: 190 })
)
expect(selectAll).not.toHaveBeenCalled()
marquee.handleMarqueePointerDown(
createPointerEvent('pointerdown', {
target: first,
clientX: 30,
clientY: 30,
ctrlKey: true
})
)
window.dispatchEvent(
createPointerEvent('pointermove', { clientX: 90, clientY: 90 })
)
expect(selectedIdsArray(selectedIds.value).sort()).toEqual(['a', 'b'])
})
it('does not start marquee selection in disabled panel states', () => {
const { isListView, marquee, panel, selectAll, showEmptyState } =
mountMarquee()
panel.append(
createAssetElement('a', { left: 20, top: 20, width: 80, height: 80 })
)
isListView.value = true
marquee.handleMarqueePointerDown(
createPointerEvent('pointerdown', {
target: panel,
clientX: 10,
clientY: 10
})
)
window.dispatchEvent(
createPointerEvent('pointermove', { clientX: 100, clientY: 100 })
)
isListView.value = false
showEmptyState.value = true
marquee.handleMarqueePointerDown(
createPointerEvent('pointerdown', {
target: panel,
clientX: 10,
clientY: 10
})
)
window.dispatchEvent(
createPointerEvent('pointermove', { clientX: 100, clientY: 100 })
)
expect(selectAll).not.toHaveBeenCalled()
})
it('clears native text selection during a drag from search without blurring', () => {
const { marquee, panel, selectedIds } = mountMarquee([createAsset('a')])
const searchInput = document.createElement('input')
searchInput.type = 'search'
searchInput.value = 'Search Assets'
setRect(searchInput, { left: 10, top: 10, width: 160, height: 32 })
panel.append(
searchInput,
createAssetElement('a', { left: 40, top: 80, width: 80, height: 80 })
)
searchInput.focus()
searchInput.setSelectionRange(0, searchInput.value.length)
marquee.handleMarqueePointerDown(
createPointerEvent('pointerdown', {
target: searchInput,
clientX: 20,
clientY: 20
})
)
window.dispatchEvent(
createPointerEvent('pointermove', { clientX: 120, clientY: 120 })
)
expect(selectedIdsArray(selectedIds.value)).toEqual(['a'])
expect(searchInput.selectionStart).toBe(searchInput.value.length)
expect(searchInput.selectionEnd).toBe(searchInput.value.length)
expect(document.activeElement).toBe(searchInput)
})
it('prevents selectstart inside the panel during a marquee drag', () => {
const { marquee, panel } = mountMarquee([createAsset('a')])
const label = document.createElement('span')
panel.append(
label,
createAssetElement('a', { left: 40, top: 80, width: 80, height: 80 })
)
marquee.handleMarqueePointerDown(
createPointerEvent('pointerdown', {
target: label,
clientX: 20,
clientY: 20
})
)
window.dispatchEvent(
createPointerEvent('pointermove', { clientX: 120, clientY: 120 })
)
const event = new Event('selectstart', {
bubbles: true,
cancelable: true
})
label.dispatchEvent(event)
expect(event.defaultPrevented).toBe(true)
})
it('delays pointer capture until a marquee drag starts', () => {
const { marquee, panel } = mountMarquee([createAsset('a')])
const { setPointerCapture } = installPointerCapture(panel)
const button = document.createElement('button')
panel.append(
button,
createAssetElement('a', { left: 40, top: 80, width: 80, height: 80 })
)
marquee.handleMarqueePointerDown(
createPointerEvent('pointerdown', {
target: button,
clientX: 20,
clientY: 20,
pointerId: 7
})
)
expect(setPointerCapture).not.toHaveBeenCalled()
window.dispatchEvent(
createPointerEvent('pointermove', {
clientX: 22,
clientY: 22,
pointerId: 7
})
)
expect(setPointerCapture).not.toHaveBeenCalled()
window.dispatchEvent(
createPointerEvent('pointermove', {
clientX: 120,
clientY: 120,
pointerId: 7
})
)
expect(setPointerCapture).toHaveBeenCalledWith(7)
})
it('selects all only when the panel is hovered and text input is not focused', () => {
const { panel, selectedIds, selectAll } = mountMarquee()
const input = document.createElement('input')
panel.append(input)
hoverPanel(panel)
window.dispatchEvent(createKeyboardEvent())
expect(selectAll).toHaveBeenCalledTimes(1)
expect(selectedIdsArray(selectedIds.value)).toEqual(['a', 'b'])
input.focus()
const focusedEvent = createKeyboardEvent()
input.dispatchEvent(focusedEvent)
expect(selectAll).toHaveBeenCalledTimes(1)
expect(focusedEvent.defaultPrevented).toBe(false)
input.blur()
leavePanel(panel)
window.dispatchEvent(createKeyboardEvent())
expect(selectAll).toHaveBeenCalledTimes(1)
})
it('does not select all while a modal dialog is open', () => {
const { panel, selectAll } = mountMarquee()
const modal = document.createElement('div')
modal.setAttribute('role', 'dialog')
modal.setAttribute('aria-modal', 'true')
document.body.append(modal)
hoverPanel(panel)
const event = createKeyboardEvent()
window.dispatchEvent(event)
expect(selectAll).not.toHaveBeenCalled()
expect(event.defaultPrevented).toBe(false)
})
it('suppresses the trailing click after a marquee drag', () => {
const { marquee, panel, selectedIds } = mountMarquee()
selectedIds.value = new Set(['a'])
panel.append(
createAssetElement('a', { left: 20, top: 20, width: 80, height: 80 })
)
marquee.handleMarqueePointerDown(
createPointerEvent('pointerdown', {
target: panel,
clientX: 10,
clientY: 10
})
)
window.dispatchEvent(
createPointerEvent('pointermove', { clientX: 100, clientY: 100 })
)
window.dispatchEvent(
createPointerEvent('pointerup', { clientX: 100, clientY: 100 })
)
const trailingClick = new MouseEvent('click', {
bubbles: true,
cancelable: true
})
marquee.handleAssetPanelClickCapture(trailingClick)
expect(trailingClick.defaultPrevented).toBe(true)
})
it('does not suppress later panel clicks when no trailing click arrives', () => {
vi.useFakeTimers()
const { marquee, panel } = mountMarquee()
panel.append(
createAssetElement('a', { left: 20, top: 20, width: 80, height: 80 })
)
marquee.handleMarqueePointerDown(
createPointerEvent('pointerdown', {
target: panel,
clientX: 10,
clientY: 10
})
)
window.dispatchEvent(
createPointerEvent('pointermove', { clientX: 100, clientY: 100 })
)
window.dispatchEvent(
createPointerEvent('pointerup', { clientX: 500, clientY: 500 })
)
vi.runOnlyPendingTimers()
const laterClick = new MouseEvent('click', {
bubbles: true,
cancelable: true
})
marquee.handleAssetPanelClickCapture(laterClick)
expect(laterClick.defaultPrevented).toBe(false)
})
it('continues selecting when pointer capture is unavailable', () => {
const { marquee, panel, selectedIds } = mountMarquee([createAsset('a')])
Object.defineProperty(panel, 'setPointerCapture', {
configurable: true,
value: vi.fn(() => {
throw new Error('unsupported')
})
})
panel.append(
createAssetElement('a', { left: 20, top: 20, width: 80, height: 80 })
)
marquee.handleMarqueePointerDown(
createPointerEvent('pointerdown', {
target: panel,
clientX: 10,
clientY: 10,
pointerId: 7
})
)
window.dispatchEvent(
createPointerEvent('pointermove', {
clientX: 100,
clientY: 100,
pointerId: 7
})
)
window.dispatchEvent(
createPointerEvent('pointerup', {
clientX: 100,
clientY: 100,
pointerId: 7
})
)
expect(selectedIdsArray(selectedIds.value)).toEqual(['a'])
expect(marquee.isMarqueeSelecting.value).toBe(false)
})
it('does not throw when releasing pointer capture fails', () => {
const { marquee, panel } = mountMarquee([createAsset('a')])
const releasePointerCapture = vi.fn(() => {
throw new Error('already released')
})
Object.defineProperties(panel, {
setPointerCapture: {
configurable: true,
value: vi.fn()
},
releasePointerCapture: {
configurable: true,
value: releasePointerCapture
}
})
panel.append(
createAssetElement('a', { left: 20, top: 20, width: 80, height: 80 })
)
marquee.handleMarqueePointerDown(
createPointerEvent('pointerdown', {
target: panel,
clientX: 10,
clientY: 10,
pointerId: 7
})
)
window.dispatchEvent(
createPointerEvent('pointermove', {
clientX: 100,
clientY: 100,
pointerId: 7
})
)
expect(marquee.isMarqueeSelecting.value).toBe(true)
window.dispatchEvent(
createPointerEvent('pointerup', {
clientX: 100,
clientY: 100,
pointerId: 7
})
)
expect(releasePointerCapture).toHaveBeenCalledWith(7)
expect(marquee.isMarqueeSelecting.value).toBe(false)
})
it('ends an off-panel drag when the canvas stops pointerup propagation', () => {
const { marquee, panel } = mountMarquee([createAsset('a')])
const { releasePointerCapture, setPointerCapture } =
installPointerCapture(panel)
const canvas = document.createElement('canvas')
canvas.addEventListener('pointerup', (event) => event.stopPropagation(), {
capture: true
})
document.body.append(canvas)
panel.append(
createAssetElement('a', { left: 20, top: 20, width: 80, height: 80 })
)
marquee.handleMarqueePointerDown(
createPointerEvent('pointerdown', {
target: panel,
clientX: 10,
clientY: 10,
pointerId: 7
})
)
canvas.dispatchEvent(
createPointerEvent('pointermove', {
clientX: 100,
clientY: 100,
pointerId: 7
})
)
expect(setPointerCapture).toHaveBeenCalledWith(7)
expect(marquee.isMarqueeSelecting.value).toBe(true)
canvas.dispatchEvent(
createPointerEvent('pointerup', {
clientX: 100,
clientY: 100,
pointerId: 7
})
)
expect(marquee.isMarqueeSelecting.value).toBe(false)
expect(releasePointerCapture).toHaveBeenCalledWith(7)
})
})

View File

@@ -0,0 +1,342 @@
import type { CSSProperties, Ref } from 'vue'
import { computed, onScopeDispose, ref } from 'vue'
import { useElementHover, useEventListener } from '@vueuse/core'
import type { AssetId, AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { Point, RectEdges } from '@/utils/mathUtil'
import {
clampRectToBounds,
getRectFromPoints,
hasRectArea,
rectsIntersect
} from '@/utils/mathUtil'
type ReadonlyRef<T> = Readonly<Ref<T>>
type MarqueeDragState = {
pointerId: number
captureTarget: HTMLElement | null
start: Point
current: Point
startTarget: EventTarget | null
baseSelection: AssetId[]
hasDragged: boolean
}
type UseAssetMarqueeSelectionOptions = {
assetPanelRef: Ref<HTMLElement | null>
isListView: ReadonlyRef<boolean>
showLoadingState: ReadonlyRef<boolean>
showEmptyState: ReadonlyRef<boolean>
visibleAssets: ReadonlyRef<AssetItem[]>
selectedIds: ReadonlyRef<ReadonlySet<AssetId>>
selectAll: (assets: AssetItem[]) => void
setSelectedIds: (ids: AssetId[], allAssets: AssetItem[]) => void
}
const MARQUEE_DRAG_THRESHOLD = 4
const MARQUEE_ASSET_SELECTOR = '[data-asset-id]'
export function useAssetMarqueeSelection({
assetPanelRef,
isListView,
showLoadingState,
showEmptyState,
visibleAssets,
selectedIds,
selectAll,
setSelectedIds
}: UseAssetMarqueeSelectionOptions) {
const isPointerOverAssetsPanel = useElementHover(assetPanelRef)
const marqueeDrag = ref<MarqueeDragState | null>(null)
const shouldSuppressNextPanelClick = ref(false)
let suppressClickResetTimeout: number | null = null
const isMarqueeSelecting = computed(
() => marqueeDrag.value?.hasDragged ?? false
)
const marqueeStyle = computed<CSSProperties>(() => {
const marqueeRect = getClippedMarqueeRect()
if (!marqueeRect) return {}
return {
left: `${marqueeRect.left}px`,
top: `${marqueeRect.top}px`,
width: `${marqueeRect.right - marqueeRect.left}px`,
height: `${marqueeRect.bottom - marqueeRect.top}px`
}
})
function handleAssetPanelClickCapture(event: MouseEvent) {
if (!shouldSuppressNextPanelClick.value) {
return
}
event.preventDefault()
event.stopImmediatePropagation()
resetSuppressedClick()
}
function handleAssetPanelDragStartCapture(event: DragEvent) {
if (marqueeDrag.value) {
event.preventDefault()
}
}
function handleMarqueePointerDown(event: PointerEvent) {
if (marqueeDrag.value) {
return
}
resetSuppressedClick()
if (
isListView.value ||
event.button !== 0 ||
showLoadingState.value ||
showEmptyState.value
) {
return
}
if (
event.target instanceof Element &&
event.target.closest(MARQUEE_ASSET_SELECTOR) &&
!event.ctrlKey &&
!event.metaKey
) {
return
}
const start = { x: event.clientX, y: event.clientY }
const pointerId = event.pointerId
marqueeDrag.value = {
pointerId,
captureTarget: null,
start,
current: start,
startTarget: event.target,
baseSelection:
event.shiftKey || event.ctrlKey || event.metaKey
? Array.from(selectedIds.value)
: [],
hasDragged: false
}
}
function handleMarqueePointerMove(event: PointerEvent) {
const drag = marqueeDrag.value
if (!drag || drag.pointerId !== event.pointerId) {
return
}
drag.current = { x: event.clientX, y: event.clientY }
if (
!drag.hasDragged &&
Math.hypot(event.clientX - drag.start.x, event.clientY - drag.start.y) <
MARQUEE_DRAG_THRESHOLD
) {
return
}
const startedDragging = !drag.hasDragged
event.preventDefault()
if (startedDragging) {
drag.captureTarget = capturePointer(assetPanelRef.value, drag.pointerId)
}
drag.hasDragged = true
shouldSuppressNextPanelClick.value = true
clearNativeTextSelection(drag.startTarget)
setSelectedIds(getMarqueeAssetIds(), visibleAssets.value)
}
function handleMarqueePointerUp(event: PointerEvent) {
const drag = marqueeDrag.value
if (!drag || drag.pointerId !== event.pointerId) {
return
}
if (drag.hasDragged) {
event.preventDefault()
clearNativeTextSelection(drag.startTarget)
setSelectedIds(getMarqueeAssetIds(), visibleAssets.value)
scheduleSuppressClickReset()
}
clearMarqueeDrag()
}
function handleMarqueePointerCancel(event: PointerEvent) {
if (marqueeDrag.value?.pointerId === event.pointerId) {
resetSuppressedClick()
clearMarqueeDrag()
}
}
function handleDocumentSelectStart(event: Event) {
const panel = assetPanelRef.value
if (
!marqueeDrag.value ||
!panel ||
!(event.target instanceof Node) ||
!panel.contains(event.target)
) {
return
}
event.preventDefault()
}
function handleAssetPanelKeydown(event: KeyboardEvent) {
const isSelectAllShortcut =
(event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a'
if (
isSelectAllShortcut &&
isPointerOverAssetsPanel.value &&
!isEditableTarget(document.activeElement) &&
!document.querySelector('[role="dialog"][aria-modal="true"]')
) {
event.preventDefault()
event.stopImmediatePropagation()
selectAll(visibleAssets.value)
}
}
function getClippedMarqueeRect(): RectEdges | null {
const panel = assetPanelRef.value
const drag = marqueeDrag.value
if (!panel || !drag) return null
const clipped = clampRectToBounds(
getRectFromPoints(drag.start, drag.current),
panel.getBoundingClientRect()
)
return hasRectArea(clipped) ? clipped : null
}
function getMarqueeAssetIds(): AssetId[] {
const drag = marqueeDrag.value
const panel = assetPanelRef.value
const marqueeRect = getClippedMarqueeRect()
if (!panel || !marqueeRect) return []
const assetIds = new Set(drag?.baseSelection ?? [])
for (const assetElement of panel.querySelectorAll<HTMLElement>(
MARQUEE_ASSET_SELECTOR
)) {
const assetId = assetElement.dataset.assetId
if (
assetId &&
rectsIntersect(marqueeRect, assetElement.getBoundingClientRect())
) {
assetIds.add(assetId)
}
}
return visibleAssets.value
.filter((asset) => assetIds.has(asset.id))
.map((asset) => asset.id)
}
function resetSuppressedClick() {
clearTimeoutIfSet(suppressClickResetTimeout)
suppressClickResetTimeout = null
shouldSuppressNextPanelClick.value = false
}
function scheduleSuppressClickReset() {
clearTimeoutIfSet(suppressClickResetTimeout)
suppressClickResetTimeout = window.setTimeout(() => {
suppressClickResetTimeout = null
shouldSuppressNextPanelClick.value = false
}, 0)
}
function clearMarqueeDrag() {
const drag = marqueeDrag.value
if (drag) {
releasePointerCapture(drag.captureTarget, drag.pointerId)
}
marqueeDrag.value = null
}
useEventListener(window, 'pointermove', handleMarqueePointerMove, {
capture: true
})
useEventListener(window, 'pointerup', handleMarqueePointerUp, {
capture: true
})
useEventListener(window, 'pointercancel', handleMarqueePointerCancel, {
capture: true
})
useEventListener(window, 'selectstart', handleDocumentSelectStart, {
capture: true
})
useEventListener(window, 'keydown', handleAssetPanelKeydown, {
capture: true
})
onScopeDispose(() => {
clearMarqueeDrag()
resetSuppressedClick()
})
return {
isMarqueeSelecting,
marqueeStyle,
handleAssetPanelClickCapture,
handleAssetPanelDragStartCapture,
handleMarqueePointerDown
}
}
function clearTimeoutIfSet(timeout: number | null) {
if (timeout !== null) {
window.clearTimeout(timeout)
}
}
function capturePointer(target: HTMLElement | null, pointerId: number) {
if (!target) return null
try {
target.setPointerCapture(pointerId)
return target
} catch {
return null
}
}
function releasePointerCapture(target: HTMLElement | null, pointerId: number) {
if (!target) return
try {
target.releasePointerCapture(pointerId)
} catch {
return
}
}
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false
return (
target.isContentEditable ||
target.matches('input, textarea, select, [role="textbox"]')
)
}
function clearNativeTextSelection(target: EventTarget | null) {
window.getSelection()?.removeAllRanges()
if (
target instanceof HTMLTextAreaElement ||
(target instanceof HTMLInputElement &&
['password', 'search', 'tel', 'text', 'url'].includes(target.type))
) {
const end = target.value.length
target.setSelectionRange(end, end)
}
}

View File

@@ -246,6 +246,46 @@ describe('useAssetSelection', () => {
selectAll(assets)
expect(selectedCount.value).toBe(5)
})
it('clears selection when selecting all from an empty view', () => {
const selection = useAssetSelection()
const store = useAssetSelectionStore()
const assets = createMockAssets(1)
selection.handleAssetClick(assets[0], 0, assets)
selection.selectAll([])
expect(store.selectedAssetIds.size).toBe(0)
expect(store.lastSelectedIndex).toBe(-1)
expect(store.lastSelectedAssetId).toBeNull()
})
})
describe('setSelectedIds', () => {
it('replaces selection and anchors on the last selected visible asset', () => {
const selection = useAssetSelection()
const store = useAssetSelectionStore()
const assets = createMockAssets(4)
selection.setSelectedIds(['asset-1', 'asset-3'], assets)
expect(Array.from(store.selectedAssetIds)).toEqual(['asset-1', 'asset-3'])
expect(store.lastSelectedIndex).toBe(3)
expect(store.lastSelectedAssetId).toBe('asset-3')
})
it('clears the anchor when replacing selection with no visible assets', () => {
const selection = useAssetSelection()
const store = useAssetSelectionStore()
const assets = createMockAssets(2)
selection.setSelectedIds(['asset-1'], assets)
selection.setSelectedIds([], assets)
expect(store.selectedAssetIds.size).toBe(0)
expect(store.lastSelectedIndex).toBe(-1)
expect(store.lastSelectedAssetId).toBeNull()
})
})
describe('clearSelection', () => {

View File

@@ -93,12 +93,24 @@ export function useAssetSelection() {
* Select all assets in the current view
*/
function selectAll(allAssets: AssetItem[]) {
if (allAssets.length === 0) {
selectionStore.clearSelection()
return
}
const allIds = allAssets.map((a) => a.id)
selectionStore.setSelection(allIds)
if (allAssets.length > 0) {
const lastIndex = allAssets.length - 1
setAnchor(lastIndex, allAssets[lastIndex].id)
}
const lastIndex = allAssets.length - 1
setAnchor(lastIndex, allAssets[lastIndex].id)
}
function setSelectedIds(ids: string[], allAssets: AssetItem[]) {
selectionStore.setSelection(ids)
const selected = new Set(ids)
const anchorIndex = allAssets.findLastIndex((asset) =>
selected.has(asset.id)
)
setAnchor(anchorIndex, anchorIndex >= 0 ? allAssets[anchorIndex].id : null)
}
/**
@@ -182,6 +194,7 @@ export function useAssetSelection() {
// Selection actions
handleAssetClick,
selectAll,
setSelectedIds,
clearSelection: () => selectionStore.clearSelection(),
getSelectedAssets,
reconcileSelection,

View File

@@ -2,11 +2,15 @@ import { describe, expect, it } from 'vitest'
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
import {
clampRectToBounds,
computeUnionBounds,
denormalize,
getRectFromPoints,
gcd,
hasRectArea,
lcm,
normalize
normalize,
rectsIntersect
} from '@/utils/mathUtil'
describe('mathUtil', () => {
@@ -137,4 +141,48 @@ describe('mathUtil', () => {
expect(result!.height).toBe(242)
})
})
describe('clampRectToBounds', () => {
const bounds = { left: 0, top: 0, right: 100, bottom: 100 }
it('clamps rectangle edges to the provided bounds', () => {
expect(
clampRectToBounds(
{ left: -20, top: 10, right: 130, bottom: 90 },
bounds
)
).toEqual({ left: 0, top: 10, right: 100, bottom: 90 })
})
})
describe('rect helpers', () => {
it('builds ordered rect edges from unordered points', () => {
expect(getRectFromPoints({ x: 30, y: 10 }, { x: 5, y: 40 })).toEqual({
left: 5,
top: 10,
right: 30,
bottom: 40
})
})
it('detects positive-area rects', () => {
expect(hasRectArea({ left: 0, top: 0, right: 1, bottom: 1 })).toBe(true)
expect(hasRectArea({ left: 0, top: 0, right: 0, bottom: 1 })).toBe(false)
})
it('detects intersecting rects', () => {
expect(
rectsIntersect(
{ left: 0, top: 0, right: 10, bottom: 10 },
{ left: 5, top: 5, right: 15, bottom: 15 }
)
).toBe(true)
expect(
rectsIntersect(
{ left: 0, top: 0, right: 10, bottom: 10 },
{ left: 20, top: 20, right: 30, bottom: 30 }
)
).toBe(false)
})
})
})

View File

@@ -1,6 +1,20 @@
import { clamp } from 'es-toolkit'
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
import type { Bounds } from '@/renderer/core/layout/types'
export type RectEdges = {
left: number
top: number
right: number
bottom: number
}
export type Point = {
x: number
y: number
}
/**
* Linearly maps a value from [min, max] to [0, 1].
* Returns 0 when min equals max to avoid division by zero.
@@ -138,3 +152,37 @@ export function anyItemOverlapsRect(
}
return false
}
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)
}
}
export function getRectFromPoints(start: Point, end: Point): RectEdges {
return {
left: Math.min(start.x, end.x),
top: Math.min(start.y, end.y),
right: Math.max(start.x, end.x),
bottom: Math.max(start.y, end.y)
}
}
export function hasRectArea(rect: RectEdges): boolean {
return rect.left < rect.right && rect.top < rect.bottom
}
export function rectsIntersect(first: RectEdges, second: RectEdges): boolean {
return !(
first.right < second.left ||
first.left > second.right ||
first.bottom < second.top ||
first.top > second.bottom
)
}