Compare commits

...

26 Commits

Author SHA1 Message Date
Nathaniel Parson Koroso
6915fb06e8 refactor: pass the selection clip surface by ref instead of selectors
SelectionRectangle no longer discovers .graph-canvas-panel and
#graph-canvas through global queries. GraphCanvas owns a measuring
element inside its own graph-canvas-panel slot and passes it down as a
prop, and the canvas element comes from the LGraphCanvas instance the
store already holds. Addresses ryan-di review feedback.
2026-07-14 06:39:05 -07:00
Nathaniel Parson Koroso
cff72bc8fa refactor: rename clampRectToBounds to clipRectToBounds
Each edge is limited independently, so the rect shrinks to the
overlapping region rather than moving while keeping its size - clip
semantics, not clamp. Doc comment now names the two interaction
surfaces that rely on it. Addresses ryan-di review feedback.
2026-07-14 06:30:23 -07:00
Nathaniel Parson Koroso
3898b0710c test: property-based invariants for marquee selection
Pins the mode duality (additive = base union covered, subtractive =
base minus covered), base-order preservation, non-mutation, and
normalizeMarqueeRect edge ordering across generated geometry, matching
the sibling assetSortUtils property suite.
2026-07-06 14:59:51 -07:00
Nathaniel Parson Koroso
8764db6093 test: assert the surviving card identity in the subtractive marquee e2e
A count-only assertion cannot distinguish subtracted-beta from
replaced-with-beta; both leave one card selected.
2026-07-06 14:51:36 -07:00
Nathaniel Parson Koroso
9e2df5d525 test: cover Cmd+Shift, rubber-band restore, and subtractive marquee e2e
Adds the macOS Cmd+Shift subtractive variant and a two-move shrink-restore
test (an accumulator rewrite of applyMarquee would pass the single-move
suite while breaking the recompute-from-base semantics), plus a Playwright
scenario for the subtractive mode alongside the additive one.
2026-07-06 13:52:36 -07:00
Nathaniel Parson Koroso
16253a0886 feat: Ctrl/Cmd+Shift marquee to deselect assets
Holding Ctrl/Cmd+Shift while dragging a marquee now removes the boxed
asset cards from the current selection instead of adding them, the
subtractive counterpart to the additive modifier drag. Approved by
design (Alex Tov). Covers AustinMroz's review question.
2026-07-06 13:35:47 -07:00
Nathaniel Parson Koroso
a637438d1a refactor: derive assets marquee panel ref via useTemplateRef + computed
Replaces the ref + watchEffect wiring with useTemplateRef and a computed,
matching the project's preference for computed over ref+watch. No behavior
change. Addresses AustinMroz review feedback.
2026-07-06 12:32:43 -07:00
Nathaniel Parson Koroso
72be7e4bce fix: address review nits from AustinMroz
- assetsSidebarTab spec: type the evaluate param, drop the instanceof guard
- SelectionRectangle: undefined over null for panelBounds; early-return when
  the canvas is absent instead of clearing
- AssetsSidebarTab: same-name prop shorthand; rename shouldShowOutputCount to
  showOutputCount so show-output-count can use the shorthand too
- MediaAssetCard test: use createTestingPinia
2026-07-03 19:07:46 -07:00
CodeJuggernaut
4308aad97f Merge branch 'main' into nathaniel/qol-fixes-FE-910 2026-07-02 17:10:15 -07:00
CodeJuggernaut
46273bf368 Merge branch 'main' into nathaniel/qol-fixes-FE-910 2026-07-02 11:48:21 -07:00
Nathaniel Parson Koroso
1518bbd585 test: drop redundant empty-space marquee e2e
The empty-space marquee behavior is already covered deterministically by
the useAssetGridSelection unit test "selects intersecting cards when
dragging from empty space" and end-to-end by the passing "panel header"
marquee test (identical onPointerDown code path: both are non-card,
non-interactive presses). The removed test.fixme only exercised a headless
mouse-synthesis quirk, not product behavior, so no meaningful coverage is lost.
2026-07-02 11:16:11 -07:00
Nathaniel Parson Koroso
a3437b0676 test: reference FE-910 in the skipped empty-space marquee fixme
Gives the test.fixme an explicit follow-up path per review; the
headless-CI flake is tracked under FE-910.
2026-07-01 16:55:01 -07:00
Nathaniel Parson Koroso
752084faa2 refactor: type RectEdges as a DOMRect subset, drop the Box alias
Per review: RectEdges is now Pick<DOMRect,'left'|'top'|'right'|'bottom'> (the built-in subset a DOMRect already satisfies) rather than a bespoke interface, and the redundant Box alias is removed in favor of RectEdges directly. Type-only, no runtime change.
2026-07-01 16:04:42 -07:00
Nathaniel Parson Koroso
2c0afbc70a test: name asset select-all guard for the outside-rect case 2026-07-01 14:56:14 -07:00
Nathaniel Parson Koroso
30a77a9a73 feat: enable graph live selection by default
Nodes select/deselect in real time during the rubber-band drag, matching the media assets panel. Comfy.Graph.LiveSelection already drove canvas.liveSelection via useLitegraphSettings; this flips its default on.
2026-07-01 11:41:21 -07:00
Nathaniel Parson Koroso
55edc67677 fix: recover asset-panel select-all after deselect all
Ctrl/Cmd+A select-all was gated only on useElementHover(panel). When the
absolute selection bar under the cursor unmounts on "deselect all", the browser
fires mouseleave up the panel's hover chain with no compensating mouseenter, so
isHoveringPanel latches false while the pointer is still inside the panel. The
next Ctrl/Cmd+A bailed and the browser's native select-all highlighted page text.

