Compare commits

..

7 Commits

Author SHA1 Message Date
huang47
7cd3f1983e feat: tag Cloud RUM with product surface 2026-07-15 22:19:37 -07:00
huang47
3b837e19fc feat: tag Cloud RUM with navigation type 2026-07-15 22:13:57 -07:00
huang47
d6d362f041 feat: report workflow outcomes to Datadog RUM 2026-07-15 22:10:11 -07:00
huang47
4c3c25bb59 fix: initialize Cloud RUM before app bootstrap 2026-07-15 16:35:45 -07:00
ShihChi Huang
6d0bbd7d7c perf: shard Chromium E2E across 16 jobs (#13650)
## Summary

> [!NOTE]
> Bumping to [24
shards](https://github.com/Comfy-Org/ComfyUI_frontend/pull/13664)
reduces time from 11m14s to 9m31s, but costs 15% more in GitHub Action
runner time. Keep it 16 for now to be conservative and bump to 24 if it
works well for a week

Split Chromium E2E into 16 shards with two Playwright workers each,
preserving the current per-runner worker density while doubling
effective concurrency.

### 16 shards
| Run | E2E time | Shards |
|---|---:|---:|
|
[1](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29370163062)
| 11m38s | 16/16 pass |
|
[2](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29371313957)
| 11m32s | 16/16 pass |
|
[3](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29372283949)
| 10m50s | 16/16 pass |
|
[4](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29373163198)
| 11m00s | 16/16 pass |
|
[5](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29374055787)
| 11m12s | 16/16 pass |

### 24 shards
| Run | E2E time | Shards |
|---|---:|---:|
|
[1](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29379798083)
| 9m13s | 24/24 pass |
|
[2](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29380286857)
| 9m33s | 24/24 pass |
|
[3](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29380761689)
| 9m38s | 24/24 pass |
|
[4](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29381219308)
| 10m02s | 24/24 pass |
|
[5](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29381669526)
| 9m07s | 24/24 pass |

## Changes

- **What**: Run Chromium as 16 shards with explicit `--workers=2`.

## Review Focus

- Compare full E2E time, shard skew, runner queue time, and flakiness
with the 8×2 baseline.

## Validation

- YAML parse and oxfmt check
- Pre-commit formatting, linting, and root typecheck
- Pre-push Knip
- Local CodeRabbit review is rate-limited for 24 minutes; server-side
CodeRabbit remains pending.

Created by Codex


<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Changes are limited to CI workflow and Playwright config; no
application runtime, auth, or data paths are touched.
> 
> **Overview**
> **Chromium E2E CI** now runs as **16 parallel shards** (up from 8),
with **`workers: 2` on CI** in `playwright.config.ts` so each runner
keeps two Playwright workers while overall concurrency doubles.
> 
> Reporter wiring shifts so **blob output is chosen in config** when
`PLAYWRIGHT_BLOB_OUTPUT_DIR` is set (default reporter is `html`
otherwise); workflow steps drop inline `--reporter=blob` from the
Playwright CLI for both sharded Chromium and the other browser matrix
jobs.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
3707e70611. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: huang47 <157390+huang47@users.noreply.github.com>
2026-07-15 20:08:33 +00:00
imick-io
4341972be3 fix(website): remove opacity-80 washing out footer logo colors (#13648)
## Problem
The animated logo in the site footer (a webp frame sequence drawn to a
`<canvas>`) renders with dull, darkened colors. The brand neon yellow
`rgb(242, 255, 90)` shows up as a muddy olive `≈ rgb(198, 209, 80)`, and
the gray/pastel faces are darkened and purple-tinted.

## Cause
The footer `<canvas>` carried an `opacity-80` Tailwind class. At 80%
opacity the browser composites the animation 20% over the dark purple
footer background (`rgb(33, 25, 39)`), shifting every color. The source
webp frames themselves are correct — the shift only happens at display
time.

## Fix
Remove `opacity-80` from the canvas in `SiteFooter.vue` so it renders
the authored frame colors at full opacity.

## Verify
- Pre-check (no deploy): in DevTools, select the footer `<canvas>` and
untick `opacity: 0.8` — colors pop back immediately.
- After change: sample a yellow cube face in the footer animation with a
color picker — it should read `#F2FF5A` / `rgb(242, 255, 90)`, not
`rgb(198, 209, 80)`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 14:29:55 +00:00
CodeJuggernaut
98700cfcc7 feat: marquee select and Ctrl/Cmd+A in the Media Assets panel (#13323)
## 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
2026-07-15 09:20:31 +00:00
36 changed files with 2243 additions and 541 deletions

View File

@@ -73,8 +73,8 @@ jobs:
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
shardTotal: [8]
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
shardTotal: [16]
steps:
- name: Checkout repository
uses: actions/checkout@v6
@@ -93,7 +93,7 @@ jobs:
# Run sharded tests (browsers pre-installed in container)
- name: Run Playwright tests (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
id: playwright
run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob
run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
env:
PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report
COLLECT_COVERAGE: 'true'
@@ -150,7 +150,7 @@ jobs:
# Run tests (browsers pre-installed in container)
- name: Run Playwright tests (${{ matrix.browser }})
id: playwright
run: pnpm exec playwright test --project=${{ matrix.browser }} --reporter=blob
run: pnpm exec playwright test --project=${{ matrix.browser }}
env:
PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report

View File

@@ -175,10 +175,7 @@ const contactColumn: { title: string; links: FooterLink[] } = {
</div>
<!-- Logo -->
<canvas
ref="canvasRef"
class="pointer-events-none size-52 opacity-80 lg:mt-28"
/>
<canvas ref="canvasRef" class="pointer-events-none size-52 lg:mt-28" />
</div>
</footer>
</template>

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 40 KiB

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

@@ -16,6 +16,7 @@ const maybeLocalOptions: PlaywrightTestConfig = process.env.PLAYWRIGHT_LOCAL
}
: {
retries: process.env.CI ? 3 : 0,
workers: process.env.CI ? 2 : undefined,
use: {
trace: 'on-first-retry'
}
@@ -25,7 +26,7 @@ export default defineConfig({
testDir: './browser_tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
reporter: 'html',
reporter: process.env.PLAYWRIGHT_BLOB_OUTPUT_DIR ? 'blob' : 'html',
...maybeLocalOptions,
globalSetup: './browser_tests/globalSetup.ts',

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

@@ -833,44 +833,6 @@ describe('useLoad3d', () => {
expect(composable.sceneConfig.value.backgroundImage).toBe('')
expect(mockLoad3d.setBackgroundImage).toHaveBeenCalledWith('')
})
it('should reset a leftover panorama mode to tiled when uploading a new image', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded-image.jpg')
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.sceneConfig.value.backgroundRenderMode = 'panorama'
await nextTick()
const file = new File([''], 'test.jpg', { type: 'image/jpeg' })
await composable.handleBackgroundImageUpdate(file)
expect(composable.sceneConfig.value.backgroundRenderMode).toBe('tiled')
await nextTick()
expect(mockLoad3d.setBackgroundRenderMode).toHaveBeenLastCalledWith(
'tiled'
)
})
it('should not clear the background or touch render mode when the upload fails', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue(undefined)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.sceneConfig.value.backgroundImage = 'existing.jpg'
composable.sceneConfig.value.backgroundRenderMode = 'panorama'
await nextTick()
const file = new File([''], 'test.jpg', { type: 'image/jpeg' })
await composable.handleBackgroundImageUpdate(file)
expect(composable.sceneConfig.value.backgroundImage).toBe('existing.jpg')
expect(composable.sceneConfig.value.backgroundRenderMode).toBe('panorama')
})
})
describe('model export', () => {

View File

@@ -770,9 +770,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
: '3d'
const uploadedPath = await Load3dUtils.uploadFile(file, subfolder)
if (!uploadedPath) return
sceneConfig.value.backgroundRenderMode = 'tiled'
sceneConfig.value.backgroundImage = uploadedPath
await load3d?.setBackgroundImage(uploadedPath)
}

View File

@@ -560,44 +560,6 @@ describe('useLoad3dViewer', () => {
expect(viewer.hasBackgroundImage.value).toBe(false)
})
it('should reset render mode to tiled when uploading a new image', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce(
'uploaded-image.jpg'
)
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
viewer.backgroundRenderMode.value = 'panorama'
await nextTick()
const file = new File([''], 'test.jpg', { type: 'image/jpeg' })
await viewer.handleBackgroundImageUpdate(file)
expect(viewer.backgroundRenderMode.value).toBe('tiled')
})
it('should not clear the background or touch render mode when the upload fails', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce(undefined)
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
viewer.backgroundImage.value = 'existing.jpg'
viewer.backgroundRenderMode.value = 'panorama'
await nextTick()
const file = new File([''], 'test.jpg', { type: 'image/jpeg' })
await viewer.handleBackgroundImageUpdate(file)
expect(viewer.backgroundImage.value).toBe('existing.jpg')
expect(viewer.backgroundRenderMode.value).toBe('panorama')
})
it('should handle upload errors', async () => {
vi.mocked(Load3dUtils.uploadFile).mockRejectedValueOnce(
new Error('Upload failed')

View File

@@ -752,7 +752,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
)
if (uploadPath) {
backgroundRenderMode.value = 'tiled'
backgroundImage.value = uploadPath
hasBackgroundImage.value = true
}

View File

@@ -353,10 +353,7 @@ describe('Load3d', () => {
expect(sceneResize).toHaveBeenCalledWith(800, 400)
})
function makeRenderMainSceneContext(
backgroundType: 'color' | 'image' = 'color',
activeCamera: THREE.Camera = ctx.cameraManager.activeCamera
) {
it('renderMainScene applies the letterboxed viewport and feeds aspect to the camera', () => {
const setViewport = vi.fn()
const setScissor = vi.fn()
const setScissorTest = vi.fn()
@@ -365,10 +362,6 @@ describe('Load3d', () => {
const render = vi.fn()
const updateAspectRatio = vi.fn()
const renderBackground = vi.fn()
const getCurrentBackgroundInfo = vi.fn(() => ({
type: backgroundType,
value: ''
}))
const scene = {} as THREE.Scene
@@ -390,133 +383,19 @@ describe('Load3d', () => {
targetHeight: 200,
targetAspectRatio: 2,
isViewerMode: false,
cameraManager: {
...ctx.cameraManager,
updateAspectRatio,
activeCamera
},
sceneManager: {
...ctx.sceneManager,
renderBackground,
getCurrentBackgroundInfo,
scene
}
})
return {
setViewport,
setScissor,
setScissorTest,
clear,
render,
updateAspectRatio,
renderBackground,
scene
}
}
it('renderMainScene renders the full canvas with an extrapolated view offset so the letterboxed rect is unchanged', () => {
const r = makeRenderMainSceneContext()
const camera = ctx.cameraManager.activeCamera as THREE.PerspectiveCamera
let viewAtRender: THREE.PerspectiveCamera['view'] = null
r.render.mockImplementationOnce(() => {
viewAtRender = camera.view ? { ...camera.view } : null
cameraManager: { ...ctx.cameraManager, updateAspectRatio },
sceneManager: { ...ctx.sceneManager, renderBackground, scene }
})
ctx.load3d.renderMainScene()
// Container 800x600, target aspect 2:1 → letterboxed rect 800x400 at y=100
expect(r.setViewport).toHaveBeenNthCalledWith(1, 0, 0, 800, 600)
expect(r.setScissor).toHaveBeenNthCalledWith(1, 0, 0, 800, 600)
expect(r.setScissorTest).toHaveBeenCalledWith(true)
expect(r.clear).toHaveBeenCalledOnce()
expect(r.updateAspectRatio).toHaveBeenCalledWith(2)
expect(r.render).toHaveBeenNthCalledWith(1, r.scene, camera)
expect(viewAtRender).not.toBeNull()
expect(viewAtRender!.enabled).toBe(true)
expect(viewAtRender!.fullWidth).toBe(800)
expect(viewAtRender!.fullHeight).toBe(400)
expect(viewAtRender!.offsetX).toBeCloseTo(0)
expect(viewAtRender!.offsetY).toBe(-100)
expect(viewAtRender!.width).toBe(800)
expect(viewAtRender!.height).toBe(600)
expect(camera.view?.enabled ?? false).toBe(false)
})
it('renderMainScene dims the letterbox bars after rendering the scene', () => {
const r = makeRenderMainSceneContext()
ctx.load3d.renderMainScene()
expect(r.render).toHaveBeenCalledTimes(3)
expect(r.setViewport).toHaveBeenNthCalledWith(2, 0, 0, 800, 100)
expect(r.setScissor).toHaveBeenNthCalledWith(2, 0, 0, 800, 100)
expect(r.setViewport).toHaveBeenNthCalledWith(3, 0, 500, 800, 100)
expect(r.setScissor).toHaveBeenNthCalledWith(3, 0, 500, 800, 100)
})
it('renderMainScene keeps a color background covering the whole canvas', () => {
const r = makeRenderMainSceneContext('color')
ctx.load3d.renderMainScene()
expect(r.renderBackground).toHaveBeenCalledWith()
// No viewport narrowing before the bars are dimmed.
expect(r.setViewport).toHaveBeenNthCalledWith(2, 0, 0, 800, 100)
})
it('renderMainScene confines an image background to the letterboxed rect', () => {
const r = makeRenderMainSceneContext('image')
ctx.load3d.renderMainScene()
// Viewport/scissor narrow to the letterbox rect for the background
// pass, then restore to the full canvas for the scene pass.
expect(r.setViewport).toHaveBeenNthCalledWith(2, 0, 100, 800, 400)
expect(r.setScissor).toHaveBeenNthCalledWith(2, 0, 100, 800, 400)
expect(r.setViewport).toHaveBeenNthCalledWith(3, 0, 0, 800, 600)
expect(r.setScissor).toHaveBeenNthCalledWith(3, 0, 0, 800, 600)
const backgroundOrder = r.renderBackground.mock.invocationCallOrder[0]
expect(backgroundOrder).toBeGreaterThan(
r.setViewport.mock.invocationCallOrder[1]
)
expect(backgroundOrder).toBeLessThan(
r.setViewport.mock.invocationCallOrder[2]
)
})
it('renderMainScene applies the view offset to orthographic cameras too', () => {
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1)
const r = makeRenderMainSceneContext('color', camera)
let viewEnabledAtRender = false
r.render.mockImplementationOnce(() => {
viewEnabledAtRender = camera.view?.enabled ?? false
})
ctx.load3d.renderMainScene()
expect(r.render).toHaveBeenNthCalledWith(1, r.scene, camera)
expect(viewEnabledAtRender).toBe(true)
expect(camera.view?.enabled ?? false).toBe(false)
})
it('renderMainScene falls back to the letterboxed viewport for cameras without view-offset support', () => {
const camera = new THREE.Camera()
const r = makeRenderMainSceneContext('color', camera)
ctx.load3d.renderMainScene()
expect(r.setViewport).toHaveBeenNthCalledWith(1, 0, 0, 800, 600)
expect(r.setViewport).toHaveBeenNthCalledWith(2, 0, 100, 800, 400)
expect(r.setScissor).toHaveBeenNthCalledWith(2, 0, 100, 800, 400)
expect(r.render).toHaveBeenCalledTimes(1)
expect(r.render).toHaveBeenCalledWith(r.scene, camera)
expect(r.renderBackground).toHaveBeenCalledWith()
expect(setViewport).toHaveBeenNthCalledWith(1, 0, 0, 800, 600)
expect(setScissor).toHaveBeenNthCalledWith(1, 0, 0, 800, 600)
expect(setViewport).toHaveBeenNthCalledWith(2, 0, 100, 800, 400)
expect(setScissor).toHaveBeenNthCalledWith(2, 0, 100, 800, 400)
expect(updateAspectRatio).toHaveBeenCalledWith(2)
expect(setScissorTest).toHaveBeenCalledWith(true)
expect(render).toHaveBeenCalledWith(scene, ctx.cameraManager.activeCamera)
})
it('setBackgroundImage updates background size with letterbox dimensions when a texture is loaded', async () => {

View File

@@ -13,7 +13,6 @@ type CameraStub = {
getCurrentCameraType: ReturnType<typeof vi.fn>
handleResize: ReturnType<typeof vi.fn>
updateAspectRatio: ReturnType<typeof vi.fn>
dispose: ReturnType<typeof vi.fn>
activeCamera: THREE.Camera
}
@@ -49,7 +48,6 @@ function makeViewportInstance() {
getCurrentCameraType: vi.fn(() => 'perspective' as const),
handleResize: vi.fn(),
updateAspectRatio: vi.fn(),
dispose: vi.fn(),
activeCamera: new THREE.PerspectiveCamera()
}
const sceneManager: SceneStub = {
@@ -449,82 +447,6 @@ describe('Viewport3d', () => {
})
})
describe('letterbox dimmer lifecycle', () => {
type DimmerInternals = {
dimLetterboxBars(
bars: { x: number; y: number; width: number; height: number }[]
): void
disposeManagers(): void
letterboxDimmer: {
scene: THREE.Scene
camera: THREE.OrthographicCamera
geometry: THREE.PlaneGeometry
material: THREE.MeshBasicMaterial
} | null
}
it('does not create the dim overlay when there are no letterbox bars', () => {
const render = vi.fn()
Object.assign(ctx.viewport, {
view: {
renderer: { setViewport: vi.fn(), setScissor: vi.fn(), render }
}
})
const internals = ctx.viewport as unknown as DimmerInternals
internals.dimLetterboxBars([])
expect(render).not.toHaveBeenCalled()
expect(internals.letterboxDimmer ?? null).toBeNull()
})
it('renders each bar with a lazily created dim overlay', () => {
const setViewport = vi.fn()
const setScissor = vi.fn()
const render = vi.fn()
Object.assign(ctx.viewport, {
view: { renderer: { setViewport, setScissor, render } }
})
const internals = ctx.viewport as unknown as DimmerInternals
internals.dimLetterboxBars([
{ x: 0, y: 0, width: 100, height: 20 },
{ x: 0, y: 80, width: 100, height: 20 }
])
const dimmer = internals.letterboxDimmer
expect(dimmer).not.toBeNull()
expect(setViewport).toHaveBeenNthCalledWith(1, 0, 0, 100, 20)
expect(setViewport).toHaveBeenNthCalledWith(2, 0, 80, 100, 20)
expect(render).toHaveBeenCalledTimes(2)
expect(render).toHaveBeenCalledWith(dimmer!.scene, dimmer!.camera)
})
it('disposeManagers disposes the dim overlay resources', () => {
Object.assign(ctx.viewport, {
view: {
renderer: {
setViewport: vi.fn(),
setScissor: vi.fn(),
render: vi.fn()
}
}
})
const internals = ctx.viewport as unknown as DimmerInternals
internals.dimLetterboxBars([{ x: 0, y: 0, width: 100, height: 20 }])
const dimmer = internals.letterboxDimmer!
const geometryDispose = vi.spyOn(dimmer.geometry, 'dispose')
const materialDispose = vi.spyOn(dimmer.material, 'dispose')
internals.disposeManagers()
expect(geometryDispose).toHaveBeenCalledOnce()
expect(materialDispose).toHaveBeenCalledOnce()
expect(internals.letterboxDimmer).toBeNull()
})
})
describe('start / remove lifecycle', () => {
beforeEach(() => {
vi.useFakeTimers()

View File

@@ -18,52 +18,13 @@ import type {
import { attachContextMenuGuard } from './load3dContextMenuGuard'
import type { RenderLoopHandle } from './load3dRenderLoop'
import { startRenderLoop } from './load3dRenderLoop'
import type {
LetterboxedViewport,
LetterboxNdc,
ViewportRect
} from './load3dViewport'
import type { LetterboxNdc } from './load3dViewport'
import {
clientPointToLetterboxNdc,
computeLetterboxBars,
computeLetterboxedViewport,
isLoad3dActive
} from './load3dViewport'
const LETTERBOX_CLEAR_COLOR = 0x0a0a0a
const LETTERBOX_DIM_OPACITY = 0.5
type LetterboxDimmer = {
scene: THREE.Scene
camera: THREE.OrthographicCamera
geometry: THREE.PlaneGeometry
material: THREE.MeshBasicMaterial
}
function supportsViewOffset(
camera: THREE.Camera
): camera is THREE.PerspectiveCamera | THREE.OrthographicCamera {
return (
camera instanceof THREE.PerspectiveCamera ||
camera instanceof THREE.OrthographicCamera
)
}
function createLetterboxDimmer(): LetterboxDimmer {
const geometry = new THREE.PlaneGeometry(2, 2)
const material = new THREE.MeshBasicMaterial({
color: 0x000000,
transparent: true,
opacity: LETTERBOX_DIM_OPACITY,
depthTest: false,
depthWrite: false
})
const scene = new THREE.Scene()
scene.add(new THREE.Mesh(geometry, material))
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, -1, 1)
return { scene, camera, geometry, material }
}
const VIEW_HELPER_SIZE = 128
export type Viewport3dDeps = {
@@ -106,7 +67,6 @@ export class Viewport3d {
private overlay: SceneOverlay | null = null
private initialRenderTimer: ReturnType<typeof setTimeout> | null = null
private viewPixelScale = 1
private letterboxDimmer: LetterboxDimmer | null = null
constructor(
container: HTMLElement,
@@ -297,96 +257,29 @@ export class Viewport3d {
this.renderer.setScissor(0, 0, viewWidth, viewHeight)
this.renderer.setScissorTest(true)
if (!this.shouldMaintainAspectRatio()) {
if (this.shouldMaintainAspectRatio()) {
const { offsetX, offsetY, width, height } = computeLetterboxedViewport(
{ width: viewWidth, height: viewHeight },
this.targetAspectRatio
)
this.renderer.setClearColor(0x0a0a0a)
this.renderer.clear()
this.renderer.setViewport(offsetX, offsetY, width, height)
this.renderer.setScissor(offsetX, offsetY, width, height)
this.cameraManager.updateAspectRatio(width / height)
} else {
this.renderer.setClearColor(
this.view.state.clearColor,
this.view.state.clearAlpha
)
this.renderer.clear()
this.sceneManager.renderBackground()
this.renderer.render(this.sceneManager.scene, this.getRenderCamera())
return
}
const container = { width: viewWidth, height: viewHeight }
const viewport = computeLetterboxedViewport(
container,
this.targetAspectRatio
)
this.renderer.setClearColor(LETTERBOX_CLEAR_COLOR)
this.renderer.clear()
this.cameraManager.updateAspectRatio(viewport.width / viewport.height)
const camera = this.getRenderCamera()
if (!supportsViewOffset(camera)) {
this.renderer.setViewport(
viewport.offsetX,
viewport.offsetY,
viewport.width,
viewport.height
)
this.renderer.setScissor(
viewport.offsetX,
viewport.offsetY,
viewport.width,
viewport.height
)
this.sceneManager.renderBackground()
this.renderer.render(this.sceneManager.scene, camera)
return
}
camera.setViewOffset(
viewport.width,
viewport.height,
-viewport.offsetX,
-viewport.offsetY,
viewWidth,
viewHeight
)
this.renderLetterboxedBackground(viewport)
this.renderer.render(this.sceneManager.scene, camera)
camera.clearViewOffset()
this.dimLetterboxBars(computeLetterboxBars(container, viewport))
}
private renderLetterboxedBackground(viewport: LetterboxedViewport): void {
if (this.sceneManager.getCurrentBackgroundInfo().type !== 'image') {
this.sceneManager.renderBackground()
return
}
this.renderer.setViewport(
viewport.offsetX,
viewport.offsetY,
viewport.width,
viewport.height
)
this.renderer.setScissor(
viewport.offsetX,
viewport.offsetY,
viewport.width,
viewport.height
)
this.sceneManager.renderBackground()
this.renderer.setViewport(0, 0, this.view.width, this.view.height)
this.renderer.setScissor(0, 0, this.view.width, this.view.height)
}
private dimLetterboxBars(bars: ViewportRect[]): void {
if (bars.length === 0) return
const dimmer = (this.letterboxDimmer ??= createLetterboxDimmer())
for (const bar of bars) {
this.renderer.setViewport(bar.x, bar.y, bar.width, bar.height)
this.renderer.setScissor(bar.x, bar.y, bar.width, bar.height)
this.renderer.render(dimmer.scene, dimmer.camera)
}
this.renderer.render(this.sceneManager.scene, this.getRenderCamera())
}
clientPointToNdc(clientX: number, clientY: number): LetterboxNdc | null {
@@ -542,11 +435,6 @@ export class Viewport3d {
this.overlay.dispose()
this.overlay = null
}
if (this.letterboxDimmer) {
this.letterboxDimmer.geometry.dispose()
this.letterboxDimmer.material.dispose()
this.letterboxDimmer = null
}
this.sceneManager.dispose()
this.cameraManager.dispose()
this.controlsManager.dispose()

View File

@@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest'
import {
clientPointToLetterboxNdc,
computeLetterboxBars,
computeLetterboxedViewport,
isLoad3dActive
} from './load3dViewport'
@@ -83,53 +82,6 @@ describe('computeLetterboxedViewport', () => {
})
})
describe('computeLetterboxBars', () => {
it('returns left and right bars for a pillarboxed viewport', () => {
const container = { width: 800, height: 400 }
const bars = computeLetterboxBars(
container,
computeLetterboxedViewport(container, 1)
)
expect(bars).toEqual([
{ x: 0, y: 0, width: 200, height: 400 },
{ x: 600, y: 0, width: 200, height: 400 }
])
})
it('returns bottom and top bars for a letterboxed viewport', () => {
const container = { width: 400, height: 800 }
const bars = computeLetterboxBars(
container,
computeLetterboxedViewport(container, 1)
)
expect(bars).toEqual([
{ x: 0, y: 0, width: 400, height: 200 },
{ x: 0, y: 600, width: 400, height: 200 }
])
})
it('returns no bars when the viewport fills the container', () => {
const container = { width: 1024, height: 768 }
const bars = computeLetterboxBars(
container,
computeLetterboxedViewport(container, 1024 / 768)
)
expect(bars).toEqual([])
})
it('ignores sub-pixel offsets', () => {
const bars = computeLetterboxBars(
{ width: 800, height: 400.5 },
{ offsetX: 0, offsetY: 0.25, width: 800, height: 400 }
)
expect(bars).toEqual([])
})
})
describe('isLoad3dActive', () => {
const idle: Load3dActivityFlags = {
mouseOnNode: false,

View File

@@ -2,20 +2,13 @@ import { denormalize, normalize } from '@/utils/mathUtil'
type Size = { width: number; height: number }
export type LetterboxedViewport = {
type LetterboxedViewport = {
offsetX: number
offsetY: number
width: number
height: number
}
export type ViewportRect = {
x: number
y: number
width: number
height: number
}
export function computeLetterboxedViewport(
container: Size,
targetAspectRatio: number
@@ -76,37 +69,6 @@ export function clientPointToLetterboxNdc(
)
}
export function computeLetterboxBars(
container: Size,
viewport: LetterboxedViewport
): ViewportRect[] {
if (viewport.offsetX >= 1) {
return [
{ x: 0, y: 0, width: viewport.offsetX, height: container.height },
{
x: viewport.offsetX + viewport.width,
y: 0,
width: container.width - viewport.offsetX - viewport.width,
height: container.height
}
]
}
if (viewport.offsetY >= 1) {
return [
{ x: 0, y: 0, width: container.width, height: viewport.offsetY },
{
x: 0,
y: viewport.offsetY + viewport.height,
width: container.width,
height: container.height - viewport.offsetY - viewport.height
}
]
}
return []
}
export type Load3dActivityFlags = {
mouseOnNode: boolean
mouseOnScene: boolean

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

@@ -28,7 +28,8 @@ export async function initTelemetry(): Promise<void> {
{ PostHogTelemetryProvider },
{ ClickHouseTelemetryProvider },
{ SyftTelemetryProvider },
{ CustomerIoTelemetryProvider }
{ CustomerIoTelemetryProvider },
{ DatadogRumTelemetryProvider }
] = await Promise.all([
import('./TelemetryRegistry'),
import('./providers/cloud/MixpanelTelemetryProvider'),
@@ -37,7 +38,8 @@ export async function initTelemetry(): Promise<void> {
import('./providers/cloud/PostHogTelemetryProvider'),
import('./providers/cloud/ClickHouseTelemetryProvider'),
import('./providers/cloud/SyftTelemetryProvider'),
import('./providers/cloud/CustomerIoTelemetryProvider')
import('./providers/cloud/CustomerIoTelemetryProvider'),
import('./providers/cloud/DatadogRumTelemetryProvider')
])
const registry = new TelemetryRegistry()
@@ -48,6 +50,7 @@ export async function initTelemetry(): Promise<void> {
registry.registerProvider(new ClickHouseTelemetryProvider())
registry.registerProvider(new SyftTelemetryProvider())
registry.registerProvider(new CustomerIoTelemetryProvider())
registry.registerProvider(new DatadogRumTelemetryProvider())
setTelemetryRegistry(registry)
})()

View File

@@ -0,0 +1,132 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { DatadogRumTelemetryProvider } from './DatadogRumTelemetryProvider'
const addAction = vi.fn()
const setViewContextProperty = vi.fn()
function installDatadogRum(): void {
Object.defineProperty(window, 'DD_RUM', {
configurable: true,
value: { addAction, setViewContextProperty }
})
}
function setNavigationType(type?: NavigationTimingType): void {
vi.spyOn(performance, 'getEntriesByType').mockReturnValue(
type ? ([{ type }] as PerformanceNavigationTiming[]) : []
)
}
afterEach(() => {
addAction.mockReset()
setViewContextProperty.mockReset()
vi.restoreAllMocks()
Reflect.deleteProperty(window, 'DD_RUM')
})
describe('DatadogRumTelemetryProvider', () => {
it.for([
{ expected: 'navigate', type: 'navigate' },
{ expected: 'reload', type: 'reload' },
{ expected: 'back_forward', type: 'back_forward' },
{ expected: 'prerender', type: 'prerender' },
{ expected: 'unknown', type: undefined }
] as const)('sets $expected navigation context', ({ expected, type }) => {
installDatadogRum()
setNavigationType(type)
new DatadogRumTelemetryProvider()
expect(setViewContextProperty).toHaveBeenCalledWith(
'navigation_type',
expected
)
})
it.for([
{ expected: 'workspace', path: 'https://cloud.comfy.org/' },
{ expected: 'account_access', path: 'https://cloud.comfy.org/cloud/' },
{ expected: 'account_access', path: 'https://cloud.comfy.org/cloud/login' },
{
expected: 'account_access',
path: 'https://cloud.comfy.org/cloud/subscribe?plan=creator'
},
{
expected: 'oauth_consent',
path: 'https://cloud.comfy.org/cloud/oauth/consent?oauth_request_id=redacted'
},
{
expected: 'support_recovery',
path: 'https://cloud.comfy.org/cloud/forgot-password'
},
{
expected: 'support_recovery',
path: 'https://cloud.comfy.org/cloud/sorry-contact-support'
},
{
expected: 'support_recovery',
path: 'https://cloud.comfy.org/cloud/auth-timeout'
}
] as const)('sets $expected product surface', ({ expected, path }) => {
installDatadogRum()
setNavigationType('navigate')
const provider = new DatadogRumTelemetryProvider()
provider.trackPageView('ignored', { path })
expect(setViewContextProperty).toHaveBeenLastCalledWith(
'product_surface',
expected
)
})
it('tracks workflow execution starts', () => {
installDatadogRum()
setNavigationType('navigate')
new DatadogRumTelemetryProvider().trackWorkflowExecution()
expect(addAction).toHaveBeenCalledWith('workflow_execution_started', {
product: 'cloud_generation',
product_surface: 'workspace'
})
})
it.for([
{
outcome: 'success',
trackOutcome: (provider: DatadogRumTelemetryProvider) =>
provider.trackExecutionSuccess()
},
{
outcome: 'failure',
trackOutcome: (provider: DatadogRumTelemetryProvider) =>
provider.trackExecutionError()
}
] as const)(
'tracks workflow $outcome outcomes',
({ outcome, trackOutcome }) => {
installDatadogRum()
setNavigationType('navigate')
const provider = new DatadogRumTelemetryProvider()
trackOutcome(provider)
expect(addAction).toHaveBeenCalledWith('workflow_execution_completed', {
outcome,
product: 'cloud_generation',
product_surface: 'workspace'
})
}
)
it('does nothing when Datadog RUM is unavailable', () => {
const provider = new DatadogRumTelemetryProvider()
expect(() => provider.trackWorkflowExecution()).not.toThrow()
expect(() => provider.trackExecutionSuccess()).not.toThrow()
expect(() => provider.trackExecutionError()).not.toThrow()
expect(addAction).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,85 @@
import type { PageViewMetadata, TelemetryProvider } from '../../types'
interface DatadogRumClient {
addAction(name: string, context?: Record<string, unknown>): void
setViewContextProperty(name: string, value: unknown): void
}
interface WindowWithDatadogRum extends Window {
DD_RUM?: DatadogRumClient
}
const WORKFLOW_CONTEXT = {
product: 'cloud_generation',
product_surface: 'workspace'
} as const
type ProductSurface =
| 'account_access'
| 'oauth_consent'
| 'support_recovery'
| 'workspace'
const SUPPORT_RECOVERY_PATHS = new Set([
'/cloud/auth-timeout',
'/cloud/forgot-password',
'/cloud/sorry-contact-support'
])
function getDatadogRum(): DatadogRumClient | undefined {
return (window as WindowWithDatadogRum).DD_RUM
}
function getNavigationType(): NavigationTimingType | 'unknown' {
const [navigation] = performance.getEntriesByType(
'navigation'
) as PerformanceNavigationTiming[]
return navigation?.type ?? 'unknown'
}
function getProductSurface(path = window.location.href): ProductSurface {
const pathname = new URL(path, window.location.origin).pathname.replace(
/\/$/,
''
)
if (pathname === '/cloud/oauth/consent') return 'oauth_consent'
if (SUPPORT_RECOVERY_PATHS.has(pathname)) return 'support_recovery'
if (pathname === '/cloud' || pathname.startsWith('/cloud/'))
return 'account_access'
return 'workspace'
}
export class DatadogRumTelemetryProvider implements TelemetryProvider {
constructor() {
getDatadogRum()?.setViewContextProperty(
'navigation_type',
getNavigationType()
)
}
trackPageView(_pageName: string, properties?: PageViewMetadata): void {
getDatadogRum()?.setViewContextProperty(
'product_surface',
getProductSurface(properties?.path)
)
}
trackWorkflowExecution(): void {
getDatadogRum()?.addAction('workflow_execution_started', WORKFLOW_CONTEXT)
}
trackExecutionSuccess(): void {
this.trackTerminalOutcome('success')
}
trackExecutionError(): void {
this.trackTerminalOutcome('failure')
}
private trackTerminalOutcome(outcome: 'success' | 'failure'): void {
getDatadogRum()?.addAction('workflow_execution_completed', {
...WORKFLOW_CONTEXT,
outcome
})
}
}

View File

@@ -42,6 +42,11 @@ export const useExtensionService = () => {
await Promise.all(
extensions
.filter((extension) => !extension.includes('extensions/core'))
.filter(
(extension) =>
__DISTRIBUTION__ !== 'cloud' ||
extension !== '/extensions/cloud/rum.js'
)
.map(async (ext) => {
try {
await import(/* @vite-ignore */ api.fileURL(ext))

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.

View File

@@ -334,6 +334,27 @@ export default defineConfig({
tailwindcss(),
typegpuPlugin({}),
comfyAPIPlugin(IS_DEV),
{
name: 'inject-cloud-rum',
apply: 'build',
transformIndexHtml(html) {
if (DISTRIBUTION !== 'cloud') return html
return {
html,
tags: [
{
tag: 'script',
attrs: {
type: 'module',
src: '/extensions/cloud/rum.js'
},
injectTo: 'head-prepend'
}
]
}
}
},
// Exclude proprietary ABCROM fonts from non-cloud builds
{
name: 'exclude-proprietary-fonts',