mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-07 23:57:53 +00:00
Compare commits
3 Commits
codex/cove
...
nathaniel/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59858dc843 | ||
|
|
caa5568a3e | ||
|
|
f8335dc0af |
@@ -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"]'
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
150
src/components/graph/SelectionRectangle.test.ts
Normal file
150
src/components/graph/SelectionRectangle.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
778
src/platform/assets/composables/useAssetMarqueeSelection.test.ts
Normal file
778
src/platform/assets/composables/useAssetMarqueeSelection.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
342
src/platform/assets/composables/useAssetMarqueeSelection.ts
Normal file
342
src/platform/assets/composables/useAssetMarqueeSelection.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user