mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 01:07:56 +00:00
## Summary
Adds marquee (rubber-band) multi-select and Ctrl/Cmd+A select-all to the
Media Assets panel, clips the canvas drag-selection rectangle to the
canvas panel, and turns on live (real-time) node-graph rubber-band
selection by default.
## Changes
- **Marquee select** — rubber-band drag from empty grid space selects
the covered cards; hold Ctrl/Cmd to start a marquee from over a card;
Ctrl/Cmd or Shift alone makes the marquee additive to the current
selection, Ctrl/Cmd+Shift subtracts the covered cards from it
(designer-approved), and no modifier replaces it. Cards update their
selected state live during the drag.
- **Ctrl/Cmd+A** — selects all loaded assets when the pointer is over
the panel, otherwise falls through to the canvas (select all nodes). It
`stopImmediatePropagation`s so a panel select-all never also fires the
global node select-all, and it yields while an `aria-modal` dialog is
open or a text input is focused.
- **Select-all recovers after "deselect all"** — the shortcut was gated
only on `useElementHover`, which latched stale when the floating
selection bar under the cursor unmounted on deselect. It now also checks
the live pointer position against the panel rect, so a second Ctrl/Cmd+A
right after deselecting no longer falls through to the browser's native
page select-all.
- **Canvas rectangle clip** — the canvas drag-selection rectangle is
clamped to the canvas panel bounds (`SelectionRectangle.vue`,
display-only).
- **Graph live selection on by default** — flips the existing
`Comfy.Graph.LiveSelection` setting's default to on, so node-graph
rubber-band selection updates in real time during the drag (matching the
assets panel) instead of committing only on mouse-up. The behavior was
already implemented behind the setting; this changes only the default,
and users with an explicit value keep it.
- **Robustness/UX** — the pointer is captured on drag-engage rather than
on press (so a Ctrl/Cmd-click on a card isn't hijacked); no global
`document.body.userSelect` mutation (replaced by a panel-scoped
`selectstart` guard); the marquee overlay uses the semantic
`primary-background` token; post-drag click-suppression auto-resets so a
cancelled drag can't swallow a later click; `setPointerCapture` is
wrapped in try/catch; a Ctrl/Cmd-held card `dragstart` is cancelled so
no native ghost-drag image appears.
- **Breaking**: none — `useAssetSelection` is extended additively (new
`setSelectedIds` helper, nothing removed or altered) and the new
composable exposes only `{ marqueeStyle }`.
- **Dependencies**: none.
## Review Focus
- **`SelectionRectangle.vue`** is shared canvas code; the change is
display-only (clamps the rectangle to the panel; no node-selection
behavior change).
- **`coreSettings.ts`** — a one-line `Comfy.Graph.LiveSelection` default
flip is the only change that affects graph behavior; the live-select
code path itself is pre-existing.
- **`useAssetGridSelection.ts`** — listener lifecycle/teardown, the
panel-scoped `selectstart` guard, the click-suppression timer, the
capture-on-drag-engage logic, the pointer-position select-all fallback,
and the subtractive-mode snapshot at pointerdown.
- **Ctrl/Cmd+A routing** — panel hover (or a live pointer inside the
panel) gates select-all vs. the canvas, and `stopImmediatePropagation`
prevents double-handling.
- Pure geometry/selection logic is extracted into
`marqueeSelectionUtil.ts` and unit-tested in isolation (`RectEdges` is
`Pick<DOMRect, ...>`, the DOM edge subset); `MediaAssetCard.dragStart`
keeps `main`'s `display_name` payload.
Relates to Linear **FE-910**.
## Testing
- **Unit:** `useAssetGridSelection` (39 cases — marquee selection,
additive/replace, subtractive Ctrl/Cmd+Shift (incl. the macOS Cmd
variant and a shrink-restore drag), interactive-element + list-view
guards, `selectstart` scoping, click-suppression auto-reset,
pointer-capture-throw and capture-on-drag-not-press, modal-aware
Ctrl/Cmd+A, non-propagation, and the deselect-recovery pointer-in-panel
path), plus `MediaAssetCard`, `marqueeSelectionUtil` (11 cases incl.
subtractive, and a 5-case fast-check property suite pinning the
additive/subtractive set invariants), `SelectionRectangle`,
`useAssetSelection`, and `mathUtil`.
- **E2E (`assetsSidebarTab.spec.ts`):** 10 Playwright scenarios running
in CI — Ctrl/Cmd+A hover vs. canvas; a marquee from the panel header; a
modifier-held additive marquee; a Ctrl/Cmd+Shift subtractive marquee;
Ctrl/Cmd-drag from a card and within a single card; Ctrl/Cmd+A ignored
in a focused search box and under an aria-modal dialog; and a drag from
the search box not marquee-selecting. The empty-space marquee path is
covered by the panel-header scenario plus the unit suite (a dedicated
empty-space e2e could not run headless without a local backend and was
dropped as redundant).
## Future work
- **Escape key** — not handled by the marquee/select-all flow yet (the
composable handles only Ctrl/Cmd+A). Follow-up: press Escape to cancel
an in-progress marquee drag (abort the rubber-band and restore the
pre-drag selection) and to clear the current selection while the panel
has focus.
- **Ctrl+A across pagination** — select-all covers the loaded assets
only (confirmed as the intended behavior with design); a
load-all-then-select variant can follow if needed.
## Demo
https://github.com/user-attachments/assets/3841bf3c-db75-4229-a5e7-fb363b4882d6
801 lines
26 KiB
TypeScript
801 lines
26 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|