Also accept select-all when the live pointer position (tracked in the existing
window pointermove handler) lies within the panel's bounding rect, recovering
from the stale-hover desync while still declining when the pointer is genuinely
outside (e.g. over the canvas).
2026-07-01 11:31:10 -07:00
Nathaniel Parson Koroso
c3fc0a9218 test: mark flaky empty-space marquee e2e as fixme (covered by sibling tests) 2026-06-30 17:58:07 -07:00
Nathaniel Parson Koroso
fcfd314738 test: rubber-band across the full card width in the empty-space marquee e2e 2026-06-30 17:09:37 -07:00
Nathaniel Parson Koroso
d4e72f7330 fix: capture marquee pointer on drag start, and address review feedback
- Capture the pointer only once a marquee drag engages past the threshold
  (late capture). A Ctrl/Cmd+click on a card no longer captures the pointer and
  hijacks the click, which had broken Ctrl/Cmd+click multi-select
- Disable the marquee in list view explicitly via an isEnabled guard
- Snapshot card rects and container bounds once per drag instead of re-measuring
  every pointermove; cache the canvas panel bounds per drag in SelectionRectangle
- Reuse RectEdges for Box; use native Array.prototype.findLastIndex
- Assert display_name in the MediaAssetCard drag-payload test
- e2e: merge the duplicate beforeEach, add a panelHeader page-object locator,
  and add an aria-modal Ctrl/Cmd+A suppression test
2026-06-30 15:36:35 -07:00
Nathaniel Parson Koroso
3cf1dd2136 fix: keep display_name in the asset drag payload to match main 2026-06-30 14:17:39 -07:00
Nathaniel Parson Koroso
6e4162b9f8 test: assert panel select-all does not propagate to global handlers 2026-06-30 14:05:10 -07:00
Nathaniel Parson Koroso
4342c340fa test: cover marquee select edge cases from review
- unit: native drag not blocked when no marquee is tracking; text
  selection unblocks after a normal pointerup release
- e2e: Ctrl/Cmd-marquee starting from a card and within a single card;
  Ctrl/Cmd+A in the search input and a drag from it leave assets unselected
2026-06-30 12:26:23 -07:00
Nathaniel Parson Koroso
ab6ea4c32d fix: harden Media Assets marquee selection and Ctrl/Cmd+A
- Cancel the native drag image on Ctrl/Cmd-marquee from a card
- Skip Ctrl/Cmd+A select-all when an aria-modal dialog is open
- Use the primary-background theme token for the marquee overlay
- Replace global body user-select with a panel-scoped selectstart guard
- Auto-reset post-drag click suppression so a cancelled drag can't swallow a click
- Guard setPointerCapture against stale pointer ids
2026-06-30 11:27:03 -07:00
Nathaniel Parson Koroso
548c1ab40d fix: read hasSelection.value in the empty-space click guard
hasSelection is a ref, so the bare truthy check always passed; clearing
now only fires when something is actually selected.
2026-06-26 12:43:31 -07:00
Nathaniel Parson Koroso
d31052a450 fix: clip the canvas selection rectangle to the canvas panel
Clamp the graph node-selection rubber-band to the .graph-canvas-panel
bounds so it no longer renders over the sidebar. Visual only, through
the shared clampRectToBounds helper; no litegraph behavior changes.
2026-06-26 12:42:12 -07:00
Nathaniel Parson Koroso
2575a99d7c feat: add marquee select and Ctrl/Cmd+A to the Media Assets panel
Rubber-band select from an empty grid spot, or hold Ctrl/Cmd to marquee
from a card; the drag can begin anywhere in the panel (title, search,
tabs) since the grid gaps were too thin. The overlay is clamped to the
panel and the pointer is captured so a release over the canvas still
ends the drag. Ctrl/Cmd+A selects all assets while the panel is hovered
and yields to the canvas otherwise, bailing while a text field is
focused. Native card drag, trailing-click suppression, and the
shift-range anchor are preserved. Covered by unit and e2e tests.
2026-06-26 12:40:25 -07:00
18 changed files with 1961 additions and 26 deletions

View File

@@ -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'
)

View File

@@ -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()
})
})
})

View File

@@ -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)

View 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')
})
})

View File

@@ -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>

View File

@@ -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()
}
}

View 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)
)
})
})
})

View File

@@ -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

View 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)
})
})
})

View 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 }
}

View File

@@ -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 } =

View File

@@ -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,

View File

@@ -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)
}
})
)
})
})

View 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([])
})
})

View 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
}

View File

@@ -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'
},
{

View File

@@ -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 })
})
})
})

View File

@@ -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.