mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-15 11:44:10 +00:00
Compare commits
26 Commits
test/fixme
...
nathaniel/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6915fb06e8 | ||
|
|
cff72bc8fa | ||
|
|
3898b0710c | ||
|
|
8764db6093 | ||
|
|
9e2df5d525 | ||
|
|
16253a0886 | ||
|
|
a637438d1a | ||
|
|
72be7e4bce | ||
|
|
4308aad97f | ||
|
|
46273bf368 | ||
|
|
1518bbd585 | ||
|
|
a3437b0676 | ||
|
|
752084faa2 | ||
|
|
2c0afbc70a | ||
|
|
30a77a9a73 | ||
|
|
55edc67677 | ||
|
|
c3fc0a9218 | ||
|
|
fcfd314738 | ||
|
|
d4e72f7330 | ||
|
|
3cf1dd2136 | ||
|
|
6e4162b9f8 | ||
|
|
4342c340fa | ||
|
|
ab6ea4c32d | ||
|
|
548c1ab40d | ||
|
|
d31052a450 | ||
|
|
2575a99d7c |
@@ -322,6 +322,9 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
// --- Folder view ---
|
||||
public readonly backToAssetsButton: Locator
|
||||
|
||||
// --- Panel chrome ---
|
||||
public readonly panelHeader: Locator
|
||||
|
||||
// --- Loading ---
|
||||
public readonly skeletonLoaders: Locator
|
||||
|
||||
@@ -358,6 +361,7 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
this.deleteSelectedButton = page.getByTestId('assets-delete-selected')
|
||||
this.downloadSelectedButton = page.getByTestId('assets-download-selected')
|
||||
this.backToAssetsButton = page.getByText('Back to all assets')
|
||||
this.panelHeader = page.locator('.comfy-vue-side-bar-header')
|
||||
this.skeletonLoaders = page.locator(
|
||||
'.sidebar-content-container .animate-pulse'
|
||||
)
|
||||
|
||||
@@ -276,3 +276,255 @@ test.describe('FE-130 assets sidebar route mocks', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('FE-910 marquee selection and select all', () => {
|
||||
test.beforeEach(async ({ jobsRoutes, page, comfyPage }) => {
|
||||
await jobsRoutes.mockJobsQueue([])
|
||||
await jobsRoutes.mockJobsHistory(generatedJobs)
|
||||
await mockInputFiles(page, ['imported.png'])
|
||||
await mockViewFiles(page, viewFiles)
|
||||
await comfyPage.setup()
|
||||
await comfyPage.menu.assetsTab.open()
|
||||
})
|
||||
|
||||
test('Ctrl/Cmd+A selects every asset while the panel is hovered', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(2)
|
||||
|
||||
await tab.getAssetCardByName('alpha').hover()
|
||||
await comfyPage.page.keyboard.press('ControlOrMeta+a')
|
||||
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('a marquee that begins in the panel header selects the cards', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
const { page } = comfyPage
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(2)
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
|
||||
const header = await tab.panelHeader.boundingBox()
|
||||
const beta = await tab.getAssetCardByName('beta').boundingBox()
|
||||
if (!header || !beta) {
|
||||
throw new Error('panel header or asset card has no layout box')
|
||||
}
|
||||
|
||||
// Begin the rubber-band in the header (above the grid), then drag down
|
||||
// across both cards.
|
||||
await page.mouse.move(header.x + 24, header.y + 20)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(beta.x + 8, beta.y + beta.height - 8, { steps: 14 })
|
||||
await page.mouse.up()
|
||||
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
await expect(tab.selectionFooter).toBeVisible()
|
||||
})
|
||||
|
||||
test('Ctrl/Cmd+A leaves assets unselected while the canvas is hovered', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
const { page } = comfyPage
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(2)
|
||||
|
||||
const viewport = page.viewportSize()
|
||||
if (!viewport) throw new Error('viewport size is unavailable')
|
||||
|
||||
// Hover the canvas (not the panel); Ctrl/Cmd+A must yield to the canvas.
|
||||
await page.mouse.move(viewport.width - 100, viewport.height / 2)
|
||||
await page.keyboard.press('ControlOrMeta+a')
|
||||
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('a modifier-held marquee adds to the existing selection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
const { page } = comfyPage
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(2)
|
||||
|
||||
await tab.getAssetCardByName('alpha').click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
const beta = await tab.getAssetCardByName('beta').boundingBox()
|
||||
if (!beta) throw new Error('beta card has no layout box')
|
||||
|
||||
// Hold a modifier so the marquee is additive, then rubber-band over beta.
|
||||
await page.keyboard.down('Control')
|
||||
await page.mouse.move(beta.x + 12, beta.y + 12)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(beta.x + beta.width - 12, beta.y + beta.height - 12, {
|
||||
steps: 12
|
||||
})
|
||||
await page.mouse.up()
|
||||
await page.keyboard.up('Control')
|
||||
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('a Ctrl/Cmd+Shift marquee removes the covered cards from the selection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
const { page } = comfyPage
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(2)
|
||||
|
||||
await tab.getAssetCardByName('alpha').hover()
|
||||
await page.keyboard.press('ControlOrMeta+a')
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
|
||||
const beta = await tab.getAssetCardByName('beta').boundingBox()
|
||||
if (!beta) throw new Error('beta card has no layout box')
|
||||
|
||||
// Ctrl+Shift makes the marquee subtractive: rubber-band over beta only.
|
||||
await page.keyboard.down('Control')
|
||||
await page.keyboard.down('Shift')
|
||||
await page.mouse.move(beta.x + 12, beta.y + 12)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(beta.x + beta.width - 12, beta.y + beta.height - 12, {
|
||||
steps: 12
|
||||
})
|
||||
await page.mouse.up()
|
||||
await page.keyboard.up('Shift')
|
||||
await page.keyboard.up('Control')
|
||||
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
await expect(tab.getAssetCardByName('alpha')).toHaveAttribute(
|
||||
'data-selected',
|
||||
'true'
|
||||
)
|
||||
})
|
||||
|
||||
test('Ctrl/Cmd-dragging from an asset card starts a marquee selection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
const { page } = comfyPage
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(2)
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
|
||||
const alpha = await tab.getAssetCardByName('alpha').boundingBox()
|
||||
const beta = await tab.getAssetCardByName('beta').boundingBox()
|
||||
if (!alpha || !beta) throw new Error('asset cards have no layout box')
|
||||
|
||||
// Ctrl bypasses card drag, so a press that begins on a card rubber-bands.
|
||||
await page.keyboard.down('Control')
|
||||
await page.mouse.move(alpha.x + alpha.width / 2, alpha.y + alpha.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(beta.x + beta.width - 6, beta.y + beta.height - 6, {
|
||||
steps: 12
|
||||
})
|
||||
await page.mouse.up()
|
||||
await page.keyboard.up('Control')
|
||||
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
await expect(tab.selectionFooter).toBeVisible()
|
||||
})
|
||||
|
||||
test('Ctrl/Cmd-dragging within a single card selects only that card', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
const { page } = comfyPage
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(2)
|
||||
|
||||
const alpha = tab.getAssetCardByName('alpha')
|
||||
const box = await alpha.boundingBox()
|
||||
if (!box) throw new Error('alpha card has no layout box')
|
||||
|
||||
const start = { x: box.x + box.width / 2, y: box.y + box.height / 2 }
|
||||
await page.keyboard.down('Control')
|
||||
await page.mouse.move(start.x, start.y)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(start.x + 12, start.y + 12, { steps: 4 })
|
||||
await page.mouse.up()
|
||||
await page.keyboard.up('Control')
|
||||
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
await expect(alpha).toHaveAttribute('data-selected', 'true')
|
||||
})
|
||||
|
||||
test('Ctrl/Cmd+A in the focused search input does not select assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
const query = 'alpha'
|
||||
|
||||
await tab.searchInput.fill(query)
|
||||
await expect(tab.assetCards).toHaveCount(1)
|
||||
|
||||
await tab.searchInput.focus()
|
||||
await comfyPage.page.keyboard.press('ControlOrMeta+a')
|
||||
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
await expect
|
||||
.poll(() =>
|
||||
tab.searchInput.evaluate((el: HTMLInputElement) => {
|
||||
return { start: el.selectionStart, end: el.selectionEnd }
|
||||
})
|
||||
)
|
||||
.toEqual({ start: 0, end: query.length })
|
||||
})
|
||||
|
||||
test('a drag starting in the search input does not marquee-select assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
const { page } = comfyPage
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(2)
|
||||
|
||||
const search = await tab.searchInput.boundingBox()
|
||||
const beta = await tab.getAssetCardByName('beta').boundingBox()
|
||||
if (!search || !beta)
|
||||
throw new Error('search box or card has no layout box')
|
||||
|
||||
await page.mouse.move(
|
||||
search.x + search.width / 2,
|
||||
search.y + search.height / 2
|
||||
)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(beta.x + beta.width / 2, beta.y + beta.height / 2, {
|
||||
steps: 12
|
||||
})
|
||||
await page.mouse.up()
|
||||
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Ctrl/Cmd+A does not select assets while an aria-modal dialog is open', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await expect(tab.assetCards).toHaveCount(2)
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const dialog = document.createElement('div')
|
||||
dialog.id = 'test-modal'
|
||||
dialog.setAttribute('role', 'dialog')
|
||||
dialog.setAttribute('aria-modal', 'true')
|
||||
document.body.appendChild(dialog)
|
||||
})
|
||||
|
||||
await tab.getAssetCardByName('alpha').hover()
|
||||
await comfyPage.page.keyboard.press('ControlOrMeta+a')
|
||||
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
document.getElementById('test-modal')?.remove()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -39,6 +39,10 @@
|
||||
<NodePropertiesPanel v-else />
|
||||
</template>
|
||||
<template #graph-canvas-panel>
|
||||
<div
|
||||
ref="canvasPanelBoundsRef"
|
||||
class="pointer-events-none absolute inset-0"
|
||||
/>
|
||||
<GraphCanvasMenu
|
||||
v-if="canvasMenuEnabled && !isBuilderMode"
|
||||
class="pointer-events-auto"
|
||||
@@ -89,7 +93,10 @@
|
||||
/>
|
||||
|
||||
<!-- Selection rectangle overlay - rendered in DOM layer to appear above DOM widgets -->
|
||||
<SelectionRectangle v-if="comfyAppReady" />
|
||||
<SelectionRectangle
|
||||
v-if="comfyAppReady"
|
||||
:panel-el="canvasPanelBoundsRef ?? undefined"
|
||||
/>
|
||||
|
||||
<NodeTooltip v-if="tooltipEnabled" />
|
||||
<NodeSearchboxPopover ref="nodeSearchboxPopoverRef" />
|
||||
@@ -116,6 +123,7 @@ import {
|
||||
onUnmounted,
|
||||
ref,
|
||||
shallowRef,
|
||||
useTemplateRef,
|
||||
watch,
|
||||
watchEffect
|
||||
} from 'vue'
|
||||
@@ -202,6 +210,7 @@ const emit = defineEmits<{
|
||||
ready: []
|
||||
}>()
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const canvasPanelBoundsRef = useTemplateRef('canvasPanelBoundsRef')
|
||||
const nodeSearchboxPopoverRef = shallowRef<InstanceType<
|
||||
typeof NodeSearchboxPopover
|
||||
> | null>(null)
|
||||
|
||||
106
src/components/graph/SelectionRectangle.test.ts
Normal file
106
src/components/graph/SelectionRectangle.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import SelectionRectangle from './SelectionRectangle.vue'
|
||||
|
||||
const rafCallbacks: Array<() => void> = []
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useRafFn: (cb: () => void) => {
|
||||
rafCallbacks.push(cb)
|
||||
return { pause: vi.fn(), resume: vi.fn() }
|
||||
}
|
||||
}))
|
||||
|
||||
const mockCanvas = ref<unknown>(null)
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
get canvas() {
|
||||
return mockCanvas.value
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
function createPanelEl() {
|
||||
const panel = document.createElement('div')
|
||||
vi.spyOn(panel, 'getBoundingClientRect').mockReturnValue(
|
||||
fromPartial<DOMRect>({ left: 300, top: 0, right: 1000, bottom: 800 })
|
||||
)
|
||||
return panel
|
||||
}
|
||||
|
||||
function dragRectangle(eDown: [number, number], eMove: [number, number]) {
|
||||
const canvasEl = document.createElement('canvas')
|
||||
vi.spyOn(canvasEl, 'getBoundingClientRect').mockReturnValue(
|
||||
fromPartial<DOMRect>({ left: 0, top: 0, right: 1000, bottom: 800 })
|
||||
)
|
||||
mockCanvas.value = {
|
||||
canvas: canvasEl,
|
||||
dragging_rectangle: true,
|
||||
pointer: {
|
||||
eDown: { safeOffsetX: eDown[0], safeOffsetY: eDown[1] },
|
||||
eMove: { safeOffsetX: eMove[0], safeOffsetY: eMove[1] }
|
||||
}
|
||||
}
|
||||
rafCallbacks[rafCallbacks.length - 1]()
|
||||
}
|
||||
|
||||
describe('SelectionRectangle', () => {
|
||||
afterEach(() => {
|
||||
rafCallbacks.length = 0
|
||||
mockCanvas.value = null
|
||||
document.body.replaceChildren()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('clips the rectangle to the canvas panel when dragged over the sidebar', async () => {
|
||||
render(SelectionRectangle, { props: { panelEl: createPanelEl() } })
|
||||
|
||||
dragRectangle([100, 100], [800, 400])
|
||||
await nextTick()
|
||||
|
||||
const rect = screen.getByTestId('selection-rectangle')
|
||||
expect(rect.style.left).toBe('300px')
|
||||
expect(rect.style.top).toBe('100px')
|
||||
expect(rect.style.width).toBe('500px')
|
||||
expect(rect.style.height).toBe('300px')
|
||||
})
|
||||
|
||||
it('leaves a rectangle within the panel unchanged', async () => {
|
||||
render(SelectionRectangle, { props: { panelEl: createPanelEl() } })
|
||||
|
||||
dragRectangle([400, 100], [600, 300])
|
||||
await nextTick()
|
||||
|
||||
const rect = screen.getByTestId('selection-rectangle')
|
||||
expect(rect.style.left).toBe('400px')
|
||||
expect(rect.style.top).toBe('100px')
|
||||
expect(rect.style.width).toBe('200px')
|
||||
expect(rect.style.height).toBe('200px')
|
||||
})
|
||||
|
||||
it('normalizes and clips a rectangle dragged up-and-left', async () => {
|
||||
render(SelectionRectangle, { props: { panelEl: createPanelEl() } })
|
||||
|
||||
dragRectangle([800, 400], [100, 100])
|
||||
await nextTick()
|
||||
|
||||
const rect = screen.getByTestId('selection-rectangle')
|
||||
expect(rect.style.left).toBe('300px')
|
||||
expect(rect.style.top).toBe('100px')
|
||||
expect(rect.style.width).toBe('500px')
|
||||
expect(rect.style.height).toBe('300px')
|
||||
})
|
||||
|
||||
it('renders unclamped edges when the canvas panel is absent', async () => {
|
||||
render(SelectionRectangle)
|
||||
|
||||
dragRectangle([100, 100], [800, 400])
|
||||
await nextTick()
|
||||
|
||||
const rect = screen.getByTestId('selection-rectangle')
|
||||
expect(rect.style.left).toBe('100px')
|
||||
expect(rect.style.width).toBe('700px')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
v-show="isVisible"
|
||||
data-testid="selection-rectangle"
|
||||
class="pointer-events-none absolute z-9999 border border-blue-400 bg-blue-500/20"
|
||||
:style="rectangleStyle"
|
||||
/>
|
||||
@@ -11,6 +12,13 @@ import { useRafFn } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { clipRectToBounds } from '@/utils/mathUtil'
|
||||
import type { RectEdges } from '@/utils/mathUtil'
|
||||
|
||||
const { panelEl } = defineProps<{
|
||||
/** Clip surface owned by the caller; the rectangle renders unclipped when absent. */
|
||||
panelEl?: HTMLElement
|
||||
}>()
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
@@ -20,17 +28,18 @@ const selectionRect = ref<{
|
||||
w: number
|
||||
h: number
|
||||
} | null>(null)
|
||||
const panelBounds = ref<RectEdges>()
|
||||
|
||||
useRafFn(() => {
|
||||
const canvas = canvasStore.canvas
|
||||
if (!canvas) {
|
||||
selectionRect.value = null
|
||||
return
|
||||
}
|
||||
if (!canvas) return
|
||||
|
||||
const { pointer, dragging_rectangle } = canvas
|
||||
|
||||
if (dragging_rectangle && pointer.eDown && pointer.eMove) {
|
||||
if (!selectionRect.value) {
|
||||
panelBounds.value = getCanvasPanelBounds(canvas.canvas)
|
||||
}
|
||||
const x = pointer.eDown.safeOffsetX
|
||||
const y = pointer.eDown.safeOffsetY
|
||||
const w = pointer.eMove.safeOffsetX - x
|
||||
@@ -39,25 +48,47 @@ useRafFn(() => {
|
||||
selectionRect.value = { x, y, w, h }
|
||||
} else {
|
||||
selectionRect.value = null
|
||||
panelBounds.value = undefined
|
||||
}
|
||||
})
|
||||
|
||||
const isVisible = computed(() => selectionRect.value !== null)
|
||||
|
||||
function getCanvasPanelBounds(
|
||||
canvasEl: HTMLCanvasElement
|
||||
): RectEdges | undefined {
|
||||
if (!panelEl) return undefined
|
||||
|
||||
const panel = panelEl.getBoundingClientRect()
|
||||
const canvas = canvasEl.getBoundingClientRect()
|
||||
return {
|
||||
left: panel.left - canvas.left,
|
||||
top: panel.top - canvas.top,
|
||||
right: panel.right - canvas.left,
|
||||
bottom: panel.bottom - canvas.top
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
const edges: RectEdges = {
|
||||
left: rect.w >= 0 ? rect.x : rect.x + rect.w,
|
||||
top: rect.h >= 0 ? rect.y : rect.y + rect.h,
|
||||
right: rect.w >= 0 ? rect.x + rect.w : rect.x,
|
||||
bottom: rect.h >= 0 ? rect.y + rect.h : rect.y
|
||||
}
|
||||
const bounds = panelBounds.value
|
||||
const { left, top, right, bottom } = bounds
|
||||
? clipRectToBounds(edges, bounds)
|
||||
: edges
|
||||
|
||||
return {
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
width: `${width}px`,
|
||||
height: `${height}px`
|
||||
width: `${right - left}px`,
|
||||
height: `${bottom - top}px`
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<SidebarTabTemplate
|
||||
ref="panelRef"
|
||||
:title="isInFolderView ? '' : $t('sideToolbar.mediaAssets.title')"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
@@ -100,18 +101,19 @@
|
||||
@context-menu="handleAssetContextMenu"
|
||||
@approach-end="handleApproachEnd"
|
||||
/>
|
||||
<AssetsSidebarGridView
|
||||
v-else
|
||||
:assets="displayAssets"
|
||||
:is-selected="isSelected"
|
||||
:show-output-count="shouldShowOutputCount"
|
||||
:get-output-count="getOutputCount"
|
||||
@select-asset="handleAssetSelect"
|
||||
@context-menu="handleAssetContextMenu"
|
||||
@approach-end="handleApproachEnd"
|
||||
@zoom="handleZoomClick"
|
||||
@output-count-click="enterFolderView"
|
||||
/>
|
||||
<div v-else class="size-full">
|
||||
<AssetsSidebarGridView
|
||||
:assets="displayAssets"
|
||||
:is-selected
|
||||
:show-output-count
|
||||
:get-output-count
|
||||
@select-asset="handleAssetSelect"
|
||||
@context-menu="handleAssetContextMenu"
|
||||
@approach-end="handleApproachEnd"
|
||||
@zoom="handleZoomClick"
|
||||
@output-count-click="enterFolderView"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
@@ -125,6 +127,13 @@
|
||||
/>
|
||||
</template>
|
||||
</SidebarTabTemplate>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="marqueeStyle"
|
||||
class="pointer-events-none fixed z-9999 border border-primary-background bg-primary-background/20"
|
||||
:style="marqueeStyle"
|
||||
/>
|
||||
</Teleport>
|
||||
<MediaLightbox
|
||||
v-model:active-index="galleryActiveIndex"
|
||||
:all-gallery-items="galleryItems"
|
||||
@@ -151,6 +160,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
unrefElement,
|
||||
useAsyncState,
|
||||
useDebounceFn,
|
||||
useStorage,
|
||||
@@ -164,6 +174,7 @@ import {
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
useTemplateRef,
|
||||
watch
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -182,6 +193,7 @@ import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBa
|
||||
import MediaAssetSelectionBar from '@/platform/assets/components/MediaAssetSelectionBar.vue'
|
||||
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
|
||||
import { useAssetsApi } from '@/platform/assets/composables/media/useAssetsApi'
|
||||
import { useAssetGridSelection } from '@/platform/assets/composables/useAssetGridSelection'
|
||||
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
|
||||
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
||||
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
|
||||
@@ -239,7 +251,7 @@ const contextMenuFileKind = computed<MediaKind>(() =>
|
||||
getMediaTypeFromFilename(contextMenuAsset.value?.name ?? '')
|
||||
)
|
||||
|
||||
const shouldShowOutputCount = (item: AssetItem): boolean => {
|
||||
const showOutputCount = (item: AssetItem): boolean => {
|
||||
if (activeTab.value !== 'output' || isInFolderView.value) {
|
||||
return false
|
||||
}
|
||||
@@ -259,7 +271,10 @@ const outputAssets = useAssetsApi('output')
|
||||
// Asset selection
|
||||
const {
|
||||
isSelected,
|
||||
selectedIds,
|
||||
handleAssetClick,
|
||||
selectAll,
|
||||
setSelectedIds,
|
||||
hasSelection,
|
||||
clearSelection,
|
||||
getSelectedAssets,
|
||||
@@ -270,6 +285,12 @@ const {
|
||||
deactivate: deactivateSelection
|
||||
} = useAssetSelection()
|
||||
|
||||
const panelRef = useTemplateRef('panelRef')
|
||||
const marqueePanelRef = computed(() => {
|
||||
const el = unrefElement(panelRef)
|
||||
return el instanceof HTMLElement ? el : undefined
|
||||
})
|
||||
|
||||
const {
|
||||
downloadAssets,
|
||||
deleteAssets,
|
||||
@@ -337,6 +358,16 @@ const visibleAssets = computed(() => {
|
||||
return listViewSelectableAssets.value
|
||||
})
|
||||
|
||||
const { marqueeStyle } = useAssetGridSelection({
|
||||
marqueeContainerRef: marqueePanelRef,
|
||||
hoverTargetRef: marqueePanelRef,
|
||||
getAssets: () => visibleAssets.value,
|
||||
getSelectedIds: () => [...selectedIds.value],
|
||||
setSelectedIds,
|
||||
selectAll,
|
||||
isEnabled: () => !isListView.value
|
||||
})
|
||||
|
||||
const previewableVisibleAssets = computed(() =>
|
||||
visibleAssets.value.filter((asset) =>
|
||||
isPreviewableMediaType(getMediaTypeFromFilename(asset.name))
|
||||
@@ -575,7 +606,7 @@ const handleDeselectAll = () => {
|
||||
}
|
||||
|
||||
const handleEmptySpaceClick = () => {
|
||||
if (hasSelection) {
|
||||
if (hasSelection.value) {
|
||||
clearSelection()
|
||||
}
|
||||
}
|
||||
|
||||
118
src/platform/assets/components/MediaAssetCard.test.ts
Normal file
118
src/platform/assets/components/MediaAssetCard.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
vi.mock('@/stores/assetsStore', () => ({
|
||||
useAssetsStore: () => ({ isAssetDeleting: () => false })
|
||||
}))
|
||||
|
||||
vi.mock('../composables/useMediaAssetActions', () => ({
|
||||
useMediaAssetActions: () => ({ downloadAssets: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/schemas/assetMetadataSchema', () => ({
|
||||
getOutputAssetMetadata: () => ({
|
||||
allOutputs: [
|
||||
{
|
||||
filename: 'a.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
display_name: 'Display A'
|
||||
}
|
||||
]
|
||||
})
|
||||
}))
|
||||
|
||||
const asset: AssetItem = {
|
||||
id: 'a',
|
||||
name: 'a.png',
|
||||
tags: [],
|
||||
preview_url: '/preview.png'
|
||||
}
|
||||
|
||||
function renderCard() {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} },
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
return render(MediaAssetCard, {
|
||||
props: { asset, loading: true },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
IconGroup: true,
|
||||
LoadingOverlay: true,
|
||||
Button: true,
|
||||
MediaTitle: true
|
||||
},
|
||||
directives: { tooltip: {} }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function dispatchDragStart(
|
||||
init: { ctrlKey?: boolean; metaKey?: boolean } = {}
|
||||
) {
|
||||
const dataTransfer = new DataTransfer()
|
||||
const add = vi.spyOn(dataTransfer.items, 'add').mockImplementation(() => null)
|
||||
const event = new DragEvent('dragstart', { bubbles: true, cancelable: true })
|
||||
// happy-dom's DragEvent ignores dataTransfer/modifier init, so set them here.
|
||||
Object.defineProperties(event, {
|
||||
dataTransfer: { value: dataTransfer, configurable: true },
|
||||
ctrlKey: { value: init.ctrlKey ?? false, configurable: true },
|
||||
metaKey: { value: init.metaKey ?? false, configurable: true }
|
||||
})
|
||||
screen.getByRole('button').dispatchEvent(event)
|
||||
return { event, add }
|
||||
}
|
||||
|
||||
describe('MediaAssetCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('dragStart', () => {
|
||||
it('cancels the native drag when Ctrl is held so a marquee can start over the card', () => {
|
||||
renderCard()
|
||||
|
||||
const { event, add } = dispatchDragStart({ ctrlKey: true })
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
expect(add).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancels the native drag when Meta is held', () => {
|
||||
renderCard()
|
||||
|
||||
const { event } = dispatchDragStart({ metaKey: true })
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('includes the asset metadata with display_name in the drag payload', () => {
|
||||
renderCard()
|
||||
|
||||
const { event, add } = dispatchDragStart()
|
||||
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
expect(add).toHaveBeenCalledWith(
|
||||
JSON.stringify({
|
||||
filename: 'a.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
display_name: 'Display A'
|
||||
}),
|
||||
expect.any(String)
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -21,6 +21,7 @@
|
||||
)
|
||||
"
|
||||
:data-selected="selected"
|
||||
:data-asset-id="asset?.id"
|
||||
:draggable="true"
|
||||
@click.stop="$emit('click')"
|
||||
@contextmenu.prevent.stop="
|
||||
@@ -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
|
||||
|
||||
800
src/platform/assets/composables/useAssetGridSelection.test.ts
Normal file
800
src/platform/assets/composables/useAssetGridSelection.test.ts
Normal file
@@ -0,0 +1,800 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick, ref } from 'vue'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
import { useAssetGridSelection } from './useAssetGridSelection'
|
||||
|
||||
const assets: AssetItem[] = [
|
||||
{ id: 'a', name: 'a.png', tags: [] },
|
||||
{ id: 'b', name: 'b.png', tags: [] },
|
||||
{ id: 'c', name: 'c.png', tags: [] }
|
||||
]
|
||||
|
||||
const cardBoxes: Record<string, { left: number; right: number }> = {
|
||||
a: { left: 0, right: 50 },
|
||||
b: { left: 60, right: 110 },
|
||||
c: { left: 120, right: 170 }
|
||||
}
|
||||
|
||||
function pointer(type: string, init: PointerEventInit = {}) {
|
||||
return new PointerEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0,
|
||||
pointerId: 1,
|
||||
isPrimary: true,
|
||||
...init
|
||||
})
|
||||
}
|
||||
|
||||
function createCallbacks(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
getAssets: () => assets,
|
||||
getSelectedIds: vi.fn(() => [] as string[]),
|
||||
setSelectedIds: vi.fn(),
|
||||
selectAll: vi.fn(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
async function renderHarness(callbacks: ReturnType<typeof createCallbacks>) {
|
||||
const Harness = defineComponent({
|
||||
setup() {
|
||||
const gridContainerRef = ref<HTMLElement>()
|
||||
const hoverTargetRef = ref<HTMLElement>()
|
||||
const { marqueeStyle } = useAssetGridSelection({
|
||||
marqueeContainerRef: gridContainerRef,
|
||||
hoverTargetRef,
|
||||
getAssets: callbacks.getAssets,
|
||||
getSelectedIds: callbacks.getSelectedIds,
|
||||
setSelectedIds: callbacks.setSelectedIds,
|
||||
selectAll: callbacks.selectAll
|
||||
})
|
||||
return { gridContainerRef, hoverTargetRef, marqueeStyle }
|
||||
},
|
||||
template: `
|
||||
<div ref="hoverTargetRef" data-testid="panel">
|
||||
<input data-testid="search" />
|
||||
<textarea data-testid="textarea"></textarea>
|
||||
<div contenteditable="true" data-testid="editable"></div>
|
||||
<div ref="gridContainerRef" data-testid="grid">
|
||||
<button data-testid="grid-button">x</button>
|
||||
<div data-asset-id="a" data-testid="card-a"></div>
|
||||
<div data-asset-id="b" data-testid="card-b"></div>
|
||||
<div data-asset-id="c" data-testid="card-c"></div>
|
||||
</div>
|
||||
<div
|
||||
v-if="marqueeStyle"
|
||||
data-testid="marquee"
|
||||
:style="marqueeStyle"
|
||||
></div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
|
||||
render(Harness)
|
||||
await nextTick()
|
||||
vi.spyOn(screen.getByTestId('grid'), 'getBoundingClientRect').mockReturnValue(
|
||||
fromPartial<DOMRect>({ left: 0, top: 0, right: 1000, bottom: 1000 })
|
||||
)
|
||||
for (const id of Object.keys(cardBoxes)) {
|
||||
vi.spyOn(
|
||||
screen.getByTestId(`card-${id}`),
|
||||
'getBoundingClientRect'
|
||||
).mockReturnValue(
|
||||
fromPartial<DOMRect>({
|
||||
left: cardBoxes[id].left,
|
||||
right: cardBoxes[id].right,
|
||||
top: 0,
|
||||
bottom: 50
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const grid = () => screen.getByTestId('grid')
|
||||
const panel = () => screen.getByTestId('panel')
|
||||
const card = (id: string) => screen.getByTestId(`card-${id}`)
|
||||
|
||||
describe('useAssetGridSelection', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('marquee', () => {
|
||||
it('selects intersecting cards when dragging from empty space', async () => {
|
||||
const callbacks = createCallbacks()
|
||||
await renderHarness(callbacks)
|
||||
|
||||
grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
|
||||
window.dispatchEvent(
|
||||
pointer('pointermove', { clientX: 110, clientY: 50 })
|
||||
)
|
||||
|
||||
expect(callbacks.setSelectedIds).toHaveBeenLastCalledWith(
|
||||
['a', 'b'],
|
||||
assets
|
||||
)
|
||||
})
|
||||
|
||||
it('unions with the current selection when a modifier is held', async () => {
|
||||
const callbacks = createCallbacks({ getSelectedIds: vi.fn(() => ['c']) })
|
||||
await renderHarness(callbacks)
|
||||
|
||||
grid().dispatchEvent(
|
||||
pointer('pointerdown', { clientX: 0, clientY: 0, shiftKey: true })
|
||||
)
|
||||
window.dispatchEvent(pointer('pointermove', { clientX: 55, clientY: 50 }))
|
||||
|
||||
expect(
|
||||
[...callbacks.setSelectedIds.mock.lastCall![0]].sort(
|
||||
(a: string, b: string) => a.localeCompare(b)
|
||||
)
|
||||
).toEqual(['a', 'c'])
|
||||
})
|
||||
|
||||
it('removes covered cards from the selection when Ctrl+Shift is held (subtractive)', async () => {
|
||||
const callbacks = createCallbacks({
|
||||
getSelectedIds: vi.fn(() => ['a', 'b'])
|
||||
})
|
||||
await renderHarness(callbacks)
|
||||
|
||||
grid().dispatchEvent(
|
||||
pointer('pointerdown', {
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
ctrlKey: true,
|
||||
shiftKey: true
|
||||
})
|
||||
)
|
||||
window.dispatchEvent(pointer('pointermove', { clientX: 55, clientY: 50 }))
|
||||
|
||||
expect(callbacks.setSelectedIds.mock.lastCall![0]).toEqual(['b'])
|
||||
})
|
||||
|
||||
it('removes covered cards when Cmd+Shift is held (macOS subtractive)', async () => {
|
||||
const callbacks = createCallbacks({
|
||||
getSelectedIds: vi.fn(() => ['a', 'b'])
|
||||
})
|
||||
await renderHarness(callbacks)
|
||||
|
||||
grid().dispatchEvent(
|
||||
pointer('pointerdown', {
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
metaKey: true,
|
||||
shiftKey: true
|
||||
})
|
||||
)
|
||||
window.dispatchEvent(pointer('pointermove', { clientX: 55, clientY: 50 }))
|
||||
|
||||
expect(callbacks.setSelectedIds.mock.lastCall![0]).toEqual(['b'])
|
||||
})
|
||||
|
||||
it('restores cards when the subtractive marquee shrinks back off them', async () => {
|
||||
const callbacks = createCallbacks({
|
||||
getSelectedIds: vi.fn(() => ['a', 'b'])
|
||||
})
|
||||
await renderHarness(callbacks)
|
||||
|
||||
grid().dispatchEvent(
|
||||
pointer('pointerdown', {
|
||||
clientX: 55,
|
||||
clientY: 55,
|
||||
ctrlKey: true,
|
||||
shiftKey: true
|
||||
})
|
||||
)
|
||||
window.dispatchEvent(pointer('pointermove', { clientX: 5, clientY: 45 }))
|
||||
expect(callbacks.setSelectedIds.mock.lastCall![0]).toEqual(['b'])
|
||||
|
||||
window.dispatchEvent(pointer('pointermove', { clientX: 54, clientY: 54 }))
|
||||
expect(callbacks.setSelectedIds.mock.lastCall![0]).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('ignores movement below the drag threshold', async () => {
|
||||
const callbacks = createCallbacks()
|
||||
await renderHarness(callbacks)
|
||||
|
||||
grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
|
||||
window.dispatchEvent(pointer('pointermove', { clientX: 2, clientY: 2 }))
|
||||
|
||||
expect(callbacks.setSelectedIds).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not start a marquee on a plain pointer-down on a card', async () => {
|
||||
const callbacks = createCallbacks()
|
||||
await renderHarness(callbacks)
|
||||
|
||||
card('a').dispatchEvent(
|
||||
pointer('pointerdown', { clientX: 5, clientY: 5 })
|
||||
)
|
||||
window.dispatchEvent(pointer('pointermove', { clientX: 80, clientY: 40 }))
|
||||
|
||||
expect(callbacks.setSelectedIds).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('starts a marquee on a card when Ctrl is held and blocks native drag', async () => {
|
||||
const callbacks = createCallbacks()
|
||||
await renderHarness(callbacks)
|
||||
|
||||
card('a').dispatchEvent(
|
||||
pointer('pointerdown', { clientX: 5, clientY: 5, ctrlKey: true })
|
||||
)
|
||||
const dragEvent = new DragEvent('dragstart', {
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
card('a').dispatchEvent(dragEvent)
|
||||
window.dispatchEvent(pointer('pointermove', { clientX: 65, clientY: 40 }))
|
||||
|
||||
expect(dragEvent.defaultPrevented).toBe(true)
|
||||
expect(callbacks.setSelectedIds).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not block native drag when no marquee is tracking', async () => {
|
||||
const callbacks = createCallbacks()
|
||||
await renderHarness(callbacks)
|
||||
|
||||
const dragEvent = new DragEvent('dragstart', {
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
card('a').dispatchEvent(dragEvent)
|
||||
|
||||
expect(dragEvent.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('shows a marquee overlay while dragging and removes it on release', async () => {
|
||||
const callbacks = createCallbacks()
|
||||
await renderHarness(callbacks)
|
||||
|
||||
grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
|
||||
window.dispatchEvent(pointer('pointermove', { clientX: 30, clientY: 40 }))
|
||||
await nextTick()
|
||||
|
||||
const overlay = screen.getByTestId('marquee')
|
||||
expect(overlay.style.width).toBe('30px')
|
||||
expect(overlay.style.height).toBe('40px')
|
||||
|
||||
window.dispatchEvent(pointer('pointerup', { clientX: 30, clientY: 40 }))
|
||||
await nextTick()
|
||||
expect(screen.queryByTestId('marquee')).toBeNull()
|
||||
})
|
||||
|
||||
it('clips the overlay to the grid container when dragging past its edge', async () => {
|
||||
const callbacks = createCallbacks()
|
||||
await renderHarness(callbacks)
|
||||
|
||||
grid().dispatchEvent(pointer('pointerdown', { clientX: 10, clientY: 10 }))
|
||||
window.dispatchEvent(
|
||||
pointer('pointermove', { clientX: 2000, clientY: 2000 })
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
const overlay = screen.getByTestId('marquee')
|
||||
expect(overlay.style.left).toBe('10px')
|
||||
expect(overlay.style.top).toBe('10px')
|
||||
expect(overlay.style.width).toBe('990px')
|
||||
expect(overlay.style.height).toBe('990px')
|
||||
})
|
||||
|
||||
it('clears the overlay on dragend when a native drag swallows pointerup', async () => {
|
||||
const callbacks = createCallbacks()
|
||||
await renderHarness(callbacks)
|
||||
|
||||
grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
|
||||
window.dispatchEvent(pointer('pointermove', { clientX: 30, clientY: 40 }))
|
||||
await nextTick()
|
||||
expect(screen.getByTestId('marquee')).toBeTruthy()
|
||||
|
||||
window.dispatchEvent(new DragEvent('dragend', { bubbles: true }))
|
||||
await nextTick()
|
||||
expect(screen.queryByTestId('marquee')).toBeNull()
|
||||
})
|
||||
|
||||
it('suppresses the click that trails a drag, but not a plain click', async () => {
|
||||
const callbacks = createCallbacks()
|
||||
await renderHarness(callbacks)
|
||||
|
||||
grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
|
||||
window.dispatchEvent(pointer('pointermove', { clientX: 80, clientY: 40 }))
|
||||
window.dispatchEvent(pointer('pointerup', { clientX: 80, clientY: 40 }))
|
||||
|
||||
const trailing = new MouseEvent('click', {
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
window.dispatchEvent(trailing)
|
||||
expect(trailing.defaultPrevented).toBe(true)
|
||||
|
||||
const next = new MouseEvent('click', { bubbles: true, cancelable: true })
|
||||
window.dispatchEvent(next)
|
||||
expect(next.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('does not start a marquee for a non-primary mouse button', async () => {
|
||||
const callbacks = createCallbacks()
|
||||
await renderHarness(callbacks)
|
||||
|
||||
grid().dispatchEvent(
|
||||
pointer('pointerdown', { clientX: 0, clientY: 0, button: 2 })
|
||||
)
|
||||
window.dispatchEvent(
|
||||
pointer('pointermove', { clientX: 110, clientY: 50 })
|
||||
)
|
||||
|
||||
expect(callbacks.setSelectedIds).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not start a marquee when the press lands on an interactive control', async () => {
|
||||
const callbacks = createCallbacks()
|
||||
await renderHarness(callbacks)
|
||||
|
||||
screen
|
||||
.getByTestId('grid-button')
|
||||
.dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
|
||||
window.dispatchEvent(
|
||||
pointer('pointermove', { clientX: 110, clientY: 50 })
|
||||
)
|
||||
|
||||
expect(callbacks.setSelectedIds).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('starts a marquee on a card when Cmd/Meta is held', async () => {
|
||||
const callbacks = createCallbacks()
|
||||
await renderHarness(callbacks)
|
||||
|
||||
card('a').dispatchEvent(
|
||||
pointer('pointerdown', { clientX: 5, clientY: 5, metaKey: true })
|
||||
)
|
||||
window.dispatchEvent(pointer('pointermove', { clientX: 65, clientY: 40 }))
|
||||
|
||||
expect(callbacks.setSelectedIds).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears the selection when a no-modifier marquee covers no card', async () => {
|
||||
const callbacks = createCallbacks({ getSelectedIds: vi.fn(() => ['a']) })
|
||||
await renderHarness(callbacks)
|
||||
|
||||
grid().dispatchEvent(
|
||||
pointer('pointerdown', { clientX: 300, clientY: 300 })
|
||||
)
|
||||
window.dispatchEvent(
|
||||
pointer('pointermove', { clientX: 400, clientY: 400 })
|
||||
)
|
||||
|
||||
expect(callbacks.setSelectedIds).toHaveBeenLastCalledWith([], assets)
|
||||
})
|
||||
|
||||
it('prevents text selection during a marquee and releases it on pointercancel', async () => {
|
||||
const callbacks = createCallbacks()
|
||||
await renderHarness(callbacks)
|
||||
|
||||
grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
|
||||
window.dispatchEvent(pointer('pointermove', { clientX: 30, clientY: 40 }))
|
||||
await nextTick()
|
||||
expect(screen.getByTestId('marquee')).toBeTruthy()
|
||||
|
||||
const duringDrag = new Event('selectstart', {
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
grid().dispatchEvent(duringDrag)
|
||||
expect(duringDrag.defaultPrevented).toBe(true)
|
||||
|
||||
window.dispatchEvent(
|
||||
pointer('pointercancel', { clientX: 30, clientY: 40 })
|
||||
)
|
||||
await nextTick()
|
||||
expect(screen.queryByTestId('marquee')).toBeNull()
|
||||
|
||||
const afterEnd = new Event('selectstart', {
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
grid().dispatchEvent(afterEnd)
|
||||
expect(afterEnd.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('auto-resets click suppression when a drag ends without a trailing click', async () => {
|
||||
const callbacks = createCallbacks()
|
||||
await renderHarness(callbacks)
|
||||
|
||||
grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
|
||||
window.dispatchEvent(pointer('pointermove', { clientX: 80, clientY: 40 }))
|
||||
window.dispatchEvent(
|
||||
pointer('pointercancel', { clientX: 80, clientY: 40 })
|
||||
)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve))
|
||||
|
||||
const laterClick = new MouseEvent('click', {
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
window.dispatchEvent(laterClick)
|
||||
expect(laterClick.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('only blocks text selection inside the grid container during a marquee', async () => {
|
||||
const callbacks = createCallbacks()
|
||||
await renderHarness(callbacks)
|
||||
|
||||
grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
|
||||
window.dispatchEvent(pointer('pointermove', { clientX: 30, clientY: 40 }))
|
||||
await nextTick()
|
||||
|
||||
const insideGrid = new Event('selectstart', {
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
card('a').dispatchEvent(insideGrid)
|
||||
expect(insideGrid.defaultPrevented).toBe(true)
|
||||
|
||||
const outsideGrid = new Event('selectstart', {
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
screen.getByTestId('search').dispatchEvent(outsideGrid)
|
||||
expect(outsideGrid.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('stops blocking text selection after a normal marquee release', async () => {
|
||||
const callbacks = createCallbacks()
|
||||
await renderHarness(callbacks)
|
||||
|
||||
grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
|
||||
window.dispatchEvent(pointer('pointermove', { clientX: 30, clientY: 40 }))
|
||||
|
||||
const duringDrag = new Event('selectstart', {
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
card('a').dispatchEvent(duringDrag)
|
||||
expect(duringDrag.defaultPrevented).toBe(true)
|
||||
|
||||
window.dispatchEvent(pointer('pointerup', { clientX: 30, clientY: 40 }))
|
||||
|
||||
const afterRelease = new Event('selectstart', {
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
card('a').dispatchEvent(afterRelease)
|
||||
expect(afterRelease.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('does not suppress the click after a sub-threshold press', async () => {
|
||||
const callbacks = createCallbacks()
|
||||
await renderHarness(callbacks)
|
||||
|
||||
grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
|
||||
window.dispatchEvent(pointer('pointermove', { clientX: 2, clientY: 2 }))
|
||||
window.dispatchEvent(pointer('pointerup', { clientX: 2, clientY: 2 }))
|
||||
|
||||
const click = new MouseEvent('click', { bubbles: true, cancelable: true })
|
||||
window.dispatchEvent(click)
|
||||
expect(click.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('does not marquee or clear selection when the container has no cards', async () => {
|
||||
const setSelectedIds = vi.fn()
|
||||
const Harness = defineComponent({
|
||||
setup() {
|
||||
const containerRef = ref<HTMLElement>()
|
||||
useAssetGridSelection({
|
||||
marqueeContainerRef: containerRef,
|
||||
hoverTargetRef: containerRef,
|
||||
getAssets: () => assets,
|
||||
getSelectedIds: () => ['a'],
|
||||
setSelectedIds,
|
||||
selectAll: vi.fn()
|
||||
})
|
||||
return { containerRef }
|
||||
},
|
||||
template: `
|
||||
<div ref="containerRef" data-testid="list">
|
||||
<div data-testid="row">a.png</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
render(Harness)
|
||||
await nextTick()
|
||||
|
||||
screen
|
||||
.getByTestId('list')
|
||||
.dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
|
||||
window.dispatchEvent(
|
||||
pointer('pointermove', { clientX: 110, clientY: 50 })
|
||||
)
|
||||
|
||||
expect(setSelectedIds).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not start a marquee when disabled (e.g. list view)', async () => {
|
||||
const setSelectedIds = vi.fn()
|
||||
const Harness = defineComponent({
|
||||
setup() {
|
||||
const containerRef = ref<HTMLElement>()
|
||||
useAssetGridSelection({
|
||||
marqueeContainerRef: containerRef,
|
||||
hoverTargetRef: containerRef,
|
||||
getAssets: () => assets,
|
||||
getSelectedIds: () => [],
|
||||
setSelectedIds,
|
||||
selectAll: vi.fn(),
|
||||
isEnabled: () => false
|
||||
})
|
||||
return { containerRef }
|
||||
},
|
||||
template: `
|
||||
<div ref="containerRef" data-testid="disabled-grid">
|
||||
<div data-asset-id="a"></div>
|
||||
<div data-asset-id="b"></div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
render(Harness)
|
||||
await nextTick()
|
||||
|
||||
screen
|
||||
.getByTestId('disabled-grid')
|
||||
.dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
|
||||
window.dispatchEvent(
|
||||
pointer('pointermove', { clientX: 110, clientY: 50 })
|
||||
)
|
||||
|
||||
expect(setSelectedIds).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores a reentrant pointer-down and does not leave text selection blocked', async () => {
|
||||
const callbacks = createCallbacks()
|
||||
await renderHarness(callbacks)
|
||||
|
||||
grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
|
||||
grid().dispatchEvent(pointer('pointerdown', { clientX: 5, clientY: 5 }))
|
||||
window.dispatchEvent(pointer('pointerup', { clientX: 5, clientY: 5 }))
|
||||
|
||||
const selection = new Event('selectstart', {
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
grid().dispatchEvent(selection)
|
||||
expect(selection.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('captures the pointer once a marquee drag starts, not on press', async () => {
|
||||
const callbacks = createCallbacks()
|
||||
await renderHarness(callbacks)
|
||||
const capture = vi.spyOn(grid(), 'setPointerCapture')
|
||||
|
||||
card('a').dispatchEvent(
|
||||
pointer('pointerdown', { clientX: 5, clientY: 5 })
|
||||
)
|
||||
expect(capture).not.toHaveBeenCalled()
|
||||
|
||||
grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
|
||||
expect(capture).not.toHaveBeenCalled()
|
||||
|
||||
window.dispatchEvent(
|
||||
pointer('pointermove', { clientX: 110, clientY: 50 })
|
||||
)
|
||||
expect(capture).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('does not capture the pointer on a Ctrl/Cmd-click of a card', async () => {
|
||||
const callbacks = createCallbacks()
|
||||
await renderHarness(callbacks)
|
||||
const capture = vi.spyOn(grid(), 'setPointerCapture')
|
||||
|
||||
card('a').dispatchEvent(
|
||||
pointer('pointerdown', { clientX: 5, clientY: 5, ctrlKey: true })
|
||||
)
|
||||
window.dispatchEvent(pointer('pointerup', { clientX: 5, clientY: 5 }))
|
||||
|
||||
expect(capture).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still tracks a marquee when setPointerCapture throws', async () => {
|
||||
const callbacks = createCallbacks()
|
||||
await renderHarness(callbacks)
|
||||
vi.spyOn(grid(), 'setPointerCapture').mockImplementation(() => {
|
||||
throw new Error('stale pointer id')
|
||||
})
|
||||
|
||||
grid().dispatchEvent(pointer('pointerdown', { clientX: 0, clientY: 0 }))
|
||||
window.dispatchEvent(
|
||||
pointer('pointermove', { clientX: 110, clientY: 50 })
|
||||
)
|
||||
expect(callbacks.setSelectedIds).toHaveBeenLastCalledWith(
|
||||
['a', 'b'],
|
||||
assets
|
||||
)
|
||||
|
||||
window.dispatchEvent(pointer('pointerup', { clientX: 110, clientY: 50 }))
|
||||
await nextTick()
|
||||
expect(screen.queryByTestId('marquee')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ctrl/cmd + A', () => {
|
||||
function pressSelectAll(init: KeyboardEventInit = {}) {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'a',
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...init
|
||||
})
|
||||
window.dispatchEvent(event)
|
||||
return event
|
||||
}
|
||||
|
||||
it('selects all visible assets and blocks the event when hovered', async () => {
|
||||
const callbacks = createCallbacks()
|
||||
await renderHarness(callbacks)
|
||||
|
||||
panel().dispatchEvent(new MouseEvent('mouseenter'))
|
||||
const event = pressSelectAll()
|
||||
|
||||
expect(callbacks.selectAll).toHaveBeenCalledWith(assets)
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('selects all with the Cmd/Meta key while hovered', async () => {
|
||||
const callbacks = createCallbacks()
|
||||
await renderHarness(callbacks)
|
||||
|
||||
panel().dispatchEvent(new MouseEvent('mouseenter'))
|
||||
pressSelectAll({ ctrlKey: false, metaKey: true })
|
||||
|
||||
expect(callbacks.selectAll).toHaveBeenCalledWith(assets)
|
||||
})
|
||||
|
||||
it('does nothing when the panel is not hovered', async () => {
|
||||
const callbacks = createCallbacks()
|
||||
await renderHarness(callbacks)
|
||||
|
||||
pressSelectAll()
|
||||
|
||||
expect(callbacks.selectAll).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still selects all when hover desyncs but the pointer stays inside the panel', async () => {
|
||||
const callbacks = createCallbacks()
|
||||
await renderHarness(callbacks)
|
||||
vi.spyOn(panel(), 'getBoundingClientRect').mockReturnValue(
|
||||
fromPartial<DOMRect>({
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 500,
|
||||
bottom: 500,
|
||||
width: 500,
|
||||
height: 500
|
||||
})
|
||||
)
|
||||
|
||||
panel().dispatchEvent(new MouseEvent('mouseenter'))
|
||||
window.dispatchEvent(
|
||||
pointer('pointermove', { clientX: 100, clientY: 100 })
|
||||
)
|
||||
// The selection bar under the cursor unmounts on "deselect all", which
|
||||
// latches useElementHover false while the pointer is still inside.
|
||||
panel().dispatchEvent(new MouseEvent('mouseleave'))
|
||||
|
||||
const event = pressSelectAll()
|
||||
|
||||
expect(callbacks.selectAll).toHaveBeenCalledWith(assets)
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('does not select all when the live pointer is outside the panel rect', async () => {
|
||||
const callbacks = createCallbacks()
|
||||
await renderHarness(callbacks)
|
||||
vi.spyOn(panel(), 'getBoundingClientRect').mockReturnValue(
|
||||
fromPartial<DOMRect>({
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 500,
|
||||
bottom: 500,
|
||||
width: 500,
|
||||
height: 500
|
||||
})
|
||||
)
|
||||
|
||||
window.dispatchEvent(
|
||||
pointer('pointermove', { clientX: 900, clientY: 900 })
|
||||
)
|
||||
panel().dispatchEvent(new MouseEvent('mouseleave'))
|
||||
|
||||
const event = pressSelectAll()
|
||||
|
||||
expect(callbacks.selectAll).not.toHaveBeenCalled()
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores other keys and the unmodified A while hovered', async () => {
|
||||
const callbacks = createCallbacks()
|
||||
await renderHarness(callbacks)
|
||||
|
||||
panel().dispatchEvent(new MouseEvent('mouseenter'))
|
||||
pressSelectAll({ ctrlKey: false })
|
||||
pressSelectAll({ key: 'b' })
|
||||
|
||||
expect(callbacks.selectAll).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not hijack select-all while typing in a field', async () => {
|
||||
const callbacks = createCallbacks()
|
||||
await renderHarness(callbacks)
|
||||
|
||||
panel().dispatchEvent(new MouseEvent('mouseenter'))
|
||||
screen.getByTestId<HTMLInputElement>('search').focus()
|
||||
pressSelectAll()
|
||||
|
||||
expect(callbacks.selectAll).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not hijack select-all while focused in a textarea', async () => {
|
||||
const callbacks = createCallbacks()
|
||||
await renderHarness(callbacks)
|
||||
|
||||
panel().dispatchEvent(new MouseEvent('mouseenter'))
|
||||
screen.getByTestId<HTMLTextAreaElement>('textarea').focus()
|
||||
pressSelectAll()
|
||||
|
||||
expect(callbacks.selectAll).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not hijack select-all while focused in a contenteditable element', async () => {
|
||||
const callbacks = createCallbacks()
|
||||
await renderHarness(callbacks)
|
||||
|
||||
panel().dispatchEvent(new MouseEvent('mouseenter'))
|
||||
screen.getByTestId('editable').focus()
|
||||
pressSelectAll()
|
||||
|
||||
expect(callbacks.selectAll).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not hijack select-all while an aria-modal dialog is open', async () => {
|
||||
const callbacks = createCallbacks()
|
||||
await renderHarness(callbacks)
|
||||
|
||||
panel().dispatchEvent(new MouseEvent('mouseenter'))
|
||||
const dialog = document.createElement('div')
|
||||
dialog.setAttribute('role', 'dialog')
|
||||
dialog.setAttribute('aria-modal', 'true')
|
||||
document.body.appendChild(dialog)
|
||||
|
||||
const event = pressSelectAll()
|
||||
|
||||
expect(callbacks.selectAll).not.toHaveBeenCalled()
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
|
||||
dialog.remove()
|
||||
})
|
||||
|
||||
it('stops the select-all keystroke from reaching other handlers when hovered', async () => {
|
||||
const callbacks = createCallbacks()
|
||||
await renderHarness(callbacks)
|
||||
const downstream = vi.fn()
|
||||
window.addEventListener('keydown', downstream)
|
||||
|
||||
panel().dispatchEvent(new MouseEvent('mouseenter'))
|
||||
pressSelectAll()
|
||||
expect(callbacks.selectAll).toHaveBeenCalledTimes(1)
|
||||
expect(downstream).not.toHaveBeenCalled()
|
||||
|
||||
panel().dispatchEvent(new MouseEvent('mouseleave'))
|
||||
pressSelectAll()
|
||||
expect(callbacks.selectAll).toHaveBeenCalledTimes(1)
|
||||
expect(downstream).toHaveBeenCalledTimes(1)
|
||||
|
||||
window.removeEventListener('keydown', downstream)
|
||||
})
|
||||
})
|
||||
})
|
||||
250
src/platform/assets/composables/useAssetGridSelection.ts
Normal file
250
src/platform/assets/composables/useAssetGridSelection.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { useElementHover, useEventListener } from '@vueuse/core'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, onScopeDispose, ref } from 'vue'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import {
|
||||
normalizeMarqueeRect,
|
||||
selectMarqueeIds
|
||||
} from '@/platform/assets/utils/marqueeSelectionUtil'
|
||||
import { clipRectToBounds } from '@/utils/mathUtil'
|
||||
import type { RectEdges } from '@/utils/mathUtil'
|
||||
|
||||
const DRAG_THRESHOLD_PX = 4
|
||||
const CARD_SELECTOR = '[data-asset-id]'
|
||||
const INTERACTIVE_SELECTOR =
|
||||
'button, input, textarea, select, a[href], [role="slider"], [role="tab"], [contenteditable]'
|
||||
|
||||
interface AssetGridSelectionOptions {
|
||||
marqueeContainerRef: Ref<HTMLElement | undefined>
|
||||
hoverTargetRef: Ref<HTMLElement | undefined>
|
||||
getAssets: () => AssetItem[]
|
||||
getSelectedIds: () => string[]
|
||||
setSelectedIds: (ids: string[], allAssets: AssetItem[]) => void
|
||||
selectAll: (assets: AssetItem[]) => void
|
||||
isEnabled?: () => boolean
|
||||
}
|
||||
|
||||
function isTextEntryTarget(element: Element | null): boolean {
|
||||
return (
|
||||
element instanceof HTMLInputElement ||
|
||||
element instanceof HTMLTextAreaElement ||
|
||||
(element instanceof HTMLElement && element.isContentEditable)
|
||||
)
|
||||
}
|
||||
|
||||
export function useAssetGridSelection(options: AssetGridSelectionOptions) {
|
||||
const {
|
||||
marqueeContainerRef,
|
||||
hoverTargetRef,
|
||||
getAssets,
|
||||
getSelectedIds,
|
||||
setSelectedIds,
|
||||
selectAll,
|
||||
isEnabled = () => true
|
||||
} = options
|
||||
|
||||
const marqueeRect = ref<RectEdges | null>(null)
|
||||
const isHoveringPanel = useElementHover(hoverTargetRef)
|
||||
|
||||
let startX = 0
|
||||
let startY = 0
|
||||
let pointerId = 0
|
||||
let baseIds: string[] = []
|
||||
let isSubtractive = false
|
||||
let isTracking = false
|
||||
let isDragging = false
|
||||
let suppressNextClick = false
|
||||
let suppressClickResetTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let dragCards: { id: string; rect: DOMRect }[] = []
|
||||
let dragBounds: DOMRect | null = null
|
||||
let pointerClientX = 0
|
||||
let pointerClientY = 0
|
||||
let hasPointerSample = false
|
||||
|
||||
function collectCards(container: HTMLElement) {
|
||||
return [...container.querySelectorAll<HTMLElement>(CARD_SELECTOR)].flatMap(
|
||||
(el) => {
|
||||
const id = el.dataset.assetId
|
||||
return id ? [{ id, rect: el.getBoundingClientRect() }] : []
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function snapshotDrag() {
|
||||
const container = marqueeContainerRef.value
|
||||
if (!container) return
|
||||
dragCards = collectCards(container)
|
||||
dragBounds = container.getBoundingClientRect()
|
||||
}
|
||||
|
||||
function applyMarquee(clientX: number, clientY: number) {
|
||||
if (!dragBounds) return
|
||||
const rect = clipRectToBounds(
|
||||
normalizeMarqueeRect(
|
||||
{ x: startX, y: startY },
|
||||
{ x: clientX, y: clientY }
|
||||
),
|
||||
dragBounds
|
||||
)
|
||||
marqueeRect.value = rect
|
||||
setSelectedIds(
|
||||
[...selectMarqueeIds(dragCards, rect, baseIds, isSubtractive)],
|
||||
getAssets()
|
||||
)
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
pointerClientX = e.clientX
|
||||
pointerClientY = e.clientY
|
||||
hasPointerSample = true
|
||||
if (!isTracking) return
|
||||
if (
|
||||
!isDragging &&
|
||||
Math.hypot(e.clientX - startX, e.clientY - startY) < DRAG_THRESHOLD_PX
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!isDragging) {
|
||||
isDragging = true
|
||||
snapshotDrag()
|
||||
capturePointer()
|
||||
}
|
||||
applyMarquee(e.clientX, e.clientY)
|
||||
}
|
||||
|
||||
function capturePointer() {
|
||||
try {
|
||||
marqueeContainerRef.value?.setPointerCapture(pointerId)
|
||||
} catch {
|
||||
// Stale/invalid pointerId: window listeners still end the drag.
|
||||
}
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
if (!isTracking) return
|
||||
isTracking = false
|
||||
marqueeRect.value = null
|
||||
dragCards = []
|
||||
dragBounds = null
|
||||
if (isDragging) scheduleSuppressNextClick()
|
||||
isDragging = false
|
||||
}
|
||||
|
||||
function scheduleSuppressNextClick() {
|
||||
suppressNextClick = true
|
||||
clearSuppressTimer()
|
||||
suppressClickResetTimeout = setTimeout(() => {
|
||||
suppressNextClick = false
|
||||
suppressClickResetTimeout = null
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function clearSuppressTimer() {
|
||||
if (suppressClickResetTimeout !== null) {
|
||||
clearTimeout(suppressClickResetTimeout)
|
||||
suppressClickResetTimeout = null
|
||||
}
|
||||
}
|
||||
|
||||
function preventDragStart(e: Event) {
|
||||
if (isTracking) e.preventDefault()
|
||||
}
|
||||
|
||||
function preventTextSelection(e: Event) {
|
||||
if (!isTracking) return
|
||||
const container = marqueeContainerRef.value
|
||||
if (container && e.target instanceof Node && container.contains(e.target)) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
if (isTracking) return
|
||||
if (!isEnabled()) return
|
||||
suppressNextClick = false
|
||||
clearSuppressTimer()
|
||||
const container = marqueeContainerRef.value
|
||||
if (!container) return
|
||||
if (!container.querySelector(CARD_SELECTOR)) return
|
||||
const target = e.target
|
||||
if (!(target instanceof HTMLElement)) return
|
||||
if (target.closest(INTERACTIVE_SELECTOR)) return
|
||||
|
||||
const onCard = target.closest(CARD_SELECTOR)
|
||||
if (onCard && !e.ctrlKey && !e.metaKey) return
|
||||
|
||||
startX = e.clientX
|
||||
startY = e.clientY
|
||||
pointerId = e.pointerId
|
||||
baseIds = e.shiftKey || e.ctrlKey || e.metaKey ? getSelectedIds() : []
|
||||
isSubtractive = (e.ctrlKey || e.metaKey) && e.shiftKey
|
||||
isDragging = false
|
||||
isTracking = true
|
||||
}
|
||||
|
||||
function onClickCapture(e: MouseEvent) {
|
||||
if (!suppressNextClick) return
|
||||
suppressNextClick = false
|
||||
clearSuppressTimer()
|
||||
e.stopImmediatePropagation()
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
// useElementHover latches stale-false when an overlay under the cursor (the
|
||||
// selection bar) unmounts on "deselect all"; recheck the live pointer against
|
||||
// the panel rect so Ctrl/Cmd+A still resolves against the panel it is over.
|
||||
function isPointerInsidePanel(): boolean {
|
||||
const el = hoverTargetRef.value
|
||||
if (!el || !hasPointerSample) return false
|
||||
const { left, top, right, bottom } = el.getBoundingClientRect()
|
||||
return (
|
||||
pointerClientX >= left &&
|
||||
pointerClientX <= right &&
|
||||
pointerClientY >= top &&
|
||||
pointerClientY <= bottom
|
||||
)
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (!(e.ctrlKey || e.metaKey) || (e.key !== 'a' && e.key !== 'A')) return
|
||||
if (
|
||||
!(isHoveringPanel.value || isPointerInsidePanel()) ||
|
||||
isTextEntryTarget(document.activeElement) ||
|
||||
document.querySelector('[role="dialog"][aria-modal="true"]')
|
||||
) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
selectAll(getAssets())
|
||||
}
|
||||
|
||||
useEventListener(marqueeContainerRef, 'pointerdown', onPointerDown)
|
||||
useEventListener(marqueeContainerRef, 'dragstart', preventDragStart, {
|
||||
capture: true
|
||||
})
|
||||
useEventListener(window, 'pointermove', onPointerMove)
|
||||
useEventListener(window, ['pointerup', 'pointercancel', 'dragend'], endDrag)
|
||||
useEventListener(window, 'click', onClickCapture, { capture: true })
|
||||
useEventListener(window, 'keydown', onKeydown, { capture: true })
|
||||
useEventListener(window, 'selectstart', preventTextSelection, {
|
||||
capture: true
|
||||
})
|
||||
|
||||
onScopeDispose(clearSuppressTimer)
|
||||
|
||||
const marqueeStyle = computed(() => {
|
||||
const rect = marqueeRect.value
|
||||
if (!rect) return null
|
||||
return {
|
||||
left: `${rect.left}px`,
|
||||
top: `${rect.top}px`,
|
||||
width: `${rect.right - rect.left}px`,
|
||||
height: `${rect.bottom - rect.top}px`
|
||||
}
|
||||
})
|
||||
|
||||
return { marqueeStyle }
|
||||
}
|
||||
@@ -248,6 +248,36 @@ describe('useAssetSelection', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('setSelectedIds', () => {
|
||||
it('replaces selection and anchors on the last selected asset', () => {
|
||||
const selection = useAssetSelection()
|
||||
const store = useAssetSelectionStore()
|
||||
const assets = createMockAssets(5)
|
||||
|
||||
selection.setSelectedIds(['asset-1', 'asset-3'], assets)
|
||||
|
||||
expect(Array.from(store.selectedAssetIds).sort()).toEqual([
|
||||
'asset-1',
|
||||
'asset-3'
|
||||
])
|
||||
expect(store.lastSelectedIndex).toBe(3)
|
||||
expect(store.lastSelectedAssetId).toBe('asset-3')
|
||||
})
|
||||
|
||||
it('clears the anchor when the selection is empty', () => {
|
||||
const selection = useAssetSelection()
|
||||
const store = useAssetSelectionStore()
|
||||
const assets = createMockAssets(3)
|
||||
store.setLastSelectedIndex(2)
|
||||
store.setLastSelectedAssetId('asset-2')
|
||||
|
||||
selection.setSelectedIds([], assets)
|
||||
|
||||
expect(store.lastSelectedIndex).toBe(-1)
|
||||
expect(store.lastSelectedAssetId).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearSelection', () => {
|
||||
it('clears all selections', () => {
|
||||
const { handleAssetClick, clearSelection, selectedCount } =
|
||||
|
||||
@@ -101,6 +101,19 @@ export function useAssetSelection() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the selection (e.g. from a marquee) and keep the shift-range anchor
|
||||
* on the last selected asset, the same way selectAll maintains it.
|
||||
*/
|
||||
function setSelectedIds(ids: string[], allAssets: AssetItem[]) {
|
||||
selectionStore.setSelection(ids)
|
||||
const selected = new Set(ids)
|
||||
const anchorIndex = allAssets.findLastIndex((asset) =>
|
||||
selected.has(asset.id)
|
||||
)
|
||||
setAnchor(anchorIndex, anchorIndex >= 0 ? allAssets[anchorIndex].id : null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actual asset objects for selected IDs
|
||||
*/
|
||||
@@ -182,6 +195,7 @@ export function useAssetSelection() {
|
||||
// Selection actions
|
||||
handleAssetClick,
|
||||
selectAll,
|
||||
setSelectedIds,
|
||||
clearSelection: () => selectionStore.clearSelection(),
|
||||
getSelectedAssets,
|
||||
reconcileSelection,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import * as fc from 'fast-check'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { MarqueeCard } from './marqueeSelectionUtil'
|
||||
import { normalizeMarqueeRect, selectMarqueeIds } from './marqueeSelectionUtil'
|
||||
|
||||
const ID_POOL = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'] as const
|
||||
|
||||
const arbPoint = fc.record({
|
||||
x: fc.integer({ min: -100, max: 100 }),
|
||||
y: fc.integer({ min: -100, max: 100 })
|
||||
})
|
||||
|
||||
const arbRect = fc
|
||||
.tuple(arbPoint, arbPoint)
|
||||
.map(([start, end]) => normalizeMarqueeRect(start, end))
|
||||
|
||||
const arbCards: fc.Arbitrary<MarqueeCard[]> = fc.uniqueArray(
|
||||
fc.record({ id: fc.constantFrom(...ID_POOL), rect: arbRect }),
|
||||
{ selector: (card) => card.id, maxLength: ID_POOL.length }
|
||||
)
|
||||
|
||||
const arbBaseIds = fc.uniqueArray(fc.constantFrom(...ID_POOL), {
|
||||
maxLength: ID_POOL.length
|
||||
})
|
||||
|
||||
describe('marqueeSelectionUtil properties', () => {
|
||||
it('additive result is the union of the base and the covered ids', () => {
|
||||
fc.assert(
|
||||
fc.property(arbCards, arbRect, arbBaseIds, (cards, marquee, base) => {
|
||||
const covered = selectMarqueeIds(cards, marquee)
|
||||
const result = selectMarqueeIds(cards, marquee, base)
|
||||
expect([...result].sort()).toEqual(
|
||||
[...new Set([...base, ...covered])].sort()
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('subtractive result is the base minus the covered ids', () => {
|
||||
fc.assert(
|
||||
fc.property(arbCards, arbRect, arbBaseIds, (cards, marquee, base) => {
|
||||
const covered = selectMarqueeIds(cards, marquee)
|
||||
const result = selectMarqueeIds(cards, marquee, base, true)
|
||||
expect([...result].sort()).toEqual(
|
||||
base.filter((id) => !covered.has(id)).sort()
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('subtractive mode preserves the base order of surviving ids', () => {
|
||||
fc.assert(
|
||||
fc.property(arbCards, arbRect, arbBaseIds, (cards, marquee, base) => {
|
||||
const covered = selectMarqueeIds(cards, marquee)
|
||||
const result = selectMarqueeIds(cards, marquee, base, true)
|
||||
expect([...result]).toEqual(base.filter((id) => !covered.has(id)))
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('never mutates baseIds in either mode', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
arbCards,
|
||||
arbRect,
|
||||
arbBaseIds,
|
||||
fc.boolean(),
|
||||
(cards, marquee, base, subtract) => {
|
||||
const original = [...base]
|
||||
selectMarqueeIds(cards, marquee, base, subtract)
|
||||
expect(base).toEqual(original)
|
||||
}
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizeMarqueeRect always yields ordered edges containing both points', () => {
|
||||
fc.assert(
|
||||
fc.property(arbPoint, arbPoint, (start, end) => {
|
||||
const rect = normalizeMarqueeRect(start, end)
|
||||
expect(rect.left).toBeLessThanOrEqual(rect.right)
|
||||
expect(rect.top).toBeLessThanOrEqual(rect.bottom)
|
||||
for (const point of [start, end]) {
|
||||
expect(point.x).toBeGreaterThanOrEqual(rect.left)
|
||||
expect(point.x).toBeLessThanOrEqual(rect.right)
|
||||
expect(point.y).toBeGreaterThanOrEqual(rect.top)
|
||||
expect(point.y).toBeLessThanOrEqual(rect.bottom)
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
93
src/platform/assets/utils/marqueeSelectionUtil.test.ts
Normal file
93
src/platform/assets/utils/marqueeSelectionUtil.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { RectEdges } from '@/utils/mathUtil'
|
||||
|
||||
import type { MarqueeCard } from './marqueeSelectionUtil'
|
||||
import { normalizeMarqueeRect, selectMarqueeIds } from './marqueeSelectionUtil'
|
||||
|
||||
const box = (
|
||||
left: number,
|
||||
top: number,
|
||||
right: number,
|
||||
bottom: number
|
||||
): RectEdges => ({
|
||||
left,
|
||||
top,
|
||||
right,
|
||||
bottom
|
||||
})
|
||||
|
||||
describe('normalizeMarqueeRect', () => {
|
||||
it('orders corners when dragging down-right', () => {
|
||||
expect(normalizeMarqueeRect({ x: 10, y: 20 }, { x: 50, y: 80 })).toEqual(
|
||||
box(10, 20, 50, 80)
|
||||
)
|
||||
})
|
||||
|
||||
it('orders corners when dragging up-left', () => {
|
||||
expect(normalizeMarqueeRect({ x: 50, y: 80 }, { x: 10, y: 20 })).toEqual(
|
||||
box(10, 20, 50, 80)
|
||||
)
|
||||
})
|
||||
|
||||
it('orders corners when dragging across axes', () => {
|
||||
expect(normalizeMarqueeRect({ x: 50, y: 20 }, { x: 10, y: 80 })).toEqual(
|
||||
box(10, 20, 50, 80)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectMarqueeIds', () => {
|
||||
const cards: MarqueeCard[] = [
|
||||
{ id: 'a', rect: box(0, 0, 10, 10) },
|
||||
{ id: 'b', rect: box(20, 0, 30, 10) },
|
||||
{ id: 'c', rect: box(40, 0, 50, 10) }
|
||||
]
|
||||
|
||||
it('selects only intersecting cards when base is empty (replace)', () => {
|
||||
const result = selectMarqueeIds(cards, box(15, 0, 35, 10))
|
||||
expect([...result]).toEqual(['b'])
|
||||
})
|
||||
|
||||
it('unions intersecting cards with the base selection (additive)', () => {
|
||||
const result = selectMarqueeIds(cards, box(35, 0, 55, 10), ['a'])
|
||||
expect([...result].sort()).toEqual(['a', 'c'])
|
||||
})
|
||||
|
||||
it('removes intersecting cards from the base selection (subtractive)', () => {
|
||||
const result = selectMarqueeIds(cards, box(15, 0, 35, 10), ['a', 'b'], true)
|
||||
expect([...result]).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('returns a copy of the base when nothing intersects', () => {
|
||||
const base = new Set(['a'])
|
||||
const result = selectMarqueeIds(cards, box(100, 100, 110, 110), base)
|
||||
expect([...result]).toEqual(['a'])
|
||||
expect(result).not.toBe(base)
|
||||
})
|
||||
|
||||
it('does not mutate the provided base set', () => {
|
||||
const base = new Set(['a'])
|
||||
selectMarqueeIds(cards, box(15, 0, 35, 10), base)
|
||||
expect([...base]).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('includes a card whose edge merely touches the marquee', () => {
|
||||
const touching = [{ id: 'edge', rect: box(0, 0, 10, 10) }]
|
||||
expect([...selectMarqueeIds(touching, box(10, 0, 20, 10))]).toEqual([
|
||||
'edge'
|
||||
])
|
||||
})
|
||||
|
||||
it('includes a card that contains the marquee and vice versa', () => {
|
||||
const around = [{ id: 'around', rect: box(40, 40, 60, 60) }]
|
||||
expect([...selectMarqueeIds(around, box(0, 0, 100, 100))]).toEqual([
|
||||
'around'
|
||||
])
|
||||
})
|
||||
|
||||
it('excludes a card separated on a single axis', () => {
|
||||
const below = [{ id: 'below', rect: box(0, 20, 10, 30) }]
|
||||
expect([...selectMarqueeIds(below, box(0, 0, 10, 10))]).toEqual([])
|
||||
})
|
||||
})
|
||||
48
src/platform/assets/utils/marqueeSelectionUtil.ts
Normal file
48
src/platform/assets/utils/marqueeSelectionUtil.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { RectEdges } from '@/utils/mathUtil'
|
||||
|
||||
export interface MarqueeCard {
|
||||
id: string
|
||||
rect: RectEdges
|
||||
}
|
||||
|
||||
export function normalizeMarqueeRect(
|
||||
start: { x: number; y: number },
|
||||
end: { x: number; y: number }
|
||||
): 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)
|
||||
}
|
||||
}
|
||||
|
||||
function rectsIntersect(a: RectEdges, b: RectEdges): boolean {
|
||||
return !(
|
||||
a.right < b.left ||
|
||||
a.left > b.right ||
|
||||
a.bottom < b.top ||
|
||||
a.top > b.bottom
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the asset ids a marquee covers, starting from `baseIds` (the selection
|
||||
* to preserve when a modifier makes the drag additive). With `subtract`, covered
|
||||
* ids are removed from `baseIds` instead of added. A fresh Set is returned;
|
||||
* `baseIds` is never mutated.
|
||||
*/
|
||||
export function selectMarqueeIds(
|
||||
cards: readonly MarqueeCard[],
|
||||
marquee: RectEdges,
|
||||
baseIds: Iterable<string> = [],
|
||||
subtract = false
|
||||
): Set<string> {
|
||||
const result = new Set(baseIds)
|
||||
for (const { id, rect } of cards) {
|
||||
if (!rectsIntersect(rect, marquee)) continue
|
||||
if (subtract) result.delete(id)
|
||||
else result.add(id)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -779,7 +779,7 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
tooltip:
|
||||
'When enabled, nodes are selected/deselected in real-time as you drag the selection rectangle, similar to other design tools.',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
defaultValue: true,
|
||||
versionAdded: '1.36.1'
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||
import {
|
||||
clipRectToBounds,
|
||||
computeUnionBounds,
|
||||
denormalize,
|
||||
gcd,
|
||||
@@ -137,4 +138,29 @@ describe('mathUtil', () => {
|
||||
expect(result!.height).toBe(242)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clipRectToBounds', () => {
|
||||
const bounds = { left: 0, top: 0, right: 100, bottom: 100 }
|
||||
|
||||
it('returns the rect unchanged when fully inside the bounds', () => {
|
||||
expect(
|
||||
clipRectToBounds({ left: 10, top: 10, right: 40, bottom: 40 }, bounds)
|
||||
).toEqual({ left: 10, top: 10, right: 40, bottom: 40 })
|
||||
})
|
||||
|
||||
it('clamps every edge that extends past the bounds', () => {
|
||||
expect(
|
||||
clipRectToBounds(
|
||||
{ left: -20, top: -10, right: 150, bottom: 130 },
|
||||
bounds
|
||||
)
|
||||
).toEqual({ left: 0, top: 0, right: 100, bottom: 100 })
|
||||
})
|
||||
|
||||
it('clamps only the overflowing side', () => {
|
||||
expect(
|
||||
clipRectToBounds({ left: 10, top: 10, right: 200, bottom: 40 }, bounds)
|
||||
).toEqual({ left: 10, top: 10, right: 100, bottom: 40 })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,30 @@
|
||||
import { clamp } from 'es-toolkit/math'
|
||||
|
||||
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
|
||||
/** A rectangle's viewport edges: the DOMRect subset, so a DOMRect is directly assignable. */
|
||||
export type RectEdges = Pick<DOMRect, 'left' | 'top' | 'right' | 'bottom'>
|
||||
|
||||
/**
|
||||
* Clips a rectangle to `bounds`: each edge is limited independently, so the
|
||||
* rect shrinks to the overlapping region rather than being moved to fit.
|
||||
* Keeps an interaction rectangle (the canvas selection box, the assets
|
||||
* marquee) inside its own surface. Both the rect and the bounds use
|
||||
* viewport-style edges (left/top/right/bottom), e.g. a DOMRect.
|
||||
*/
|
||||
export function clipRectToBounds(
|
||||
rect: RectEdges,
|
||||
bounds: RectEdges
|
||||
): RectEdges {
|
||||
return {
|
||||
left: clamp(rect.left, bounds.left, bounds.right),
|
||||
top: clamp(rect.top, bounds.top, bounds.bottom),
|
||||
right: clamp(rect.right, bounds.left, bounds.right),
|
||||
bottom: clamp(rect.bottom, bounds.top, bounds.bottom)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Linearly maps a value from [min, max] to [0, 1].
|
||||
* Returns 0 when min equals max to avoid division by zero.
|
||||
|
||||
Reference in New Issue
Block a user