mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-18 01:38:03 +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
107 lines
3.2 KiB
TypeScript
107 lines
3.2 KiB
TypeScript
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')
|
|
})
|
|
})
|