Compare commits

...

6 Commits

Author SHA1 Message Date
jaeone94
e5975ddccb refactor: merge subgraph error entries and drop read-only guard tests 2026-07-16 04:39:12 +09:00
jaeone94
9b140ca405 test: add null input guard for recordNodeErrors normalization 2026-07-16 02:42:41 +09:00
jaeone94
56f5e7c0a7 test: guard read-only execution error state and drop redundant queuePrompt case
Add read-only boundary tests asserting direct writes to lastNodeErrors,
lastExecutionError, and lastPromptError are ignored, so re-widening the
store surface back to writable refs fails both typecheck and runtime.

Remove the null node_errors queuePrompt case that duplicated the undefined
branch through a fromAny cast, along with its now-unused imports; the empty
record and omitted cases keep the discriminating coverage.
2026-07-16 01:56:34 +09:00
jaeone94
e9841e6564 Merge branch 'main' into jaeone/refactor-execution-error-store-encapsulation 2026-07-15 21:17:19 +09: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
jaeone94
23e2882f21 refactor: encapsulate execution error store writes behind record actions
Raw error state (lastNodeErrors/lastExecutionError/lastPromptError) was
directly assigned from app.ts, executionStore, and subgraphStore, with the
empty-record normalization and PromptError shape construction copy-pasted
at each site. Introduce recordNodeErrors/recordExecutionError/
recordPromptError actions, expose the state as read-only computeds, and
extract normalizePromptError plus shared errorsForSlot/hasErrorForSlot
slot-matching predicates. queuePrompt's public boolean result is preserved
byte-for-byte (including empty/null/absent node_errors and multi-item
queue runs) and pinned by regression tests.
2026-07-15 00:05:21 +09:00
38 changed files with 2569 additions and 480 deletions

View File

@@ -322,6 +322,9 @@ export class AssetsSidebarTab extends SidebarTab {
// --- Folder view ---
public readonly backToAssetsButton: Locator
// --- Panel chrome ---
public readonly panelHeader: Locator
// --- Loading ---
public readonly skeletonLoaders: Locator
@@ -358,6 +361,7 @@ export class AssetsSidebarTab extends SidebarTab {
this.deleteSelectedButton = page.getByTestId('assets-delete-selected')
this.downloadSelectedButton = page.getByTestId('assets-download-selected')
this.backToAssetsButton = page.getByText('Back to all assets')
this.panelHeader = page.locator('.comfy-vue-side-bar-header')
this.skeletonLoaders = page.locator(
'.sidebar-content-container .animate-pulse'
)

View File

@@ -276,3 +276,255 @@ test.describe('FE-130 assets sidebar route mocks', () => {
)
})
})
test.describe('FE-910 marquee selection and select all', () => {
test.beforeEach(async ({ jobsRoutes, page, comfyPage }) => {
await jobsRoutes.mockJobsQueue([])
await jobsRoutes.mockJobsHistory(generatedJobs)
await mockInputFiles(page, ['imported.png'])
await mockViewFiles(page, viewFiles)
await comfyPage.setup()
await comfyPage.menu.assetsTab.open()
})
test('Ctrl/Cmd+A selects every asset while the panel is hovered', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await expect(tab.assetCards).toHaveCount(2)
await tab.getAssetCardByName('alpha').hover()
await comfyPage.page.keyboard.press('ControlOrMeta+a')
await expect(tab.selectedCards).toHaveCount(2)
})
test('a marquee that begins in the panel header selects the cards', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
const { page } = comfyPage
await expect(tab.assetCards).toHaveCount(2)
await expect(tab.selectedCards).toHaveCount(0)
const header = await tab.panelHeader.boundingBox()
const beta = await tab.getAssetCardByName('beta').boundingBox()
if (!header || !beta) {
throw new Error('panel header or asset card has no layout box')
}
// Begin the rubber-band in the header (above the grid), then drag down
// across both cards.
await page.mouse.move(header.x + 24, header.y + 20)
await page.mouse.down()
await page.mouse.move(beta.x + 8, beta.y + beta.height - 8, { steps: 14 })
await page.mouse.up()
await expect(tab.selectedCards).toHaveCount(2)
await expect(tab.selectionFooter).toBeVisible()
})
test('Ctrl/Cmd+A leaves assets unselected while the canvas is hovered', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
const { page } = comfyPage
await expect(tab.assetCards).toHaveCount(2)
const viewport = page.viewportSize()
if (!viewport) throw new Error('viewport size is unavailable')
// Hover the canvas (not the panel); Ctrl/Cmd+A must yield to the canvas.
await page.mouse.move(viewport.width - 100, viewport.height / 2)
await page.keyboard.press('ControlOrMeta+a')
await expect(tab.selectedCards).toHaveCount(0)
})
test('a modifier-held marquee adds to the existing selection', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
const { page } = comfyPage
await expect(tab.assetCards).toHaveCount(2)
await tab.getAssetCardByName('alpha').click()
await expect(tab.selectedCards).toHaveCount(1)
const beta = await tab.getAssetCardByName('beta').boundingBox()
if (!beta) throw new Error('beta card has no layout box')
// Hold a modifier so the marquee is additive, then rubber-band over beta.
await page.keyboard.down('Control')
await page.mouse.move(beta.x + 12, beta.y + 12)
await page.mouse.down()
await page.mouse.move(beta.x + beta.width - 12, beta.y + beta.height - 12, {
steps: 12
})
await page.mouse.up()
await page.keyboard.up('Control')
await expect(tab.selectedCards).toHaveCount(2)
})
test('a Ctrl/Cmd+Shift marquee removes the covered cards from the selection', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
const { page } = comfyPage
await expect(tab.assetCards).toHaveCount(2)
await tab.getAssetCardByName('alpha').hover()
await page.keyboard.press('ControlOrMeta+a')
await expect(tab.selectedCards).toHaveCount(2)
const beta = await tab.getAssetCardByName('beta').boundingBox()
if (!beta) throw new Error('beta card has no layout box')
// Ctrl+Shift makes the marquee subtractive: rubber-band over beta only.
await page.keyboard.down('Control')
await page.keyboard.down('Shift')
await page.mouse.move(beta.x + 12, beta.y + 12)
await page.mouse.down()
await page.mouse.move(beta.x + beta.width - 12, beta.y + beta.height - 12, {
steps: 12
})
await page.mouse.up()
await page.keyboard.up('Shift')
await page.keyboard.up('Control')
await expect(tab.selectedCards).toHaveCount(1)
await expect(tab.getAssetCardByName('alpha')).toHaveAttribute(
'data-selected',
'true'
)
})
test('Ctrl/Cmd-dragging from an asset card starts a marquee selection', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
const { page } = comfyPage
await expect(tab.assetCards).toHaveCount(2)
await expect(tab.selectedCards).toHaveCount(0)
const alpha = await tab.getAssetCardByName('alpha').boundingBox()
const beta = await tab.getAssetCardByName('beta').boundingBox()
if (!alpha || !beta) throw new Error('asset cards have no layout box')
// Ctrl bypasses card drag, so a press that begins on a card rubber-bands.
await page.keyboard.down('Control')
await page.mouse.move(alpha.x + alpha.width / 2, alpha.y + alpha.height / 2)
await page.mouse.down()
await page.mouse.move(beta.x + beta.width - 6, beta.y + beta.height - 6, {
steps: 12
})
await page.mouse.up()
await page.keyboard.up('Control')
await expect(tab.selectedCards).toHaveCount(2)
await expect(tab.selectionFooter).toBeVisible()
})
test('Ctrl/Cmd-dragging within a single card selects only that card', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
const { page } = comfyPage
await expect(tab.assetCards).toHaveCount(2)
const alpha = tab.getAssetCardByName('alpha')
const box = await alpha.boundingBox()
if (!box) throw new Error('alpha card has no layout box')
const start = { x: box.x + box.width / 2, y: box.y + box.height / 2 }
await page.keyboard.down('Control')
await page.mouse.move(start.x, start.y)
await page.mouse.down()
await page.mouse.move(start.x + 12, start.y + 12, { steps: 4 })
await page.mouse.up()
await page.keyboard.up('Control')
await expect(tab.selectedCards).toHaveCount(1)
await expect(alpha).toHaveAttribute('data-selected', 'true')
})
test('Ctrl/Cmd+A in the focused search input does not select assets', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
const query = 'alpha'
await tab.searchInput.fill(query)
await expect(tab.assetCards).toHaveCount(1)
await tab.searchInput.focus()
await comfyPage.page.keyboard.press('ControlOrMeta+a')
await expect(tab.selectedCards).toHaveCount(0)
await expect
.poll(() =>
tab.searchInput.evaluate((el: HTMLInputElement) => {
return { start: el.selectionStart, end: el.selectionEnd }
})
)
.toEqual({ start: 0, end: query.length })
})
test('a drag starting in the search input does not marquee-select assets', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
const { page } = comfyPage
await expect(tab.assetCards).toHaveCount(2)
const search = await tab.searchInput.boundingBox()
const beta = await tab.getAssetCardByName('beta').boundingBox()
if (!search || !beta)
throw new Error('search box or card has no layout box')
await page.mouse.move(
search.x + search.width / 2,
search.y + search.height / 2
)
await page.mouse.down()
await page.mouse.move(beta.x + beta.width / 2, beta.y + beta.height / 2, {
steps: 12
})
await page.mouse.up()
await expect(tab.selectedCards).toHaveCount(0)
})
test('Ctrl/Cmd+A does not select assets while an aria-modal dialog is open', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await expect(tab.assetCards).toHaveCount(2)
await comfyPage.page.evaluate(() => {
const dialog = document.createElement('div')
dialog.id = 'test-modal'
dialog.setAttribute('role', 'dialog')
dialog.setAttribute('aria-modal', 'true')
document.body.appendChild(dialog)
})
await tab.getAssetCardByName('alpha').hover()
await comfyPage.page.keyboard.press('ControlOrMeta+a')
await expect(tab.selectedCards).toHaveCount(0)
await comfyPage.page.evaluate(() => {
document.getElementById('test-modal')?.remove()
})
})
})

View File

@@ -152,9 +152,9 @@ describe('ErrorOverlay', () => {
renderOverlay()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
executionErrorStore.recordNodeErrors({
'1': makeNodeError(['Only error'])
}
})
executionErrorStore.showErrorOverlay()
await nextTick()
@@ -189,9 +189,9 @@ describe('ErrorOverlay', () => {
renderOverlay({ appMode: true })
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
executionErrorStore.recordNodeErrors({
'1': makeNodeError(['Only error'])
}
})
executionErrorStore.showErrorOverlay()
await nextTick()

View File

@@ -131,9 +131,9 @@ describe('useErrorOverlayState', () => {
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
executionErrorStore.recordNodeErrors({
'1': makeNodeError(['Only error'])
}
})
executionErrorStore.showErrorOverlay()
await nextTick()
@@ -168,9 +168,9 @@ describe('useErrorOverlayState', () => {
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
executionErrorStore.recordNodeErrors({
'1': makeNodeError(['Required input is missing'])
}
})
executionErrorStore.showErrorOverlay()
await nextTick()
@@ -207,9 +207,9 @@ describe('useErrorOverlayState', () => {
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
executionErrorStore.recordNodeErrors({
'1': makeNodeError(['Raw validation error'])
}
})
executionErrorStore.showErrorOverlay()
await nextTick()
@@ -248,7 +248,7 @@ describe('useErrorOverlayState', () => {
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastExecutionError = {
executionErrorStore.recordExecutionError({
prompt_id: 'prompt',
node_id: 1,
node_type: 'KSampler',
@@ -257,7 +257,7 @@ describe('useErrorOverlayState', () => {
exception_type: 'torch.OutOfMemoryError',
traceback: [],
timestamp: Date.now()
}
})
executionErrorStore.showErrorOverlay()
await nextTick()
@@ -474,9 +474,9 @@ describe('useErrorOverlayState', () => {
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
executionErrorStore.recordNodeErrors({
'1': makeNodeError(['Only error'])
}
})
executionErrorStore.showErrorOverlay()
await nextTick()

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

@@ -63,10 +63,7 @@ const LOADER_NODE = { id: '2', title: 'LoaderNode' }
function seedTwoErrorGroups(pinia: TestingPinia) {
const executionErrorStore = useExecutionErrorStore(pinia)
executionErrorStore.lastNodeErrors = fromAny<
typeof executionErrorStore.lastNodeErrors,
unknown
>({
executionErrorStore.recordNodeErrors({
'1': {
class_type: 'KSampler',
dependent_outputs: [],
@@ -83,7 +80,11 @@ function seedTwoErrorGroups(pinia: TestingPinia) {
class_type: 'CLIPLoader',
dependent_outputs: [],
errors: [
{ type: 'weird_error', message: 'Something odd happened', details: '' }
{
type: 'weird_error',
message: 'Something odd happened',
details: ''
}
]
}
})

View File

@@ -1,19 +1,28 @@
import { createTestingPinia } from '@pinia/testing'
import type { TestingPinia } from '@pinia/testing'
import { render, screen, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import TabErrors from './TabErrors.vue'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { MissingNodeType } from '@/types/comfy'
import { nodeError, validationError } from '@/utils/__tests__/nodeErrorHelpers'
const mockFocusNode = vi.hoisted(() => vi.fn())
const { mockFocusNode, mockRefreshMissingModels } = vi.hoisted(() => ({
mockFocusNode: vi.fn(),
mockRefreshMissingModels: vi.fn()
}))
vi.mock('@/scripts/app', () => ({
app: {
refreshMissingModels: mockRefreshMissingModels,
rootGraph: {
serialize: vi.fn(() => ({})),
getNodeById: vi.fn()
@@ -97,18 +106,16 @@ describe('TabErrors.vue', () => {
})
})
function renderComponent(initialState = {}) {
function renderComponent(seed?: (pinia: TestingPinia) => void) {
const user = userEvent.setup()
const pinia = createTestingPinia({
createSpy: vi.fn,
stubActions: false
})
seed?.(pinia)
render(TabErrors, {
global: {
plugins: [
PrimeVue,
i18n,
createTestingPinia({
createSpy: vi.fn,
initialState
})
],
plugins: [PrimeVue, i18n, pinia],
stubs: {
AsyncSearchInput: {
template:
@@ -129,14 +136,12 @@ describe('TabErrors.vue', () => {
})
it('renders prompt-level errors with resolved display message', async () => {
renderComponent({
executionError: {
lastPromptError: {
type: 'prompt_no_outputs',
message: 'Server Error: No outputs',
details: 'Error details'
}
}
renderComponent((pinia) => {
useExecutionErrorStore(pinia).recordPromptError({
type: 'prompt_no_outputs',
message: 'Server Error: No outputs',
details: 'Error details'
})
})
expect(screen.getAllByText('Prompt has no outputs').length).toBeGreaterThan(
@@ -162,45 +167,40 @@ describe('TabErrors.vue', () => {
} as ReturnType<typeof getNodeByExecutionId>
})
const { user } = renderComponent({
executionError: {
lastNodeErrors: {
'2': {
class_type: 'CLIPTextEncode',
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing',
details: 'Input: clip',
extra_info: {
input_name: 'clip'
}
}
]
},
'1': {
class_type: 'KSampler',
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing',
details: 'Input: positive',
extra_info: {
input_name: 'positive'
}
},
{
type: 'required_input_missing',
message: 'Required input is missing',
details: 'Input: model',
extra_info: {
input_name: 'model'
}
}
]
}
}
}
const { user } = renderComponent((pinia) => {
useExecutionErrorStore(pinia).recordNodeErrors({
'2': nodeError(
[
validationError(
'required_input_missing',
'clip',
{},
'Required input is missing',
'Input: clip'
)
],
'CLIPTextEncode'
),
'1': nodeError(
[
validationError(
'required_input_missing',
'positive',
{},
'Required input is missing',
'Input: positive'
),
validationError(
'required_input_missing',
'model',
{},
'Required input is missing',
'Input: model'
)
],
'KSampler'
)
})
})
expect(screen.getByText('Missing connection')).toBeInTheDocument()
@@ -269,18 +269,17 @@ describe('TabErrors.vue', () => {
title: 'KSampler'
} as ReturnType<typeof getNodeByExecutionId>)
const { user } = renderComponent({
executionError: {
lastExecutionError: {
prompt_id: 'abc',
node_id: '10',
node_type: 'KSampler',
exception_message: 'Out of memory',
exception_type: 'RuntimeError',
traceback: ['Line 1', 'Line 2'],
timestamp: Date.now()
}
}
const { user } = renderComponent((pinia) => {
useExecutionErrorStore(pinia).recordExecutionError({
prompt_id: 'abc',
node_id: '10',
node_type: 'KSampler',
executed: [],
exception_message: 'Out of memory',
exception_type: 'RuntimeError',
traceback: ['Line 1', 'Line 2'],
timestamp: Date.now()
})
})
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
@@ -300,19 +299,17 @@ describe('TabErrors.vue', () => {
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
vi.mocked(getNodeByExecutionId).mockReturnValue(null)
const { user } = renderComponent({
executionError: {
lastNodeErrors: {
'1': {
class_type: 'CLIPTextEncode',
errors: [{ message: 'Missing text input' }]
},
'2': {
class_type: 'KSampler',
errors: [{ message: 'Out of memory' }]
}
}
}
const { user } = renderComponent((pinia) => {
useExecutionErrorStore(pinia).recordNodeErrors({
'1': nodeError(
[validationError('unknown', undefined, {}, 'Missing text input', '')],
'CLIPTextEncode'
),
'2': nodeError(
[validationError('unknown', undefined, {}, 'Out of memory', '')],
'KSampler'
)
})
})
expect(screen.getAllByText('CLIPTextEncode').length).toBeGreaterThanOrEqual(
@@ -337,18 +334,17 @@ describe('TabErrors.vue', () => {
const mockCopy = vi.fn()
vi.mocked(useCopyToClipboard).mockReturnValue({ copyToClipboard: mockCopy })
const { user } = renderComponent({
executionError: {
lastExecutionError: {
prompt_id: 'abc',
node_id: '1',
node_type: 'TestNode',
exception_message: 'Test message',
exception_type: 'RuntimeError',
traceback: ['Test details'],
timestamp: Date.now()
}
}
const { user } = renderComponent((pinia) => {
useExecutionErrorStore(pinia).recordExecutionError({
prompt_id: 'abc',
node_id: '1',
node_type: 'TestNode',
executed: [],
exception_message: 'Test message',
exception_type: 'RuntimeError',
traceback: ['Test details'],
timestamp: Date.now()
})
})
await user.click(screen.getByTestId('error-card-copy'))
@@ -364,18 +360,17 @@ describe('TabErrors.vue', () => {
title: 'KSampler'
} as ReturnType<typeof getNodeByExecutionId>)
renderComponent({
executionError: {
lastExecutionError: {
prompt_id: 'abc',
node_id: '10',
node_type: 'KSampler',
exception_message: 'Out of memory',
exception_type: 'RuntimeError',
traceback: ['Line 1', 'Line 2'],
timestamp: Date.now()
}
}
renderComponent((pinia) => {
useExecutionErrorStore(pinia).recordExecutionError({
prompt_id: 'abc',
node_id: '10',
node_type: 'KSampler',
executed: [],
exception_message: 'Out of memory',
exception_type: 'RuntimeError',
traceback: ['Line 1', 'Line 2'],
timestamp: Date.now()
})
})
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
@@ -399,12 +394,9 @@ describe('TabErrors.vue', () => {
isAssetSupported: true
} satisfies MissingModelCandidate
const { user } = renderComponent({
missingModel: {
missingModelCandidates: [missingModel]
}
const { user } = renderComponent((pinia) => {
useMissingModelStore(pinia).setMissingModels([missingModel])
})
const missingModelStore = useMissingModelStore()
expect(screen.getByText('Missing Models')).toBeInTheDocument()
expect(
@@ -413,33 +405,31 @@ describe('TabErrors.vue', () => {
await user.click(screen.getByTestId('missing-model-header-refresh'))
expect(missingModelStore.refreshMissingModels).toHaveBeenCalled()
expect(mockRefreshMissingModels).toHaveBeenCalledWith({ silent: true })
})
it('counts missing models per file when several share one directory', () => {
renderComponent({
missingModel: {
missingModelCandidates: [
{
nodeId: '1',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
name: 'model-a.safetensors',
directory: 'checkpoints',
isMissing: true,
isAssetSupported: true
},
{
nodeId: '2',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
name: 'model-b.safetensors',
directory: 'checkpoints',
isMissing: true,
isAssetSupported: true
}
] satisfies MissingModelCandidate[]
}
renderComponent((pinia) => {
useMissingModelStore(pinia).setMissingModels([
{
nodeId: '1',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
name: 'model-a.safetensors',
directory: 'checkpoints',
isMissing: true,
isAssetSupported: true
},
{
nodeId: '2',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
name: 'model-b.safetensors',
directory: 'checkpoints',
isMissing: true,
isAssetSupported: true
}
])
})
expect(
@@ -461,10 +451,8 @@ describe('TabErrors.vue', () => {
isAssetSupported: true
} satisfies MissingModelCandidate
renderComponent({
missingModel: {
missingModelCandidates: [missingModel]
}
renderComponent((pinia) => {
useMissingModelStore(pinia).setMissingModels([missingModel])
})
expect(screen.getByText('Missing Models')).toBeInTheDocument()
@@ -483,10 +471,8 @@ describe('TabErrors.vue', () => {
isMissing: true
} satisfies MissingMediaCandidate
renderComponent({
missingMedia: {
missingMediaCandidates: [missingMedia]
}
renderComponent((pinia) => {
useMissingMediaStore(pinia).setMissingMedia([missingMedia])
})
expect(screen.getByText('Missing Inputs')).toBeInTheDocument()
@@ -507,27 +493,25 @@ describe('TabErrors.vue', () => {
} as ReturnType<typeof getNodeByExecutionId>
})
const { user } = renderComponent({
missingMedia: {
missingMediaCandidates: [
{
nodeId: '3',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'shared.png',
isMissing: true
},
{
nodeId: '4',
nodeType: 'PreviewImage',
widgetName: 'image',
mediaType: 'image',
name: 'shared.png',
isMissing: true
}
] satisfies MissingMediaCandidate[]
}
const { user } = renderComponent((pinia) => {
useMissingMediaStore(pinia).setMissingMedia([
{
nodeId: '3',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'shared.png',
isMissing: true
},
{
nodeId: '4',
nodeType: 'PreviewImage',
widgetName: 'image',
mediaType: 'image',
name: 'shared.png',
isMissing: true
}
])
})
expect(screen.getAllByTestId('missing-media-row')).toHaveLength(2)
@@ -551,59 +535,58 @@ describe('TabErrors.vue', () => {
title: 'Node'
} as ReturnType<typeof getNodeByExecutionId>)
renderComponent({
executionError: {
lastNodeErrors: {
'1': {
class_type: 'KSampler',
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing',
details: 'Input: model',
extra_info: { input_name: 'model' }
},
{
type: 'required_input_missing',
message: 'Required input is missing',
details: 'Input: positive',
extra_info: { input_name: 'positive' }
}
]
},
'2': {
class_type: 'CLIPTextEncode',
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing',
details: 'Input: clip',
extra_info: { input_name: 'clip' }
}
]
}
renderComponent((pinia) => {
useExecutionErrorStore(pinia).recordNodeErrors({
'1': nodeError(
[
validationError(
'required_input_missing',
'model',
{},
'Required input is missing',
'Input: model'
),
validationError(
'required_input_missing',
'positive',
{},
'Required input is missing',
'Input: positive'
)
],
'KSampler'
),
'2': nodeError(
[
validationError(
'required_input_missing',
'clip',
{},
'Required input is missing',
'Input: clip'
)
],
'CLIPTextEncode'
)
})
useMissingMediaStore(pinia).setMissingMedia([
{
nodeId: '3',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'a.png',
isMissing: true
},
{
nodeId: '4',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'b.png',
isMissing: true
}
},
missingMedia: {
missingMediaCandidates: [
{
nodeId: '3',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'a.png',
isMissing: true
},
{
nodeId: '4',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'b.png',
isMissing: true
}
]
} satisfies { missingMediaCandidates: MissingMediaCandidate[] }
])
})
// 3 validation items + 2 missing media references
@@ -626,13 +609,8 @@ describe('TabErrors.vue', () => {
}
} satisfies MissingNodeType
renderComponent({
missingNodesError: {
missingNodesError: {
message: 'Missing Node Packs',
nodeTypes: [swapNode]
}
}
renderComponent((pinia) => {
useMissingNodesErrorStore(pinia).setMissingNodeTypes([swapNode])
})
expect(screen.getByText('Swap Nodes')).toBeInTheDocument()
@@ -660,10 +638,8 @@ describe('TabErrors.vue', () => {
isAssetSupported: true
} satisfies MissingModelCandidate
renderComponent({
missingModel: {
missingModelCandidates: [missingModel]
}
renderComponent((pinia) => {
useMissingModelStore(pinia).setMissingModels([missingModel])
})
expect(screen.getByTestId('missing-model-header-refresh')).toBeVisible()

View File

@@ -428,7 +428,7 @@ describe('useErrorGroups', () => {
it('uses fallback catalog grouping for unknown node validation errors', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
store.recordNodeErrors({
'1': {
class_type: 'KSampler',
dependent_outputs: [],
@@ -440,7 +440,7 @@ describe('useErrorGroups', () => {
}
]
}
}
})
await nextTick()
const execGroups = groups.allErrorGroups.value.filter(
@@ -453,7 +453,7 @@ describe('useErrorGroups', () => {
it('resolves required_input_missing item display copy', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
store.recordNodeErrors({
'1': {
class_type: 'KSampler',
dependent_outputs: [],
@@ -468,7 +468,7 @@ describe('useErrorGroups', () => {
}
]
}
}
})
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
@@ -509,7 +509,7 @@ describe('useErrorGroups', () => {
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) => {
return actualGetNodeByExecutionId(rootGraph, String(nodeId))
})
store.lastNodeErrors = {
store.recordNodeErrors({
'12:5': nodeError(
[
validationError(
@@ -521,7 +521,7 @@ describe('useErrorGroups', () => {
],
'InteriorClass'
)
}
})
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
@@ -540,7 +540,7 @@ describe('useErrorGroups', () => {
it('groups node validation errors by catalog id across node types', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
store.recordNodeErrors({
'1': {
class_type: 'KSampler',
dependent_outputs: [],
@@ -569,7 +569,7 @@ describe('useErrorGroups', () => {
}
]
}
}
})
await nextTick()
const execGroups = groups.allErrorGroups.value.filter(
@@ -590,7 +590,7 @@ describe('useErrorGroups', () => {
it('uses general execution_failed display fields for unrecognized runtime execution errors', async () => {
mockIsCloud.value = true
const { store, groups } = createErrorGroups()
store.lastExecutionError = {
store.recordExecutionError({
prompt_id: 'test-prompt',
timestamp: Date.now(),
node_id: 5,
@@ -601,7 +601,7 @@ describe('useErrorGroups', () => {
traceback: ['line 1', 'line 2'],
current_inputs: {},
current_outputs: {}
}
})
await nextTick()
const execGroups = groups.allErrorGroups.value.filter(
@@ -627,7 +627,7 @@ describe('useErrorGroups', () => {
it('adds display fields for targeted runtime execution errors', async () => {
mockIsCloud.value = true
const { store, groups } = createErrorGroups()
store.lastExecutionError = {
store.recordExecutionError({
prompt_id: 'test-prompt',
timestamp: Date.now(),
node_id: 5,
@@ -639,7 +639,7 @@ describe('useErrorGroups', () => {
traceback: ['line 1', 'line 2'],
current_inputs: {},
current_outputs: {}
}
})
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
@@ -660,11 +660,11 @@ describe('useErrorGroups', () => {
it('includes prompt error when present', async () => {
const { store, groups } = createErrorGroups()
store.lastPromptError = {
store.recordPromptError({
type: 'prompt_no_outputs',
message: 'No outputs',
details: ''
}
})
await nextTick()
const promptGroup = groups.allErrorGroups.value.find(
@@ -682,11 +682,11 @@ describe('useErrorGroups', () => {
typeof canvasStore.selectedItems,
unknown
>([{ id: '1' }])
store.lastPromptError = {
store.recordPromptError({
type: 'prompt_no_outputs',
message: 'No outputs',
details: ''
}
})
await nextTick()
const promptGroup = groups.allErrorGroups.value.find(
@@ -698,7 +698,7 @@ describe('useErrorGroups', () => {
it('sorts cards within an execution group by nodeId numerically', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
store.recordNodeErrors({
'10': {
class_type: 'KSampler',
dependent_outputs: [],
@@ -714,7 +714,7 @@ describe('useErrorGroups', () => {
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
}
}
})
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
@@ -726,7 +726,7 @@ describe('useErrorGroups', () => {
it('sorts cards with subpath nodeIds before higher root IDs', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
store.recordNodeErrors({
'2': {
class_type: 'KSampler',
dependent_outputs: [],
@@ -742,7 +742,7 @@ describe('useErrorGroups', () => {
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
}
}
})
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
@@ -754,7 +754,7 @@ describe('useErrorGroups', () => {
it('sorts deeply nested nodeIds by each segment numerically', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
store.recordNodeErrors({
'10:11:99': {
class_type: 'KSampler',
dependent_outputs: [],
@@ -770,7 +770,7 @@ describe('useErrorGroups', () => {
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
}
}
})
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
@@ -784,13 +784,13 @@ describe('useErrorGroups', () => {
describe('filteredGroups', () => {
it('returns all groups when search query is empty', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
store.recordNodeErrors({
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'value_error', message: 'Bad value', details: '' }]
}
}
})
await nextTick()
expect(groups.filteredGroups.value.length).toBeGreaterThan(0)
@@ -798,7 +798,7 @@ describe('useErrorGroups', () => {
it('filters groups based on search query', async () => {
const { store, groups, searchQuery } = createErrorGroups()
store.lastNodeErrors = {
store.recordNodeErrors({
'1': {
class_type: 'KSampler',
dependent_outputs: [],
@@ -821,7 +821,7 @@ describe('useErrorGroups', () => {
}
]
}
}
})
await nextTick()
searchQuery.value = 'sampler'
@@ -1097,11 +1097,11 @@ describe('useErrorGroups', () => {
typeof canvasStore.selectedItems,
unknown
>([{ id: '1' }])
store.lastPromptError = {
store.recordPromptError({
type: 'prompt_no_outputs',
message: 'No outputs',
details: ''
}
})
await nextTick()
const promptGroup = groups.allErrorGroups.value.find(
@@ -1116,13 +1116,13 @@ describe('useErrorGroups', () => {
it('reports no selection state when nothing is selected', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
store.recordNodeErrors({
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'value_error', message: 'Bad value', details: '' }]
}
}
})
await nextTick()
expect(groups.hasSelection.value).toBe(false)
@@ -1145,7 +1145,7 @@ describe('useErrorGroups', () => {
typeof canvasStore.selectedItems,
unknown
>([selectedNode])
store.lastNodeErrors = {
store.recordNodeErrors({
'1': {
class_type: 'KSampler',
dependent_outputs: [],
@@ -1158,7 +1158,7 @@ describe('useErrorGroups', () => {
{ type: 'file_not_found', message: 'File not found', details: '' }
]
}
}
})
await nextTick()
expect(groups.hasSelection.value).toBe(true)
@@ -1254,13 +1254,13 @@ describe('useErrorGroups', () => {
typeof canvasStore.selectedItems,
unknown
>([selectedNode])
store.lastNodeErrors = {
store.recordNodeErrors({
'2:5': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'value_error', message: 'Bad value', details: '' }]
}
}
})
await nextTick()
expect(groups.selectionErrorCount.value).toBe(1)
@@ -1284,7 +1284,7 @@ describe('useErrorGroups', () => {
typeof canvasStore.selectedItems,
unknown
>([containerNode])
store.lastNodeErrors = {
store.recordNodeErrors({
'2:5': {
class_type: 'KSampler',
dependent_outputs: [],
@@ -1297,7 +1297,7 @@ describe('useErrorGroups', () => {
{ type: 'file_not_found', message: 'File not found', details: '' }
]
}
}
})
await nextTick()
expect(groups.selectionErrorCount.value).toBe(1)

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

@@ -169,7 +169,7 @@ describe('Widget change error clearing via onWidgetChanged', () => {
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
store.lastNodeErrors = {
store.recordNodeErrors({
[String(node.id)]: {
errors: [
{
@@ -182,7 +182,7 @@ describe('Widget change error clearing via onWidgetChanged', () => {
dependent_outputs: [],
class_type: 'TestNode'
}
}
})
node.onWidgetChanged!.call(node, 'steps', 50, 20, node.widgets![0])
@@ -201,7 +201,7 @@ describe('Widget change error clearing via onWidgetChanged', () => {
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
store.lastNodeErrors = {
store.recordNodeErrors({
[String(node.id)]: {
errors: [
{
@@ -214,7 +214,7 @@ describe('Widget change error clearing via onWidgetChanged', () => {
dependent_outputs: [],
class_type: 'TestNode'
}
}
})
node.onWidgetChanged!.call(node, 'steps', 150, 20, node.widgets![0])
@@ -232,7 +232,7 @@ describe('Widget change error clearing via onWidgetChanged', () => {
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(
fromAny<LGraph, unknown>(undefined)
)
store.lastNodeErrors = {
store.recordNodeErrors({
[String(node.id)]: {
errors: [
{
@@ -245,7 +245,7 @@ describe('Widget change error clearing via onWidgetChanged', () => {
dependent_outputs: [],
class_type: 'TestNode'
}
}
})
node.onWidgetChanged!.call(node, 'steps', 50, 20, node.widgets![0])

View File

@@ -514,7 +514,7 @@ describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
it('sets has_errors on nodes referenced in lastNodeErrors', async () => {
const { nodeA, nodeB, store } = setupGraphWithStore()
store.lastNodeErrors = {
store.recordNodeErrors({
[String(nodeA.id)]: {
errors: [
{
@@ -527,7 +527,7 @@ describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
dependent_outputs: [],
class_type: 'KSampler'
}
}
})
await nextTick()
expect(nodeA.has_errors).toBe(true)
@@ -537,7 +537,7 @@ describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
it('sets slot hasErrors for inputs matching error input_name', async () => {
const { nodeA, store } = setupGraphWithStore()
store.lastNodeErrors = {
store.recordNodeErrors({
[String(nodeA.id)]: {
errors: [
{
@@ -550,7 +550,7 @@ describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
dependent_outputs: [],
class_type: 'KSampler'
}
}
})
await nextTick()
expect(nodeA.inputs[0].hasErrors).toBe(true)
@@ -560,7 +560,7 @@ describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
it('clears has_errors and slot hasErrors when errors are removed', async () => {
const { nodeA, store } = setupGraphWithStore()
store.lastNodeErrors = {
store.recordNodeErrors({
[String(nodeA.id)]: {
errors: [
{
@@ -573,12 +573,12 @@ describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
dependent_outputs: [],
class_type: 'KSampler'
}
}
})
await nextTick()
expect(nodeA.has_errors).toBe(true)
expect(nodeA.inputs[1].hasErrors).toBe(true)
store.lastNodeErrors = null
store.recordNodeErrors(null)
await nextTick()
expect(nodeA.has_errors).toBeFalsy()
@@ -603,7 +603,7 @@ describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
// Error on interior node: execution ID = "50:<interiorNodeId>"
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
store.lastNodeErrors = {
store.recordNodeErrors({
[interiorExecId]: {
errors: [
{
@@ -616,7 +616,7 @@ describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
dependent_outputs: [],
class_type: 'InnerNode'
}
}
})
await nextTick()
// Interior node should have the error
@@ -626,6 +626,56 @@ describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
expect(subgraphNode.has_errors).toBe(true)
})
it('merges slot errors when execution IDs resolve to the same node', async () => {
const subgraph = createTestSubgraph()
const interiorNode = new LGraphNode('InnerNode')
interiorNode.addInput('first', 'INT')
interiorNode.addInput('second', 'INT')
subgraph.add(interiorNode)
const firstInstance = createTestSubgraphNode(subgraph, { id: 50 })
const secondInstance = createTestSubgraphNode(subgraph, { id: 51 })
const graph = firstInstance.graph as LGraph
graph.add(firstInstance)
graph.add(secondInstance)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(true)
useGraphNodeManager(graph)
const store = useExecutionErrorStore()
store.recordNodeErrors({
[`${firstInstance.id}:${interiorNode.id}`]: {
errors: [
{
type: 'required_input_missing',
message: 'Missing first',
details: '',
extra_info: { input_name: 'first' }
}
],
dependent_outputs: [],
class_type: 'InnerNode'
},
[`${secondInstance.id}:${interiorNode.id}`]: {
errors: [
{
type: 'required_input_missing',
message: 'Missing second',
details: '',
extra_info: { input_name: 'second' }
}
],
dependent_outputs: [],
class_type: 'InnerNode'
}
})
await nextTick()
expect(interiorNode.inputs[0].hasErrors).toBe(true)
expect(interiorNode.inputs[1].hasErrors).toBe(true)
})
it('sets has_errors on nodes with missing models', async () => {
const { nodeA, nodeB } = setupGraphWithStore()
const missingModelStore = useMissingModelStore()

View File

@@ -8,6 +8,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import type { NodeError } from '@/schemas/apiSchema'
import { getParentExecutionIds } from '@/types/nodeIdentification'
import { hasErrorForSlot } from '@/utils/executionErrorUtil'
import { forEachNode, getNodeByExecutionId } from '@/utils/graphTraversalUtil'
function setNodeHasErrors(node: LGraphNode, hasErrors: boolean): void {
@@ -39,7 +40,7 @@ function reconcileNodeErrorFlags(
// Collect nodes and slot info that should be flagged
// Includes both error-owning nodes and their ancestor containers
const flaggedNodes = new Set<LGraphNode>()
const errorSlots = new Map<LGraphNode, Set<string>>()
const errorsByNode = new Map<LGraphNode, NodeError['errors']>()
if (nodeErrors) {
for (const [executionId, nodeError] of Object.entries(nodeErrors)) {
@@ -47,12 +48,10 @@ function reconcileNodeErrorFlags(
if (!node) continue
flaggedNodes.add(node)
const slotNames = new Set<string>()
for (const error of nodeError.errors) {
const name = error.extra_info?.input_name
if (name) slotNames.add(name)
}
if (slotNames.size > 0) errorSlots.set(node, slotNames)
errorsByNode.set(node, [
...(errorsByNode.get(node) ?? []),
...nodeError.errors
])
for (const parentId of getParentExecutionIds(executionId)) {
const parentNode = getNodeByExecutionId(rootGraph, parentId)
@@ -75,9 +74,10 @@ function reconcileNodeErrorFlags(
setNodeHasErrors(node, flaggedNodes.has(node))
if (node.inputs) {
const nodeSlotNames = errorSlots.get(node)
const ownErrors = errorsByNode.get(node)
for (const slot of node.inputs) {
slot.hasErrors = !!nodeSlotNames?.has(slot.name)
slot.hasErrors =
!!slot.name && !!ownErrors && hasErrorForSlot(ownErrors, slot.name)
}
}
})

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

@@ -94,7 +94,7 @@ function renderControls({
useAppModeStore().selectedOutputs = [toNodeId(1)]
if (hasError) {
useExecutionErrorStore().lastNodeErrors = nodeErrors
useExecutionErrorStore().recordNodeErrors(nodeErrors)
}
const toastTarget = document.createElement('div')

View File

@@ -20,6 +20,7 @@ import {
createNodeLocatorId
} from '@/types/nodeIdentification'
import { widgetId } from '@/types/widgetId'
import { validationError } from '@/utils/__tests__/nodeErrorHelpers'
const GRAPH_ID = 'graph-test'
@@ -156,7 +157,7 @@ describe('hasWidgetError', () => {
it('returns true when node has matching input error', () => {
const widget = createMockWidget({ name: 'seed' })
const nodeErrors = {
errors: [{ extra_info: { input_name: 'seed' } }]
errors: [validationError('required_input_missing', 'seed')]
}
expect(
hasWidgetError(
@@ -174,7 +175,7 @@ describe('hasWidgetError', () => {
name: 'seed',
sourceExecutionId: createNodeExecutionId([toNodeId(65), toNodeId(18)])
})
executionErrorStore.lastNodeErrors = {
executionErrorStore.recordNodeErrors({
'65:18': {
errors: [
{
@@ -187,7 +188,7 @@ describe('hasWidgetError', () => {
class_type: 'TestNode',
dependent_outputs: []
}
}
})
expect(
hasWidgetError(
widget,
@@ -219,7 +220,7 @@ describe('hasWidgetError', () => {
sourceWidgetName: 'internal_name'
})
const nodeErrors = {
errors: [{ extra_info: { input_name: 'display_slot' } }]
errors: [validationError('required_input_missing', 'display_slot')]
}
expect(
hasWidgetError(
@@ -263,7 +264,7 @@ describe('hasWidgetError', () => {
sourceExecutionId,
sourceWidgetName: 'ckpt_name'
})
executionErrorStore.lastNodeErrors = {
executionErrorStore.recordNodeErrors({
[sourceExecutionId]: {
errors: [
{
@@ -276,7 +277,7 @@ describe('hasWidgetError', () => {
class_type: 'CheckpointLoaderSimple',
dependent_outputs: []
}
}
})
expect(
hasWidgetError(
widget,
@@ -711,7 +712,7 @@ describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
sourceWidgetName: 'ckpt_name'
})
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
executionErrorStore.recordNodeErrors({
[sourceExecutionId]: {
errors: [
{
@@ -724,7 +725,7 @@ describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
class_type: 'CheckpointLoaderSimple',
dependent_outputs: []
}
}
})
const [processed] = processWidgets([widget])
processed.updateHandler('real_model.safetensors')
@@ -741,7 +742,7 @@ describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
executionErrorStore.lastNodeErrors = {
executionErrorStore.recordNodeErrors({
[NODE_ID]: {
errors: [
{
@@ -754,7 +755,7 @@ describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
class_type: 'TestNode',
dependent_outputs: []
}
}
})
const [processed] = processWidgets([widget])
@@ -762,7 +763,7 @@ describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
hasWidgetError(
widget,
createNodeExecutionId([NODE_ID]),
executionErrorStore.lastNodeErrors[NODE_ID],
executionErrorStore.lastNodeErrors?.[NODE_ID],
executionErrorStore,
missingModelStore
)

View File

@@ -14,6 +14,7 @@ import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import type { NodeError } from '@/schemas/apiSchema'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import WidgetDOM from '@/renderer/extensions/vueNodes/widgets/components/WidgetDOM.vue'
@@ -39,6 +40,7 @@ import type { NodeId } from '@/types/nodeId'
import type { WidgetId } from '@/types/widgetId'
import { widgetId } from '@/types/widgetId'
import type { WidgetState } from '@/types/widgetState'
import { hasErrorForSlot } from '@/utils/executionErrorUtil'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import type {
LinkedUpstreamInfo,
@@ -121,9 +123,7 @@ function createWidgetUpdateHandler(
export function hasWidgetError(
widget: SafeWidgetData,
nodeExecId: NodeExecutionId,
nodeErrors:
| { errors: { extra_info?: { input_name?: string } }[] }
| undefined,
nodeErrors: Pick<NodeError, 'errors'> | undefined,
executionErrorStore: ReturnType<typeof useExecutionErrorStore>,
missingModelStore: ReturnType<typeof useMissingModelStore>
): boolean {
@@ -135,7 +135,7 @@ export function hasWidgetError(
? (widget.sourceWidgetName ?? widget.name)
: widget.name
return (
!!errors?.some((e) => e.extra_info?.input_name === errorInputName) ||
(!!errors && hasErrorForSlot(errors, errorInputName)) ||
missingModelStore.isWidgetMissingModel(nodeExecId, widget.name)
)
}

View File

@@ -21,7 +21,8 @@ import {
} from '@/composables/usePaste'
import { getWorkflowDataFromFile } from '@/scripts/metadata/parser'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { api } from '@/scripts/api'
import { PromptExecutionError, api } from '@/scripts/api'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useExecutionStore } from '@/stores/executionStore'
import type { NodeError } from '@/schemas/apiSchema'
@@ -189,6 +190,21 @@ describe('ComfyApp', () => {
})
describe('queuePrompt', () => {
function prepareEmptyPromptQueue() {
const workflow = new ComfyWorkflow({
path: 'workflows/review.json',
modified: 0,
size: 0
})
Reflect.set(app, 'rootGraphInternal', new LGraph())
mockWorkspaceWorkflow.activeWorkflow = workflow
vi.spyOn(app, 'graphToPrompt').mockResolvedValue({
output: {},
workflow: createWorkflowGraphData()
})
vi.spyOn(api, 'dispatchCustomEvent').mockImplementation(() => true)
}
it('shows the error overlay for successful prompt responses with node errors', async () => {
const graph = new LGraph()
const workflow = new ComfyWorkflow({
@@ -242,6 +258,77 @@ describe('ComfyApp', () => {
)
expect(mockCanvas.draw).toHaveBeenCalledWith(true, true)
})
it('preserves a failed result when prompt errors include an empty node error record', async () => {
prepareEmptyPromptQueue()
vi.spyOn(api, 'queuePrompt').mockRejectedValue(
new PromptExecutionError({
node_errors: {},
error: {
type: 'prompt_no_outputs',
message: 'Prompt has no outputs',
details: ''
}
})
)
await expect(app.queuePrompt(0)).resolves.toBe(false)
const errorStore = useExecutionErrorStore()
expect(errorStore.lastNodeErrors).toBeNull()
expect(errorStore.lastPromptError).toMatchObject({
type: 'prompt_no_outputs'
})
})
it('preserves a successful result when prompt errors omit node errors', async () => {
prepareEmptyPromptQueue()
vi.spyOn(api, 'queuePrompt').mockRejectedValue(
new PromptExecutionError({
error: {
type: 'prompt_no_outputs',
message: 'Prompt has no outputs',
details: ''
}
})
)
await expect(app.queuePrompt(0)).resolves.toBe(true)
})
it('uses the last processed queue item result after an earlier failure', async () => {
prepareEmptyPromptQueue()
let rejectFirst!: (reason?: unknown) => void
const firstResponse = new Promise<never>((_, reject) => {
rejectFirst = reject
})
vi.spyOn(api, 'queuePrompt')
.mockImplementationOnce(() => firstResponse)
.mockResolvedValueOnce({
prompt_id: 'job-2',
error: ''
})
const firstQueue = app.queuePrompt(0)
await vi.waitFor(() => {
expect(api.queuePrompt).toHaveBeenCalledTimes(1)
})
await expect(app.queuePrompt(0)).resolves.toBe(false)
rejectFirst(
new PromptExecutionError({
node_errors: {},
error: {
type: 'prompt_no_outputs',
message: 'Prompt has no outputs',
details: ''
}
})
)
await expect(firstQueue).resolves.toBe(true)
expect(useExecutionErrorStore().lastNodeErrors).toBeNull()
})
})
describe('refreshComboInNodes', () => {

View File

@@ -95,6 +95,7 @@ import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { ComfyExtension, MissingNodeType } from '@/types/comfy'
import type { ExtensionManager } from '@/types/extensionTypes'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { normalizePromptError } from '@/utils/executionErrorUtil'
import { graphToPrompt } from '@/utils/executionUtil'
import { parseJsonWithNonFinite } from '@/utils/jsonUtil'
import { getCnrIdFromProperties } from '@/platform/nodeReplacement/cnrIdUtil'
@@ -1631,6 +1632,7 @@ export class ComfyApp {
const executionStore = useExecutionStore()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.clearAllErrors()
let queueResultOverride: boolean | null = null
// Get auth token for backend nodes - uses workspace token if enabled, otherwise Firebase token
const comfyOrgAuthToken = await useAuthStore().getAuthToken()
@@ -1673,12 +1675,8 @@ export class ComfyApp {
})
delete api.authToken
delete api.apiKey
const nodeErrors = res.node_errors
const hasNodeErrors =
nodeErrors && Object.keys(nodeErrors).length > 0
executionErrorStore.lastNodeErrors = hasNodeErrors
? nodeErrors
: null
executionErrorStore.recordNodeErrors(res.node_errors ?? null)
queueResultOverride = null
try {
if (res.prompt_id) {
executionStore.storeJob({
@@ -1694,7 +1692,7 @@ export class ComfyApp {
error
})
}
if (hasNodeErrors) {
if (executionErrorStore.hasNodeError) {
if (useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) {
executionErrorStore.showErrorOverlay()
}
@@ -1766,30 +1764,18 @@ export class ComfyApp {
console.error(error)
if (error instanceof PromptExecutionError) {
executionErrorStore.lastNodeErrors =
error.response.node_errors ?? null
// Keep the legacy result before empty node errors are normalized.
const nodeErrors = error.response.node_errors
queueResultOverride = !nodeErrors
executionErrorStore.recordNodeErrors(nodeErrors ?? null)
// Store prompt-level error separately only when no node-specific errors exist,
// because node errors already carry the full context. Prompt-level errors
// (e.g. prompt_no_outputs, no_prompt) lack node IDs and need their own path.
const nodeErrors = error.response.node_errors
const hasNodeErrors =
nodeErrors && Object.keys(nodeErrors).length > 0
if (!hasNodeErrors) {
const respError = error.response.error
if (respError && typeof respError === 'object') {
executionErrorStore.lastPromptError = {
type: respError.type,
message: respError.message,
details: respError.details ?? ''
}
} else if (typeof respError === 'string') {
executionErrorStore.lastPromptError = {
type: 'error',
message: respError,
details: ''
}
if (!executionErrorStore.hasNodeError) {
const promptError = normalizePromptError(error.response.error)
if (promptError) {
executionErrorStore.recordPromptError(promptError)
}
}
@@ -1828,7 +1814,7 @@ export class ComfyApp {
} finally {
this.processingQueue = false
}
return !executionErrorStore.lastNodeErrors
return queueResultOverride ?? !executionErrorStore.lastNodeErrors
}
showErrorOnFileLoad(file: File) {

View File

@@ -68,7 +68,7 @@ describe('executionErrorStore — node error operations', () => {
describe('clearSimpleNodeErrors', () => {
it('does nothing if lastNodeErrors is null', () => {
const store = useExecutionErrorStore()
store.lastNodeErrors = null
store.recordNodeErrors(null)
// Should not error
store.clearSimpleNodeErrors(
createNodeExecutionId([toNodeId(123)]),
@@ -79,7 +79,7 @@ describe('executionErrorStore — node error operations', () => {
it('clears entirely if there are only simple errors for the same slot', () => {
const store = useExecutionErrorStore()
store.lastNodeErrors = {
store.recordNodeErrors({
'123': {
errors: [
{
@@ -92,7 +92,7 @@ describe('executionErrorStore — node error operations', () => {
dependent_outputs: [],
class_type: 'TestNode'
}
}
})
store.clearSimpleNodeErrors(
createNodeExecutionId([toNodeId(123)]),
@@ -105,7 +105,7 @@ describe('executionErrorStore — node error operations', () => {
it('clears only the specific slot errors, leaving other errors alone', () => {
const store = useExecutionErrorStore()
store.lastNodeErrors = {
store.recordNodeErrors({
'123': {
errors: [
{
@@ -124,7 +124,7 @@ describe('executionErrorStore — node error operations', () => {
dependent_outputs: [],
class_type: 'TestNode'
}
}
})
store.clearSimpleNodeErrors(
createNodeExecutionId([toNodeId(123)]),
@@ -141,7 +141,7 @@ describe('executionErrorStore — node error operations', () => {
it('does nothing if executionId is not found in lastNodeErrors', () => {
const store = useExecutionErrorStore()
store.lastNodeErrors = {
store.recordNodeErrors({
'123': {
errors: [
{
@@ -154,7 +154,7 @@ describe('executionErrorStore — node error operations', () => {
dependent_outputs: [],
class_type: 'TestNode'
}
}
})
store.clearSimpleNodeErrors(
createNodeExecutionId([toNodeId(999)]),
@@ -167,7 +167,7 @@ describe('executionErrorStore — node error operations', () => {
it('preserves complex errors when slot has both simple and complex errors', () => {
const store = useExecutionErrorStore()
store.lastNodeErrors = {
store.recordNodeErrors({
'123': {
errors: [
{
@@ -186,7 +186,7 @@ describe('executionErrorStore — node error operations', () => {
dependent_outputs: [],
class_type: 'TestNode'
}
}
})
store.clearSimpleNodeErrors(
createNodeExecutionId([toNodeId(123)]),
@@ -199,7 +199,7 @@ describe('executionErrorStore — node error operations', () => {
it('clears one node while preserving another in multi-node errors', () => {
const store = useExecutionErrorStore()
store.lastNodeErrors = {
store.recordNodeErrors({
'123': {
errors: [
{
@@ -224,7 +224,7 @@ describe('executionErrorStore — node error operations', () => {
dependent_outputs: [],
class_type: 'LoadModel'
}
}
})
store.clearSimpleNodeErrors(
createNodeExecutionId([toNodeId(123)]),
@@ -238,7 +238,7 @@ describe('executionErrorStore — node error operations', () => {
it('clears entire node when no slotName and all errors are simple', () => {
const store = useExecutionErrorStore()
store.lastNodeErrors = {
store.recordNodeErrors({
'123': {
errors: [
{
@@ -257,7 +257,7 @@ describe('executionErrorStore — node error operations', () => {
dependent_outputs: [],
class_type: 'TestNode'
}
}
})
store.clearSimpleNodeErrors(createNodeExecutionId([toNodeId(123)]))
@@ -266,7 +266,7 @@ describe('executionErrorStore — node error operations', () => {
it('does not clear when no slotName and some errors are not simple', () => {
const store = useExecutionErrorStore()
store.lastNodeErrors = {
store.recordNodeErrors({
'123': {
errors: [
{
@@ -285,7 +285,7 @@ describe('executionErrorStore — node error operations', () => {
dependent_outputs: [],
class_type: 'TestNode'
}
}
})
store.clearSimpleNodeErrors(createNodeExecutionId([toNodeId(123)]))
@@ -294,7 +294,7 @@ describe('executionErrorStore — node error operations', () => {
it('does not clear if the error is not simple', () => {
const store = useExecutionErrorStore()
store.lastNodeErrors = {
store.recordNodeErrors({
'123': {
errors: [
{
@@ -307,7 +307,7 @@ describe('executionErrorStore — node error operations', () => {
dependent_outputs: [],
class_type: 'TestNode'
}
}
})
store.clearSimpleNodeErrors(
createNodeExecutionId([toNodeId(123)]),
@@ -323,11 +323,11 @@ describe('executionErrorStore — node error operations', () => {
mockGraphReady(rootGraph)
const store = useExecutionErrorStore()
store.lastNodeErrors = {
store.recordNodeErrors({
'12:5': nodeError([
validationError('required_input_missing', 'seed_input')
])
}
})
expect(store.surfacedNodeErrors).toHaveProperty('12')
@@ -342,7 +342,7 @@ describe('executionErrorStore — node error operations', () => {
mockGraphReady(rootGraph)
const store = useExecutionErrorStore()
store.lastNodeErrors = {
store.recordNodeErrors({
'12:5': nodeError([
validationError(
'custom_validation_failed',
@@ -351,7 +351,7 @@ describe('executionErrorStore — node error operations', () => {
'Custom validation failed'
)
])
}
})
expect(store.surfacedNodeErrors).toHaveProperty('12')
@@ -389,11 +389,11 @@ describe('executionErrorStore — node error operations', () => {
mockGraphReady(rootGraph)
const store = useExecutionErrorStore()
store.lastNodeErrors = {
store.recordNodeErrors({
'1:2:3': nodeError([
validationError('required_input_missing', 'seed_input')
])
}
})
expect(store.surfacedNodeErrors).toHaveProperty('1')
@@ -413,7 +413,7 @@ describe('executionErrorStore — node error operations', () => {
describe('clearWidgetRelatedErrors', () => {
it('clears error if value is valid (isValueStillOutOfRange is false)', () => {
const store = useExecutionErrorStore()
store.lastNodeErrors = {
store.recordNodeErrors({
'123': {
errors: [
{
@@ -426,7 +426,7 @@ describe('executionErrorStore — node error operations', () => {
dependent_outputs: [],
class_type: 'TestNode'
}
}
})
// Valid value (5 < 10)
store.clearWidgetRelatedErrors(
@@ -444,7 +444,7 @@ describe('executionErrorStore — node error operations', () => {
it('optimistically clears value_not_in_list error for string combo values', () => {
const store = useExecutionErrorStore()
store.lastNodeErrors = {
store.recordNodeErrors({
'123': {
errors: [
{
@@ -457,7 +457,7 @@ describe('executionErrorStore — node error operations', () => {
dependent_outputs: [],
class_type: 'KSampler'
}
}
})
store.clearWidgetRelatedErrors(
createNodeExecutionId([toNodeId(123)]),
@@ -471,7 +471,7 @@ describe('executionErrorStore — node error operations', () => {
it('does not clear error if value is still out of range', () => {
const store = useExecutionErrorStore()
store.lastNodeErrors = {
store.recordNodeErrors({
'123': {
errors: [
{
@@ -484,7 +484,7 @@ describe('executionErrorStore — node error operations', () => {
dependent_outputs: [],
class_type: 'TestNode'
}
}
})
// Invalid value (15 > 10)
store.clearWidgetRelatedErrors(
@@ -503,13 +503,13 @@ describe('executionErrorStore — node error operations', () => {
it('validates the base target against live widget bounds, not recorded ones', () => {
const store = useExecutionErrorStore()
store.lastNodeErrors = {
store.recordNodeErrors({
'123': nodeError([
validationError('value_bigger_than_max', 'testWidget', {
input_config: ['INT', { max: 100 }]
})
])
}
})
store.clearWidgetRelatedErrors(
createNodeExecutionId([toNodeId(123)]),
@@ -530,11 +530,11 @@ describe('executionErrorStore — node error operations', () => {
mockGraphReady(rootGraph)
const store = useExecutionErrorStore()
store.lastNodeErrors = {
store.recordNodeErrors({
'12:5': nodeError([
validationError('value_bigger_than_max', 'seed_input', {}, 'Too high')
])
}
})
expect(store.surfacedNodeErrors).toHaveProperty('12')
@@ -570,7 +570,7 @@ describe('executionErrorStore — node error operations', () => {
mockGraphReady(rootGraph)
const store = useExecutionErrorStore()
store.lastNodeErrors = {
store.recordNodeErrors({
'12:5': nodeError([
validationError('value_bigger_than_max', 'seed_input', {
input_config: ['INT', { max: 100 }]
@@ -581,7 +581,7 @@ describe('executionErrorStore — node error operations', () => {
input_config: ['INT', { max: 50 }]
})
])
}
})
expect(store.surfacedNodeErrors?.['12'].errors).toHaveLength(2)
@@ -610,11 +610,11 @@ describe('executionErrorStore — node error operations', () => {
mockGraphReady(rootGraph)
const store = useExecutionErrorStore()
store.lastNodeErrors = {
store.recordNodeErrors({
'12:5': nodeError([
validationError('required_input_missing', 'seed_input')
])
}
})
const hostLocatorId = createNodeLocatorId(null, toNodeId(12))
@@ -770,6 +770,28 @@ describe('surfaceMissingMedia — silent option', () => {
})
})
describe('recordNodeErrors', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('normalizes an empty error record to null', () => {
const store = useExecutionErrorStore()
store.recordNodeErrors({})
expect(store.lastNodeErrors).toBeNull()
})
it('keeps a null error record as null', () => {
const store = useExecutionErrorStore()
store.recordNodeErrors(null)
expect(store.lastNodeErrors).toBeNull()
})
})
describe('clearAllErrors', () => {
let executionErrorStore: ReturnType<typeof useExecutionErrorStore>
let missingNodesStore: ReturnType<typeof useMissingNodesErrorStore>
@@ -782,7 +804,7 @@ describe('clearAllErrors', () => {
})
it('resets all error categories and closes error overlay', () => {
executionErrorStore.lastExecutionError = {
executionErrorStore.recordExecutionError({
prompt_id: 'test',
timestamp: 0,
node_id: '1',
@@ -791,13 +813,13 @@ describe('clearAllErrors', () => {
exception_message: 'fail',
exception_type: 'RuntimeError',
traceback: []
}
executionErrorStore.lastPromptError = {
})
executionErrorStore.recordPromptError({
type: 'execution',
message: 'fail',
details: ''
}
executionErrorStore.lastNodeErrors = {
})
executionErrorStore.recordNodeErrors({
'1': {
errors: [
{
@@ -810,7 +832,7 @@ describe('clearAllErrors', () => {
dependent_outputs: [],
class_type: 'Test'
}
}
})
missingNodesStore.setMissingNodeTypes(
fromAny<MissingNodeType[], unknown>([{ type: 'MissingNode', hint: '' }])
)

View File

@@ -33,7 +33,9 @@ import {
} from '@/utils/graphTraversalUtil'
import {
SIMPLE_ERROR_TYPES,
errorsForSlot,
getInputConfigBounds,
hasErrorForSlot,
isValueStillOutOfRange
} from '@/utils/executionErrorUtil'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
@@ -59,6 +61,20 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
const isErrorOverlayOpen = ref(false)
/** Replaces the full record; empty or null means the run produced no errors. */
function recordNodeErrors(nodeErrors: Record<string, NodeError> | null) {
lastNodeErrors.value =
nodeErrors && Object.keys(nodeErrors).length > 0 ? nodeErrors : null
}
function recordExecutionError(detail: ExecutionErrorWsMessage) {
lastExecutionError.value = detail
}
function recordPromptError(promptError: PromptError) {
lastPromptError.value = promptError
}
function showErrorOverlay() {
isErrorOverlayOpen.value = true
}
@@ -82,10 +98,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
function clearExecutionStartErrors() {
lastExecutionError.value = null
lastPromptError.value = null
if (
!lastNodeErrors.value ||
Object.keys(lastNodeErrors.value).length === 0
) {
if (!lastNodeErrors.value) {
isErrorOverlayOpen.value = false
}
}
@@ -105,7 +118,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
const isSlotScoped = slotName !== undefined
const relevantErrors = isSlotScoped
? nodeError.errors.filter((e) => e.extra_info?.input_name === slotName)
? errorsForSlot(nodeError.errors, slotName)
: nodeError.errors
if (relevantErrors.length === 0) return null
@@ -117,7 +130,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
if (isSlotScoped) {
const remainingErrors = nodeError.errors.filter(
(e) => e.extra_info?.input_name !== slotName
(error) => !relevantErrors.includes(error)
)
if (remainingErrors.length === 0) {
delete updated[executionId]
@@ -218,9 +231,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
const nodeError = nodeErrors[target.executionId]
if (!nodeError) return false
const errors = nodeError.errors.filter(
(error) => error.extra_info?.input_name === target.slotName
)
const errors = errorsForSlot(nodeError.errors, target.slotName)
const options = target.useRecordedBounds
? getTargetRangeOptions(errors, callerOptions)
: callerOptions
@@ -374,9 +385,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
const hasPromptError = computed(() => !!lastPromptError.value)
const hasNodeError = computed(
() => !!lastNodeErrors.value && Object.keys(lastNodeErrors.value).length > 0
)
const hasNodeError = computed(() => lastNodeErrors.value !== null)
// Re-lifts only when the record changes; topology is assumed stable while errors are displayed.
const surfacedNodeErrors = computed(() =>
@@ -495,7 +504,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
const nodeError = getNodeErrors(nodeLocatorId)
if (!nodeError) return false
return nodeError.errors.some((e) => e.extra_info?.input_name === slotName)
return hasErrorForSlot(nodeError.errors, slotName)
}
/**
@@ -525,10 +534,15 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
useNodeErrorFlagSync(surfacedNodeErrors, missingModelStore, missingMediaStore)
return {
// Raw state
lastNodeErrors,
lastExecutionError,
lastPromptError,
// Read-only state
lastNodeErrors: computed(() => lastNodeErrors.value),
lastExecutionError: computed(() => lastExecutionError.value),
lastPromptError: computed(() => lastPromptError.value),
// Recording
recordNodeErrors,
recordExecutionError,
recordPromptError,
// Clearing
clearAllErrors,

View File

@@ -935,7 +935,7 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
})
it('should return node error by locator ID for root graph node', () => {
store.lastNodeErrors = {
store.recordNodeErrors({
'123': {
errors: [
{
@@ -948,7 +948,7 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
class_type: 'TestNode',
dependent_outputs: []
}
}
})
const result = store.getNodeErrors(
createNodeLocatorId(null, toNodeId(123))
@@ -974,7 +974,7 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
vi.mocked(app.rootGraph.getNodeById).mockReturnValue(mockNode)
store.lastNodeErrors = {
store.recordNodeErrors({
'123:456': {
errors: [
{
@@ -987,7 +987,7 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
class_type: 'SubgraphNode',
dependent_outputs: []
}
}
})
const locatorId = createNodeLocatorId(subgraphUuid, toNodeId(456))
const result = store.getNodeErrors(locatorId)
@@ -1006,7 +1006,7 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
})
it('should return false when node has errors but slot is not mentioned', () => {
store.lastNodeErrors = {
store.recordNodeErrors({
'123': {
errors: [
{
@@ -1019,7 +1019,7 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
class_type: 'TestNode',
dependent_outputs: []
}
}
})
const result = store.slotHasError(
createNodeLocatorId(null, toNodeId(123)),
@@ -1029,7 +1029,7 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
})
it('should return true when slot has error', () => {
store.lastNodeErrors = {
store.recordNodeErrors({
'123': {
errors: [
{
@@ -1042,7 +1042,7 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
class_type: 'TestNode',
dependent_outputs: []
}
}
})
const result = store.slotHasError(
createNodeLocatorId(null, toNodeId(123)),
@@ -1052,7 +1052,7 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
})
it('should return true when multiple errors exist for the same slot', () => {
store.lastNodeErrors = {
store.recordNodeErrors({
'123': {
errors: [
{
@@ -1071,7 +1071,7 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
class_type: 'TestNode',
dependent_outputs: []
}
}
})
const result = store.slotHasError(
createNodeLocatorId(null, toNodeId(123)),
@@ -1081,7 +1081,7 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
})
it('should handle errors without extra_info', () => {
store.lastNodeErrors = {
store.recordNodeErrors({
'123': {
errors: [
{
@@ -1093,7 +1093,7 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
class_type: 'TestNode',
dependent_outputs: []
}
}
})
const result = store.slotHasError(
createNodeLocatorId(null, toNodeId(123)),
@@ -1339,7 +1339,7 @@ describe('useExecutionStore - WebSocket event handlers', () => {
]
}
}
errorStore.lastExecutionError = {
errorStore.recordExecutionError({
prompt_id: 'old-job',
timestamp: 0,
node_id: '1',
@@ -1348,13 +1348,13 @@ describe('useExecutionStore - WebSocket event handlers', () => {
exception_message: 'boom',
exception_type: 'RuntimeError',
traceback: []
}
errorStore.lastPromptError = {
})
errorStore.recordPromptError({
type: 'old-error',
message: 'old prompt error',
details: ''
}
errorStore.lastNodeErrors = nodeErrors
})
errorStore.recordNodeErrors(nodeErrors)
errorStore.showErrorOverlay()
fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })

View File

@@ -554,7 +554,7 @@ export const useExecutionStore = defineStore('execution', () => {
}
setWorkflowStatus(e.detail.prompt_id, 'failed')
executionErrorStore.lastExecutionError = e.detail
executionErrorStore.recordExecutionError(e.detail)
clearInitializationByJobId(e.detail.prompt_id)
resetExecutionState(e.detail.prompt_id)
}
@@ -580,13 +580,13 @@ export const useExecutionStore = defineStore('execution', () => {
clearInitializationByJobId(detail.prompt_id)
resetExecutionState(detail.prompt_id)
executionErrorStore.lastPromptError = {
executionErrorStore.recordPromptError({
type: detail.exception_type ?? 'error',
message: detail.exception_type
? `${detail.exception_type}: ${detail.exception_message}`
: (detail.exception_message ?? ''),
details: detail.traceback?.join('\n') ?? ''
}
})
return true
}
@@ -600,9 +600,9 @@ export const useExecutionStore = defineStore('execution', () => {
resetExecutionState(detail.prompt_id)
if (result.kind === 'nodeErrors') {
executionErrorStore.lastNodeErrors = result.nodeErrors
executionErrorStore.recordNodeErrors(result.nodeErrors)
} else {
executionErrorStore.lastPromptError = result.promptError
executionErrorStore.recordPromptError(result.promptError)
}
return true
}

View File

@@ -80,7 +80,7 @@ export const useSubgraphStore = defineStore('subgraph', () => {
dependent_outputs: []
}
}
useExecutionErrorStore().lastNodeErrors = errors
useExecutionErrorStore().recordNodeErrors(errors)
useCanvasStore().getCanvas().draw(true, true)
throw new Error(
'The root graph of a subgraph blueprint must consist of only a single subgraph node'

View File

@@ -9,10 +9,10 @@ export function seedRequiredInputMissingNodeError(
executionId: NodeExecutionId,
inputName: string
): void {
store.lastNodeErrors = {
store.recordNodeErrors({
[executionId]: nodeError(
[validationError('required_input_missing', inputName, {}, 'Missing', '')],
'TestNode'
)
}
})
}

View File

@@ -1,6 +1,10 @@
import type { NodeError, PromptError } from '@/schemas/apiSchema'
import type { SerializedNodeId } from '@/types/nodeId'
type RawPromptError =
| string
| { type?: string; message?: string; details?: string }
/**
* The standard prompt validation response shape (`{ error, node_errors }`).
* In cloud, this is embedded as JSON inside `execution_error.exception_message`
@@ -8,7 +12,7 @@ import type { SerializedNodeId } from '@/types/nodeId'
* rather than as direct HTTP responses.
*/
interface CloudValidationError {
error?: { type?: string; message?: string; details?: string } | string
error?: RawPromptError
node_errors?: Record<SerializedNodeId, NodeError>
}
@@ -51,6 +55,22 @@ type CloudValidationResult =
| { kind: 'nodeErrors'; nodeErrors: Record<SerializedNodeId, NodeError> }
| { kind: 'promptError'; promptError: PromptError }
export function normalizePromptError(
error: RawPromptError | undefined
): PromptError | null {
if (error && typeof error === 'object') {
return {
type: error.type ?? 'error',
message: error.message ?? '',
details: error.details ?? ''
}
}
return typeof error === 'string'
? { type: 'error', message: error, details: '' }
: null
}
/**
* Classifies an embedded cloud validation error from `exception_message`
* as either node-level errors or a prompt-level error.
@@ -70,25 +90,22 @@ export function classifyCloudValidationError(
return { kind: 'nodeErrors', nodeErrors: node_errors }
}
if (error && typeof error === 'object') {
return {
kind: 'promptError',
promptError: {
type: error.type ?? 'error',
message: error.message ?? '',
details: error.details ?? ''
}
}
}
const promptError = normalizePromptError(error)
return promptError ? { kind: 'promptError', promptError } : null
}
if (typeof error === 'string') {
return {
kind: 'promptError',
promptError: { type: 'error', message: error, details: '' }
}
}
export function errorsForSlot(
errors: NodeError['errors'],
slotName: string
): NodeError['errors'] {
return errors.filter((error) => error.extra_info?.input_name === slotName)
}
return null
export function hasErrorForSlot(
errors: NodeError['errors'],
slotName: string
): boolean {
return errors.some((error) => error.extra_info?.input_name === slotName)
}
/**

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.