Compare commits

..

6 Commits

Author SHA1 Message Date
bigcat88
a66f9afcd5 chore: relocate main.ts bootstrap comment and mock api in comfyApi.test 2026-06-24 11:29:13 +03:00
bigcat88
083317f378 fix(remoteConfig): scope the abort timeout to the no-auth bootstrap 2026-06-24 11:29:13 +03:00
bigcat88
e9047bf610 fix(remoteConfig): respect path prefix in the no-auth /features bootstrap 2026-06-24 11:29:12 +03:00
bigcat88
3852b69b19 test(firebase): test staging detection against a prod fallback 2026-06-24 11:29:12 +03:00
bigcat88
ae343602b5 fix: drop unnecessary comments 2026-06-24 11:29:11 +03:00
bigcat88
ce6ca4dafd feat: follow --comfy-api-base for staging and preview backends
Read the api and platform base from /features (remoteConfig) for all builds, and pick the dev Firebase project when the api base is staging-tier, so a prod bundle can talk to a staging or preview backend without a rebuild.
2026-06-24 11:29:11 +03:00
148 changed files with 1195 additions and 6784 deletions

View File

@@ -5,7 +5,6 @@ on:
types: [created]
pull_request_target:
types: [opened, synchronize, closed]
merge_group:
permissions:
actions: write
@@ -41,7 +40,7 @@ jobs:
# Allowlist bots so they don't need to sign (optional, comma-separated).
# *[bot] is a catch-all for any GitHub App bot account.
allowlist: actions-user,ampagent,claude,coderabbitai[bot],comfy-pr-bot,dependabot[bot],github-actions[bot],copilot-swe-agent[bot],devin-ai-integration[bot],*[bot]
allowlist: ampagent,claude,coderabbitai[bot],comfy-pr-bot,dependabot[bot],github-actions[bot],copilot-swe-agent[bot],devin-ai-integration[bot],*[bot]
# Custom PR comment messages
custom-notsigned-prcomment: |

View File

@@ -56,16 +56,12 @@ class ComfyPropertiesPanel {
readonly panelTitle: Locator
readonly searchBox: Locator
readonly titleEditor: TitleEditor
readonly toggleButton: Locator
constructor(readonly page: Page) {
this.root = page.getByTestId(TestIds.propertiesPanel.root)
this.panelTitle = this.root.locator('h3')
this.searchBox = this.root.getByPlaceholder(/^Search/)
this.titleEditor = new TitleEditor(this.root)
this.toggleButton = page.getByRole('button', {
name: 'Toggle properties panel'
})
}
}

View File

@@ -112,10 +112,6 @@ export const TestIds = {
root: 'properties-panel',
errorsTab: 'panel-tab-errors'
},
assets: {
browserModal: 'asset-browser-modal',
card: 'asset-card'
},
subgraphEditor: {
hiddenSection: 'subgraph-editor-hidden-section',
iconEye: 'icon-eye',

View File

@@ -223,23 +223,4 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
await expect(comfyPage.settingDialog.root).toBeVisible()
await expect(comfyPage.settingDialog.category('Keybinding')).toBeVisible()
})
test('should focus keybindings search when opening manage shortcuts', async ({
comfyPage
}) => {
const { bottomPanel } = comfyPage
await bottomPanel.keyboardShortcutsButton.click()
await bottomPanel.shortcuts.manageButton.click()
await expect(comfyPage.settingDialog.root).toBeVisible()
await expect(comfyPage.settingDialog.category('Keybinding')).toBeVisible()
await expect(
comfyPage.page.getByPlaceholder('Search Keybindings...')
).toBeFocused()
await expect(
comfyPage.page.getByPlaceholder('Search Settings...')
).not.toBeFocused()
})
})

View File

@@ -1,99 +0,0 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import {
assetRequestIncludesTag,
createCloudAssetsFixture
} from '@e2e/fixtures/assetApiFixture'
import {
STABLE_CHECKPOINT,
STABLE_CHECKPOINT_2
} from '@e2e/fixtures/data/assetFixtures'
import { TestIds } from '@e2e/fixtures/selectors'
const WORKFLOW = 'missing/missing_model_promoted_widget'
const HOST_NODE_ID = 2
const WIDGET_NAME = 'ckpt_name'
const SELECTED_MODEL = STABLE_CHECKPOINT_2.name
const test = createCloudAssetsFixture([STABLE_CHECKPOINT, STABLE_CHECKPOINT_2])
interface WidgetSnapshot {
type: string
value: string
hasLayout: boolean
}
async function getHostWidgetSnapshot(page: Page): Promise<WidgetSnapshot> {
return await page.evaluate(
({ nodeId, widgetName }) => {
const node = window.app!.graph.getNodeById(nodeId)
const widget = node?.widgets?.find((widget) => widget.name === widgetName)
return {
type: widget?.type ?? '',
value: String(widget?.value ?? ''),
hasLayout: widget?.last_y != null
}
},
{ nodeId: HOST_NODE_ID, widgetName: WIDGET_NAME }
)
}
test.describe(
'Promoted subgraph asset widgets',
{ tag: ['@cloud', '@canvas', '@widget'] },
() => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.nodeOps.clearGraph()
})
test('legacy asset browser selection updates the promoted host widget value', async ({
cloudAssetRequests,
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', true)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await expect
.poll(
() =>
cloudAssetRequests.some((url) =>
assetRequestIncludesTag(url, 'checkpoints')
),
{ timeout: 10_000 }
)
.toBe(true)
await expect
.poll(() => getHostWidgetSnapshot(comfyPage.page))
.toMatchObject({
type: 'asset',
hasLayout: true
})
const initialWidget = await getHostWidgetSnapshot(comfyPage.page)
expect(initialWidget.value).not.toBe(SELECTED_MODEL)
const hostNode = await comfyPage.nodeOps.getNodeRefById(HOST_NODE_ID)
await hostNode.centerOnNode()
const promotedWidget = await hostNode.getWidgetByName(WIDGET_NAME)
await promotedWidget.click()
const modal = comfyPage.page.getByTestId(TestIds.assets.browserModal)
await expect(modal).toBeVisible()
const assetCard = modal
.getByTestId(TestIds.assets.card)
.filter({ hasText: SELECTED_MODEL })
.first()
await expect(assetCard).toBeVisible()
await assetCard.getByRole('button', { name: 'Use' }).click()
await expect(modal).toBeHidden()
await expect
.poll(() =>
getHostWidgetSnapshot(comfyPage.page).then((widget) => widget.value)
)
.toBe(SELECTED_MODEL)
})
}
)

View File

@@ -1,9 +1,6 @@
import type { ConsoleMessage } from '@playwright/test'
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { getPseudoPreviewWidgets } from '@e2e/fixtures/utils/promotedWidgets'
const domPreviewSelector = '.image-preview'
@@ -98,225 +95,4 @@ test.describe('Subgraph Lifecycle', { tag: ['@subgraph'] }, () => {
await expect(comfyPage.page.locator(domPreviewSelector)).toHaveCount(0)
})
})
test.describe('Detach Race Repro', { tag: ['@vue-nodes'] }, () => {
const SUBGRAPH_NODE_TITLE = 'New Subgraph'
// Queues legacy onNodeRemoved/onSelectionChange so unpack completes first,
// widening the race window so a guard regression deterministically surfaces.
async function deferLegacyHandlers(comfyPage: ComfyPage) {
return await comfyPage.page.evaluateHandle(() => {
const graph = window.app!.graph!
const canvas = window.app!.canvas!
const queue: Array<() => void> = []
const originalNodeRemoved = graph.onNodeRemoved
const originalSelectionChange = canvas.onSelectionChange
graph.onNodeRemoved = function (node) {
queue.push(() => originalNodeRemoved?.call(this, node))
}
canvas.onSelectionChange = function (selected) {
queue.push(() => originalSelectionChange?.call(this, selected))
}
return {
drain: () => {
for (const fn of queue.splice(0)) fn()
},
restore: () => {
graph.onNodeRemoved = originalNodeRemoved
canvas.onSelectionChange = originalSelectionChange
}
}
})
}
type DeferredHandlers = Awaited<ReturnType<typeof deferLegacyHandlers>>
// Defers only the legacy selection-change callback, so the detached host
// node lingers in the reactive selection while onNodeRemoved still runs
// normally and clears it from the canvas. This isolates the panel render
// path: a panel mounted during this window reads the stale selection.
async function deferSelectionChange(
comfyPage: ComfyPage
): Promise<DeferredHandlers> {
return await comfyPage.page.evaluateHandle(() => {
const canvas = window.app!.canvas!
const queue: Array<() => void> = []
const original = canvas.onSelectionChange
canvas.onSelectionChange = function (selected) {
queue.push(() => original?.call(this, selected))
}
return {
drain: () => {
for (const fn of queue.splice(0)) fn()
},
restore: () => {
canvas.onSelectionChange = original
}
}
})
}
function isNullGraphErrorText(text: string): boolean {
return text.includes('NullGraphError') || text.endsWith('has no graph')
}
// Vue's default errorHandler routes render throws to console.error,
// not pageerror - listen to both.
function captureNullGraphErrors(comfyPage: ComfyPage) {
const captured: string[] = []
const onPageError = (err: Error) => {
if (
err.name === 'NullGraphError' ||
isNullGraphErrorText(err.message ?? '')
) {
captured.push(`pageerror ${err.name}: ${err.message}`)
}
}
const onConsoleMessage = (msg: ConsoleMessage) => {
if (msg.type() !== 'error') return
const text = msg.text()
if (isNullGraphErrorText(text)) {
captured.push(`console.error: ${text}`)
}
}
comfyPage.page.on('pageerror', onPageError)
comfyPage.page.on('console', onConsoleMessage)
return {
getErrors: () => [...captured],
stop: () => {
comfyPage.page.off('pageerror', onPageError)
comfyPage.page.off('console', onConsoleMessage)
}
}
}
async function unpackViaContextMenu(comfyPage: ComfyPage, title: string) {
const fixture = await comfyPage.vueNodes.getFixtureByTitle(title)
await comfyPage.contextMenu.openForVueNode(fixture.header)
await comfyPage.contextMenu.clickMenuItemExact('Unpack Subgraph')
}
async function reopenRightSidePanel(comfyPage: ComfyPage) {
const { propertiesPanel } = comfyPage.menu
await propertiesPanel.toggleButton.click()
await expect(propertiesPanel.root).toBeHidden()
await propertiesPanel.toggleButton.click()
await comfyPage.nextFrame()
}
// Unpacks the subgraph behind deferred teardown, runs an optional
// interaction while the node is detached but not yet cleaned up, then
// drains the deferred handlers and reports any NullGraphErrors seen.
async function unpackAndCaptureNullGraphErrors(
comfyPage: ComfyPage,
options: {
defer: (comfyPage: ComfyPage) => Promise<DeferredHandlers>
duringWindow?: (comfyPage: ComfyPage) => Promise<void>
}
): Promise<string[]> {
const subgraphNode =
comfyPage.vueNodes.getNodeByTitle(SUBGRAPH_NODE_TITLE)
const errors = captureNullGraphErrors(comfyPage)
const deferred = await options.defer(comfyPage)
try {
await unpackViaContextMenu(comfyPage, SUBGRAPH_NODE_TITLE)
await expect(subgraphNode).toHaveCount(0)
await options.duringWindow?.(comfyPage)
await deferred.evaluate((handlers) => handlers.drain())
// Let drained-handler reactive flushes settle before stop().
await comfyPage.nextFrame()
return errors.getErrors()
} finally {
await deferred.evaluate((handlers) => handlers.restore())
await deferred.dispose()
errors.stop()
}
}
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.RightSidePanel.IsOpen', true)
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const subgraphNode =
comfyPage.vueNodes.getNodeByTitle(SUBGRAPH_NODE_TITLE)
await expect(subgraphNode).toBeVisible()
const fixture =
await comfyPage.vueNodes.getFixtureByTitle(SUBGRAPH_NODE_TITLE)
await fixture.header.click()
await expect(
comfyPage.page.getByTestId(TestIds.propertiesPanel.root)
).toBeVisible()
await comfyPage.nextFrame()
})
test('unpack does not surface NullGraphError on the LGraphNode render path', async ({
comfyPage
}) => {
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
defer: deferLegacyHandlers
})
expect(
nullGraphErrors,
'LGraphNode render path: detach race must not surface NullGraphError'
).toEqual([])
})
test('unpack does not surface NullGraphError from the TabSubgraphInputs panel', async ({
comfyPage
}) => {
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
defer: deferLegacyHandlers
})
expect(
nullGraphErrors,
'TabSubgraphInputs panel: detach race must not surface NullGraphError'
).toEqual([])
})
test('unpack with subgraph editor open does not surface NullGraphError from the SubgraphEditor panel', async ({
comfyPage
}) => {
await comfyPage.page.getByTestId(TestIds.subgraphEditor.toggle).click()
await comfyPage.nextFrame()
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
defer: deferLegacyHandlers
})
expect(
nullGraphErrors,
'SubgraphEditor panel: detach race must not surface NullGraphError'
).toEqual([])
})
test('reopening the right side panel after unpack does not surface NullGraphError', async ({
comfyPage
}) => {
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
defer: deferSelectionChange,
duringWindow: reopenRightSidePanel
})
expect(
nullGraphErrors,
'TabSubgraphInputs remount: stale selection must not surface NullGraphError'
).toEqual([])
})
test('reopening the right side panel with the subgraph editor open does not surface NullGraphError', async ({
comfyPage
}) => {
await comfyPage.page.getByTestId(TestIds.subgraphEditor.toggle).click()
await comfyPage.nextFrame()
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
defer: deferSelectionChange,
duringWindow: reopenRightSidePanel
})
expect(
nullGraphErrors,
'SubgraphEditor remount: stale selection must not surface NullGraphError'
).toEqual([])
})
})
})

View File

@@ -335,30 +335,6 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
await comfyPage.canvasOps.moveMouseToEmptyArea()
})
test('pointerCancel stops autopan', async ({ comfyPage }) => {
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await ksampler.header.click({ trial: true })
await comfyPage.page.mouse.down()
const getOffset = () => comfyPage.canvasOps.getOffset()
const initialOffset = await getOffset()
await comfyPage.page.mouse.move(10, 10, { steps: 20 })
await expect.poll(getOffset, 'drag with autopan').not.toEqual(initialOffset)
await test.step('move outside pan range and cancel drag', async () => {
await comfyPage.page.mouse.move(400, 400, { steps: 20 })
await ksampler.header.evaluate((node) =>
node.dispatchEvent(new PointerEvent('pointercancel', { bubbles: true }))
)
})
const secondaryOffset = await getOffset()
await comfyPage.page.mouse.move(10, 10, { steps: 20 })
await comfyPage.nextFrame()
expect(await getOffset(), 'drag canceled').toEqual(secondaryOffset)
})
test(
'@mobile should allow moving nodes by dragging on touch devices',
{ tag: '@screenshot' },

View File

@@ -344,15 +344,6 @@ export const zDynamicComboInputSpec = z.tuple([
})
])
export const zDynamicGroupInputSpec = z.tuple([
z.literal('COMFY_DYNAMICGROUP_V3'),
zBaseInputOptions.extend({
template: zComfyInputsSpec,
min: z.number().int().nonnegative().optional().default(0),
max: z.number().int().positive().max(100).optional().default(50)
})
])
export const zMatchTypeOptions = z.object({
...zBaseInputOptions.shape,
type: z.literal('COMFY_MATCHTYPE_V3'),

View File

@@ -1,224 +0,0 @@
/* eslint-disable testing-library/no-container, testing-library/no-node-access, testing-library/prefer-user-event */
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import WidgetBoundingBoxes from './WidgetBoundingBoxes.vue'
import boundingBoxes from '@/locales/en/main.json'
import type { BoundingBox } from '@/types/boundingBoxes'
const { appState } = vi.hoisted(() => ({ appState: { node: null as unknown } }))
vi.mock('@/scripts/app', () => ({
app: { canvas: { graph: { getNodeById: () => appState.node } } }
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
boundingBoxes: boundingBoxes.boundingBoxes,
palette: { swatchTitle: 'Edit', addColor: 'Add' }
}
}
})
const box = (over: Partial<BoundingBox> = {}): BoundingBox => ({
x: 51,
y: 51,
width: 256,
height: 256,
metadata: { type: 'obj', text: '', desc: '', palette: ['#ff0000'] },
...over
})
const fakeCtx = {
measureText: (s: string) => ({ width: s.length * 7 }),
setTransform: () => {},
clearRect: () => {},
fillRect: () => {},
strokeRect: () => {},
fillText: () => {},
drawImage: () => {},
save: () => {},
restore: () => {},
beginPath: () => {},
rect: () => {},
clip: () => {},
font: '',
fillStyle: '',
strokeStyle: '',
lineWidth: 0
} as unknown as CanvasRenderingContext2D
function prepCanvas(canvas: HTMLCanvasElement) {
Object.defineProperty(canvas, 'clientWidth', {
value: 100,
configurable: true
})
Object.defineProperty(canvas, 'clientHeight', {
value: 100,
configurable: true
})
canvas.getContext = (() =>
fakeCtx) as unknown as HTMLCanvasElement['getContext']
canvas.getBoundingClientRect = () =>
({
left: 0,
top: 0,
right: 100,
bottom: 100,
width: 100,
height: 100,
x: 0,
y: 0,
toJSON: () => ({})
}) as DOMRect
canvas.setPointerCapture = () => {}
canvas.releasePointerCapture = () => {}
}
function renderWidget(modelValue: BoundingBox[]) {
const result = render(WidgetBoundingBoxes, {
props: { nodeId: '1', modelValue },
global: { plugins: [i18n] }
})
const canvas = screen.getByTestId('bounding-boxes').querySelector('canvas')!
prepCanvas(canvas)
return { ...result, canvas }
}
const lastBoxes = (emitted: () => Record<string, unknown[][]>) => {
const calls = emitted()['update:modelValue']
return calls[calls.length - 1][0] as BoundingBox[]
}
beforeEach(() => {
setActivePinia(createPinia())
appState.node = {
widgets: [
{ name: 'width', value: 512 },
{ name: 'height', value: 512 }
],
findInputSlot: () => -1,
getInputNode: () => null
}
vi.stubGlobal('requestAnimationFrame', () => 1)
vi.stubGlobal('cancelAnimationFrame', () => {})
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('WidgetBoundingBoxes', () => {
it('renders the canvas and editor shell', () => {
renderWidget([])
expect(
screen.getByTestId('bounding-boxes').querySelector('canvas')
).not.toBeNull()
})
it('shows the region editor panel when a region is active', () => {
renderWidget([box()])
expect(screen.getByText('obj')).toBeTruthy()
expect(screen.getByText('text')).toBeTruthy()
})
it('reveals the text field after switching the region to text', async () => {
renderWidget([box()])
expect(
screen.queryByPlaceholderText('text to render (verbatim)')
).toBeNull()
await userEvent.click(screen.getByText('text'))
expect(
screen.getByPlaceholderText('text to render (verbatim)')
).toBeTruthy()
})
it('clears all regions via the clear button', async () => {
const { emitted } = renderWidget([box()])
await userEvent.click(screen.getByText('Clear all'))
expect(lastBoxes(emitted)).toEqual([])
})
it('draws a region through canvas pointer events', async () => {
const { canvas, emitted } = renderWidget([])
await fireEvent.pointerDown(canvas, {
button: 0,
clientX: 10,
clientY: 10,
pointerId: 1
})
await fireEvent.pointerMove(canvas, {
clientX: 60,
clientY: 60,
pointerId: 1
})
await fireEvent.pointerUp(canvas, {
clientX: 60,
clientY: 60,
pointerId: 1
})
expect(lastBoxes(emitted)).toHaveLength(1)
})
it('tracks focus and blur on the canvas', async () => {
const { canvas } = renderWidget([box()])
await fireEvent.focus(canvas)
await fireEvent.blur(canvas)
expect(canvas).toBeTruthy()
})
it('opens an inline editor on double click', async () => {
const { canvas, container } = renderWidget([box()])
await fireEvent.dblClick(canvas, { clientX: 30, clientY: 30 })
expect(container.querySelector('textarea')).not.toBeNull()
})
it('syncs description edits back to the model', async () => {
const { emitted } = renderWidget([box()])
await fireEvent.update(
screen.getByPlaceholderText('description of this region'),
'a caption'
)
expect(lastBoxes(emitted)[0].metadata.desc).toBe('a caption')
})
it('edits the text field once the region is a text region', async () => {
const { emitted } = renderWidget([box()])
await userEvent.click(screen.getByText('text'))
await fireEvent.update(
screen.getByPlaceholderText('text to render (verbatim)'),
'hello'
)
expect(lastBoxes(emitted)[0].metadata.text).toBe('hello')
})
it('deletes the active region with the Delete key', async () => {
const { canvas, emitted } = renderWidget([box()])
await fireEvent.keyDown(canvas, { key: 'Delete' })
expect(lastBoxes(emitted)).toEqual([])
})
it('clears hover state on pointer leave', async () => {
const { canvas } = renderWidget([
box({ x: 10, y: 10, width: 256, height: 256 })
])
await fireEvent.pointerMove(canvas, { clientX: 15, clientY: 15 })
await fireEvent.pointerLeave(canvas)
expect(canvas).toBeTruthy()
})
it('commits the inline editor on blur', async () => {
const { canvas, container, emitted } = renderWidget([box()])
await fireEvent.dblClick(canvas, { clientX: 30, clientY: 30 })
const editor = container.querySelector('textarea')!
await fireEvent.update(editor, 'committed')
await fireEvent.blur(editor)
expect(lastBoxes(emitted)[0].metadata.desc).toBe('committed')
})
})

View File

@@ -1,181 +0,0 @@
<template>
<div
class="widget-expands flex size-full flex-col gap-1 select-none"
data-testid="bounding-boxes"
@pointerdown.stop
>
<div
ref="canvasContainer"
class="relative w-full shrink-0 overflow-hidden rounded-sm border border-component-node-border bg-node-component-surface"
:style="canvasStyle"
>
<canvas
ref="canvasEl"
tabindex="0"
class="absolute inset-0 size-full rounded-sm outline-none"
:style="{ cursor: canvasCursor }"
@pointerdown="onPointerDown"
@pointermove="onCanvasPointerMove"
@pointerup="onDocPointerUp"
@pointercancel="onDocPointerUp"
@pointerleave="onPointerLeave"
@lostpointercapture="onDocPointerUp"
@dblclick="onDoubleClick"
@keydown="onCanvasKeyDown"
@focus="focused = true"
@blur="focused = false"
/>
<textarea
v-if="inlineEditor"
ref="inlineEditorEl"
v-model="inlineEditor.value"
class="absolute box-border resize-none rounded-sm border-2 bg-black/90 p-1 font-mono text-xs text-white outline-none"
:style="inlineEditor.style"
data-capture-wheel="true"
@keydown.stop="onInlineKeyDown"
@blur="commitInlineEditor"
/>
</div>
<div
v-if="activeRegion"
class="flex flex-col gap-2 rounded-sm bg-node-component-surface p-2 text-xs"
>
<div
class="flex h-8 items-center gap-1 rounded-sm bg-component-node-widget-background p-1"
>
<Button
variant="textonly"
size="unset"
:class="
cn(
'flex-1 self-stretch px-2 text-xs transition-colors',
activeRegion.type === 'obj'
? 'rounded-sm bg-component-node-widget-background-selected text-base-foreground'
: 'text-node-text-muted hover:text-node-text'
)
"
@click="setActiveType('obj')"
>
{{ $t('boundingBoxes.typeObj') }}
</Button>
<Button
variant="textonly"
size="unset"
:class="
cn(
'flex-1 self-stretch px-2 text-xs transition-colors',
activeRegion.type === 'text'
? 'rounded-sm bg-component-node-widget-background-selected text-base-foreground'
: 'text-node-text-muted hover:text-node-text'
)
"
@click="setActiveType('text')"
>
{{ $t('boundingBoxes.typeText') }}
</Button>
</div>
<div
v-if="activeRegion.type === 'text'"
class="group relative rounded-lg transition-all focus-within:ring focus-within:ring-component-node-widget-background-highlighted hover:bg-component-node-widget-background-hovered"
>
<span
class="pointer-events-none absolute top-1.5 left-3 z-10 text-2xs text-muted-foreground"
>
{{ $t('boundingBoxes.textLabel') }}
</span>
<Textarea
v-model="activeRegion.text"
:placeholder="$t('boundingBoxes.textPlaceholder')"
class="min-h-14 resize-none overflow-hidden pt-5 text-(length:--comfy-textarea-font-size) leading-normal not-disabled:bg-component-node-widget-background not-disabled:text-component-node-foreground hover:overflow-auto focus:overflow-auto"
data-capture-wheel="true"
@update:model-value="syncState"
/>
</div>
<div
class="group relative rounded-lg transition-all focus-within:ring focus-within:ring-component-node-widget-background-highlighted hover:bg-component-node-widget-background-hovered"
>
<span
class="pointer-events-none absolute top-1.5 left-3 z-10 text-2xs text-muted-foreground"
>
{{ $t('boundingBoxes.descLabel') }}
</span>
<Textarea
v-model="activeRegion.desc"
:placeholder="$t('boundingBoxes.descPlaceholder')"
class="min-h-20 resize-none overflow-hidden pt-5 text-(length:--comfy-textarea-font-size) leading-normal not-disabled:bg-component-node-widget-background not-disabled:text-component-node-foreground hover:overflow-auto focus:overflow-auto"
data-capture-wheel="true"
@update:model-value="syncState"
/>
</div>
<div class="flex items-center gap-2">
<span class="shrink-0 truncate text-sm text-muted-foreground">
{{ $t('boundingBoxes.colors') }}
</span>
<PaletteSwatchRow
v-model="activeRegion.palette"
:max="maxColors"
@update:model-value="syncState"
/>
</div>
</div>
<div v-else-if="hasRegions" class="text-node-text-muted px-1 text-xs">
{{ $t('boundingBoxes.clickRegionToEdit') }}
</div>
<Button
variant="secondary"
size="md"
class="gap-2 rounded-lg border border-component-node-border bg-component-node-background text-xs text-muted-foreground hover:text-base-foreground"
@click="clearAll"
>
<i class="icon-[lucide--undo-2]" />
{{ $t('boundingBoxes.clearAll') }}
</Button>
</div>
</template>
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import PaletteSwatchRow from '@/components/palette/PaletteSwatchRow.vue'
import Button from '@/components/ui/button/Button.vue'
import Textarea from '@/components/ui/textarea/Textarea.vue'
import { useBoundingBoxes } from '@/composables/boundingBoxes/useBoundingBoxes'
import type { BoundingBox } from '@/types/boundingBoxes'
const { nodeId } = defineProps<{ nodeId: string }>()
const modelValue = defineModel<BoundingBox[]>({ default: () => [] })
const canvasEl = useTemplateRef<HTMLCanvasElement>('canvasEl')
const canvasContainer = useTemplateRef<HTMLDivElement>('canvasContainer')
const inlineEditorEl = useTemplateRef<HTMLTextAreaElement>('inlineEditorEl')
const {
canvasStyle,
canvasCursor,
focused,
activeRegion,
hasRegions,
inlineEditor,
maxColors,
onPointerDown,
onCanvasPointerMove,
onDocPointerUp,
onPointerLeave,
onDoubleClick,
onCanvasKeyDown,
onInlineKeyDown,
commitInlineEditor,
setActiveType,
clearAll,
syncState
} = useBoundingBoxes(nodeId, {
canvasEl,
canvasContainer,
inlineEditorEl,
modelValue
})
</script>

View File

@@ -8,7 +8,6 @@
v-model="filters['global'].value"
class="max-w-96"
size="lg"
autofocus
:placeholder="
$t('g.searchPlaceholder', { subject: $t('g.keybindings') })
"

View File

@@ -1,126 +0,0 @@
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import HdrViewerContent from './HdrViewerContent.vue'
vi.mock('@/base/common/downloadUtil', () => ({ downloadFile: vi.fn() }))
const holder = vi.hoisted(() => ({ viewer: undefined as unknown }))
vi.mock('@/composables/useHdrViewer', () => ({
useHdrViewer: () => holder.viewer,
CHANNEL_MODES: ['rgb', 'r', 'g', 'b', 'a', 'luminance']
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { loading: 'Loading', downloadImage: 'Download' },
hdrViewer: {
failedToLoad: 'Failed',
exposure: 'Exposure',
normalizeExposure: 'Auto exposure',
channel: 'Channel',
channels: {
rgb: 'RGB',
r: 'R',
g: 'G',
b: 'B',
a: 'Alpha',
luminance: 'Luminance'
},
sourceGamut: 'Source gamut',
dither: 'Dither',
clipWarnings: 'Clip warnings',
fitView: 'Fit',
histogram: 'Histogram',
resolution: 'Resolution',
min: 'Min',
max: 'Max',
mean: 'Mean',
stdDev: 'Std dev',
nan: 'NaN',
inf: 'Inf'
}
}
}
})
function makeViewer(overrides: Record<string, unknown> = {}) {
return {
exposureStops: ref(0),
dither: ref(true),
clipWarnings: ref(false),
gamut: ref('sRGB'),
channel: ref('r'),
loading: ref(false),
error: ref(null),
dimensions: ref('512 x 512'),
stats: ref({
min: 0,
max: 4,
mean: 0.5,
stdDev: 0.2,
nanCount: 2,
infCount: 1
}),
histogram: ref(new Uint32Array([1, 2, 3, 4])),
pixel: ref({ x: 1, y: 2, r: 0.1, g: 0.2, b: 0.3, a: 1 }),
mount: vi.fn(),
dispose: vi.fn(),
fitView: vi.fn(),
normalizeExposure: vi.fn(),
...overrides
}
}
function renderViewer() {
return render(HdrViewerContent, {
props: { imageUrl: '/api/view?filename=out.exr' },
global: { plugins: [i18n], stubs: { Button: true } }
})
}
describe('HdrViewerContent', () => {
beforeEach(() => {
holder.viewer = makeViewer()
})
it('renders the full statistics set including NaN/Inf', () => {
renderViewer()
for (const label of [
'Resolution',
'Min',
'Max',
'Mean',
'Std dev',
'NaN',
'Inf'
]) {
screen.getByText(label)
}
})
it('shows the pixel readout when a pixel is hovered', () => {
renderViewer()
expect(screen.getByTestId('hdr-pixel-readout')).toBeInTheDocument()
})
it('colors the histogram according to the selected channel', () => {
holder.viewer = makeViewer({ channel: ref('g') })
const { container } = renderViewer()
const path = container.querySelector('svg path')
expect(path?.getAttribute('class')).toContain('text-green-500')
})
it('renders an option for each channel mode', () => {
renderViewer()
expect(
screen.getByRole('option', { name: 'Luminance' })
).toBeInTheDocument()
})
})

View File

@@ -1,258 +0,0 @@
<template>
<div class="flex size-full bg-base-background">
<div class="relative flex-1">
<div
ref="containerRef"
class="absolute size-full"
data-testid="hdr-viewer-canvas"
/>
<div
v-if="viewer.loading.value"
class="absolute inset-0 flex items-center justify-center text-base-foreground"
>
{{ $t('g.loading') }}...
</div>
<div
v-else-if="viewer.error.value"
role="alert"
class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-base-foreground"
>
<i class="icon-[lucide--image-off] size-12" />
<p class="text-sm">{{ $t('hdrViewer.failedToLoad') }}</p>
</div>
<div
v-if="viewer.pixel.value"
class="absolute top-2 left-2 rounded-sm bg-base-background/80 px-2 py-1 font-mono text-xs text-base-foreground"
data-testid="hdr-pixel-readout"
>
<div>{{ viewer.pixel.value.x }}, {{ viewer.pixel.value.y }}</div>
<div>
{{ formatNum(viewer.pixel.value.r) }}
{{ formatNum(viewer.pixel.value.g) }}
{{ formatNum(viewer.pixel.value.b) }}
<template v-if="viewer.pixel.value.a !== null">
{{ formatNum(viewer.pixel.value.a) }}
</template>
</div>
</div>
</div>
<div class="flex w-72 flex-col" data-testid="hdr-viewer-sidebar">
<div class="flex-1 overflow-y-auto p-4">
<div class="space-y-2">
<div class="space-y-4 p-2">
<div class="flex flex-col gap-2">
<label>{{ $t('hdrViewer.exposure') }}: {{ exposureLabel }}</label>
<input
v-model.number="viewer.exposureStops.value"
type="range"
min="-10"
max="10"
step="0.1"
class="w-full"
:aria-label="$t('hdrViewer.exposure')"
/>
</div>
<Button
variant="secondary"
class="w-full"
@click="viewer.normalizeExposure"
>
{{ $t('hdrViewer.normalizeExposure') }}
</Button>
</div>
<div class="space-y-4 p-2">
<div class="flex flex-col gap-2">
<label>{{ $t('hdrViewer.channel') }}</label>
<select
v-model="viewer.channel.value"
class="bg-base-component-surface w-full rounded-sm px-2 py-1"
:aria-label="$t('hdrViewer.channel')"
>
<option v-for="mode in channelModes" :key="mode" :value="mode">
{{ channelLabels[mode] }}
</option>
</select>
</div>
<div class="flex flex-col gap-2">
<label>{{ $t('hdrViewer.sourceGamut') }}</label>
<select
v-model="viewer.gamut.value"
class="bg-base-component-surface w-full rounded-sm px-2 py-1"
:aria-label="$t('hdrViewer.sourceGamut')"
>
<option v-for="name in gamutNames" :key="name" :value="name">
{{ name }}
</option>
</select>
</div>
</div>
<div class="space-y-4 p-2">
<div class="flex items-center gap-2">
<input
id="hdr-dither"
v-model="viewer.dither.value"
type="checkbox"
class="size-4 cursor-pointer accent-node-component-surface-highlight"
/>
<label for="hdr-dither" class="cursor-pointer">
{{ $t('hdrViewer.dither') }}
</label>
</div>
<div class="flex items-center gap-2">
<input
id="hdr-clip"
v-model="viewer.clipWarnings.value"
type="checkbox"
class="size-4 cursor-pointer accent-node-component-surface-highlight"
/>
<label for="hdr-clip" class="cursor-pointer">
{{ $t('hdrViewer.clipWarnings') }}
</label>
</div>
</div>
<div v-if="histogramPath" class="space-y-2 p-2">
<label>{{ $t('hdrViewer.histogram') }}</label>
<svg
viewBox="0 0 1 1"
preserveAspectRatio="none"
class="bg-base-component-surface aspect-3/2 w-full rounded-sm"
>
<path
:d="histogramPath"
:class="histogramColorClass"
fill="currentColor"
fill-opacity="0.5"
stroke="none"
/>
</svg>
</div>
<div
v-if="viewer.stats.value"
class="space-y-1 p-2 text-xs tabular-nums"
>
<div v-if="viewer.dimensions.value" class="flex justify-between">
<span>{{ $t('hdrViewer.resolution') }}</span>
<span>{{ viewer.dimensions.value }}</span>
</div>
<div class="flex justify-between">
<span>{{ $t('hdrViewer.min') }}</span>
<span>{{ formatNum(viewer.stats.value.min) }}</span>
</div>
<div class="flex justify-between">
<span>{{ $t('hdrViewer.max') }}</span>
<span>{{ formatNum(viewer.stats.value.max) }}</span>
</div>
<div class="flex justify-between">
<span>{{ $t('hdrViewer.mean') }}</span>
<span>{{ formatNum(viewer.stats.value.mean) }}</span>
</div>
<div class="flex justify-between">
<span>{{ $t('hdrViewer.stdDev') }}</span>
<span>{{ formatNum(viewer.stats.value.stdDev) }}</span>
</div>
<div
v-if="viewer.stats.value.nanCount"
class="flex justify-between text-error"
>
<span>{{ $t('hdrViewer.nan') }}</span>
<span>{{ viewer.stats.value.nanCount }}</span>
</div>
<div
v-if="viewer.stats.value.infCount"
class="flex justify-between text-error"
>
<span>{{ $t('hdrViewer.inf') }}</span>
<span>{{ viewer.stats.value.infCount }}</span>
</div>
</div>
</div>
</div>
<div class="p-4">
<div class="flex gap-2">
<Button variant="secondary" class="flex-1" @click="viewer.fitView">
{{ $t('hdrViewer.fitView') }}
</Button>
<Button variant="secondary" class="flex-1" @click="handleDownload">
{{ $t('g.downloadImage') }}
</Button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
import Button from '@/components/ui/button/Button.vue'
import type { ChannelMode } from '@/composables/useHdrViewer'
import { CHANNEL_MODES, useHdrViewer } from '@/composables/useHdrViewer'
import { GAMUT_NAMES } from '@/renderer/hdr/colorGamut'
import { toFullResolutionUrl } from '@/utils/hdrFormatUtil'
import { histogramToPath } from '@/utils/histogramUtil'
const { imageUrl } = defineProps<{ imageUrl: string }>()
const { t } = useI18n()
const viewer = useHdrViewer()
const gamutNames = GAMUT_NAMES
const channelModes = CHANNEL_MODES
const containerRef = useTemplateRef<HTMLDivElement>('containerRef')
const exposureLabel = computed(() => {
const value = viewer.exposureStops.value
return `${value > 0 ? '+' : ''}${value.toFixed(1)}`
})
const histogramPath = computed(() =>
viewer.histogram.value ? histogramToPath(viewer.histogram.value) : ''
)
const histogramColorClass = computed(() => {
switch (viewer.channel.value) {
case 'r':
return 'text-red-500'
case 'g':
return 'text-green-500'
case 'b':
return 'text-blue-500'
default:
return 'text-base-foreground'
}
})
const channelLabels = computed<Record<ChannelMode, string>>(() => ({
rgb: t('hdrViewer.channels.rgb'),
r: t('hdrViewer.channels.r'),
g: t('hdrViewer.channels.g'),
b: t('hdrViewer.channels.b'),
a: t('hdrViewer.channels.a'),
luminance: t('hdrViewer.channels.luminance')
}))
function formatNum(value: number): string {
if (!Number.isFinite(value)) return String(value)
return Math.abs(value) >= 1000 || (value !== 0 && Math.abs(value) < 0.001)
? value.toExponential(3)
: value.toFixed(4)
}
function handleDownload() {
downloadFile(toFullResolutionUrl(imageUrl))
}
onMounted(() => {
if (containerRef.value) void viewer.mount(containerRef.value, imageUrl)
})
</script>

View File

@@ -1,70 +0,0 @@
/* eslint-disable testing-library/no-container, testing-library/no-node-access, testing-library/prefer-user-event */
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import PaletteSwatchRow from './PaletteSwatchRow.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { palette: { swatchTitle: 'Edit', addColor: 'Add' } } }
})
function renderRow(modelValue: string[], max = 5) {
return render(PaletteSwatchRow, {
props: { modelValue, max },
global: { plugins: [i18n] }
})
}
const lastEmit = (emitted: () => Record<string, unknown[][]>) => {
const calls = emitted()['update:modelValue']
return calls[calls.length - 1][0]
}
describe('PaletteSwatchRow', () => {
it('renders one swatch per color', () => {
const { container } = renderRow(['#ff0000', '#00ff00'])
expect(container.querySelectorAll('[data-index]')).toHaveLength(2)
})
it('appends a color when the add button is clicked', async () => {
const { emitted } = renderRow(['#ff0000'])
await userEvent.click(screen.getByRole('button'))
expect(lastEmit(emitted)).toEqual(['#ff0000', '#ffffff'])
})
it('removes a color on right click', async () => {
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
await fireEvent.contextMenu(container.querySelector('[data-index="0"]')!)
expect(lastEmit(emitted)).toEqual(['#00ff00'])
})
it('hides the add button once the max is reached', () => {
renderRow(['#a', '#b'], 2)
expect(screen.queryByRole('button')).toBeNull()
})
it('writes a picked color back through the hidden color input', async () => {
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
await fireEvent.click(container.querySelector('[data-index="1"]')!)
const input = container.querySelector(
'input[type="color"]'
) as HTMLInputElement
input.value = '#0000ff'
await fireEvent.input(input)
expect(lastEmit(emitted)).toEqual(['#ff0000', '#0000ff'])
})
it('starts a drag on pointer down without emitting', async () => {
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
await fireEvent.pointerDown(container.querySelector('[data-index="0"]')!, {
button: 0,
clientX: 5,
clientY: 5
})
expect(emitted()['update:modelValue']).toBeUndefined()
})
})

View File

@@ -1,48 +0,0 @@
<template>
<div ref="container" class="flex flex-wrap items-center gap-1">
<div
v-for="(hex, i) in modelValue"
:key="`${i}-${hex}`"
:data-index="i"
:data-hex="hex"
class="relative size-5 cursor-pointer rounded-sm border border-component-node-border"
:style="{ background: hex }"
:title="t('palette.swatchTitle')"
@click="openPicker(i, $event)"
@contextmenu.prevent.stop="remove(i)"
@pointerdown="onPointerDown(i, $event)"
/>
<button
v-if="modelValue.length < max"
type="button"
class="h-5 rounded-sm border border-component-node-border bg-component-node-widget-background px-2 text-xs leading-none"
:title="t('palette.addColor')"
@click="addColor"
>
+
</button>
<input
ref="picker"
type="color"
class="pointer-events-none absolute size-0 opacity-0"
@input="onPickerInput"
/>
</div>
</template>
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { usePaletteSwatchRow } from '@/composables/palette/usePaletteSwatchRow'
const { max = 5 } = defineProps<{ max?: number }>()
const modelValue = defineModel<string[]>({ required: true })
const { t } = useI18n()
const container = useTemplateRef<HTMLDivElement>('container')
const picker = useTemplateRef<HTMLInputElement>('picker')
const { openPicker, onPickerInput, remove, addColor, onPointerDown } =
usePaletteSwatchRow({ modelValue, container, picker })
</script>

View File

@@ -1,54 +0,0 @@
/* eslint-disable testing-library/no-node-access, testing-library/no-container, testing-library/prefer-user-event */
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import WidgetColors from './WidgetColors.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { palette: { swatchTitle: 'Edit', addColor: 'Add' } } }
})
function renderWidget(modelValue: string[], widget?: { name: string }) {
return render(WidgetColors, {
props: { modelValue, widget },
global: { plugins: [i18n] }
})
}
const cleanups: Array<() => void> = []
afterEach(() => {
while (cleanups.length) cleanups.pop()?.()
})
describe('WidgetColors', () => {
it('renders the palette swatch row for each color', () => {
renderWidget(['#ff0000', '#00ff00'])
const root = screen.getByTestId('colors')
expect(root.querySelectorAll('[data-index]')).toHaveLength(2)
})
it('shows the widget name as an inline label', () => {
renderWidget(['#ff0000'], { name: 'color_palette' })
expect(screen.getByText('color_palette')).toBeInTheDocument()
})
it('emits an updated palette when a color is added', async () => {
const { emitted } = renderWidget([])
await userEvent.click(screen.getByRole('button'))
const calls = emitted()['update:modelValue'] as unknown[][]
expect(calls[calls.length - 1][0]).toEqual(['#ffffff'])
})
it('does not stop swatch pointer moves from reaching document drag handlers', async () => {
const { container } = renderWidget(['#ff0000'])
const onDocMove = vi.fn()
document.addEventListener('pointermove', onDocMove)
cleanups.push(() => document.removeEventListener('pointermove', onDocMove))
await fireEvent.pointerMove(container.querySelector('[data-index="0"]')!)
expect(onDocMove).toHaveBeenCalled()
})
})

View File

@@ -1,29 +0,0 @@
<template>
<div
class="flex size-full items-center gap-2"
data-testid="colors"
@pointerdown.stop
>
<span
v-if="widget?.name"
class="shrink-0 truncate text-node-component-slot-text"
>
{{ widget.label || widget.name }}
</span>
<PaletteSwatchRow v-model="modelValue" :max="MAX_COLORS" />
</div>
</template>
<script setup lang="ts">
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import PaletteSwatchRow from './PaletteSwatchRow.vue'
const MAX_COLORS = 16
const { widget } = defineProps<{
widget?: Pick<SimplifiedWidget<string[]>, 'name' | 'label'>
}>()
const modelValue = defineModel<string[]>({ default: () => [] })
</script>

View File

@@ -1,5 +1,4 @@
import { render, screen } from '@testing-library/vue'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, ref } from 'vue'
@@ -166,9 +165,7 @@ describe('WidgetRange', () => {
outputsHolder.nodeOutputs = {
loc1: { histogram_range_w: [1, 2, 3, 4] }
}
renderWidget(
makeWidget({}, { nodeLocatorId: createNodeLocatorId(null, 'loc1') })
)
renderWidget(makeWidget({}, { nodeLocatorId: 'loc1' }))
expect(screen.getByTestId('range-editor').dataset.hasHistogram).toBe(
'true'
)
@@ -178,9 +175,7 @@ describe('WidgetRange', () => {
outputsHolder.nodeOutputs = {
loc1: { histogram_range_w: [] }
}
renderWidget(
makeWidget({}, { nodeLocatorId: createNodeLocatorId(null, 'loc1') })
)
renderWidget(makeWidget({}, { nodeLocatorId: 'loc1' }))
expect(screen.getByTestId('range-editor').dataset.hasHistogram).toBe(
'false'
)

View File

@@ -1,7 +1,6 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ErrorNodeCard from './ErrorNodeCard.vue'
import type { ErrorCardData } from './types'
import { createNodeExecutionId } from '@/types/nodeIdentification'
const meta: Meta<typeof ErrorNodeCard> = {
title: 'RightSidePanel/Errors/ErrorNodeCard',
@@ -24,7 +23,7 @@ type Story = StoryObj<typeof meta>
const singleErrorCard: ErrorCardData = {
id: 'node-10',
title: 'CLIPTextEncode',
nodeId: createNodeExecutionId([10]),
nodeId: '10',
nodeTitle: 'CLIP Text Encode (Prompt)',
isSubgraphNode: false,
errors: [
@@ -38,7 +37,7 @@ const singleErrorCard: ErrorCardData = {
const multipleErrorsCard: ErrorCardData = {
id: 'node-24',
title: 'VAEDecode',
nodeId: createNodeExecutionId([24]),
nodeId: '24',
nodeTitle: 'VAE Decode',
isSubgraphNode: false,
errors: [
@@ -56,7 +55,7 @@ const multipleErrorsCard: ErrorCardData = {
const runtimeErrorCard: ErrorCardData = {
id: 'exec-45',
title: 'KSampler',
nodeId: createNodeExecutionId([45]),
nodeId: '45',
nodeTitle: 'KSampler',
isSubgraphNode: false,
errors: [
@@ -76,7 +75,7 @@ const runtimeErrorCard: ErrorCardData = {
const subgraphErrorCard: ErrorCardData = {
id: 'node-3:15',
title: 'KSampler',
nodeId: createNodeExecutionId([3, 15]),
nodeId: '3:15',
nodeTitle: 'Nested KSampler',
isSubgraphNode: true,
errors: [

View File

@@ -6,7 +6,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ErrorNodeCard from './ErrorNodeCard.vue'
import type { ErrorCardData } from './types'
import { createNodeExecutionId } from '@/types/nodeIdentification'
const mockGetLogs = vi.fn(() => Promise.resolve('mock server logs'))
const mockSerialize = vi.fn(() => ({ nodes: [] }))
@@ -157,7 +156,7 @@ describe('ErrorNodeCard.vue', () => {
return {
id: `exec-${++cardIdCounter}`,
title: 'KSampler',
nodeId: createNodeExecutionId([10]),
nodeId: '10',
nodeTitle: 'KSampler',
errors: [
{
@@ -250,7 +249,7 @@ describe('ErrorNodeCard.vue', () => {
renderCard({
id: `node-${++cardIdCounter}`,
title: 'KSampler',
nodeId: createNodeExecutionId([10]),
nodeId: '10',
nodeTitle: 'KSampler',
errors: [
{
@@ -388,7 +387,7 @@ describe('ErrorNodeCard.vue', () => {
const card: ErrorCardData = {
id: `exec-${++cardIdCounter}`,
title: 'KSampler',
nodeId: createNodeExecutionId([10]),
nodeId: '10',
nodeTitle: 'KSampler',
errors: [
{

View File

@@ -1,5 +1,4 @@
import type { ResolvedErrorMessage } from '@/platform/errorCatalog/types'
import type { NodeExecutionId } from '@/types/nodeIdentification'
export interface ErrorItem extends ResolvedErrorMessage {
/** Raw source/API-compatible message. */
@@ -13,7 +12,7 @@ export interface ErrorItem extends ResolvedErrorMessage {
export interface ErrorCardData {
id: string
title: string
nodeId?: NodeExecutionId
nodeId?: string
nodeTitle?: string
graphNodeId?: string
isSubgraphNode?: boolean

View File

@@ -671,30 +671,6 @@ describe('useErrorGroups', () => {
expect(nodeIds).toEqual(['1', '2', '10'])
})
it('marks only nested execution paths as subgraph node cards', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
},
'1:20': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
}
}
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'execution'
)
expect(execGroup?.cards).toMatchObject([
{ nodeId: '1', isSubgraphNode: false },
{ nodeId: '1:20', isSubgraphNode: true }
])
})
it('sorts cards with subpath nodeIds before higher root IDs', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {

View File

@@ -39,8 +39,8 @@ import {
resolveRunErrorMessage
} from '@/platform/errorCatalog/errorMessageResolver'
import {
compareExecutionId,
tryNormalizeNodeExecutionId
isNodeExecutionId,
compareExecutionId
} from '@/types/nodeIdentification'
const PROMPT_CARD_ID = '__prompt__'
@@ -82,7 +82,7 @@ interface ErrorSearchItem {
type CataloguedErrorItem = ErrorItem & ResolvedCatalogErrorMessage
/** Resolve display info for a node by its execution ID. */
function resolveNodeInfo(nodeId: NodeExecutionId) {
function resolveNodeInfo(nodeId: string) {
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
return {
@@ -119,7 +119,7 @@ function getOrCreateGroup(
}
function createErrorCard(
nodeId: NodeExecutionId,
nodeId: string,
classType: string,
idPrefix: string
): ErrorCardData {
@@ -130,7 +130,7 @@ function createErrorCard(
nodeId,
nodeTitle: nodeInfo.title,
graphNodeId: nodeInfo.graphNodeId,
isSubgraphNode: nodeId.includes(':'),
isSubgraphNode: isNodeExecutionId(nodeId),
errors: []
}
}
@@ -288,7 +288,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
return map
})
function isErrorInSelection(executionNodeId: NodeExecutionId): boolean {
function isErrorInSelection(executionNodeId: string): boolean {
const nodeIds = selectedNodeInfo.value.nodeIds
if (!nodeIds) return true
@@ -305,7 +305,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
function addNodeErrorToGroup(
groupsMap: Map<string, GroupEntry>,
nodeId: NodeExecutionId,
nodeId: string,
classType: string,
idPrefix: string,
error: CataloguedErrorItem,
@@ -371,11 +371,9 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
) {
if (!executionErrorStore.lastNodeErrors) return
for (const [rawNodeId, nodeError] of Object.entries(
for (const [nodeId, nodeError] of Object.entries(
executionErrorStore.lastNodeErrors
)) {
const nodeId = tryNormalizeNodeExecutionId(rawNodeId)
if (!nodeId) continue
const nodeDisplayName =
resolveNodeInfo(nodeId).title || nodeError.class_type
for (const e of nodeError.errors) {
@@ -406,12 +404,9 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
if (!executionErrorStore.lastExecutionError) return
const e = executionErrorStore.lastExecutionError
const nodeId = tryNormalizeNodeExecutionId(e.node_id)
if (!nodeId) return
addNodeErrorToGroup(
groupsMap,
nodeId,
String(e.node_id),
e.node_type,
'exec',
{
@@ -422,7 +417,8 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
...resolveRunErrorMessage({
kind: 'execution',
error: e,
nodeDisplayName: resolveNodeInfo(nodeId).title || e.node_type
nodeDisplayName:
resolveNodeInfo(String(e.node_id)).title || e.node_type
})
},
filterBySelection
@@ -673,7 +669,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
]
}
function isAssetErrorInSelection(executionNodeId: NodeExecutionId): boolean {
function isAssetErrorInSelection(executionNodeId: string): boolean {
const nodeIds = selectedNodeInfo.value.nodeIds
if (!nodeIds) return true
@@ -695,17 +691,12 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
return false
}
function isAssetCandidateInSelection(nodeId: string | number): boolean {
const executionNodeId = tryNormalizeNodeExecutionId(nodeId)
return executionNodeId ? isAssetErrorInSelection(executionNodeId) : false
}
const filteredMissingModelGroups = computed(() => {
if (!selectedNodeInfo.value.nodeIds) return missingModelGroups.value
const candidates = missingModelStore.missingModelCandidates
if (!candidates?.length) return []
const filtered = candidates.filter(
(c) => c.nodeId != null && isAssetCandidateInSelection(c.nodeId)
(c) => c.nodeId != null && isAssetErrorInSelection(String(c.nodeId))
)
if (!filtered.length) return []
return groupMissingModelCandidates(filtered, isCloud)
@@ -716,7 +707,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
const candidates = missingMediaStore.missingMediaCandidates
if (!candidates?.length) return []
const filtered = candidates.filter(
(c) => c.nodeId != null && isAssetCandidateInSelection(c.nodeId)
(c) => c.nodeId != null && isAssetErrorInSelection(String(c.nodeId))
)
if (!filtered.length) return []
return groupCandidatesByMediaType(filtered)

View File

@@ -4,7 +4,6 @@ import { nextTick, ref } from 'vue'
import type { useSystemStatsStore } from '@/stores/systemStatsStore'
import type { ErrorCardData } from './types'
import { createNodeExecutionId } from '@/types/nodeIdentification'
import { useErrorReport } from './useErrorReport'
async function flushPromises() {
@@ -104,7 +103,7 @@ function makeCard(overrides: Partial<ErrorCardData> = {}): ErrorCardData {
return {
id: 'card-1',
title: 'KSampler',
nodeId: createNodeExecutionId([42]),
nodeId: '42',
errors: [],
...overrides
}
@@ -182,7 +181,7 @@ describe('useErrorReport', () => {
exceptionType: 'RuntimeError',
exceptionMessage: 'CUDA oom',
traceback: 'trace-0',
nodeId: createNodeExecutionId([42]),
nodeId: '42',
nodeType: 'KSampler',
systemStats: sampleSystemStats,
serverLogs: 'server logs',

View File

@@ -3,12 +3,17 @@ import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { describe, expect, it } from 'vitest'
import type { ComponentProps } from 'vue-component-type-helpers'
import { createI18n } from 'vue-i18n'
import SidebarIcon from './SidebarIcon.vue'
type SidebarIconProps = ComponentProps<typeof SidebarIcon>
type SidebarIconProps = {
icon: string
selected: boolean
tooltip?: string
class?: string
iconBadge?: string | (() => string | null)
}
const i18n = createI18n({
legacy: false,
@@ -79,20 +84,4 @@ describe('SidebarIcon', () => {
tooltipText
)
})
it('falls back to label for tooltip when no tooltip is provided', async () => {
const labelText = 'WASNodeSuitePreprocessors'
const { user } = renderSidebarIcon({ label: labelText })
expect(screen.getByRole('button')).toHaveAttribute('aria-label', labelText)
await user.hover(screen.getByRole('button'))
await waitFor(
() => {
expect(screen.getByRole('tooltip')).toHaveTextContent(labelText)
},
{ timeout: 1000 }
)
})
})

View File

@@ -40,11 +40,9 @@
</span>
</div>
</slot>
<!-- w-max sizes the label to the rail instead of the padding-inset
button content box, which is too narrow for one-line labels -->
<span
v-if="label && !isSmall"
class="side-bar-button-label line-clamp-2 w-max max-w-[calc(var(--sidebar-width)-var(--sidebar-padding))] text-center text-2xs wrap-break-word whitespace-normal"
class="side-bar-button-label text-center text-2xs"
>{{ st(label, label) }}</span
>
</div>
@@ -85,14 +83,7 @@ const overlayValue = computed(() =>
typeof iconBadge === 'function' ? (iconBadge() ?? '') : iconBadge
)
const shouldShowBadge = computed(() => !!overlayValue.value)
/**
* Falls back to the label when no tooltip is provided, so labels clamped
* to two lines can always be recovered in full on hover.
*/
const computedTooltip = computed(() => {
const text = tooltip || label
return st(text, text) + tooltipSuffix
})
const computedTooltip = computed(() => st(tooltip, tooltip) + tooltipSuffix)
</script>
<style>

View File

@@ -1,237 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { BoundingBox } from '@/types/boundingBoxes'
import type { HitMode, Region } from './boundingBoxesUtil'
import {
applyDrag,
boxesAt,
fromBoundingBoxes,
tagRects,
toBoundingBoxes
} from './boundingBoxesUtil'
const region = (over: Partial<Region> = {}): Region => ({
x: 0.2,
y: 0.2,
w: 0.2,
h: 0.2,
type: 'obj',
text: '',
desc: '',
palette: [],
...over
})
describe('applyDrag', () => {
it('moves without resizing and keeps width/height', () => {
const out = applyDrag('move', region({ x: 0.2, y: 0.2 }), 0.1, 0.1)
expect(out.x).toBeCloseTo(0.3)
expect(out.y).toBeCloseTo(0.3)
expect(out.w).toBeCloseTo(0.2)
expect(out.h).toBeCloseTo(0.2)
})
it('clamps a move so the box stays inside the unit square', () => {
const out = applyDrag(
'move',
region({ x: 0.9, y: 0.9, w: 0.2, h: 0.2 }),
0.5,
0.5
)
expect(out.x).toBeCloseTo(0.8)
expect(out.y).toBeCloseTo(0.8)
})
it('grows from the bottom-right for draw and resize-br', () => {
for (const mode of ['draw', 'resize-br'] as HitMode[]) {
const out = applyDrag(
mode,
region({ x: 0.2, y: 0.2, w: 0.1, h: 0.1 }),
0.1,
0.2
)
expect(out).toMatchObject({ x: 0.2, y: 0.2 })
expect(out.w).toBeCloseTo(0.2)
expect(out.h).toBeCloseTo(0.3)
}
})
it('moves the top-left corner on resize-tl', () => {
const out = applyDrag(
'resize-tl',
region({ x: 0.5, y: 0.5, w: 0.2, h: 0.2 }),
0.1,
0.1
)
expect(out.x).toBeCloseTo(0.6)
expect(out.y).toBeCloseTo(0.6)
expect(out.w).toBeCloseTo(0.1)
expect(out.h).toBeCloseTo(0.1)
})
it('normalizes a corner drag that inverts the box', () => {
const out = applyDrag(
'resize-tl',
region({ x: 0.5, y: 0.5, w: 0.2, h: 0.2 }),
0.3,
0
)
expect(out.x).toBeCloseTo(0.7)
expect(out.w).toBeCloseTo(0.1)
expect(out.y).toBeCloseTo(0.5)
expect(out.h).toBeCloseTo(0.2)
})
it('resizes single edges', () => {
expect(applyDrag('resize-r', region({ w: 0.2 }), 0.1, 0).w).toBeCloseTo(0.3)
expect(applyDrag('resize-b', region({ h: 0.2 }), 0, 0.1).h).toBeCloseTo(0.3)
const top = applyDrag('resize-t', region({ y: 0.4, h: 0.2 }), 0, 0.1)
expect(top.y).toBeCloseTo(0.5)
expect(top.h).toBeCloseTo(0.1)
const left = applyDrag('resize-l', region({ x: 0.4, w: 0.2 }), 0.1, 0)
expect(left.x).toBeCloseTo(0.5)
expect(left.w).toBeCloseTo(0.1)
})
})
describe('boxesAt', () => {
const regions: Region[] = [region({ x: 0.2, y: 0.2, w: 0.2, h: 0.2 })]
it('detects a corner handle', () => {
const hits = boxesAt(regions, 0.2, 0.2, 6, 100, 100, -1)
expect(hits[0]).toEqual({ index: 0, mode: 'resize-tl' })
})
it('detects an interior move', () => {
const hits = boxesAt(regions, 0.3, 0.3, 6, 100, 100, -1)
expect(hits[0]).toEqual({ index: 0, mode: 'move' })
})
it('returns nothing when the pointer misses every box', () => {
expect(boxesAt(regions, 0.9, 0.9, 6, 100, 100, -1)).toEqual([])
})
it('brings the active box to the front of overlapping candidates', () => {
const overlapping: Region[] = [
region({ x: 0.2, y: 0.2, w: 0.2, h: 0.2 }),
region({ x: 0.25, y: 0.25, w: 0.2, h: 0.2 })
]
const hits = boxesAt(overlapping, 0.3, 0.3, 6, 100, 100, 1)
expect(hits).toHaveLength(2)
expect(hits[0].index).toBe(1)
})
})
describe('tagRects', () => {
const measure = (s: string) => s.length * 7
it('places the first tag at the top-left corner', () => {
const rects = tagRects(
[region({ x: 0.1, y: 0.1, w: 0.3, h: 0.3 })],
100,
100,
measure
)
expect(rects[0]).toMatchObject({ x: 10, y: 10, tag: '01' })
expect(rects[0].w).toBe(measure('01') + 8)
})
it('moves a colliding tag to a different corner', () => {
const boxes = [
region({ x: 0.1, y: 0.1, w: 0.3, h: 0.3 }),
region({ x: 0.1, y: 0.1, w: 0.3, h: 0.3 })
]
const rects = tagRects(boxes, 100, 100, measure)
const sameSpot = rects[1].x === rects[0].x && rects[1].y === rects[0].y
expect(sameSpot).toBe(false)
})
})
describe('fromBoundingBoxes', () => {
it('converts pixel boxes to normalized regions with metadata', () => {
const boxes: BoundingBox[] = [
{
x: 100,
y: 200,
width: 300,
height: 400,
metadata: { type: 'text', text: 'hi', desc: 'd', palette: ['#fff'] }
}
]
expect(fromBoundingBoxes(boxes, 1000, 1000)[0]).toEqual({
x: 0.1,
y: 0.2,
w: 0.3,
h: 0.4,
type: 'text',
text: 'hi',
desc: 'd',
palette: ['#fff']
})
})
it('fills defaults when metadata is missing or partial', () => {
const boxes = [{ x: 0, y: 0, width: 10, height: 10 }] as BoundingBox[]
expect(fromBoundingBoxes(boxes, 100, 100)[0]).toMatchObject({
type: 'obj',
text: '',
desc: '',
palette: []
})
})
it('drops entries that are not bounding boxes', () => {
const boxes = [null, { x: 1 }, undefined] as unknown as BoundingBox[]
expect(fromBoundingBoxes(boxes, 100, 100)).toEqual([])
})
it('guards against zero dimensions', () => {
const boxes: BoundingBox[] = [
{
x: 5,
y: 5,
width: 5,
height: 5,
metadata: { type: 'obj', text: '', desc: '', palette: [] }
}
]
expect(fromBoundingBoxes(boxes, 0, 0)[0]).toMatchObject({
x: 5,
y: 5,
w: 5,
h: 5
})
})
})
describe('toBoundingBoxes', () => {
it('rounds normalized regions back to pixels and copies the palette', () => {
const palette = ['#abc']
const regions: Region[] = [
region({ x: 0.1, y: 0.2, w: 0.3, h: 0.4, palette })
]
const [box] = toBoundingBoxes(regions, 1000, 1000)
expect(box).toMatchObject({ x: 100, y: 200, width: 300, height: 400 })
expect(box.metadata.palette).toEqual(['#abc'])
expect(box.metadata.palette).not.toBe(palette)
})
it('round-trips from pixels to regions and back', () => {
const boxes: BoundingBox[] = [
{
x: 100,
y: 200,
width: 300,
height: 400,
metadata: { type: 'obj', text: '', desc: '', palette: [] }
}
]
const restored = toBoundingBoxes(
fromBoundingBoxes(boxes, 1000, 1000),
1000,
1000
)
expect(restored).toEqual(boxes)
})
})

View File

@@ -1,246 +0,0 @@
import type { BoundingBox, BoundingBoxMetadata } from '@/types/boundingBoxes'
export type HitMode =
| 'move'
| 'draw'
| 'resize-tl'
| 'resize-tr'
| 'resize-bl'
| 'resize-br'
| 'resize-t'
| 'resize-b'
| 'resize-l'
| 'resize-r'
export interface Region extends BoundingBoxMetadata {
x: number
y: number
w: number
h: number
}
interface BoxCandidate {
index: number
mode: HitMode
}
interface TagRect {
x: number
y: number
w: number
h: number
tag: string
}
const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
function normalizeBox(b: Region): Region {
let { x, y, w, h } = b
if (w < 0) {
x += w
w = -w
}
if (h < 0) {
y += h
h = -h
}
x = clamp01(x)
y = clamp01(y)
w = Math.min(w, 1 - x)
h = Math.min(h, 1 - y)
return { ...b, x, y, w: Math.max(0, w), h: Math.max(0, h) }
}
function rectHitTest(
mx: number,
my: number,
x1: number,
y1: number,
x2: number,
y2: number,
rx: number,
ry: number
): HitMode | null {
const h = (cx: number, cy: number) =>
Math.abs(mx - cx) < rx && Math.abs(my - cy) < ry
if (h(x1, y1)) return 'resize-tl'
if (h(x2, y1)) return 'resize-tr'
if (h(x1, y2)) return 'resize-bl'
if (h(x2, y2)) return 'resize-br'
if (mx >= x1 && mx <= x2 && Math.abs(my - y1) < ry) return 'resize-t'
if (mx >= x1 && mx <= x2 && Math.abs(my - y2) < ry) return 'resize-b'
if (my >= y1 && my <= y2 && Math.abs(mx - x1) < rx) return 'resize-l'
if (my >= y1 && my <= y2 && Math.abs(mx - x2) < rx) return 'resize-r'
if (mx >= x1 && mx <= x2 && my >= y1 && my <= y2) return 'move'
return null
}
export function applyDrag(
mode: HitMode,
start: Region,
dx: number,
dy: number
): Region {
let { x, y, w, h } = start
switch (mode) {
case 'move':
x += dx
y += dy
x = clamp01(Math.min(x, 1 - w))
y = clamp01(Math.min(y, 1 - h))
break
case 'draw':
case 'resize-br':
w += dx
h += dy
break
case 'resize-tl':
x += dx
y += dy
w -= dx
h -= dy
break
case 'resize-tr':
y += dy
w += dx
h -= dy
break
case 'resize-bl':
x += dx
w -= dx
h += dy
break
case 'resize-t':
y += dy
h -= dy
break
case 'resize-b':
h += dy
break
case 'resize-l':
x += dx
w -= dx
break
case 'resize-r':
w += dx
break
}
return mode === 'move'
? { ...start, x, y }
: normalizeBox({ ...start, x, y, w, h })
}
export function boxesAt(
regions: readonly Region[],
mxN: number,
myN: number,
handlePx: number,
logW: number,
logH: number,
activeIdx: number
): BoxCandidate[] {
const rx = handlePx / Math.max(1, logW)
const ry = handlePx / Math.max(1, logH)
const res: BoxCandidate[] = []
for (let i = 0; i < regions.length; i++) {
const b = regions[i]
const mode = rectHitTest(mxN, myN, b.x, b.y, b.x + b.w, b.y + b.h, rx, ry)
if (mode) res.push({ index: i, mode })
}
const ai = res.findIndex((c) => c.index === activeIdx)
if (ai > 0) res.unshift(res.splice(ai, 1)[0])
return res
}
export function tagRects(
regions: readonly Region[],
logW: number,
logH: number,
measureWidth: (s: string) => number,
height = 14
): TagRect[] {
const placed: TagRect[] = []
const rects: TagRect[] = []
const hits = (a: TagRect, b: TagRect) =>
a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y
for (let i = 0; i < regions.length; i++) {
const b = regions[i]
const x1 = b.x * logW
const y1 = b.y * logH
const x2 = (b.x + b.w) * logW
const y2 = (b.y + b.h) * logH
const tag = String(i + 1).padStart(2, '0')
const w = measureWidth(tag) + 8
let pick: [number, number] = [x1, y1]
for (const [cx, cy] of [
[x1, y1],
[x2 - w, y1],
[x2 - w, y2 - height],
[x1, y2 - height]
] as const) {
const candidate: TagRect = { x: cx, y: cy, w, h: height, tag }
if (!placed.some((p) => hits(candidate, p))) {
pick = [cx, cy]
break
}
}
const r: TagRect = { x: pick[0], y: pick[1], w, h: height, tag }
placed.push(r)
rects[i] = r
}
return rects
}
function isBoundingBox(b: unknown): b is BoundingBox {
if (!b || typeof b !== 'object') return false
const box = b as Record<string, unknown>
return (
typeof box.x === 'number' &&
typeof box.y === 'number' &&
typeof box.width === 'number' &&
typeof box.height === 'number'
)
}
export function fromBoundingBoxes(
boxes: readonly BoundingBox[],
width: number,
height: number
): Region[] {
const w = width || 1
const h = height || 1
return boxes.filter(isBoundingBox).map((box) => {
const meta = (box.metadata ?? {}) as Partial<BoundingBoxMetadata>
return {
x: box.x / w,
y: box.y / h,
w: box.width / w,
h: box.height / h,
type: meta.type === 'text' ? 'text' : 'obj',
text: typeof meta.text === 'string' ? meta.text : '',
desc: typeof meta.desc === 'string' ? meta.desc : '',
palette: Array.isArray(meta.palette)
? meta.palette.filter((c): c is string => typeof c === 'string')
: []
}
})
}
export function toBoundingBoxes(
regions: readonly Region[],
width: number,
height: number
): BoundingBox[] {
return regions.map((r) => ({
x: Math.round(r.x * width),
y: Math.round(r.y * height),
width: Math.round(r.w * width),
height: Math.round(r.h * height),
metadata: {
type: r.type,
text: r.text,
desc: r.desc,
palette: r.palette.slice()
}
}))
}

View File

@@ -1,249 +0,0 @@
import { render } from '@testing-library/vue'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Ref, ShallowRef } from 'vue'
import { defineComponent, h, nextTick, ref, shallowRef } from 'vue'
import { useBoundingBoxes } from './useBoundingBoxes'
import type { BoundingBox } from '@/types/boundingBoxes'
const { appState } = vi.hoisted(() => ({
appState: { node: null as unknown }
}))
vi.mock('@/scripts/app', () => ({
app: { canvas: { graph: { getNodeById: () => appState.node } } }
}))
const ctx = {
measureText: (s: string) => ({ width: s.length * 7 }),
setTransform: () => {},
clearRect: () => {},
fillRect: () => {},
strokeRect: () => {},
fillText: () => {},
drawImage: () => {},
save: () => {},
restore: () => {},
beginPath: () => {},
rect: () => {},
clip: () => {},
font: '',
fillStyle: '',
strokeStyle: '',
lineWidth: 0
} as unknown as CanvasRenderingContext2D
function makeCanvas(): HTMLCanvasElement {
const el = document.createElement('canvas')
Object.defineProperty(el, 'clientWidth', { value: 100, configurable: true })
Object.defineProperty(el, 'clientHeight', { value: 100, configurable: true })
el.getContext = (() => ctx) as unknown as HTMLCanvasElement['getContext']
el.getBoundingClientRect = () =>
({
left: 0,
top: 0,
right: 100,
bottom: 100,
width: 100,
height: 100,
x: 0,
y: 0,
toJSON: () => ({})
}) as DOMRect
el.focus = () => {}
el.setPointerCapture = () => {}
el.releasePointerCapture = () => {}
return el
}
function makeNode() {
return {
widgets: [
{ name: 'width', value: 512 },
{ name: 'height', value: 512 }
],
findInputSlot: () => -1,
getInputNode: () => null
}
}
const pe = (
clientX: number,
clientY: number,
over: Partial<PointerEvent> = {}
) =>
({
button: 0,
clientX,
clientY,
altKey: false,
pointerId: 1,
preventDefault: () => {},
stopPropagation: () => {},
...over
}) as unknown as PointerEvent
const flush = async () => {
await Promise.resolve()
await nextTick()
}
type Api = ReturnType<typeof useBoundingBoxes>
interface Captured extends Api {
canvasEl: ShallowRef<HTMLCanvasElement | null>
modelValue: Ref<BoundingBox[]>
}
function setup(initial: BoundingBox[] = []) {
let captured: Captured | undefined
const Harness = defineComponent({
setup() {
const canvasEl = shallowRef<HTMLCanvasElement | null>(null)
const canvasContainer = shallowRef<HTMLDivElement | null>(null)
const inlineEditorEl = shallowRef<HTMLTextAreaElement | null>(null)
const modelValue = ref(initial)
const api = useBoundingBoxes('1', {
canvasEl,
canvasContainer,
inlineEditorEl,
modelValue
})
captured = { canvasEl, modelValue, ...api }
return () => h('div')
}
})
render(Harness)
captured!.canvasEl.value = makeCanvas()
return captured!
}
const box = (over: Partial<BoundingBox> = {}): BoundingBox => ({
x: 51,
y: 51,
width: 256,
height: 256,
metadata: { type: 'obj', text: '', desc: '', palette: ['#ff0000'] },
...over
})
beforeEach(() => {
setActivePinia(createPinia())
appState.node = makeNode()
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
void Promise.resolve().then(() => cb(0))
return 1
})
vi.stubGlobal('cancelAnimationFrame', () => {})
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('useBoundingBoxes initialization', () => {
it('derives regions from the initial model value', () => {
const c = setup([box()])
expect(c.hasRegions.value).toBe(true)
expect(c.activeRegion.value).toMatchObject({ type: 'obj' })
})
it('exposes an aspect-ratio canvas style from the node width/height', () => {
const c = setup()
expect(c.canvasStyle.value).toEqual({ aspectRatio: '512 / 512' })
})
it('starts with no active region when empty', () => {
const c = setup()
expect(c.hasRegions.value).toBe(false)
expect(c.activeRegion.value).toBeNull()
})
})
describe('useBoundingBoxes drawing', () => {
it('draws a new region and syncs it to the model value', async () => {
const c = setup()
c.onPointerDown(pe(10, 10))
c.onCanvasPointerMove(pe(60, 60))
c.onDocPointerUp(pe(60, 60))
await flush()
expect(c.modelValue.value).toHaveLength(1)
expect(c.modelValue.value[0].width).toBeGreaterThan(0)
})
it('discards a zero-size draw', async () => {
const c = setup()
c.onPointerDown(pe(10, 10))
c.onDocPointerUp(pe(10, 10))
await flush()
expect(c.modelValue.value).toHaveLength(0)
})
it('selects an existing region instead of drawing when clicking inside it', async () => {
const c = setup([box()])
c.onPointerDown(pe(30, 30))
c.onDocPointerUp(pe(30, 30))
await flush()
expect(c.modelValue.value).toHaveLength(1)
})
})
describe('useBoundingBoxes region editing', () => {
it('changes the active region type', async () => {
const c = setup([box()])
c.setActiveType('text')
await flush()
expect(c.modelValue.value[0].metadata.type).toBe('text')
})
it('deletes the active region on Delete', async () => {
const c = setup([box()])
c.onCanvasKeyDown({
key: 'Delete',
preventDefault: () => {},
stopPropagation: () => {}
} as unknown as KeyboardEvent)
await flush()
expect(c.modelValue.value).toHaveLength(0)
})
it('clears all regions', async () => {
const c = setup([box(), box({ x: 0 })])
c.clearAll()
await flush()
expect(c.modelValue.value).toHaveLength(0)
})
})
describe('useBoundingBoxes inline editor', () => {
it('opens on double click and commits the description', async () => {
const c = setup([box()])
c.onDoubleClick(pe(30, 30) as unknown as MouseEvent)
await flush()
expect(c.inlineEditor.value).not.toBeNull()
c.inlineEditor.value!.value = 'a label'
c.commitInlineEditor()
await flush()
expect(c.modelValue.value[0].metadata.desc).toBe('a label')
expect(c.inlineEditor.value).toBeNull()
})
it('closes the inline editor on Escape', async () => {
const c = setup([box()])
c.onDoubleClick(pe(30, 30) as unknown as MouseEvent)
await flush()
c.onInlineKeyDown({ key: 'Escape' } as KeyboardEvent)
expect(c.inlineEditor.value).toBeNull()
})
})
describe('useBoundingBoxes hover cursor', () => {
it('switches to a pointer cursor over a tag', async () => {
const c = setup([box({ x: 10, y: 10, width: 256, height: 256 })])
expect(c.canvasCursor.value).toBe('crosshair')
c.onCanvasPointerMove(pe(15, 15))
await flush()
expect(c.canvasCursor.value).toBe('pointer')
})
})

View File

@@ -1,614 +0,0 @@
import { useElementSize } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import type { Ref, ShallowRef } from 'vue'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import {
applyDrag,
boxesAt,
fromBoundingBoxes,
tagRects,
toBoundingBoxes
} from '@/composables/boundingBoxes/boundingBoxesUtil'
import type {
HitMode,
Region
} from '@/composables/boundingBoxes/boundingBoxesUtil'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import type { BoundingBox } from '@/types/boundingBoxes'
import { readableTextColor, textOnColor } from '@/utils/colorUtil'
const HANDLE_PX = 8
const DIMENSION_STEP = 16
const BG_DIM = 0.75
const MAX_ELEMENT_COLORS = 5
interface InlineEditorState {
value: string
style: Record<string, string>
index: number
}
interface UseBoundingBoxesOptions {
canvasEl: Readonly<ShallowRef<HTMLCanvasElement | null>>
canvasContainer: Readonly<ShallowRef<HTMLDivElement | null>>
inlineEditorEl: Readonly<ShallowRef<HTMLTextAreaElement | null>>
modelValue: Ref<BoundingBox[]>
}
export function useBoundingBoxes(
nodeId: string,
{
canvasEl,
canvasContainer,
inlineEditorEl,
modelValue
}: UseBoundingBoxesOptions
) {
const focused = ref(false)
const drawing = ref(false)
const dragMode = ref<HitMode | null>(null)
const dragStartNorm = ref<{ x: number; y: number } | null>(null)
const boxAtStart = ref<Region | null>(null)
const hoverIndex = ref<number | null>(null)
const hoverTagIndex = ref<number | null>(null)
const bgImage = ref<HTMLImageElement | null>(null)
const inlineEditor = ref<InlineEditorState | null>(null)
const { width: containerWidth } = useElementSize(canvasContainer)
const litegraphNode = computed(() =>
nodeId && app.canvas?.graph ? app.canvas.graph.getNodeById(nodeId) : null
)
const { selectedNodeIds } = storeToRefs(useCanvasStore())
const isNodeSelected = computed(() =>
selectedNodeIds.value.has(String(nodeId))
)
function dimWidget(name: 'width' | 'height'): number | undefined {
const v = litegraphNode.value?.widgets?.find((w) => w.name === name)?.value
return typeof v === 'number' && v > 0 ? v : undefined
}
const widthValue = computed(() => dimWidget('width') ?? 1024)
const heightValue = computed(() => dimWidget('height') ?? 1024)
const state = ref({
regions: fromBoundingBoxes(
modelValue.value ?? [],
widthValue.value,
heightValue.value
)
})
const activeIndex = ref(state.value.regions.length ? 0 : -1)
const aspectRatio = computed(
() => `${widthValue.value} / ${heightValue.value}`
)
const canvasStyle = computed(() => ({ aspectRatio: aspectRatio.value }))
const activeRegion = computed(() =>
activeIndex.value >= 0 ? state.value.regions[activeIndex.value] : null
)
const hasRegions = computed(() => state.value.regions.length > 0)
function clampToCanvas(n: number) {
return Math.max(0, Math.min(1, n))
}
function logicalSize() {
const el = canvasEl.value
return { w: el?.clientWidth || 1, h: el?.clientHeight || 1 }
}
function pointerNorm(e: PointerEvent) {
const el = canvasEl.value
if (!el) return { x: 0, y: 0 }
const r = el.getBoundingClientRect()
return {
x: clampToCanvas((e.clientX - r.left) / r.width),
y: clampToCanvas((e.clientY - r.top) / r.height)
}
}
let rafHandle = 0
function requestDraw() {
if (rafHandle) return
rafHandle = requestAnimationFrame(() => {
rafHandle = 0
drawCanvas()
})
}
function measureWidth(ctx: CanvasRenderingContext2D, s: string) {
return ctx.measureText(s).width
}
function drawCanvas() {
const el = canvasEl.value
if (!el) return
const { w: W, h: H } = logicalSize()
const dpr = window.devicePixelRatio || 1
const bw = Math.max(1, Math.round(W * dpr))
const bh = Math.max(1, Math.round(H * dpr))
if (el.width !== bw || el.height !== bh) {
el.width = bw
el.height = bh
}
const ctx = el.getContext('2d')
if (!ctx) return
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, W, H)
if (bgImage.value) {
ctx.drawImage(bgImage.value, 0, 0, W, H)
ctx.fillStyle = `rgba(0,0,0,${BG_DIM})`
ctx.fillRect(0, 0, W, H)
}
const showActive = focused.value || isNodeSelected.value
const aIdx = showActive ? activeIndex.value : -1
const order = state.value.regions
.map((_, i) => i)
.filter((i) => i !== aIdx)
.reverse()
if (aIdx >= 0 && aIdx < state.value.regions.length) order.push(aIdx)
ctx.font = 'bold 11px monospace'
const tag_rects = tagRects(state.value.regions, W, H, (s) =>
measureWidth(ctx, s)
)
for (const i of order) {
const b = state.value.regions[i]
const active = i === aIdx
const pal = (b.palette || []).filter(Boolean)
const col = pal.length ? pal[0] : '#8c8c8c'
const x1 = b.x * W
const y1 = b.y * H
const x2 = (b.x + b.w) * W
const y2 = (b.y + b.h) * H
const w = x2 - x1
const h = y2 - y1
const hovered = i === hoverIndex.value || active
if (active) {
ctx.fillStyle = 'rgba(26,26,26,0.88)'
ctx.fillRect(x1, y1, w, h)
}
ctx.fillStyle = col + (hovered ? '3a' : '22')
ctx.fillRect(x1, y1, w, h)
const lw = active ? 2 : hovered ? 1.5 : 1
ctx.strokeStyle = col
ctx.lineWidth = lw
ctx.strokeRect(x1 + lw / 2, y1 + lw / 2, w - lw, h - lw)
if (pal.length) {
const sw = w / pal.length
const sh = 7
for (let p = 0; p < pal.length; p++) {
const sx = x1 + Math.round(p * sw)
ctx.fillStyle = pal[p]
ctx.fillRect(sx, y1, x1 + Math.round((p + 1) * sw) - sx, sh)
}
}
ctx.save()
ctx.beginPath()
ctx.rect(x1, y1, w, h)
ctx.clip()
let body = b.desc || ''
if (b.type === 'text' && b.text)
body = `"${b.text}"` + (body ? `${body}` : '')
if (body) {
ctx.font = '12px monospace'
ctx.fillStyle = readableTextColor(col)
const pad = 4
const lh = 14
let ty = y1 + 15 + 12
for (const line of wrapLines(ctx, body, w - pad * 2)) {
if (ty > y1 + h) break
ctx.fillText(line, x1 + pad, ty)
ty += lh
}
}
const tr = tag_rects[i]
ctx.font = 'bold 11px monospace'
ctx.fillStyle = col
ctx.fillRect(tr.x, tr.y, tr.w, 14)
if (i === hoverTagIndex.value) {
ctx.fillStyle = 'rgba(255,255,255,0.25)'
ctx.fillRect(tr.x, tr.y, tr.w, 14)
ctx.strokeStyle = '#fff'
ctx.lineWidth = 1
ctx.strokeRect(tr.x + 0.5, tr.y + 0.5, tr.w - 1, 13)
}
ctx.fillStyle = textOnColor(col)
ctx.fillText(tr.tag, tr.x + 4, tr.y + 11)
ctx.restore()
}
}
function wrapLines(
ctx: CanvasRenderingContext2D,
text: string,
maxW: number
): string[] {
const out: string[] = []
for (const para of text.split('\n')) {
let line = ''
for (const word of para.split(/\s+/)) {
if (!word) continue
const test = line ? `${line} ${word}` : word
if (line && ctx.measureText(test).width > maxW) {
out.push(line)
line = word
} else {
line = test
}
}
out.push(line)
}
return out
}
const hitTestPoint = (mN: { x: number; y: number }) => {
const { w: W, h: H } = logicalSize()
const cands = boxesAt(
state.value.regions,
mN.x,
mN.y,
HANDLE_PX,
W,
H,
activeIndex.value
)
if (!cands.length) return null
return (
cands.find((c) => c.index === activeIndex.value && c.mode !== 'move') ||
cands[0]
)
}
const titleAt = (mN: { x: number; y: number }) => {
const el = canvasEl.value
if (!el) return null
const ctx = el.getContext('2d')
if (!ctx) return null
const { w: W, h: H } = logicalSize()
const rects = tagRects(state.value.regions, W, H, (s) =>
measureWidth(ctx, s)
)
const px = mN.x * W
const py = mN.y * H
for (let i = state.value.regions.length - 1; i >= 0; i--) {
const r = rects[i]
if (r && px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h)
return i
}
return null
}
function pickForSelection(mN: { x: number; y: number }, cycle: boolean) {
const { w: W, h: H } = logicalSize()
const cands = boxesAt(
state.value.regions,
mN.x,
mN.y,
HANDLE_PX,
W,
H,
activeIndex.value
)
if (!cands.length) return null
const activeResize = cands.find(
(c) => c.index === activeIndex.value && c.mode !== 'move'
)
if (activeResize && !cycle) return activeResize
const ti = titleAt(mN)
if (ti !== null && !cycle) return { index: ti, mode: 'move' as HitMode }
if (cycle && cands.length > 1) {
const pos = cands.findIndex((c) => c.index === activeIndex.value)
return cands[(pos + 1) % cands.length]
}
return (
cands.find((c) => c.index === activeIndex.value && c.mode !== 'move') ||
cands[0]
)
}
function onPointerDown(e: PointerEvent) {
if (e.button !== 0) return
canvasEl.value?.focus()
hoverTagIndex.value = null
hoverIndex.value = null
const mN = pointerNorm(e)
const hit = pickForSelection(mN, e.altKey)
if (hit) {
activeIndex.value = hit.index
dragMode.value = hit.mode
boxAtStart.value = { ...state.value.regions[hit.index] }
} else {
dragMode.value = 'draw'
const nb: Region = {
x: mN.x,
y: mN.y,
w: 0,
h: 0,
type: 'obj',
text: '',
desc: '',
palette: []
}
state.value.regions.push(nb)
activeIndex.value = state.value.regions.length - 1
boxAtStart.value = { ...nb }
}
drawing.value = true
dragStartNorm.value = mN
canvasEl.value?.setPointerCapture(e.pointerId)
e.preventDefault()
requestDraw()
}
function onDocPointerMove(e: PointerEvent) {
if (
!drawing.value ||
!boxAtStart.value ||
!dragStartNorm.value ||
!dragMode.value
)
return
const mN = pointerNorm(e)
const dx = mN.x - dragStartNorm.value.x
const dy = mN.y - dragStartNorm.value.y
const nb = applyDrag(dragMode.value, boxAtStart.value, dx, dy)
state.value.regions[activeIndex.value] = nb
requestDraw()
}
function onDocPointerUp(e: PointerEvent) {
if (!drawing.value) return
drawing.value = false
canvasEl.value?.releasePointerCapture?.(e.pointerId)
const b = state.value.regions[activeIndex.value]
if (b && (b.w < 0.005 || b.h < 0.005) && dragMode.value === 'draw') {
removeRegion(activeIndex.value)
}
syncState()
}
function onCanvasPointerMove(e: PointerEvent) {
if (drawing.value) onDocPointerMove(e)
else onPointerMove(e)
}
function onPointerMove(e: PointerEvent) {
if (drawing.value) return
const mN = pointerNorm(e)
const ti = titleAt(mN)
const hit = hitTestPoint(mN)
const hb = ti !== null ? ti : hit ? hit.index : null
if (ti !== hoverTagIndex.value || hb !== hoverIndex.value) {
hoverTagIndex.value = ti
hoverIndex.value = hb
requestDraw()
}
}
function onPointerLeave() {
if (hoverTagIndex.value !== null || hoverIndex.value !== null) {
hoverTagIndex.value = null
hoverIndex.value = null
requestDraw()
}
}
const canvasCursor = computed(() =>
hoverTagIndex.value !== null ? 'pointer' : 'crosshair'
)
function onDoubleClick(e: MouseEvent) {
e.preventDefault()
const mN = pointerNormFromMouse(e)
const { w: W, h: H } = logicalSize()
const cands = boxesAt(
state.value.regions,
mN.x,
mN.y,
HANDLE_PX,
W,
H,
activeIndex.value
)
const target = cands.find((c) => c.index === activeIndex.value) || cands[0]
if (!target) return
openInlineEditor(target.index)
}
function pointerNormFromMouse(e: MouseEvent) {
const el = canvasEl.value
if (!el) return { x: 0, y: 0 }
const r = el.getBoundingClientRect()
return {
x: clampToCanvas((e.clientX - r.left) / r.width),
y: clampToCanvas((e.clientY - r.top) / r.height)
}
}
function openInlineEditor(index: number) {
const b = state.value.regions[index]
if (!b) return
activeIndex.value = index
const { w: W, h: H } = logicalSize()
const w = Math.min(W, Math.max(70, b.w * W))
const h = Math.min(H, Math.max(42, b.h * H))
const left = Math.max(0, Math.min(b.x * W, W - w))
const top = Math.max(0, Math.min(b.y * H, H - h))
inlineEditor.value = {
value: b.desc || '',
index,
style: {
left: `${left}px`,
top: `${top}px`,
width: `${w}px`,
height: `${h}px`,
borderColor: (b.palette || []).find(Boolean) || '#46b4e6'
}
}
void nextTick(() => {
inlineEditorEl.value?.focus()
inlineEditorEl.value?.select()
})
}
function onInlineKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
inlineEditor.value = null
} else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
commitInlineEditor()
}
}
function commitInlineEditor() {
const ed = inlineEditor.value
if (!ed) return
const b = state.value.regions[ed.index]
if (b) b.desc = ed.value
inlineEditor.value = null
syncState()
}
function onCanvasKeyDown(e: KeyboardEvent) {
if (drawing.value) return
const idx = activeIndex.value
if ((e.key === 'Delete' || e.key === 'Backspace') && idx >= 0) {
e.preventDefault()
e.stopPropagation()
removeRegion(idx)
syncState()
}
}
function removeRegion(i: number) {
state.value.regions.splice(i, 1)
if (!state.value.regions.length) activeIndex.value = -1
else if (i <= activeIndex.value)
activeIndex.value = Math.max(0, activeIndex.value - 1)
}
function setActiveType(t: 'obj' | 'text') {
if (activeRegion.value) {
activeRegion.value.type = t
syncState()
}
}
function clearAll() {
state.value.regions = []
activeIndex.value = -1
syncState()
}
function syncState() {
modelValue.value = toBoundingBoxes(
state.value.regions,
widthValue.value,
heightValue.value
)
requestDraw()
}
watch(containerWidth, () => requestDraw())
watch(
() => state.value.regions.length,
() => requestDraw()
)
watch(isNodeSelected, () => requestDraw())
watch([widthValue, heightValue], () => syncState())
const nodeOutputStore = useNodeOutputStore()
function applyImageDimensions(naturalWidth: number, naturalHeight: number) {
const node = litegraphNode.value
if (!node) return
const snap = (v: number) =>
Math.max(DIMENSION_STEP, Math.round(v / DIMENSION_STEP) * DIMENSION_STEP)
const targetW = snap(naturalWidth)
const targetH = snap(naturalHeight)
const widthWidget = node.widgets?.find((w) => w.name === 'width')
const heightWidget = node.widgets?.find((w) => w.name === 'height')
if (widthWidget && widthWidget.value !== targetW) {
widthWidget.value = targetW
widthWidget.callback?.(targetW)
}
if (heightWidget && heightWidget.value !== targetH) {
heightWidget.value = targetH
heightWidget.callback?.(targetH)
}
}
let lastBgUrl = ''
function updateBgImage() {
const node = litegraphNode.value
if (!node) return
const slot = node.findInputSlot('background')
const inputNode = slot >= 0 ? node.getInputNode(slot) : null
const url = inputNode
? nodeOutputStore.getNodeImageUrls(inputNode)?.[0]
: undefined
if (!url) {
if (bgImage.value) {
bgImage.value = null
lastBgUrl = ''
requestDraw()
}
return
}
if (url === lastBgUrl) return
lastBgUrl = url
const currentUrl = url
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
if (currentUrl !== lastBgUrl) return
bgImage.value = img
applyImageDimensions(img.naturalWidth, img.naturalHeight)
requestDraw()
}
img.src = url
}
watch(() => nodeOutputStore.nodeOutputs, updateBgImage, { deep: true })
watch(() => nodeOutputStore.nodePreviewImages, updateBgImage, { deep: true })
updateBgImage()
void nextTick(() => requestDraw())
onBeforeUnmount(() => {
if (rafHandle) cancelAnimationFrame(rafHandle)
})
return {
canvasStyle,
canvasCursor,
focused,
activeRegion,
hasRegions,
inlineEditor,
maxColors: MAX_ELEMENT_COLORS,
onPointerDown,
onCanvasPointerMove,
onDocPointerUp,
onPointerLeave,
onDoubleClick,
onCanvasKeyDown,
onInlineKeyDown,
commitInlineEditor,
setActiveType,
clearAll,
syncState
}
}

View File

@@ -21,7 +21,6 @@ import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { createNodeExecutionId } from '@/types/nodeIdentification'
import { seedRequiredInputMissingNodeError } from '@/utils/__tests__/executionErrorTestUtils'
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
@@ -51,11 +50,7 @@ describe('Connection error clearing via onConnectionsChange', () => {
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
seedRequiredInputMissingNodeError(
store,
createNodeExecutionId([node.id]),
'clip'
)
seedRequiredInputMissingNodeError(store, String(node.id), 'clip')
node.onConnectionsChange!(NodeSlotType.INPUT, 0, true, null, node.inputs[0])
@@ -67,11 +62,7 @@ describe('Connection error clearing via onConnectionsChange', () => {
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
seedRequiredInputMissingNodeError(
store,
createNodeExecutionId([node.id]),
'clip'
)
seedRequiredInputMissingNodeError(store, String(node.id), 'clip')
node.onConnectionsChange!(
NodeSlotType.INPUT,
@@ -90,11 +81,7 @@ describe('Connection error clearing via onConnectionsChange', () => {
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
seedRequiredInputMissingNodeError(
store,
createNodeExecutionId([node.id]),
'clip'
)
seedRequiredInputMissingNodeError(store, String(node.id), 'clip')
node.onConnectionsChange!(
NodeSlotType.OUTPUT,
@@ -116,11 +103,7 @@ describe('Connection error clearing via onConnectionsChange', () => {
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
seedRequiredInputMissingNodeError(
store,
createNodeExecutionId([node.id]),
'model'
)
seedRequiredInputMissingNodeError(store, String(node.id), 'model')
node.onConnectionsChange!(NodeSlotType.INPUT, 0, true, null, node.inputs[0])
@@ -246,11 +229,7 @@ describe('Widget change error clearing via onWidgetChanged', () => {
const store = useExecutionErrorStore()
const mediaStore = useMissingMediaStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
seedRequiredInputMissingNodeError(
store,
createNodeExecutionId([node.id]),
'image'
)
seedRequiredInputMissingNodeError(store, String(node.id), 'image')
mediaStore.setMissingMedia([
{
nodeId: String(node.id),
@@ -300,11 +279,7 @@ describe('installErrorClearingHooks lifecycle', () => {
// Verify the hooks actually work
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
seedRequiredInputMissingNodeError(
store,
createNodeExecutionId([lateNode.id]),
'value'
)
seedRequiredInputMissingNodeError(store, String(lateNode.id), 'value')
lateNode.onConnectionsChange!(
NodeSlotType.INPUT,

View File

@@ -34,7 +34,6 @@ import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacem
import { getCnrIdFromNode } from '@/platform/nodeReplacement/cnrIdUtil'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { appendNodeExecutionId } from '@/types/nodeIdentification'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import {
collectAllNodes,
@@ -84,7 +83,7 @@ function installNodeHooks(node: LGraphNode): void {
const promotedSource = widgetPromotedSource(node, widget)
const executionId = promotedSource
? appendNodeExecutionId(hostExecId, promotedSource.nodeId)
? `${hostExecId}:${promotedSource.nodeId}`
: hostExecId
const widgetName = promotedSource?.widgetName ?? widget.name

View File

@@ -703,55 +703,3 @@ describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
expect(subgraphNode.has_errors).toBe(true)
})
})
describe('Pre-remove vueNodeData drain', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('drops vueNodeData entry before node.onRemoved fires', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
expect(vueNodeData.has(String(node.id))).toBe(true)
let dataPresentInOnRemoved: boolean | undefined
node.onRemoved = () => {
dataPresentInOnRemoved = vueNodeData.has(String(node.id))
}
graph.remove(node)
expect(
dataPresentInOnRemoved,
'vueNodeData entry must be cleared before node.onRemoved fires so reactive consumers cannot observe the detached node'
).toBe(false)
})
it('clears vueNodeData when LGraph.clear() dispatches node:before-removed for each node', () => {
const graph = new LGraph()
const nodeA = new LGraphNode('a')
const nodeB = new LGraphNode('b')
graph.add(nodeA)
graph.add(nodeB)
const { vueNodeData } = useGraphNodeManager(graph)
expect(vueNodeData.size).toBe(2)
const beforeRemovedSpy = vi.fn()
graph.events.addEventListener('node:before-removed', beforeRemovedSpy)
graph.clear()
expect(
beforeRemovedSpy,
'clear() must dispatch node:before-removed so reactive consumers can drop refs before nodes detach'
).toHaveBeenCalledTimes(2)
expect(
vueNodeData.size,
'node:before-removed listener must drain vueNodeData when clear() removes every node'
).toBe(0)
})
})

View File

@@ -30,8 +30,6 @@ import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
import { normalizeControlOption } from '@/types/simplifiedWidget'
import { getWidgetIdForNode } from '@/utils/litegraphUtil'
import type { NodeId as WorkflowNodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import type { WidgetId } from '@/types/widgetId'
import type {
@@ -84,7 +82,6 @@ export interface SafeWidgetData {
advanced?: boolean
hidden?: boolean
read_only?: boolean
removable?: boolean
values?: unknown
}
/** Input specification from node definition */
@@ -97,7 +94,7 @@ export interface SafeWidgetData {
* host subgraph node. Used for missing-model lookups that key by
* execution ID (e.g. `"65:42"` vs the host node's `"65"`).
*/
sourceExecutionId?: NodeExecutionId
sourceExecutionId?: string
/**
* Interior source widget name. Only set for promoted widgets, where `name`
* is the host input slot name; missing-model lookups key by the interior
@@ -140,7 +137,7 @@ export interface GraphNodeManager {
vueNodeData: ReadonlyMap<string, VueNodeData>
// Access to original LiteGraph nodes (non-reactive)
getNode(id: WorkflowNodeId): LGraphNode | undefined
getNode(id: string): LGraphNode | undefined
// Lifecycle methods
cleanup(): void
@@ -214,8 +211,7 @@ function extractWidgetDisplayOptions(
canvasOnly: widget.options.canvasOnly,
advanced: widget.options?.advanced ?? widget.advanced,
hidden: widget.options.hidden,
read_only: widget.options.read_only,
removable: widget.options.removable
read_only: widget.options.read_only
}
}
@@ -229,7 +225,7 @@ function isDOMBackedWidget(widget: IBaseWidget): boolean {
interface PromotedWidgetMetadata {
controlWidget?: SafeControlWidget
isDOMWidget: boolean
sourceExecutionId?: NodeExecutionId
sourceExecutionId?: string
sourceWidgetName?: string
}
@@ -520,8 +516,8 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
}
// Get access to original LiteGraph node (non-reactive)
const getNode = (id: WorkflowNodeId): LGraphNode | undefined => {
return nodeRefs.get(String(id))
const getNode = (id: string): LGraphNode | undefined => {
return nodeRefs.get(id)
}
const syncWithGraph = () => {
@@ -612,20 +608,27 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
}
}
const dropNodeReferences = (node: LGraphNode) => {
const id = String(node.id)
nodeRefs.delete(id)
vueNodeData.delete(id)
}
/**
* Handles node removal from the graph - cleans up all references
*/
const handleNodeRemoved = (
node: LGraphNode,
originalCallback?: (node: LGraphNode) => void
) => {
const id = String(node.id)
// Remove node from layout store
setSource(LayoutSource.Canvas)
void deleteNode(id)
originalCallback?.(node)
// Clean up all tracking references
nodeRefs.delete(id)
vueNodeData.delete(id)
// Call original callback if provided
if (originalCallback) {
originalCallback(node)
}
}
/**
@@ -634,8 +637,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
const createCleanupFunction = (
originalOnNodeAdded: ((node: LGraphNode) => void) | undefined,
originalOnNodeRemoved: ((node: LGraphNode) => void) | undefined,
originalOnTrigger: ((event: LGraphTriggerEvent) => void) | undefined,
beforeNodeRemovedListener: (e: CustomEvent<{ node: LGraphNode }>) => void
originalOnTrigger: ((event: LGraphTriggerEvent) => void) | undefined
) => {
return () => {
// Restore original callbacks
@@ -643,17 +645,15 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
graph.onNodeRemoved = originalOnNodeRemoved || undefined
graph.onTrigger = originalOnTrigger || undefined
graph.events.removeEventListener(
'node:before-removed',
beforeNodeRemovedListener
)
// Clear all state maps
nodeRefs.clear()
vueNodeData.clear()
}
}
/**
* Sets up event listeners - now simplified with extracted handlers
*/
const setupEventListeners = (): (() => void) => {
// Store original callbacks
const originalOnNodeAdded = graph.onNodeAdded
@@ -669,16 +669,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
handleNodeRemoved(node, originalOnNodeRemoved)
}
const beforeNodeRemovedListener = (
e: CustomEvent<{ node: LGraphNode }>
) => {
dropNodeReferences(e.detail.node)
}
graph.events.addEventListener(
'node:before-removed',
beforeNodeRemovedListener
)
const triggerHandlers: {
[K in LGraphTriggerAction]: (event: LGraphTriggerParam<K>) => void
} = {
@@ -827,11 +817,11 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Initialize state
syncWithGraph()
// Return cleanup function
return createCleanupFunction(
originalOnNodeAdded || undefined,
originalOnNodeRemoved || undefined,
originalOnTrigger || undefined,
beforeNodeRemovedListener
originalOnTrigger || undefined
)
}

View File

@@ -137,18 +137,6 @@ describe(usePromotedPreviews, () => {
expect(promotedPreviews.value).toEqual([])
})
it('returns empty array (does not throw) when SubgraphNode is detached', () => {
const setup = createSetup()
const parentGraph = setup.subgraphNode.graph!
parentGraph.add(setup.subgraphNode)
parentGraph.remove(setup.subgraphNode)
expect(setup.subgraphNode.graph).toBeNull()
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(() => promotedPreviews.value).not.toThrow()
expect(promotedPreviews.value).toEqual([])
})
it('returns empty array when no $$ promotions exist', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10 })

View File

@@ -6,11 +6,7 @@ import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { UUID } from '@/utils/uuid'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import {
appendNodeExecutionId,
createNodeLocatorId
} from '@/types/nodeIdentification'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { createNodeLocatorId } from '@/types/nodeIdentification'
interface PromotedPreview {
sourceNodeId: string
@@ -42,7 +38,7 @@ export function usePromotedPreviews(
function readReactivePreviewUrls(
leafHost: SubgraphNode,
leafSourceNodeId: string,
leafExecutionId: NodeExecutionId,
leafExecutionId: string,
interiorNode: LGraphNode
): string[] | undefined {
const locatorId = createNodeLocatorId(
@@ -72,7 +68,6 @@ export function usePromotedPreviews(
const promotedPreviews = computed((): PromotedPreview[] => {
const node = toValue(lgraphNode)
if (!(node instanceof SubgraphNode)) return []
if (node.isDetached) return []
const rootGraphId = node.rootGraph.id
const hostLocator = String(node.id)
@@ -126,7 +121,7 @@ export function usePromotedPreviews(
const urls = readReactivePreviewUrls(
leafHost,
leaf.sourceNodeId,
appendNodeExecutionId(leafHostLocator, leaf.sourceNodeId),
`${leafHostLocator}:${leaf.sourceNodeId}`,
interiorNode
)
if (!urls?.length) return []

View File

@@ -1,114 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { EffectScope } from 'vue'
import { effectScope, ref, shallowRef } from 'vue'
import { usePaletteSwatchRow } from './usePaletteSwatchRow'
const scopes: EffectScope[] = []
afterEach(() => {
while (scopes.length) scopes.pop()?.stop()
})
function setup(initial: string[]) {
const modelValue = ref(initial)
const container = shallowRef(document.createElement('div'))
const picker = shallowRef(document.createElement('input'))
const scope = effectScope()
scopes.push(scope)
const api = scope.run(() =>
usePaletteSwatchRow({ modelValue, container, picker })
)!
return { modelValue, container, picker, ...api }
}
const mouseEvent = () => ({ stopPropagation: vi.fn() }) as unknown as MouseEvent
describe('usePaletteSwatchRow', () => {
it('appends a default color', () => {
const { modelValue, addColor } = setup(['#000000'])
addColor()
expect(modelValue.value).toEqual(['#000000', '#ffffff'])
})
it('removes a color by index', () => {
const { modelValue, remove } = setup(['#a', '#b', '#c'])
remove(1)
expect(modelValue.value).toEqual(['#a', '#c'])
})
it('seeds the picker input with the clicked color before opening it', () => {
const { picker, openPicker } = setup(['#112233'])
const click = vi.spyOn(picker.value!, 'click')
openPicker(0, mouseEvent())
expect(picker.value!.value).toBe('#112233')
expect(click).toHaveBeenCalled()
})
it('falls back to white when the slot is empty', () => {
const { picker, openPicker } = setup([''])
openPicker(0, mouseEvent())
expect(picker.value!.value).toBe('#ffffff')
})
it('writes the picked color back to the open slot', () => {
const { modelValue, openPicker, onPickerInput } = setup(['#a', '#b'])
openPicker(1, mouseEvent())
onPickerInput({ target: { value: '#123456' } } as unknown as Event)
expect(modelValue.value).toEqual(['#a', '#123456'])
})
it('ignores picker input when no slot is open', () => {
const { modelValue, onPickerInput } = setup(['#a'])
onPickerInput({ target: { value: '#123456' } } as unknown as Event)
expect(modelValue.value).toEqual(['#a'])
})
it('reorders via drag when the pointer crosses another swatch', () => {
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
for (const i of [0, 1]) {
const swatch = document.createElement('div')
swatch.setAttribute('data-index', String(i))
container.value!.appendChild(swatch)
}
const second = container.value!.children[1] as HTMLDivElement
second.getBoundingClientRect = () =>
({ left: 100, right: 140, top: 0, bottom: 20, width: 40 }) as DOMRect
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
document.dispatchEvent(
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 1 })
)
expect(modelValue.value).toEqual(['#b', '#a'])
})
it('cancels a stale drag when the primary button is no longer pressed', () => {
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
for (const i of [0, 1]) {
const swatch = document.createElement('div')
swatch.setAttribute('data-index', String(i))
container.value!.appendChild(swatch)
}
const second = container.value!.children[1] as HTMLDivElement
second.getBoundingClientRect = () =>
({ left: 100, right: 140, top: 0, bottom: 20, width: 40 }) as DOMRect
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
document.dispatchEvent(
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 0 })
)
expect(modelValue.value).toEqual(['#a', '#b'])
})
it('ignores non-left-button pointer downs', () => {
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
const swatch = document.createElement('div')
swatch.setAttribute('data-index', '1')
container.value!.appendChild(swatch)
onPointerDown(0, { button: 2, clientX: 10, clientY: 10 } as PointerEvent)
document.dispatchEvent(
new MouseEvent('pointermove', { clientX: 130, clientY: 10 })
)
expect(modelValue.value).toEqual(['#a', '#b'])
})
})

View File

@@ -1,114 +0,0 @@
import { useEventListener } from '@vueuse/core'
import type { Ref, ShallowRef } from 'vue'
import { ref } from 'vue'
interface UsePaletteSwatchRowOptions {
modelValue: Ref<string[]>
container: Readonly<ShallowRef<HTMLDivElement | null>>
picker: Readonly<ShallowRef<HTMLInputElement | null>>
}
export function usePaletteSwatchRow({
modelValue,
container,
picker
}: UsePaletteSwatchRowOptions) {
const pickerIndex = ref<number | null>(null)
function openPicker(i: number, e: MouseEvent) {
e.stopPropagation()
pickerIndex.value = i
const el = picker.value
if (!el) return
el.value = modelValue.value[i] || '#ffffff'
el.click()
}
function onPickerInput(e: Event) {
const v = (e.target as HTMLInputElement).value
if (pickerIndex.value === null) return
const next = modelValue.value.slice()
next[pickerIndex.value] = v
modelValue.value = next
}
function remove(i: number) {
const next = modelValue.value.slice()
next.splice(i, 1)
modelValue.value = next
}
function addColor() {
modelValue.value = [...modelValue.value, '#ffffff']
}
const drag = ref<{
index: number
startX: number
startY: number
active: boolean
} | null>(null)
function onPointerDown(i: number, e: PointerEvent) {
if (e.button !== 0) return
drag.value = {
index: i,
startX: e.clientX,
startY: e.clientY,
active: false
}
}
useEventListener(document, 'pointermove', (e: PointerEvent) => {
const d = drag.value
if (!d) return
if ((e.buttons & 1) === 0) {
drag.value = null
return
}
if (!d.active) {
if (Math.abs(e.clientX - d.startX) + Math.abs(e.clientY - d.startY) < 4)
return
d.active = true
}
const rows =
container.value?.querySelectorAll<HTMLDivElement>('[data-index]')
if (!rows) return
for (const other of rows) {
if (parseInt(other.dataset.index || '-1', 10) === d.index) continue
const r = other.getBoundingClientRect()
if (
e.clientX >= r.left &&
e.clientX <= r.right &&
e.clientY >= r.top - 6 &&
e.clientY <= r.bottom + 6
) {
const oi = parseInt(other.dataset.index || '-1', 10)
if (oi < 0) continue
const next = modelValue.value.slice()
const [moved] = next.splice(d.index, 1)
const insertAt = e.clientX > r.left + r.width / 2 ? oi + 1 : oi
next.splice(insertAt > d.index ? insertAt - 1 : insertAt, 0, moved)
modelValue.value = next
drag.value = null
return
}
}
})
useEventListener(document, 'pointerup', () => {
drag.value = null
})
useEventListener(document, 'pointercancel', () => {
drag.value = null
})
return {
openPicker,
onPickerInput,
remove,
addColor,
onPointerDown
}
}

View File

@@ -1,443 +0,0 @@
import * as THREE from 'three'
import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader'
import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader'
import { computed, onUnmounted, ref, shallowRef, watch } from 'vue'
import type { ChromaticityCoords, GamutName } from '@/renderer/hdr/colorGamut'
import {
detectGamutFromChromaticities,
gamutToSrgbMatrix
} from '@/renderer/hdr/colorGamut'
import {
HDR_VIEWER_FRAGMENT_SHADER,
HDR_VIEWER_VERTEX_SHADER
} from '@/renderer/hdr/hdrViewerShader'
import type { ChannelHistograms, ImageStats } from '@/renderer/hdr/hdrStats'
import {
computeChannelHistograms,
computeImageStats
} from '@/renderer/hdr/hdrStats'
import { WebGLViewport } from '@/renderer/three/WebGLViewport'
import { getImageFilenameFromUrl } from '@/utils/hdrFormatUtil'
const MIN_ZOOM = 0.05
const MAX_ZOOM = 64
export type ChannelMode = 'rgb' | 'r' | 'g' | 'b' | 'a' | 'luminance'
export const CHANNEL_MODES: ChannelMode[] = [
'rgb',
'r',
'g',
'b',
'a',
'luminance'
]
const CHANNEL_INDEX: Record<ChannelMode, number> = {
rgb: 0,
r: 1,
g: 2,
b: 3,
a: 4,
luminance: 5
}
export interface PixelReadout {
x: number
y: number
r: number
g: number
b: number
a: number | null
}
interface ExrTexData {
header?: { chromaticities?: ChromaticityCoords }
}
function createLoader(url: string) {
const filename = getImageFilenameFromUrl(url)
if (filename?.toLowerCase().endsWith('.hdr')) return new RGBELoader()
const loader = new EXRLoader()
loader.setDataType(THREE.FloatType)
return loader
}
function makeReader(
data: ArrayLike<number>,
type: THREE.TextureDataType
): (index: number) => number {
if (type === THREE.HalfFloatType) {
return (index) => THREE.DataUtils.fromHalfFloat(data[index])
}
return (index) => data[index]
}
function loadHdrTexture(
url: string
): Promise<{ texture: THREE.DataTexture; gamut: GamutName }> {
return new Promise((resolve, reject) => {
createLoader(url).load(
url,
(texture, texData) => {
const chromaticities = (texData as ExrTexData)?.header?.chromaticities
resolve({
texture,
gamut: detectGamutFromChromaticities(chromaticities)
})
},
undefined,
reject
)
})
}
export function useHdrViewer() {
const exposureStops = ref(0)
const dither = ref(true)
const clipWarnings = ref(false)
const gamut = ref<GamutName>('sRGB')
const channel = ref<ChannelMode>('rgb')
const loading = ref(true)
const error = ref<string | null>(null)
const dimensions = ref<string | null>(null)
const stats = ref<ImageStats | null>(null)
const histograms = shallowRef<ChannelHistograms | null>(null)
const pixel = ref<PixelReadout | null>(null)
const histogram = computed<Uint32Array | null>(() => {
const channelHistograms = histograms.value
if (!channelHistograms) return null
switch (channel.value) {
case 'r':
return channelHistograms.r
case 'g':
return channelHistograms.g
case 'b':
return channelHistograms.b
case 'a':
return channelHistograms.a
default:
return channelHistograms.luminance
}
})
const containerRef = shallowRef<HTMLElement | null>(null)
let renderer: THREE.WebGLRenderer | null = null
let viewport: WebGLViewport | null = null
let scene: THREE.Scene | null = null
let camera: THREE.OrthographicCamera | null = null
let material: THREE.ShaderMaterial | null = null
let mesh: THREE.Mesh | null = null
let texture: THREE.Texture | null = null
let imageAspect = 1
let frameRequested = false
let readSample: ((index: number) => number) | null = null
let imageWidth = 0
let imageHeight = 0
let imageChannels = 4
const raycaster = new THREE.Raycaster()
const pointerNdc = new THREE.Vector2()
function requestRender() {
if (!renderer || frameRequested) return
frameRequested = true
requestAnimationFrame(() => {
frameRequested = false
if (renderer && scene && camera) renderer.render(scene, camera)
})
}
function containerSize() {
const el = containerRef.value
return {
width: el?.clientWidth || 1,
height: el?.clientHeight || 1
}
}
function updateProjection() {
if (!camera) return
const { width, height } = containerSize()
const halfH = 0.5
const halfW = (0.5 * width) / height
camera.left = -halfW
camera.right = halfW
camera.top = halfH
camera.bottom = -halfH
camera.updateProjectionMatrix()
}
function fitView() {
if (!camera) return
const { width, height } = containerSize()
const containerAspect = width / height
camera.zoom = Math.min(1, containerAspect / imageAspect)
camera.position.set(0, 0, 1)
camera.updateProjectionMatrix()
requestRender()
}
function applyUniforms() {
if (!material) return
material.uniforms.uGain.value = Math.pow(2, exposureStops.value)
material.uniforms.uDither.value = dither.value
material.uniforms.uClipWarnings.value = clipWarnings.value
material.uniforms.uChannel.value = CHANNEL_INDEX[channel.value]
const m = gamutToSrgbMatrix(gamut.value)
;(material.uniforms.uGamutToSRGB.value as THREE.Matrix3).set(
m[0],
m[1],
m[2],
m[3],
m[4],
m[5],
m[6],
m[7],
m[8]
)
requestRender()
}
function buildScene() {
renderer = new THREE.WebGLRenderer({ antialias: false, alpha: false })
viewport = new WebGLViewport(renderer)
renderer.outputColorSpace = THREE.LinearSRGBColorSpace
renderer.setPixelRatio(window.devicePixelRatio)
renderer.setClearColor(0x0a0a0a, 1)
scene = new THREE.Scene()
camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10)
camera.position.set(0, 0, 1)
material = new THREE.ShaderMaterial({
glslVersion: THREE.GLSL3,
vertexShader: HDR_VIEWER_VERTEX_SHADER,
fragmentShader: HDR_VIEWER_FRAGMENT_SHADER,
uniforms: {
uImage: { value: null },
uGamutToSRGB: { value: new THREE.Matrix3() },
uGain: { value: 1 },
uChannel: { value: 0 },
uDither: { value: true },
uClipWarnings: { value: false },
uClipRange: { value: new THREE.Vector2(0, 1) }
}
})
mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material)
scene.add(mesh)
}
function resize() {
if (!renderer) return
const { width, height } = containerSize()
renderer.setSize(width, height, false)
updateProjection()
requestRender()
}
function setTexture(loaded: THREE.DataTexture) {
if (!material || !mesh) return
loaded.colorSpace = THREE.LinearSRGBColorSpace
loaded.minFilter = THREE.LinearFilter
loaded.magFilter = THREE.LinearFilter
loaded.needsUpdate = true
const { width, height, data } = loaded.image
texture = loaded
imageAspect = width / height
mesh.scale.set(imageAspect, 1, 1)
material.uniforms.uImage.value = loaded
dimensions.value = `${width} x ${height}`
if (!data) return
imageWidth = width
imageHeight = height
imageChannels = data.length / (width * height)
readSample = makeReader(data, loaded.type)
stats.value = computeImageStats(readSample, data.length, imageChannels)
histograms.value = computeChannelHistograms(
readSample,
data.length,
imageChannels
)
}
async function mount(container: HTMLElement, url: string) {
containerRef.value = container
loading.value = true
error.value = null
try {
buildScene()
container.appendChild(renderer!.domElement)
renderer!.domElement.classList.add('block', 'size-full')
resize()
applyUniforms()
attachInteractions(renderer!.domElement)
viewport!.observeResize(container, resize)
const { texture: loaded, gamut: detectedGamut } =
await loadHdrTexture(url)
if (!material || !mesh) {
loaded.dispose()
return
}
gamut.value = detectedGamut
setTexture(loaded)
applyUniforms()
fitView()
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
dispose()
} finally {
loading.value = false
}
}
function normalizeExposure() {
const max = stats.value?.max ?? 0
exposureStops.value = max > 0 ? -Math.log2(max) : 0
}
function attachInteractions(canvas: HTMLCanvasElement) {
canvas.addEventListener('wheel', onWheel, { passive: false })
canvas.addEventListener('pointerdown', onPointerDown)
canvas.addEventListener('pointermove', onHoverMove)
canvas.addEventListener('pointerleave', onHoverLeave)
}
function onWheel(event: WheelEvent) {
if (!camera) return
event.preventDefault()
const factor = Math.exp(-event.deltaY * 0.001)
const nextZoom = THREE.MathUtils.clamp(
camera.zoom * factor,
MIN_ZOOM,
MAX_ZOOM
)
camera.zoom = nextZoom
camera.updateProjectionMatrix()
requestRender()
}
let dragStart: { x: number; y: number; camX: number; camY: number } | null =
null
function onPointerDown(event: PointerEvent) {
if (!camera) return
dragStart = {
x: event.clientX,
y: event.clientY,
camX: camera.position.x,
camY: camera.position.y
}
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
}
function onPointerMove(event: PointerEvent) {
if (!camera || !dragStart) return
const { height } = containerSize()
const worldPerPixel = 1 / (height * camera.zoom)
camera.position.x =
dragStart.camX - (event.clientX - dragStart.x) * worldPerPixel
camera.position.y =
dragStart.camY + (event.clientY - dragStart.y) * worldPerPixel
requestRender()
}
function onPointerUp() {
dragStart = null
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
}
function onHoverMove(event: PointerEvent) {
if (!camera || !mesh || !renderer || dragStart || !readSample) return
const rect = renderer.domElement.getBoundingClientRect()
pointerNdc.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
pointerNdc.y = -(((event.clientY - rect.top) / rect.height) * 2 - 1)
raycaster.setFromCamera(pointerNdc, camera)
const hit = raycaster.intersectObject(mesh)[0]
if (!hit?.uv) {
pixel.value = null
return
}
const col = THREE.MathUtils.clamp(
Math.floor(hit.uv.x * imageWidth),
0,
imageWidth - 1
)
const row = THREE.MathUtils.clamp(
Math.floor(hit.uv.y * imageHeight),
0,
imageHeight - 1
)
const base = (row * imageWidth + col) * imageChannels
pixel.value = {
x: col,
y: imageHeight - 1 - row,
r: readSample(base),
g: readSample(base + 1),
b: readSample(base + 2),
a: imageChannels === 4 ? readSample(base + 3) : null
}
}
function onHoverLeave() {
pixel.value = null
}
function dispose() {
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
if (renderer) {
renderer.domElement.removeEventListener('wheel', onWheel)
renderer.domElement.removeEventListener('pointerdown', onPointerDown)
renderer.domElement.removeEventListener('pointermove', onHoverMove)
renderer.domElement.removeEventListener('pointerleave', onHoverLeave)
}
viewport?.disposeRenderer()
texture?.dispose()
material?.dispose()
mesh?.geometry.dispose()
renderer = null
viewport = null
scene = null
camera = null
material = null
mesh = null
texture = null
readSample = null
}
watch([exposureStops, dither, clipWarnings, gamut, channel], applyUniforms)
onUnmounted(dispose)
return {
exposureStops,
dither,
clipWarnings,
gamut,
channel,
loading,
error,
dimensions,
stats,
histogram,
pixel,
mount,
dispose,
fitView,
normalizeExposure
}
}

101
src/config/comfyApi.test.ts Normal file
View File

@@ -0,0 +1,101 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { refreshRemoteConfig } from '@/platform/remoteConfig/refreshRemoteConfig'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from './comfyApi'
vi.mock('@/scripts/api', () => ({
api: {
apiURL: (route: string) => `/api${route}`,
fetchApi: vi.fn()
}
}))
vi.stubGlobal('fetch', vi.fn())
describe('getComfyApiBaseUrl', () => {
const originalConfig = remoteConfig.value
beforeEach(() => {
remoteConfig.value = {}
})
afterEach(() => {
remoteConfig.value = originalConfig
})
it('honors the server-provided override', () => {
remoteConfig.value = { comfy_api_base_url: 'https://my-ephem.example.com' }
expect(getComfyApiBaseUrl()).toBe('https://my-ephem.example.com')
})
it('falls back to the build-time default when the key is absent', () => {
expect(getComfyApiBaseUrl()).toBe('https://stagingapi.comfy.org')
})
it('falls back to the build-time default when the value is empty', () => {
remoteConfig.value = { comfy_api_base_url: '' }
expect(getComfyApiBaseUrl()).toBe('https://stagingapi.comfy.org')
})
})
describe('getComfyPlatformBaseUrl', () => {
const originalConfig = remoteConfig.value
beforeEach(() => {
remoteConfig.value = {}
})
afterEach(() => {
remoteConfig.value = originalConfig
})
it('honors the server-provided override', () => {
remoteConfig.value = {
comfy_platform_base_url: 'https://my-ephem-platform.example.com'
}
expect(getComfyPlatformBaseUrl()).toBe(
'https://my-ephem-platform.example.com'
)
})
it('falls back to the build-time default when the key is absent', () => {
expect(getComfyPlatformBaseUrl()).toBe('https://stagingplatform.comfy.org')
})
it('falls back to the build-time default when the value is empty', () => {
remoteConfig.value = { comfy_platform_base_url: '' }
expect(getComfyPlatformBaseUrl()).toBe('https://stagingplatform.comfy.org')
})
})
describe('compatibility with comfyui servers that predate the override keys', () => {
const originalConfig = remoteConfig.value
beforeEach(() => {
vi.clearAllMocks()
remoteConfig.value = {}
})
afterEach(() => {
remoteConfig.value = originalConfig
})
it('falls back to build-time defaults when /features omits the URL keys', async () => {
// An older comfyui server has /features but doesn't know about
// comfy_api_base_url / comfy_platform_base_url yet.
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
supports_preview_metadata: true,
max_upload_size: 104857600
})
} as Response)
await refreshRemoteConfig({ useAuth: false })
expect(getComfyApiBaseUrl()).toBe('https://stagingapi.comfy.org')
expect(getComfyPlatformBaseUrl()).toBe('https://stagingplatform.comfy.org')
})
})

View File

@@ -1,4 +1,3 @@
import { isCloud } from '@/platform/distribution/types'
import {
configValueOrDefault,
remoteConfig
@@ -20,10 +19,6 @@ const BUILD_TIME_PLATFORM_BASE_URL = __USE_PROD_CONFIG__
STAGING_PLATFORM_BASE_URL)
export function getComfyApiBaseUrl(): string {
if (!isCloud) {
return BUILD_TIME_API_BASE_URL
}
return configValueOrDefault(
remoteConfig.value,
'comfy_api_base_url',
@@ -32,10 +27,6 @@ export function getComfyApiBaseUrl(): string {
}
export function getComfyPlatformBaseUrl(): string {
if (!isCloud) {
return BUILD_TIME_PLATFORM_BASE_URL
}
return configValueOrDefault(
remoteConfig.value,
'comfy_platform_base_url',

View File

@@ -0,0 +1,62 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
async function loadFirebase(useProdConfig: boolean) {
vi.resetModules()
vi.stubGlobal('__USE_PROD_CONFIG__', useProdConfig)
const { remoteConfig } = await import('@/platform/remoteConfig/remoteConfig')
const { getFirebaseConfig } = await import('./firebase')
return { getFirebaseConfig, remoteConfig }
}
describe('getFirebaseConfig', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('honors a full server-provided firebase_config (cloud builds)', async () => {
const cloud = {
apiKey: 'cloud-key',
authDomain: 'cloud.example.com',
projectId: 'some-cloud-project',
storageBucket: 'cloud.appspot.com',
messagingSenderId: '1',
appId: '1:1:web:abc'
}
const { getFirebaseConfig, remoteConfig } = await loadFirebase(true)
remoteConfig.value = { firebase_config: cloud }
expect(getFirebaseConfig()).toEqual(cloud)
})
it('uses the dev project for a staging-tier api base even when the build-time fallback is prod', async () => {
const { getFirebaseConfig, remoteConfig } = await loadFirebase(true)
remoteConfig.value = { comfy_api_base_url: 'https://stagingapi.comfy.org' }
expect(getFirebaseConfig().projectId).toBe('dreamboothy-dev')
remoteConfig.value = {
comfy_api_base_url: 'https://pr-1-registry.testenvs.comfy.org'
}
expect(getFirebaseConfig().projectId).toBe('dreamboothy-dev')
})
it('uses the prod project for a prod api base in a prod build', async () => {
const { getFirebaseConfig, remoteConfig } = await loadFirebase(true)
remoteConfig.value = { comfy_api_base_url: 'https://api.comfy.org' }
expect(getFirebaseConfig().projectId).toBe('dreamboothy')
})
it('treats an unparseable api base as non-staging and falls back to build-time', async () => {
const { getFirebaseConfig, remoteConfig } = await loadFirebase(true)
remoteConfig.value = { comfy_api_base_url: 'not-a-url' }
expect(getFirebaseConfig().projectId).toBe('dreamboothy')
})
it('falls back to the build-time config when nothing matches', async () => {
const prod = await loadFirebase(true)
prod.remoteConfig.value = {}
expect(prod.getFirebaseConfig().projectId).toBe('dreamboothy')
const dev = await loadFirebase(false)
dev.remoteConfig.value = {}
expect(dev.getFirebaseConfig().projectId).toBe('dreamboothy-dev')
})
})

View File

@@ -1,6 +1,5 @@
import type { FirebaseOptions } from 'firebase/app'
import { isCloud } from '@/platform/distribution/types'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
const DEV_CONFIG: FirebaseOptions = {
@@ -27,16 +26,28 @@ const PROD_CONFIG: FirebaseOptions = {
const BUILD_TIME_CONFIG = __USE_PROD_CONFIG__ ? PROD_CONFIG : DEV_CONFIG
const STAGING_API_HOST = 'stagingapi.comfy.org'
const TESTENV_HOST_SUFFIX = '.testenvs.comfy.org'
// staging + the ephemeral testenvs use the dev Firebase project (prod uses prod)
function isStagingTierApiBase(apiBase: string | undefined): boolean {
if (!apiBase) return false
try {
const host = new URL(apiBase).hostname
return host === STAGING_API_HOST || host.endsWith(TESTENV_HOST_SUFFIX)
} catch {
return false
}
}
/**
* Returns the Firebase configuration for the current environment.
* - Cloud builds use runtime configuration delivered via feature flags
* - OSS / localhost builds fall back to the build-time config determined by __USE_PROD_CONFIG__
* Firebase config for the current backend: the server's firebase_config (cloud builds),
* else the bundled DEV_CONFIG when the api base is staging-tier, else the build-time default.
*/
export function getFirebaseConfig(): FirebaseOptions {
if (!isCloud) {
return BUILD_TIME_CONFIG
}
const runtimeConfig = remoteConfig.value.firebase_config
return runtimeConfig ?? BUILD_TIME_CONFIG
if (runtimeConfig) return runtimeConfig
if (isStagingTierApiBase(remoteConfig.value.comfy_api_base_url))
return DEV_CONFIG
return BUILD_TIME_CONFIG
}

View File

@@ -522,22 +522,6 @@ describe('hasUnpromotedWidgets', () => {
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
})
it('returns false (does not throw) when SubgraphNode is detached', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const parentGraph = subgraphNode.graph!
parentGraph.add(subgraphNode)
const interiorNode = new LGraphNode('InnerNode')
subgraph.add(interiorNode)
interiorNode.addWidget('text', 'seed', '123', () => {})
parentGraph.remove(subgraphNode)
expect(subgraphNode.graph).toBeNull()
expect(() => hasUnpromotedWidgets(subgraphNode)).not.toThrow()
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
})
})
describe('isLinkedPromotion', () => {

View File

@@ -633,7 +633,6 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
}
export function hasUnpromotedWidgets(subgraphNode: SubgraphNode): boolean {
if (subgraphNode.isDetached) return false
const { subgraph } = subgraphNode
return subgraph.nodes.some((interiorNode) =>

View File

@@ -1,9 +1,5 @@
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import {
zAutogrowOptions,
zDynamicGroupInputSpec,
zMatchTypeOptions
} from '@/schemas/nodeDefSchema'
import { zAutogrowOptions, zMatchTypeOptions } from '@/schemas/nodeDefSchema'
import type { InputSpec } from '@/schemas/nodeDefSchema'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
@@ -12,7 +8,6 @@ const dynamicTypeResolvers: Record<
(inputSpec: InputSpecV2) => string[]
> = {
COMFY_AUTOGROW_V3: resolveAutogrowType,
COMFY_DYNAMICGROUP_V3: resolveDynamicGroupType,
COMFY_MATCHTYPE_V3: (input) =>
zMatchTypeOptions
.safeParse(input)
@@ -25,21 +20,6 @@ export function resolveInputType(input: InputSpecV2): string[] {
: input.type.split(',')
}
function resolveDynamicGroupType(rawSpec: InputSpecV2): string[] {
const parsed = zDynamicGroupInputSpec.safeParse([rawSpec.type, rawSpec])
const template = parsed.data?.[1]?.template
if (!template) return []
const inputTypes: (Record<string, InputSpec> | undefined)[] = [
template.required,
template.optional
]
return inputTypes.flatMap((inputType) =>
Object.entries(inputType ?? {}).flatMap(([name, v]) =>
resolveInputType(transformInputSpecV1ToV2(v, { name }))
)
)
}
function resolveAutogrowType(rawSpec: InputSpecV2): string[] {
const { input } = zAutogrowOptions.safeParse(rawSpec).data?.template ?? {}

View File

@@ -1,9 +1,7 @@
import { setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { describe, expect, test, vi } from 'vitest'
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { Point } from '@/lib/litegraph/src/interfaces'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import type { InputSpec } from '@/schemas/nodeDefSchema'
import { useLitegraphService } from '@/services/litegraphService'
@@ -49,22 +47,6 @@ function addDynamicCombo(node: LGraphNode, inputs: DynamicInputs) {
transformInputSpecV1ToV2(inputSpec, { name: namePrefix, isOptional: false })
)
}
function addDynamicGroup(
node: LGraphNode,
template: object,
{ min, max, name = 'g' }: { min?: number; max?: number; name?: string } = {}
) {
const options: Record<string, unknown> = { template }
if (min !== undefined) options.min = min
if (max !== undefined) options.max = max
addNodeInput(
node,
transformInputSpecV1ToV2(['COMFY_DYNAMICGROUP_V3', options] as InputSpec, {
name,
isOptional: false
})
)
}
function addAutogrow(node: LGraphNode, template: unknown) {
addNodeInput(
node,
@@ -305,101 +287,3 @@ describe('Autogrow', () => {
])
})
})
describe('Dynamic Groups', () => {
const stringTemplate = { required: { a: ['STRING', {}] } }
const widgetNames = (node: LGraphNode) => node.widgets!.map((w) => w.name)
const inputNames = (node: LGraphNode) => node.inputs.map((i) => i.name)
const widgetNamed = (node: LGraphNode, name: string) =>
node.widgets!.find((w) => w.name === name)!
test('renders min rows on creation', () => {
const node = testNode()
addDynamicGroup(node, stringTemplate, { min: 2, max: 5 })
expect(widgetNames(node)).toStrictEqual([
'g',
'g.__row__0',
'g.0.a',
'g.__row__1',
'g.1.a'
])
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
})
test('add row appends a new row up to max', () => {
const node = testNode()
addDynamicGroup(node, stringTemplate, { min: 0, max: 2 })
expect(widgetNames(node)).toStrictEqual(['g'])
widgetNamed(node, 'g').callback?.(undefined)
expect(inputNames(node)).toStrictEqual(['g.0.a'])
widgetNamed(node, 'g').callback?.(undefined)
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
// At max, further adds are ignored.
widgetNamed(node, 'g').callback?.(undefined)
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
})
test('remove row renumbers later rows', () => {
const node = testNode()
addDynamicGroup(node, stringTemplate, { min: 0, max: 5 })
widgetNamed(node, 'g').callback?.(undefined)
widgetNamed(node, 'g').callback?.(undefined)
widgetNamed(node, 'g').callback?.(undefined)
const row0Field = widgetNamed(node, 'g.0.a')
const row2Field = widgetNamed(node, 'g.2.a')
widgetNamed(node, 'g.__row__1').callback?.(undefined)
expect(widgetNames(node)).toStrictEqual([
'g',
'g.__row__0',
'g.0.a',
'g.__row__1',
'g.1.a'
])
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
// Row 0 is untouched; the former row 2 shifts down into row 1.
expect(widgetNamed(node, 'g.0.a')).toBe(row0Field)
expect(widgetNamed(node, 'g.1.a')).toBe(row2Field)
})
test('rows below min are not removable', () => {
const node = testNode()
addDynamicGroup(node, stringTemplate, { min: 1, max: 5 })
widgetNamed(node, 'g').callback?.(undefined)
expect(widgetNamed(node, 'g.__row__0').options?.removable).toBe(false)
expect(widgetNamed(node, 'g.__row__1').options?.removable).toBe(true)
// Attempting to remove a protected row is a no-op.
widgetNamed(node, 'g.__row__0').callback?.(undefined)
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
})
test('canvas click removes a row only on the remove hit target', () => {
const node = testNode()
addDynamicGroup(node, stringTemplate, { min: 0, max: 5 })
widgetNamed(node, 'g').callback?.(undefined)
widgetNamed(node, 'g').callback?.(undefined)
const header = widgetNamed(node, 'g.__row__1')
const up = { type: 'pointerup' } as CanvasPointerEvent
const down = { type: 'pointerdown' } as CanvasPointerEvent
const xCenter = node.size[0] - 15 - LiteGraph.NODE_WIDGET_HEIGHT * 0.5
// Releasing away from the remove target does nothing.
header.mouse?.(up, [0, 0] as Point, node)
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
// A pointerdown on the target does nothing (only release acts).
header.mouse?.(down, [xCenter, 0] as Point, node)
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
// Releasing on the target removes the row.
header.mouse?.(up, [xCenter, 0] as Point, node)
expect(inputNames(node)).toStrictEqual(['g.0.a'])
})
})

View File

@@ -2,12 +2,10 @@ import { remove } from 'es-toolkit'
import { shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { t } from '@/i18n'
import type {
ISlotType,
INodeInputSlot,
INodeOutputSlot,
Point
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
@@ -15,14 +13,11 @@ import type { LLink } from '@/lib/litegraph/src/LLink'
import { commonType } from '@/lib/litegraph/src/utils/type'
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/utils/widget'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { ComboInputSpec, InputSpec } from '@/schemas/nodeDefSchema'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import {
zAutogrowOptions,
zDynamicComboInputSpec,
zDynamicGroupInputSpec,
zMatchTypeOptions
} from '@/schemas/nodeDefSchema'
import { useLitegraphService } from '@/services/litegraphService'
@@ -33,15 +28,6 @@ import { widgetId } from '@/types/widgetId'
const INLINE_INPUTS = false
type DynamicGroupState = {
min: number
max: number
inputSpecs: InputSpecV2[]
}
type DynamicGroupNode = LGraphNode & {
comfyDynamic: { dynamicGroup: Record<string, DynamicGroupState> }
}
type MatchTypeNode = LGraphNode &
Pick<Required<LGraphNode>, 'onConnectionsChange'> & {
comfyDynamic: { matchType: Record<string, Record<string, string>> }
@@ -224,321 +210,7 @@ function dynamicComboWidget(
return { widget, minWidth, minHeight }
}
function withComfyDynamicGroup(
node: LGraphNode
): asserts node is DynamicGroupNode {
if (node.comfyDynamic?.dynamicGroup) return
node.comfyDynamic ??= {}
node.comfyDynamic.dynamicGroup = {}
}
const ROW_MARKER = '__row__'
const rowHeaderName = (group: string, row: number) =>
`${group}.${ROW_MARKER}${row}`
const fieldName = (group: string, row: number, field: string) =>
`${group}.${row}.${field}`
/** Extract the row index from a header widget name, or `undefined`. */
function headerRowIndex(group: string, name: string): number | undefined {
const prefix = `${group}.${ROW_MARKER}`
if (!name.startsWith(prefix)) return undefined
const row = Number(name.slice(prefix.length))
return Number.isInteger(row) ? row : undefined
}
/** Rename a field that sits above the removed row, shifting its index down. */
function shiftedFieldName(
group: string,
name: string,
removedRow: number
): string | undefined {
const prefix = `${group}.`
if (!name.startsWith(prefix)) return undefined
const rest = name.slice(prefix.length)
const dot = rest.indexOf('.')
if (dot === -1) return undefined
const row = Number(rest.slice(0, dot))
if (!Number.isInteger(row) || row <= removedRow) return undefined
return fieldName(group, row - 1, rest.slice(dot + 1))
}
const belongsToRow = (group: string, name: string, row: number): boolean =>
name === rowHeaderName(group, row) || name.startsWith(`${group}.${row}.`)
const CANVAS_MARGIN = 15
/** Draw the "Add row" capsule button on the LiteGraph canvas. */
function drawGroupButton(
ctx: CanvasRenderingContext2D,
width: number,
y: number,
label: string,
disabled: boolean
): void {
const height = LiteGraph.NODE_WIDGET_HEIGHT
ctx.save()
if (disabled) ctx.globalAlpha *= 0.5
ctx.fillStyle = LiteGraph.WIDGET_BGCOLOR
ctx.strokeStyle = LiteGraph.WIDGET_OUTLINE_COLOR
ctx.beginPath()
ctx.roundRect(CANVAS_MARGIN, y, width - CANVAS_MARGIN * 2, height, [
height * 0.5
])
ctx.fill()
if (!disabled) ctx.stroke()
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR
ctx.font = `${LiteGraph.NODE_TEXT_SIZE}px ${LiteGraph.NODE_FONT}`
ctx.textAlign = 'center'
ctx.fillText(label, width * 0.5, y + height * 0.7)
ctx.restore()
}
/** Horizontal centre of a row header's remove (✕) hit target. */
const removeButtonCenterX = (width: number) =>
width - CANVAS_MARGIN - LiteGraph.NODE_WIDGET_HEIGHT * 0.5
/** Draw a row header (label on the left, ✕ on the right) on the canvas. */
function drawGroupRowHeader(
ctx: CanvasRenderingContext2D,
width: number,
y: number,
label: string,
removable: boolean
): void {
const height = LiteGraph.NODE_WIDGET_HEIGHT
ctx.save()
ctx.font = `${LiteGraph.NODE_TEXT_SIZE}px ${LiteGraph.NODE_FONT}`
ctx.fillStyle = LiteGraph.WIDGET_SECONDARY_TEXT_COLOR
ctx.textAlign = 'left'
ctx.fillText(label, CANVAS_MARGIN, y + height * 0.7)
if (removable) {
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR
ctx.textAlign = 'center'
ctx.fillText('\u2715', removeButtonCenterX(width), y + height * 0.7)
}
ctx.restore()
}
const countGroupRows = (group: string, node: LGraphNode): number =>
(node.widgets ?? []).reduce(
(count, w) =>
headerRowIndex(group, w.name) !== undefined ? count + 1 : count,
0
)
/** Build a row's header + field widgets, returning them detached from the node. */
function createRow(
group: string,
row: number,
state: DynamicGroupState,
node: DynamicGroupNode
): IBaseWidget[] {
const { addNodeInput } = useLitegraphService()
const startLen = node.widgets!.length
const header = node.addCustomWidget({
name: rowHeaderName(group, row),
type: 'dynamic_group_row',
value: row,
y: 0,
serialize: false,
callback: undefined as IBaseWidget['callback'],
draw(
this: IBaseWidget,
ctx: CanvasRenderingContext2D,
_node: LGraphNode,
width: number,
y: number
) {
const idx = headerRowIndex(group, this.name) ?? 0
const label = t('dynamicGroup.row', { index: idx + 1 })
drawGroupRowHeader(ctx, width, y, label, !!this.options?.removable)
},
mouse(this: IBaseWidget, event: CanvasPointerEvent, pos: Point) {
if (event.type !== 'pointerup' || !this.options?.removable) return false
const half = LiteGraph.NODE_WIDGET_HEIGHT * 0.5
if (Math.abs(pos[0] - removeButtonCenterX(node.size[0])) > half)
return false
const idx = headerRowIndex(group, this.name)
if (idx !== undefined) removeRow(group, idx, node)
return true
},
options: { serialize: false, socketless: true, removable: row >= state.min }
})
header.callback = function (this: IBaseWidget) {
const idx = headerRowIndex(group, this.name)
if (idx !== undefined) removeRow(group, idx, node)
}
for (const spec of state.inputSpecs)
addNodeInput(node, {
...spec,
name: fieldName(group, row, spec.name),
display_name: spec.display_name ?? spec.name
})
return node.widgets!.splice(startLen)
}
function insertRowAfterGroup(
group: string,
node: LGraphNode,
rowWidgets: IBaseWidget[]
): void {
const lastIdx = node.widgets!.findLastIndex(
(w) => w.name === group || w.name.startsWith(`${group}.`)
)
node.widgets!.splice(lastIdx + 1, 0, ...rowWidgets)
}
function syncController(group: string, node: DynamicGroupNode): void {
const state = node.comfyDynamic.dynamicGroup[group]
const controller = node.widgets?.find((w) => w.name === group)
if (!state || !controller) return
controller.options ??= {}
controller.options.disabled = countGroupRows(group, node) >= state.max
node.size[1] = node.computeSize([...node.size])[1]
}
function addRow(group: string, node: DynamicGroupNode): void {
const state = node.comfyDynamic.dynamicGroup[group]
if (!state) return
node.widgets ??= []
const row = countGroupRows(group, node)
if (row >= state.max) return
insertRowAfterGroup(group, node, createRow(group, row, state, node))
syncController(group, node)
app.canvas?.setDirty(true, true)
}
function removeRow(group: string, row: number, node: DynamicGroupNode): void {
const state = node.comfyDynamic.dynamicGroup[group]
if (!state || row < state.min) return
for (const w of remove(node.widgets!, (w) =>
belongsToRow(group, w.name, row)
))
w.onRemove?.()
remove(node.inputs, (inp) => belongsToRow(group, inp.name, row))
for (const w of node.widgets ?? []) {
const headerRow = headerRowIndex(group, w.name)
if (headerRow !== undefined && headerRow > row) {
w.name = rowHeaderName(group, headerRow - 1)
w.options ??= {}
w.options.removable = headerRow - 1 >= state.min
continue
}
const shifted = shiftedFieldName(group, w.name, row)
if (shifted !== undefined) w.name = shifted
}
for (const inp of node.inputs) {
const shifted = shiftedFieldName(group, inp.name, row)
if (shifted === undefined) continue
inp.name = shifted
if (inp.widget) inp.widget.name = shifted
}
syncController(group, node)
app.canvas?.setDirty(true, true)
}
/** Rebuild the group from scratch to hold exactly `count` rows. */
function rebuildRows(group: string, count: number, node: DynamicGroupNode) {
const state = node.comfyDynamic.dynamicGroup[group]
if (!state) return
node.widgets ??= []
const isRowMember = (name: string) => name.startsWith(`${group}.`)
for (const w of remove(node.widgets, (w) => isRowMember(w.name)))
w.onRemove?.()
remove(node.inputs, (inp) => isRowMember(inp.name))
const insertAt = node.widgets.findIndex((w) => w.name === group) + 1
const rowWidgets: IBaseWidget[] = []
for (let row = 0; row < count; row++)
rowWidgets.push(...createRow(group, row, state, node))
node.widgets.splice(insertAt, 0, ...rowWidgets)
}
function dynamicGroupWidget(
node: LGraphNode,
inputName: string,
untypedInputData: InputSpec,
_appArg: ComfyApp
) {
const parseResult = zDynamicGroupInputSpec.safeParse(untypedInputData)
if (!parseResult.success) throw new Error('invalid DynamicGroup spec')
const [, { template, min, max }] = parseResult.data
const toSpecs = (
inputs: Record<string, InputSpec> | undefined,
isOptional: boolean
) =>
Object.entries(inputs ?? {}).map(([name, spec]) =>
transformInputSpecV1ToV2(spec, { name, isOptional })
)
const inputSpecs = [
...toSpecs(template.required, false),
...toSpecs(template.optional, true)
]
withComfyDynamicGroup(node)
const typedNode = node as DynamicGroupNode
typedNode.comfyDynamic.dynamicGroup[inputName] = { min, max, inputSpecs }
node.widgets ??= []
const controller = node.addCustomWidget({
name: inputName,
type: 'dynamic_group_add',
value: min,
y: 0,
serialize: true,
callback: () => addRow(inputName, typedNode),
draw(
this: IBaseWidget,
ctx: CanvasRenderingContext2D,
_node: LGraphNode,
width: number,
y: number
) {
drawGroupButton(
ctx,
width,
y,
t('dynamicGroup.addRow'),
!!this.options?.disabled
)
},
mouse(this: IBaseWidget, event: CanvasPointerEvent) {
if (event.type !== 'pointerup' || this.options?.disabled) return false
addRow(inputName, typedNode)
return true
},
options: { serialize: false, socketless: true, disabled: false }
})
Object.defineProperty(controller, 'value', {
get() {
return countGroupRows(inputName, typedNode)
},
set(count: unknown) {
if (typeof count !== 'number') return
rebuildRows(inputName, count, typedNode)
syncController(inputName, typedNode)
},
configurable: true
})
controller.value = min
return { widget: controller }
}
export const dynamicWidgets = {
COMFY_DYNAMICCOMBO_V3: dynamicComboWidget,
COMFY_DYNAMICGROUP_V3: dynamicGroupWidget
}
export const dynamicWidgets = { COMFY_DYNAMICCOMBO_V3: dynamicComboWidget }
const dynamicInputs: Record<
string,
(node: LGraphNode, inputSpec: InputSpecV2) => void

View File

@@ -1,89 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { state } = vi.hoisted(() => ({
state: {
extension: null as { nodeCreated: (node: unknown) => void } | null,
widgetState: undefined as { options: Record<string, unknown> } | undefined
}
}))
vi.mock('@/services/extensionService', () => ({
useExtensionService: () => ({
registerExtension: (ext: { nodeCreated: (node: unknown) => void }) => {
state.extension = ext
}
})
}))
vi.mock('@/stores/widgetValueStore', () => ({
useWidgetValueStore: () => ({ getWidget: () => state.widgetState })
}))
await import('./createBoundingBoxes')
interface MockWidget {
name: string
hidden: boolean
options: Record<string, unknown>
widgetId?: string
}
function makeNode(connected: boolean, comfyClass = 'CreateBoundingBoxes') {
const widgets: MockWidget[] = [
{ name: 'width', hidden: false, options: {} },
{ name: 'height', hidden: false, options: {} },
{ name: 'other', hidden: false, options: {} }
]
return {
constructor: { comfyClass },
size: [100, 100] as [number, number],
setSize: vi.fn(),
findInputSlot: () => 0,
isInputConnected: () => connected,
widgets,
onConnectionsChange: undefined as unknown
}
}
beforeEach(() => {
state.widgetState = undefined
})
describe('Comfy.CreateBoundingBoxes extension', () => {
it('ignores nodes of other classes', () => {
const node = makeNode(true, 'SomethingElse')
state.extension!.nodeCreated(node)
expect(node.setSize).not.toHaveBeenCalled()
})
it('enlarges the node and hides width/height when a background is connected', () => {
const node = makeNode(true)
state.extension!.nodeCreated(node)
expect(node.setSize).toHaveBeenCalledWith([420, 560])
expect(node.widgets[0].hidden).toBe(true)
expect(node.widgets[1].hidden).toBe(true)
expect(node.widgets[0].options.hidden).toBe(true)
expect(node.widgets[2].hidden).toBe(false)
})
it('shows width/height when no background is connected', () => {
const node = makeNode(false)
state.extension!.nodeCreated(node)
expect(node.widgets[0].hidden).toBe(false)
expect(node.widgets[0].options.hidden).toBe(false)
})
it('writes visibility through the widget value store when present', () => {
state.widgetState = { options: {} }
const node = makeNode(true)
node.widgets[0].widgetId = 'w-0'
state.extension!.nodeCreated(node)
expect(state.widgetState.options.hidden).toBe(true)
})
it('chains a connections-change handler that re-syncs visibility', () => {
const node = makeNode(false)
state.extension!.nodeCreated(node)
expect(typeof node.onConnectionsChange).toBe('function')
})
})

View File

@@ -1,38 +0,0 @@
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useExtensionService } from '@/services/extensionService'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
const DIMENSION_WIDGETS = new Set(['width', 'height'])
useExtensionService().registerExtension({
name: 'Comfy.CreateBoundingBoxes',
nodeCreated(node) {
if (node.constructor.comfyClass !== 'CreateBoundingBoxes') return
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 420), Math.max(oldHeight, 560)])
const widgetValueStore = useWidgetValueStore()
const syncDimensionVisibility = () => {
const slot = node.findInputSlot('background')
const hidden = slot >= 0 && node.isInputConnected(slot)
for (const widget of node.widgets ?? []) {
if (!DIMENSION_WIDGETS.has(widget.name)) continue
widget.hidden = hidden
const state = widget.widgetId
? widgetValueStore.getWidget(widget.widgetId)
: undefined
if (state?.options) state.options.hidden = hidden
else widget.options.hidden = hidden
}
}
syncDimensionVisibility()
node.onConnectionsChange = useChainCallback(
node.onConnectionsChange,
syncDimensionVisibility
)
}
})

View File

@@ -2,7 +2,6 @@ import { isCloud, isNightly } from '@/platform/distribution/types'
import './clipspace'
import './contextMenuFilter'
import './createBoundingBoxes'
import './customWidgets'
import './dynamicPrompts'
import './editAttention'

View File

@@ -1,7 +1,5 @@
import * as THREE from 'three'
import { WebGLViewport } from '@/renderer/three/WebGLViewport'
import type { CameraManager } from './CameraManager'
import type { ControlsManager } from './ControlsManager'
import type { EventManager } from './EventManager'
@@ -29,7 +27,8 @@ export type Viewport3dDeps = {
viewHelperManager: ViewHelperManager
}
export class Viewport3d extends WebGLViewport {
export class Viewport3d {
renderer: THREE.WebGLRenderer
protected clock: THREE.Clock
private renderLoop: RenderLoopHandle | null = null
private onContextMenuCallback?: (event: MouseEvent) => void
@@ -53,6 +52,7 @@ export class Viewport3d extends WebGLViewport {
isViewerMode: boolean = false
private disposeContextMenuGuard: (() => void) | null = null
private resizeObserver: ResizeObserver | null = null
private getZoomScaleCallback: (() => number) | undefined
private externalActiveCamera: THREE.Camera | null = null
private overlay: SceneOverlay | null = null
@@ -63,7 +63,6 @@ export class Viewport3d extends WebGLViewport {
deps: Viewport3dDeps,
options: Load3DOptions = {}
) {
super(deps.renderer)
this.clock = new THREE.Clock()
this.isViewerMode = options.isViewerMode || false
this.onContextMenuCallback = options.onContextMenu
@@ -74,6 +73,7 @@ export class Viewport3d extends WebGLViewport {
this.applyTargetSize(options.width, options.height)
}
this.renderer = deps.renderer
this.eventManager = deps.eventManager
this.sceneManager = deps.sceneManager
this.cameraManager = deps.cameraManager
@@ -94,7 +94,7 @@ export class Viewport3d extends WebGLViewport {
this.STATUS_MOUSE_ON_VIEWER = false
this.initContextMenu()
this.observeResize(container, () => this.handleResize())
this.initResizeObserver(container)
}
start(): void {
@@ -118,6 +118,16 @@ export class Viewport3d extends WebGLViewport {
this.targetAspectRatio = width / height
}
private initResizeObserver(container: Element | HTMLElement): void {
if (typeof ResizeObserver === 'undefined') return
this.resizeObserver?.disconnect()
this.resizeObserver = new ResizeObserver(() => {
this.handleResize()
})
this.resizeObserver.observe(container)
}
private initContextMenu(): void {
this.disposeContextMenuGuard = attachContextMenuGuard(
this.renderer.domElement,
@@ -390,15 +400,29 @@ export class Viewport3d extends WebGLViewport {
this.initialRenderTimer = null
}
if (this.resizeObserver) {
this.resizeObserver.disconnect()
this.resizeObserver = null
}
this.disposeContextMenuGuard?.()
this.disposeContextMenuGuard = null
this.renderer.forceContextLoss()
const canvas = this.renderer.domElement
const event = new Event('webglcontextlost', {
bubbles: true,
cancelable: true
})
canvas.dispatchEvent(event)
this.renderLoop?.stop()
this.renderLoop = null
this.disposeManagers()
this.disposeRenderer()
this.renderer.dispose()
this.renderer.domElement.remove()
}
protected disposeManagers(): void {

View File

@@ -7,6 +7,7 @@ import type {
LLink
} from '@/lib/litegraph/src/litegraph'
import { NodeSlot } from '@/lib/litegraph/src/node/NodeSlot'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type {
IBaseWidget,
TWidgetValue
@@ -272,7 +273,17 @@ export class PrimitiveNode extends LGraphNode {
widgetName: 'value',
nodeTypeForBrowser: targetNode.comfyClass ?? '',
inputNameForBrowser: targetInputName,
defaultValue
defaultValue,
onValueChange: (widget, newValue, oldValue) => {
widget.callback?.(
widget.value,
app.canvas,
this,
app.canvas.graph_mouse,
{} as CanvasPointerEvent
)
this.onWidgetChanged?.(widget.name, newValue, oldValue, widget)
}
})
}

View File

@@ -5,7 +5,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NodeId, Subgraph } from '@/lib/litegraph/src/litegraph'
import {
LGraph,
LGraphGroup,
LGraphNode,
LiteGraph,
LLink,
@@ -324,96 +323,6 @@ describe('Graph Clearing and Callbacks', () => {
})
})
describe('node:before-removed event', () => {
it('fires node:before-removed for a successful node removal', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
graph.add(node)
const events: { node: LGraphNode; graphAtDispatch: unknown }[] = []
graph.events.addEventListener('node:before-removed', (e) => {
events.push({
node: e.detail.node,
graphAtDispatch: e.detail.node.graph
})
})
graph.remove(node)
expect(events).toHaveLength(1)
expect(events[0].node).toBe(node)
expect(events[0].graphAtDispatch).toBe(graph)
expect(node.graph).toBeNull()
})
it('does not fire node:before-removed for a node not in the graph', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
const fired = vi.fn()
graph.events.addEventListener('node:before-removed', fired)
graph.remove(node)
expect(fired).not.toHaveBeenCalled()
})
it('does not fire node:before-removed when removing an LGraphGroup', () => {
const graph = new LGraph()
const group = new LGraphGroup('test-group')
graph.add(group)
const fired = vi.fn()
graph.events.addEventListener('node:before-removed', fired)
graph.remove(group)
expect(fired).not.toHaveBeenCalled()
})
it('does not fire node:before-removed when ignore_remove is set', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
graph.add(node)
node.ignore_remove = true
const fired = vi.fn()
graph.events.addEventListener('node:before-removed', fired)
graph.remove(node)
expect(fired).not.toHaveBeenCalled()
expect(graph.nodes).toContain(node)
})
it('fires node:before-removed before node.onRemoved and detach', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
graph.add(node)
const order: string[] = []
graph.events.addEventListener('node:before-removed', () => {
order.push(
`before-removed(graph=${node.graph === graph ? 'set' : 'null'})`
)
})
node.onRemoved = () => {
order.push(`onRemoved(graph=${node.graph === graph ? 'set' : 'null'})`)
}
graph.onNodeRemoved = (n) => {
order.push(`onNodeRemoved(graph=${n.graph === null ? 'null' : 'set'})`)
}
graph.remove(node)
expect(order).toEqual([
'before-removed(graph=set)',
'onRemoved(graph=set)',
'onNodeRemoved(graph=null)'
])
})
})
describe('Subgraph Definition Garbage Collection', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
@@ -466,53 +375,6 @@ describe('Subgraph Definition Garbage Collection', () => {
expect(graphRemovedNodeIds.size).toBe(2)
})
it('subgraph-definition GC dispatches node:before-removed on the inner subgraph for each inner node', () => {
const rootGraph = new LGraph()
const { subgraph, innerNodes } = createSubgraphWithNodes(rootGraph, 2)
const dispatched: { node: LGraphNode; graphAtDispatch: unknown }[] = []
subgraph.events.addEventListener('node:before-removed', (e) => {
dispatched.push({
node: e.detail.node,
graphAtDispatch: e.detail.node.graph
})
})
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
rootGraph.add(subgraphNode)
rootGraph.remove(subgraphNode)
expect(dispatched.map((e) => e.node)).toEqual(innerNodes)
for (const entry of dispatched) {
expect(entry.graphAtDispatch).toBe(subgraph)
}
})
it('subgraph-definition GC dispatches node:before-removed before each inner node onRemoved', () => {
const rootGraph = new LGraph()
const { subgraph, innerNodes } = createSubgraphWithNodes(rootGraph, 1)
const innerNode = innerNodes[0]
const order: string[] = []
subgraph.events.addEventListener('node:before-removed', () => {
order.push('before-removed')
})
innerNode.onRemoved = () => {
order.push('onRemoved')
}
subgraph.onNodeRemoved = () => {
order.push('onNodeRemoved')
}
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
rootGraph.add(subgraphNode)
rootGraph.remove(subgraphNode)
expect(order).toEqual(['before-removed', 'onRemoved', 'onNodeRemoved'])
})
it('subgraph definition is removed when SubgraphNode is removed', () => {
const rootGraph = new LGraph()
const { subgraph } = createSubgraphWithNodes(rootGraph, 1)

View File

@@ -155,13 +155,6 @@ export interface BaseLGraph {
readonly rootGraph: LGraph
}
function fireNodeRemovalLifecycle(node: LGraphNode): void {
const graph: LGraph | null = node.graph
graph?.events.dispatch('node:before-removed', { node })
node.onRemoved?.()
graph?.onNodeRemoved?.(node)
}
/**
* LGraph is the class that contain a full graph. We instantiate one and add nodes to it, and then we can run the execution loop.
* supported callbacks:
@@ -393,7 +386,8 @@ export class LGraph
// safe clear
if (this._nodes) {
for (const _node of this._nodes) {
fireNodeRemovalLifecycle(_node)
_node.onRemoved?.()
this.onNodeRemoved?.(_node)
}
}
@@ -1052,8 +1046,6 @@ export class LGraph
// sure? - almost sure is wrong
this.beforeChange()
this.events.dispatch('node:before-removed', { node })
const { inputs, outputs } = node
// disconnect inputs
@@ -1089,7 +1081,10 @@ export class LGraph
)
if (!hasRemainingReferences) {
forEachNode(node.subgraph, fireNodeRemovalLifecycle)
forEachNode(node.subgraph, (innerNode) => {
innerNode.onRemoved?.()
innerNode.graph?.onNodeRemoved?.(innerNode)
})
this.rootGraph.subgraphs.delete(node.subgraph.id)
}
}

View File

@@ -829,7 +829,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (this._lowQualityZoomThreshold > 0) {
this._isLowQuality = scale < this._lowQualityZoomThreshold
}
this.setDirty(true, true)
}
// Initialize link renderer if graph is available

View File

@@ -1,5 +1,5 @@
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LLink, ResolvedConnection } from '@/lib/litegraph/src/LLink'
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
@@ -51,13 +51,6 @@ export interface LGraphEventMap {
closingGraph: LGraph | Subgraph
}
/**
* Fires on the owning graph before per-node teardown begins
*/
'node:before-removed': {
node: LGraphNode
}
'node:property:changed': {
nodeId: NodeId
property: string

View File

@@ -85,19 +85,6 @@ describe('SubgraphNode Construction', () => {
expect(subgraphNode.graph).toBeNull()
})
it('should return empty widgets array (not throw) after removal', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const parentGraph = subgraphNode.graph!
parentGraph.add(subgraphNode)
parentGraph.remove(subgraphNode)
expect(subgraphNode.graph).toBeNull()
expect(() => subgraphNode.widgets).not.toThrow()
expect(subgraphNode.widgets).toEqual([])
})
subgraphTest(
'should synchronize slots with subgraph definition',
({ subgraphWithNode }) => {

View File

@@ -68,10 +68,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
return this.graph.rootGraph
}
get isDetached(): boolean {
return !this.graph
}
override get displayType(): string {
return 'Subgraph node'
}

View File

@@ -1,6 +1,5 @@
import type { Bounds } from '@/renderer/core/layout/types'
import type { CurveData } from '@/components/curve/types'
import type { BoundingBox } from '@/types/boundingBoxes'
import type { WidgetId } from '@/types/widgetId'
import type {
@@ -71,7 +70,6 @@ export interface IWidgetOptions<TValues = unknown> {
// Vue widget options
disabled?: boolean
removable?: boolean
useGrouping?: boolean
placeholder?: string
showThumbnails?: boolean
@@ -143,8 +141,6 @@ export type IWidget =
| ICurveWidget
| IPainterWidget
| IRangeWidget
| IBoundingBoxesWidget
| IColorsWidget
export interface IBooleanWidget extends IBaseWidget<boolean, 'toggle'> {
type: 'toggle'
@@ -347,19 +343,6 @@ export interface IPainterWidget extends IBaseWidget<string, 'painter'> {
value: string
}
export interface IBoundingBoxesWidget extends IBaseWidget<
BoundingBox[],
'boundingboxes'
> {
type: 'boundingboxes'
value: BoundingBox[]
}
export interface IColorsWidget extends IBaseWidget<string[], 'colors'> {
type: 'colors'
value: string[]
}
export interface RangeValue {
min: number
max: number

View File

@@ -1,42 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { DrawWidgetOptions } from '@/lib/litegraph/src/widgets/BaseWidget'
import { BoundingBoxesWidget } from './BoundingBoxesWidget'
function fakeCtx() {
return {
save: vi.fn(),
restore: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillText: vi.fn(),
fillStyle: '',
strokeStyle: '',
font: '',
textAlign: '',
textBaseline: ''
} as unknown as CanvasRenderingContext2D
}
describe('BoundingBoxesWidget', () => {
it('has the boundingboxes type and draws the Vue-only placeholder', () => {
const node = new LGraphNode('Test')
const widget = new BoundingBoxesWidget(
{
type: 'boundingboxes',
name: 'editor_state',
value: [],
options: {},
y: 0
},
node
)
expect(widget.type).toBe('boundingboxes')
const ctx = fakeCtx()
widget.drawWidget(ctx, { width: 200 } as DrawWidgetOptions)
expect(ctx.fillText).toHaveBeenCalled()
expect(() => widget.onClick({} as never)).not.toThrow()
})
})

View File

@@ -1,16 +0,0 @@
import type { IBoundingBoxesWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
export class BoundingBoxesWidget
extends BaseWidget<IBoundingBoxesWidget>
implements IBoundingBoxesWidget
{
override type = 'boundingboxes' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'Bounding Boxes')
}
onClick(_options: WidgetEventOptions): void {}
}

View File

@@ -1,36 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { DrawWidgetOptions } from '@/lib/litegraph/src/widgets/BaseWidget'
import { ColorsWidget } from './ColorsWidget'
function fakeCtx() {
return {
save: vi.fn(),
restore: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillText: vi.fn(),
fillStyle: '',
strokeStyle: '',
font: '',
textAlign: '',
textBaseline: ''
} as unknown as CanvasRenderingContext2D
}
describe('ColorsWidget', () => {
it('has the colors type and draws the Vue-only placeholder', () => {
const node = new LGraphNode('Test')
const widget = new ColorsWidget(
{ type: 'colors', name: 'palette', value: [], options: {}, y: 0 },
node
)
expect(widget.type).toBe('colors')
const ctx = fakeCtx()
widget.drawWidget(ctx, { width: 200 } as DrawWidgetOptions)
expect(ctx.fillText).toHaveBeenCalled()
expect(() => widget.onClick({} as never)).not.toThrow()
})
})

View File

@@ -1,16 +0,0 @@
import type { IColorsWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
export class ColorsWidget
extends BaseWidget<IColorsWidget>
implements IColorsWidget
{
override type = 'colors' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'Colors')
}
onClick(_options: WidgetEventOptions): void {}
}

View File

@@ -21,8 +21,6 @@ import { FileUploadWidget } from './FileUploadWidget'
import { GalleriaWidget } from './GalleriaWidget'
import { GradientSliderWidget } from './GradientSliderWidget'
import { ImageCompareWidget } from './ImageCompareWidget'
import { BoundingBoxesWidget } from './BoundingBoxesWidget'
import { ColorsWidget } from './ColorsWidget'
import { PainterWidget } from './PainterWidget'
import { RangeWidget } from './RangeWidget'
import { ImageCropWidget } from './ImageCropWidget'
@@ -64,8 +62,6 @@ export type WidgetTypeMap = {
curve: CurveWidget
painter: PainterWidget
range: RangeWidget
boundingboxes: BoundingBoxesWidget
colors: ColorsWidget
[key: string]: BaseWidget
}
@@ -148,10 +144,6 @@ export function toConcreteWidget<TWidget extends IWidget | IBaseWidget>(
return toClass(PainterWidget, narrowedWidget, node)
case 'range':
return toClass(RangeWidget, narrowedWidget, node)
case 'boundingboxes':
return toClass(BoundingBoxesWidget, narrowedWidget, node)
case 'colors':
return toClass(ColorsWidget, narrowedWidget, node)
default: {
if (wrapLegacyWidgets) return toClass(LegacyWidget, widget, node)
}

View File

@@ -387,35 +387,6 @@
"collapseAll": "Collapse all",
"expandAll": "Expand all"
},
"hdrViewer": {
"title": "HDR Viewer",
"openInHdrViewer": "Open in HDR Viewer",
"hdrImage": "HDR image",
"failedToLoad": "Failed to load HDR image",
"exposure": "Exposure",
"normalizeExposure": "Auto exposure",
"channel": "Channel",
"channels": {
"rgb": "RGB",
"r": "R",
"g": "G",
"b": "B",
"a": "Alpha",
"luminance": "Luminance"
},
"sourceGamut": "Source gamut",
"dither": "Dither",
"clipWarnings": "Clip warnings",
"fitView": "Fit",
"histogram": "Histogram",
"resolution": "Resolution",
"min": "Min",
"max": "Max",
"mean": "Mean",
"stdDev": "Std dev",
"nan": "NaN",
"inf": "Inf"
},
"manager": {
"title": "Nodes Manager",
"nodePackInfo": "Node Pack Info",
@@ -895,8 +866,8 @@
"nodes": "Nodes",
"models": "Models",
"assets": "Assets",
"workflows": "Work\u00adflows",
"templates": "Tem\u00adplates",
"workflows": "Workflows",
"templates": "Templates",
"console": "Console",
"menu": "Menu",
"imported": "Imported",
@@ -2142,21 +2113,6 @@
"monotone_cubic": "Smooth",
"linear": "Linear"
},
"boundingBoxes": {
"clearAll": "Clear all",
"clickRegionToEdit": "Click a region to edit it.",
"typeObj": "obj",
"typeText": "text",
"textLabel": "Text",
"descLabel": "description",
"textPlaceholder": "text to render (verbatim)",
"descPlaceholder": "description of this region",
"colors": "color_palette"
},
"palette": {
"addColor": "Add a color",
"swatchTitle": "Click edit · drag reorder · right-click remove"
},
"toastMessages": {
"nothingToQueue": "Nothing to queue",
"pleaseSelectOutputNodes": "Please select output nodes",
@@ -2233,11 +2189,6 @@
"slots": "Node Slots Error",
"widgets": "Node Widgets Error"
},
"dynamicGroup": {
"addRow": "Add row",
"removeRow": "Remove row",
"row": "Row {index}"
},
"oauth": {
"consent": {
"allow": "Continue",
@@ -2995,7 +2946,7 @@
"share": "Share"
},
"shortcuts": {
"shortcuts": "Short\u00adcuts",
"shortcuts": "Shortcuts",
"essentials": "Essential",
"viewControls": "View Controls",
"manageShortcuts": "Manage Shortcuts",

View File

@@ -32,13 +32,12 @@ import { i18n } from './i18n'
const isCloud = __DISTRIBUTION__ === 'cloud'
const hasHostTelemetryBridge = Boolean(window.__comfyDesktop2?.Telemetry)
const requiresRemoteConfigBootstrap = isCloud || hasHostTelemetryBridge
if (requiresRemoteConfigBootstrap) {
const { refreshRemoteConfig } =
await import('@/platform/remoteConfig/refreshRemoteConfig')
await refreshRemoteConfig({ useAuth: false })
}
// Load remote config before initializeApp() below, so getFirebaseConfig() resolves
// against the server's runtime values instead of the build-time defaults.
const { refreshRemoteConfig } =
await import('@/platform/remoteConfig/refreshRemoteConfig')
await refreshRemoteConfig({ useAuth: false })
if (isCloud) {
const { initTelemetry } = await import('@/platform/telemetry/initTelemetry')

View File

@@ -1,7 +1,6 @@
<template>
<BaseModalLayout
v-model:right-panel-open="isRightPanelOpen"
data-testid="asset-browser-modal"
data-component-id="AssetBrowserModal"
class="size-full max-h-full max-w-full min-w-0"
:content-title="displayTitle"

View File

@@ -1,6 +1,5 @@
<template>
<div
data-testid="asset-card"
data-component-id="AssetCard"
:data-asset-id="asset.id"
:aria-labelledby="titleId"

View File

@@ -1,169 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
IBaseWidget,
IWidgetAssetOptions
} from '@/lib/litegraph/src/types/widgets'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { createAssetWidget } from './createAssetWidget'
vi.mock('@/platform/assets/composables/useAssetBrowserDialog', () => {
const show = vi.fn()
const browse = vi.fn()
return {
useAssetBrowserDialog: () => ({ show, browse })
}
})
interface HostAssetWidget extends IBaseWidget<
string,
'asset',
IWidgetAssetOptions
> {
node: LGraphNode
}
type OnWidgetChanged = NonNullable<LGraphNode['onWidgetChanged']>
function checkpointAsset(name: string): AssetItem {
return {
id: `asset-${name}`,
name,
hash: 'checkpoint-hash',
mime_type: 'application/octet-stream',
tags: []
}
}
function createAssetWidgetNode() {
const node = new LGraphNode('TestNode')
const onWidgetChanged = vi.fn<OnWidgetChanged>()
node.onWidgetChanged = onWidgetChanged
return { node, onWidgetChanged }
}
function assertAssetOptions(
options: unknown
): asserts options is IWidgetAssetOptions {
if (!options || typeof options !== 'object') {
throw new Error('Expected asset widget options')
}
if (!('openModal' in options) || typeof options.openModal !== 'function') {
throw new Error('Expected asset widget options')
}
}
function firstShowOptions() {
const showOptions = vi.mocked(useAssetBrowserDialog().show).mock.calls[0]?.[0]
if (!showOptions) {
throw new Error('Expected the asset browser dialog to open')
}
return showOptions
}
describe('createAssetWidget', () => {
let captureCanvasState: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.resetAllMocks()
captureCanvasState = vi.fn()
setActivePinia(
createTestingPinia({
stubActions: false,
initialState: {
workflow: {
activeWorkflow: {
changeTracker: { captureCanvasState }
}
}
}
})
)
})
it('preserves regular asset widget change handling for the owning widget', async () => {
const { node, onWidgetChanged } = createAssetWidgetNode()
const widget = createAssetWidget({
node,
widgetName: 'ckpt_name',
nodeTypeForBrowser: 'CheckpointLoaderSimple',
inputNameForBrowser: 'ckpt_name',
defaultValue: 'fake_model.safetensors'
})
assertAssetOptions(widget.options)
await widget.options.openModal(widget)
const showOptions = firstShowOptions()
expect(showOptions).toMatchObject({
nodeType: 'CheckpointLoaderSimple',
inputName: 'ckpt_name',
currentValue: 'fake_model.safetensors'
})
showOptions.onAssetSelected?.(checkpointAsset('real_model.safetensors'))
expect(widget.value).toBe('real_model.safetensors')
expect(onWidgetChanged).toHaveBeenCalledWith(
'ckpt_name',
'real_model.safetensors',
'fake_model.safetensors',
widget
)
expect(captureCanvasState).toHaveBeenCalledOnce()
})
it('commits cloned asset modal selections through the promoted host widget', async () => {
const { node, onWidgetChanged: sourceOnWidgetChanged } =
createAssetWidgetNode()
const sourceWidget = createAssetWidget({
node,
widgetName: 'ckpt_name',
nodeTypeForBrowser: 'CheckpointLoaderSimple',
inputNameForBrowser: 'ckpt_name',
defaultValue: 'fake_model.safetensors'
})
assertAssetOptions(sourceWidget.options)
const hostCallback = vi.fn<NonNullable<IBaseWidget['callback']>>()
const hostNode = new LGraphNode('PromotedHostNode')
const hostOnWidgetChanged = vi.fn<OnWidgetChanged>()
hostNode.onWidgetChanged = hostOnWidgetChanged
const hostWidget: HostAssetWidget = {
type: 'asset',
name: 'host_ckpt_name',
value: 'fake_model.safetensors',
callback: hostCallback,
options: sourceWidget.options,
node: hostNode,
y: 0
}
await sourceWidget.options.openModal(hostWidget)
const showOptions = firstShowOptions()
expect(showOptions).toMatchObject({
nodeType: 'CheckpointLoaderSimple',
inputName: 'ckpt_name',
currentValue: 'fake_model.safetensors'
})
showOptions.onAssetSelected?.(checkpointAsset('real_model.safetensors'))
expect(sourceOnWidgetChanged).not.toHaveBeenCalled()
expect(hostWidget.value).toBe('real_model.safetensors')
expect(hostCallback).toHaveBeenCalledWith('real_model.safetensors')
expect(hostOnWidgetChanged).toHaveBeenCalledWith(
'host_ckpt_name',
'real_model.safetensors',
'fake_model.safetensors',
hostWidget
)
expect(captureCanvasState).toHaveBeenCalledOnce()
})
})

View File

@@ -12,11 +12,8 @@ import {
assetItemSchema
} from '@/platform/assets/schemas/assetSchema'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
type WidgetWithNode = IBaseWidget & { node: LGraphNode }
interface CreateAssetWidgetParams {
/** The node to add the widget to */
node: LGraphNode
@@ -28,26 +25,34 @@ interface CreateAssetWidgetParams {
inputNameForBrowser?: string
/** Default value for the widget */
defaultValue?: string
/** Callback when widget value changes */
onValueChange?: (
widget: IBaseWidget,
newValue: string,
oldValue: unknown
) => void
}
interface CreateAssetWidgetOptionsParams {
widgetName: string
nodeTypeForBrowser: string
inputNameForBrowser?: string
}
/**
* Creates an asset widget that opens the Asset Browser dialog for model selection.
* Used by both regular nodes (via useComboWidget) and PrimitiveNode.
*
* @param params - Configuration for the asset widget
* @returns The created asset widget
*/
export function createAssetWidget(
params: CreateAssetWidgetParams
): IBaseWidget {
const {
node,
widgetName,
nodeTypeForBrowser,
inputNameForBrowser,
defaultValue,
onValueChange
} = params
function hasOwnerNode(widget: IBaseWidget): widget is WidgetWithNode {
return (
'node' in widget && typeof widget.node === 'object' && widget.node !== null
)
}
function createAssetWidgetOptions({
widgetName,
nodeTypeForBrowser,
inputNameForBrowser
}: CreateAssetWidgetOptionsParams): IWidgetAssetOptions {
const inputName = inputNameForBrowser ?? widgetName
const displayLabel = defaultValue ?? t('widgets.selectModel')
const assetBrowserDialog = useAssetBrowserDialog()
async function openModal(widget: IBaseWidget) {
@@ -55,8 +60,8 @@ function createAssetWidgetOptions({
await assetBrowserDialog.show({
nodeType: nodeTypeForBrowser,
inputName,
currentValue: String(widget.value ?? ''),
inputName: inputNameForBrowser ?? widgetName,
currentValue: widget.value as string,
onAssetSelected: (asset) => {
const validatedAsset = assetItemSchema.safeParse(asset)
@@ -93,44 +98,15 @@ function createAssetWidgetOptions({
const oldValue = widget.value
widget.value = validatedFilename.data
widget.callback?.(widget.value)
if (hasOwnerNode(widget)) {
widget.node.onWidgetChanged?.(
widget.name,
validatedFilename.data,
oldValue,
widget
)
}
if (oldValue !== validatedFilename.data) {
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
}
onValueChange?.(widget, validatedFilename.data, oldValue)
}
})
}
return {
const options: IWidgetAssetOptions = {
openModal,
nodeType: nodeTypeForBrowser
}
}
export function createAssetWidget(
params: CreateAssetWidgetParams
): IBaseWidget {
const {
node,
widgetName,
nodeTypeForBrowser,
inputNameForBrowser,
defaultValue
} = params
const displayLabel = defaultValue ?? t('widgets.selectModel')
const options = createAssetWidgetOptions({
widgetName,
nodeTypeForBrowser,
inputNameForBrowser
})
return node.addWidget('asset', widgetName, displayLabel, () => {}, options)
}

View File

@@ -1,6 +1,5 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useMissingMediaStore } from './missingMediaStore'
import type { MissingMediaCandidate } from './types'
@@ -97,6 +96,14 @@ describe('useMissingMediaStore', () => {
expect(store.missingMediaNodeIds.has('2')).toBe(true)
})
it('hasMissingMediaOnNode checks node presence', () => {
const store = useMissingMediaStore()
store.setMissingMedia([makeCandidate('42', 'photo.png')])
expect(store.hasMissingMediaOnNode('42')).toBe(true)
expect(store.hasMissingMediaOnNode('99')).toBe(false)
})
it('removeMissingMediaByWidget removes matching node+widget entry', () => {
const store = useMissingMediaStore()
store.setMissingMedia([

View File

@@ -68,6 +68,10 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
missingMediaCandidates.value = media.length ? media : null
}
function hasMissingMediaOnNode(nodeLocatorId: string): boolean {
return missingMediaNodeIds.value.has(nodeLocatorId)
}
function isContainerWithMissingMedia(node: LGraphNode): boolean {
return activeMissingMediaGraphIds.value.has(String(node.id))
}
@@ -153,6 +157,7 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
clearMissingMedia,
createVerificationAbortController,
hasMissingMediaOnNode,
isContainerWithMissingMedia
}
})

View File

@@ -13,7 +13,6 @@ import type {
IBaseWidget,
IComboWidget
} from '@/lib/litegraph/src/types/widgets'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import {
collectAllNodes,
getExecutionIdByNode
@@ -170,7 +169,7 @@ export function scanNodeModelCandidates(
function scanAssetWidget(
node: { type: string },
widget: IAssetWidget,
executionId: NodeExecutionId,
executionId: string,
getDirectory: ((nodeType: string) => string | undefined) | undefined
): MissingModelCandidate | null {
const value = widget.value
@@ -191,7 +190,7 @@ function scanAssetWidget(
function scanComboWidget(
node: { type: string },
widget: IComboWidget,
executionId: NodeExecutionId,
executionId: string,
isAssetSupported: (nodeType: string, widgetName: string) => boolean,
getDirectory: ((nodeType: string) => string | undefined) | undefined
): MissingModelCandidate | null {

View File

@@ -1,14 +1,8 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
const mockNodeLocatorIdToNodeExecutionId = vi.hoisted(() =>
vi.fn((nodeLocatorId: string) => nodeLocatorId)
)
vi.mock('@/i18n', () => ({
t: vi.fn((key: string) => `translated:${key}`),
st: vi.fn((_key: string, fallback: string) => fallback)
@@ -18,12 +12,6 @@ vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
nodeLocatorIdToNodeExecutionId: mockNodeLocatorIdToNodeExecutionId
})
}))
import { useMissingModelStore } from './missingModelStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { app } from '@/scripts/app'
@@ -51,9 +39,6 @@ describe('missingModelStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.restoreAllMocks()
mockNodeLocatorIdToNodeExecutionId.mockImplementation(
(nodeLocatorId: string) => nodeLocatorId
)
})
describe('setMissingModels', () => {
@@ -161,9 +146,7 @@ describe('missingModelStore', () => {
makeModelCandidate('model_a.safetensors', { nodeId: '5' })
])
expect(store.hasMissingModelOnNode(createNodeLocatorId(null, 5))).toBe(
true
)
expect(store.hasMissingModelOnNode('5')).toBe(true)
})
it('returns false when node has no missing model', () => {
@@ -172,30 +155,12 @@ describe('missingModelStore', () => {
makeModelCandidate('model_a.safetensors', { nodeId: '5' })
])
expect(store.hasMissingModelOnNode(createNodeLocatorId(null, 99))).toBe(
false
)
expect(store.hasMissingModelOnNode('99')).toBe(false)
})
it('returns false when no models are missing', () => {
const store = useMissingModelStore()
expect(store.hasMissingModelOnNode(createNodeLocatorId(null, 1))).toBe(
false
)
})
it('compares subgraph locators against missing model execution IDs', () => {
const store = useMissingModelStore()
const locatorId = createNodeLocatorId(
'11111111-1111-1111-1111-111111111111',
63
)
mockNodeLocatorIdToNodeExecutionId.mockReturnValueOnce('65:70:63')
store.setMissingModels([
makeModelCandidate('model_a.safetensors', { nodeId: '65:70:63' })
])
expect(store.hasMissingModelOnNode(locatorId)).toBe(true)
expect(store.hasMissingModelOnNode('1')).toBe(false)
})
})

View File

@@ -6,11 +6,10 @@ import { t } from '@/i18n'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { getAncestorExecutionIds } from '@/types/nodeIdentification'
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
/**
@@ -20,7 +19,6 @@ import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
*/
export const useMissingModelStore = defineStore('missingModel', () => {
const canvasStore = useCanvasStore()
const workflowStore = useWorkflowStore()
const missingModelCandidates = ref<MissingModelCandidate[] | null>(null)
const isRefreshingMissingModels = ref(false)
@@ -195,10 +193,8 @@ export const useMissingModelStore = defineStore('missingModel', () => {
missingModelCandidates.value = [...existing, ...newModels]
}
function hasMissingModelOnNode(nodeLocatorId: NodeLocatorId): boolean {
const executionId =
workflowStore.nodeLocatorIdToNodeExecutionId(nodeLocatorId)
return executionId ? missingModelNodeIds.value.has(executionId) : false
function hasMissingModelOnNode(nodeLocatorId: string): boolean {
return missingModelNodeIds.value.has(nodeLocatorId)
}
function isWidgetMissingModel(nodeId: string, widgetName: string): boolean {

View File

@@ -53,7 +53,6 @@ import { getCnrIdFromNode } from '@/platform/nodeReplacement/cnrIdUtil'
import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacementStore'
import { rescanAndSurfaceMissingNodes } from './missingNodeScan'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { createNodeExecutionId } from '@/types/nodeIdentification'
function mockNode(
id: number,
@@ -139,9 +138,7 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
it('uses executionId when available for nodeId', () => {
vi.mocked(collectAllNodes).mockReturnValue([mockNode(1, 'Missing')])
vi.mocked(getExecutionIdByNode).mockReturnValue(
createNodeExecutionId(['exec-42'])
)
vi.mocked(getExecutionIdByNode).mockReturnValue('exec-42')
rescanAndSurfaceMissingNodes(mockGraph())

View File

@@ -7,7 +7,8 @@ import { remoteConfig } from './remoteConfig'
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: vi.fn()
fetchApi: vi.fn(),
apiURL: vi.fn((route: string) => `/ComfyUI/api${route}`)
}
}))
@@ -43,9 +44,10 @@ describe('refreshRemoteConfig', () => {
await refreshRemoteConfig({ useAuth: true })
expect(api.fetchApi).toHaveBeenCalledWith('/features', {
cache: 'no-store'
})
expect(api.fetchApi).toHaveBeenCalledWith(
'/features',
expect.objectContaining({ cache: 'no-store' })
)
expect(global.fetch).not.toHaveBeenCalled()
expect(remoteConfig.value).toEqual(mockConfig)
expect(window.__CONFIG__).toEqual(mockConfig)
@@ -59,23 +61,56 @@ describe('refreshRemoteConfig', () => {
expect(api.fetchApi).toHaveBeenCalled()
expect(global.fetch).not.toHaveBeenCalled()
})
it('does not pass an abort signal on the authed branch (so it is never aborted)', async () => {
vi.mocked(api.fetchApi).mockResolvedValue(mockSuccessResponse())
await refreshRemoteConfig({ useAuth: true })
const init = vi.mocked(api.fetchApi).mock.calls[0][1]
expect(init?.signal).toBeUndefined()
})
})
describe('without auth', () => {
it('uses raw fetch when useAuth is false', async () => {
it('builds the no-auth url via api.apiURL so a path prefix is respected', async () => {
vi.mocked(global.fetch).mockResolvedValue(mockSuccessResponse())
await refreshRemoteConfig({ useAuth: false })
expect(global.fetch).toHaveBeenCalledWith('/api/features', {
cache: 'no-store'
})
expect(api.apiURL).toHaveBeenCalledWith('/features')
expect(global.fetch).toHaveBeenCalledWith(
'/ComfyUI/api/features',
expect.objectContaining({ cache: 'no-store' })
)
expect(api.fetchApi).not.toHaveBeenCalled()
expect(remoteConfig.value).toEqual(mockConfig)
expect(window.__CONFIG__).toEqual(mockConfig)
})
})
describe('timeout', () => {
it('passes an AbortSignal so a wedged /features cannot hang startup', async () => {
vi.mocked(global.fetch).mockResolvedValue(mockSuccessResponse())
await refreshRemoteConfig({ useAuth: false })
const init = vi.mocked(global.fetch).mock.calls[0][1]
expect(init?.signal).toBeInstanceOf(AbortSignal)
})
it('falls back to empty config when the request aborts', async () => {
vi.mocked(global.fetch).mockRejectedValue(
new DOMException('Aborted', 'AbortError')
)
await refreshRemoteConfig({ useAuth: false })
expect(remoteConfig.value).toEqual({})
expect(window.__CONFIG__).toEqual({})
})
})
describe('error handling', () => {
it('clears config on 401 response', async () => {
vi.mocked(api.fetchApi).mockResolvedValue(

View File

@@ -4,6 +4,11 @@ import {
remoteConfigState
} from './remoteConfig'
// Cap the bootstrap fetch so a wedged /features endpoint can never block app.mount indefinitely.
// A same-origin GET against the local comfyui server should resolve in well under a second;
// on timeout the catch below clears remoteConfig and consumers fall back to build-time defaults.
const FEATURES_FETCH_TIMEOUT_MS = 5_000
interface RefreshRemoteConfigOptions {
/**
* Whether to use authenticated API (default: true).
@@ -12,10 +17,14 @@ interface RefreshRemoteConfigOptions {
useAuth?: boolean
}
async function fetchRemoteConfig(useAuth: boolean): Promise<Response> {
if (!useAuth) return fetch('/api/features', { cache: 'no-store' })
async function fetchRemoteConfig(
useAuth: boolean,
signal?: AbortSignal
): Promise<Response> {
const { api } = await import('@/scripts/api')
if (!useAuth) {
return fetch(api.apiURL('/features'), { cache: 'no-store', signal })
}
return api.fetchApi('/features', { cache: 'no-store' })
}
@@ -33,8 +42,13 @@ export async function refreshRemoteConfig(
): Promise<void> {
const { useAuth = true } = options
const controller = useAuth ? null : new AbortController()
const timeoutId = controller
? setTimeout(() => controller.abort(), FEATURES_FETCH_TIMEOUT_MS)
: null
try {
const response = await fetchRemoteConfig(useAuth)
const response = await fetchRemoteConfig(useAuth, controller?.signal)
if (response.ok) {
const config = await response.json()
@@ -59,5 +73,7 @@ export async function refreshRemoteConfig(
window.__CONFIG__ = {}
remoteConfig.value = {}
remoteConfigState.value = 'error'
} finally {
if (timeoutId !== null) clearTimeout(timeoutId)
}
}

View File

@@ -12,7 +12,7 @@
size="md"
:placeholder="$t('g.searchSettings') + '...'"
:debounce-time="128"
:autofocus="activeCategoryKey !== 'keybinding'"
autofocus
@search="handleSearch"
/>
</div>

View File

@@ -17,7 +17,6 @@ import { useWorkflowDraftStoreV2 } from '@/platform/workflow/persistence/stores/
import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { defaultGraph, defaultGraphJSON } from '@/scripts/defaultGraph'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import { isSubgraph } from '@/utils/typeGuardUtil'
import {
createMockCanvas,
@@ -888,75 +887,81 @@ describe('useWorkflowStore', () => {
})
})
describe('executionIdToCurrentId', () => {
it('should convert an execution ID to the active subgraph node ID', () => {
const result = store.executionIdToCurrentId('123:456')
expect(result).toBe('456')
describe('nodeExecutionIdToNodeLocatorId', () => {
it('should convert execution ID to NodeLocatorId', () => {
const result = store.nodeExecutionIdToNodeLocatorId('123:456')
expect(result).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890:456')
})
it('should return undefined for execution IDs outside the active subgraph', () => {
expect(() => store.executionIdToCurrentId('999:456')).not.toThrow()
expect(store.executionIdToCurrentId('999:456')).toBeUndefined()
it('should return simple node ID for root level nodes', () => {
const result = store.nodeExecutionIdToNodeLocatorId('123')
expect(result).toBe('123')
})
it('should return undefined for malformed execution IDs', () => {
expect(() => store.executionIdToCurrentId('123::456')).not.toThrow()
expect(store.executionIdToCurrentId('123::456')).toBeUndefined()
it('should return null for invalid execution IDs', () => {
const result = store.nodeExecutionIdToNodeLocatorId('999:456')
expect(result).toBeNull()
})
})
describe('nodeLocatorIdToNodeId', () => {
it('should extract node ID from NodeLocatorId', () => {
const result = store.nodeLocatorIdToNodeId(
createNodeLocatorId('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 456)
'a1b2c3d4-e5f6-7890-abcd-ef1234567890:456'
)
expect(result).toBe(456)
})
it('should handle string node IDs', () => {
const result = store.nodeLocatorIdToNodeId(
createNodeLocatorId('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'node_1')
'a1b2c3d4-e5f6-7890-abcd-ef1234567890:node_1'
)
expect(result).toBe('node_1')
})
it('should handle simple node IDs (root graph)', () => {
const result = store.nodeLocatorIdToNodeId(
createNodeLocatorId(null, 123)
)
const result = store.nodeLocatorIdToNodeId('123')
expect(result).toBe(123)
const stringResult = store.nodeLocatorIdToNodeId(
createNodeLocatorId(null, 'node_1')
)
const stringResult = store.nodeLocatorIdToNodeId('node_1')
expect(stringResult).toBe('node_1')
})
it('should return null for invalid NodeLocatorId', () => {
const result = store.nodeLocatorIdToNodeId('invalid:format')
expect(result).toBeNull()
})
})
describe('nodeLocatorIdToNodeExecutionId', () => {
it('should convert NodeLocatorId to execution ID', () => {
// Need to mock isSubgraph to identify our mockSubgraph
vi.mocked(isSubgraph).mockImplementation((obj): obj is Subgraph => {
return obj === store.activeSubgraph
})
const result = store.nodeLocatorIdToNodeExecutionId(
createNodeLocatorId('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 456)
'a1b2c3d4-e5f6-7890-abcd-ef1234567890:456'
)
expect(result).toBe('123:456')
})
it('should handle simple node IDs (root graph)', () => {
const result = store.nodeLocatorIdToNodeExecutionId(
createNodeLocatorId(null, 123)
)
const result = store.nodeLocatorIdToNodeExecutionId('123')
expect(result).toBe('123')
})
it('should return null for unknown subgraph UUID', () => {
const result = store.nodeLocatorIdToNodeExecutionId(
createNodeLocatorId('unknown-uuid-1234-5678-90ab-cdef12345678', 456)
'unknown-uuid-1234-5678-90ab-cdef12345678:456'
)
expect(result).toBeNull()
})
it('should return null for invalid NodeLocatorId', () => {
const result = store.nodeLocatorIdToNodeExecutionId('invalid:format')
expect(result).toBeNull()
})
})
})

View File

@@ -83,9 +83,12 @@ interface WorkflowStore {
executionIdToCurrentId: (id: string) => string | undefined
nodeIdToNodeLocatorId: (nodeId: NodeId, subgraph?: Subgraph) => NodeLocatorId
nodeToNodeLocatorId: (node: LGraphNode) => NodeLocatorId
nodeLocatorIdToNodeId: (locatorId: NodeLocatorId) => NodeId
nodeExecutionIdToNodeLocatorId: (
nodeExecutionId: NodeExecutionId | string
) => NodeLocatorId | null
nodeLocatorIdToNodeId: (locatorId: NodeLocatorId | string) => NodeId | null
nodeLocatorIdToNodeExecutionId: (
locatorId: NodeLocatorId,
locatorId: NodeLocatorId | string,
targetSubgraph?: Subgraph
) => NodeExecutionId | null
}
@@ -577,16 +580,17 @@ export const useWorkflowStore = defineStore('workflow', () => {
const getSubgraphsFromInstanceIds = (
currentGraph: LGraph | Subgraph,
subgraphNodeIds: string[]
): Subgraph[] | undefined => {
const [currentPart, ...remainingParts] = subgraphNodeIds
if (currentPart === undefined) return []
subgraphNodeIds: string[],
subgraphs: Subgraph[] = []
): Subgraph[] => {
const currentPart = subgraphNodeIds.shift()
if (currentPart === undefined) return subgraphs
const subgraph = subgraphNodeIdToSubgraph(currentPart, currentGraph)
if (subgraph === undefined) return
if (subgraph === undefined) throw new Error('Subgraph not found')
const childSubgraphs = getSubgraphsFromInstanceIds(subgraph, remainingParts)
return childSubgraphs ? [subgraph, ...childSubgraphs] : undefined
subgraphs.push(subgraph)
return getSubgraphsFromInstanceIds(subgraph, subgraphNodeIds, subgraphs)
}
//FIXME: use existing util function
@@ -600,17 +604,17 @@ export const useWorkflowStore = defineStore('workflow', () => {
return
}
const executionPath = parseNodeExecutionId(id)?.map(String)
if (!executionPath) return
// Parse the execution ID (e.g., "123:456:789")
const subgraphNodeIds = id.split(':')
const nodeId = executionPath.at(-1)
if (nodeId === undefined) return
// Start from the root graph
const graph = comfyApp.rootGraph
const subgraphs = getSubgraphsFromInstanceIds(
comfyApp.rootGraph,
executionPath.slice(0, -1)
)
if (subgraphs?.at(-1) === subgraph) return nodeId
// If the last subgraph is the active subgraph, return the node ID
const subgraphs = getSubgraphsFromInstanceIds(graph, subgraphNodeIds)
if (subgraphs.at(-1) === subgraph) {
return subgraphNodeIds.at(-1)
}
}
watch(activeWorkflow, updateActiveGraph)
@@ -628,7 +632,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
const targetSubgraph = subgraph ?? activeSubgraph.value
if (!targetSubgraph) {
// Node is in the root graph, return the node ID as-is
return createNodeLocatorId(null, nodeId)
return String(nodeId)
}
return createNodeLocatorId(targetSubgraph.id, nodeId)
@@ -642,16 +646,55 @@ export const useWorkflowStore = defineStore('workflow', () => {
const nodeToNodeLocatorId = (node: LGraphNode): NodeLocatorId => {
if (isSubgraph(node.graph))
return createNodeLocatorId(node.graph.id, node.id)
return createNodeLocatorId(null, node.id)
return String(node.id)
}
/**
* Convert an execution ID to a NodeLocatorId
* @param nodeExecutionId The execution node ID (e.g., "123:456:789")
* @returns The NodeLocatorId or null if conversion fails
*/
const nodeExecutionIdToNodeLocatorId = (
nodeExecutionId: NodeExecutionId | string
): NodeLocatorId | null => {
// Handle simple node IDs (root graph - no colons)
if (!nodeExecutionId.includes(':')) {
return nodeExecutionId
}
const parts = parseNodeExecutionId(nodeExecutionId)
if (!parts || parts.length === 0) return null
const nodeId = parts[parts.length - 1]
const subgraphNodeIds = parts.slice(0, -1)
if (subgraphNodeIds.length === 0) {
// Node is in root graph, return the node ID as-is
return String(nodeId)
}
try {
const subgraphs = getSubgraphsFromInstanceIds(
comfyApp.rootGraph,
subgraphNodeIds.map((id) => String(id))
)
const immediateSubgraph = subgraphs[subgraphs.length - 1]
return createNodeLocatorId(immediateSubgraph.id, nodeId)
} catch {
return null
}
}
/**
* Extract the node ID from a NodeLocatorId
* @param locatorId The NodeLocatorId
* @returns The local node ID
* @returns The local node ID or null if invalid
*/
const nodeLocatorIdToNodeId = (locatorId: NodeLocatorId): NodeId => {
return parseNodeLocatorId(locatorId)!.localNodeId
const nodeLocatorIdToNodeId = (
locatorId: NodeLocatorId | string
): NodeId | null => {
const parsed = parseNodeLocatorId(locatorId)
return parsed?.localNodeId ?? null
}
/**
@@ -661,7 +704,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
* @returns The execution ID or null if the node is not accessible from the target context
*/
const nodeLocatorIdToNodeExecutionId = (
locatorId: NodeLocatorId,
locatorId: NodeLocatorId | string,
targetSubgraph?: Subgraph
): NodeExecutionId | null => {
const parsed = parseNodeLocatorId(locatorId)
@@ -671,7 +714,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
// If no subgraph UUID, this is a root graph node
if (!subgraphUuid) {
return createNodeExecutionId([localNodeId])
return String(localNodeId)
}
// Find the path from root to the subgraph with this UUID
@@ -708,7 +751,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
comfyApp.rootGraph,
path.slice(0, idx + 1).map((id) => String(id))
)
return subgraphs?.at(-1) === targetSubgraph
return subgraphs[subgraphs.length - 1] === targetSubgraph
})
) {
return null
@@ -752,6 +795,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
executionIdToCurrentId,
nodeIdToNodeLocatorId,
nodeToNodeLocatorId,
nodeExecutionIdToNodeLocatorId,
nodeLocatorIdToNodeId,
nodeLocatorIdToNodeExecutionId
}

View File

@@ -1,10 +1,7 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphCanvas, Positionable } from '@/lib/litegraph/src/litegraph'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
@@ -88,42 +85,6 @@ describe('useCanvasStore', () => {
expect(originalHandler).toHaveBeenCalledWith(2.0, app.canvas.ds.offset)
})
})
describe('node:before-removed selection cleanup', () => {
it('removes the node from store.selectedItems before its onRemoved fires', async () => {
const graph = new LGraph()
const node = new LGraphNode('test')
graph.add(node)
const selectedItems = new Set<Positionable>([node])
const fakeCanvas = {
canvas: document.createElement('canvas'),
graph,
selectedItems,
deselect: vi.fn((item: Positionable) => {
selectedItems.delete(item)
})
}
store.canvas = fakeCanvas as unknown as LGraphCanvas
await nextTick()
store.updateSelectedItems()
expect(store.selectedItems).toContain(node)
let stillSelectedInOnRemoved: boolean | undefined
node.onRemoved = () => {
stillSelectedInOnRemoved = store.selectedItems.includes(node)
}
graph.remove(node)
expect(
stillSelectedInOnRemoved,
'selectedItems must not contain the node when onRemoved fires'
).toBe(false)
expect(store.selectedItems).toEqual([])
})
})
it('Does not include groups in selected nodeIds', async () => {
store.selectedItems = [new LGraphGroup()]

View File

@@ -131,18 +131,6 @@ export const useCanvasStore = defineStore('canvas', () => {
whenever(
() => canvas.value,
(newCanvas) => {
currentGraph.value = newCanvas.graph
// Scoped to the on-screen graph: selection only holds items from it,
// so removals in other graphs can't affect the live selection.
useEventListener(
() => currentGraph.value?.events,
'node:before-removed',
(e: CustomEvent<{ node: LGraphNode }>) => {
newCanvas.deselect(e.detail.node)
updateSelectedItems()
}
)
useEventListener(
newCanvas.canvas,
'litegraph:set-graph',

View File

@@ -21,6 +21,7 @@ vi.mock('@/renderer/core/layout/transform/useTransformState', () => {
useTransformState: () => ({
camera: computed(() => mockData.mockCamera),
transformStyle: computed(() => mockData.mockTransformStyle),
canvasToScreen: vi.fn(),
screenToCanvas: vi.fn(),
isNodeInViewport: vi.fn(),
syncWithCanvas
@@ -179,6 +180,7 @@ describe('TransformPane', () => {
const transformState = useTransformState()
expect(transformState.syncWithCanvas).toBeDefined()
expect(transformState.canvasToScreen).toBeDefined()
expect(transformState.screenToCanvas).toBeDefined()
})
})

View File

@@ -1,5 +1,3 @@
import type { NodeId } from '@/renderer/core/layout/types'
/**
* Slot identifier utilities for consistent slot key generation and parsing
*
@@ -9,7 +7,7 @@ import type { NodeId } from '@/renderer/core/layout/types'
*/
interface SlotIdentifier {
nodeId: NodeId
nodeId: string
index: number
isInput: boolean
}
@@ -20,23 +18,23 @@ interface SlotIdentifier {
*/
export function getSlotKey(identifier: SlotIdentifier): string
export function getSlotKey(
nodeId: NodeId,
nodeId: string,
index: number,
isInput: boolean
): string
export function getSlotKey(
nodeIdOrIdentifier: NodeId | SlotIdentifier,
nodeIdOrIdentifier: string | SlotIdentifier,
index?: number,
isInput?: boolean
): string {
if (typeof nodeIdOrIdentifier === 'object') {
const { nodeId, index, isInput } = nodeIdOrIdentifier
return `${String(nodeId)}-${isInput ? 'in' : 'out'}-${index}`
return `${nodeId}-${isInput ? 'in' : 'out'}-${index}`
}
if (index === undefined || isInput === undefined) {
throw new Error('Missing required parameters for slot key generation')
}
return `${String(nodeIdOrIdentifier)}-${isInput ? 'in' : 'out'}-${index}`
return `${nodeIdOrIdentifier}-${isInput ? 'in' : 'out'}-${index}`
}

View File

@@ -127,8 +127,8 @@ class LayoutStoreImpl implements LayoutStore {
private isGlobalDispatchQueued = false
// CustomRef cache and trigger functions
private nodeRefs = new Map<string, Ref<NodeLayout | null>>()
private nodeTriggers = new Map<string, () => void>()
private nodeRefs = new Map<NodeId, Ref<NodeLayout | null>>()
private nodeTriggers = new Map<NodeId, () => void>()
// New data structures for hit testing
private linkLayouts = new Map<LinkId, LinkLayout>()
@@ -137,10 +137,10 @@ class LayoutStoreImpl implements LayoutStore {
private rerouteLayouts = new Map<RerouteId, RerouteLayout>()
// Spatial index managers
private spatialIndex: SpatialIndexManager<NodeId> // For nodes
private linkSegmentSpatialIndex: SpatialIndexManager<string> // For link segments (single index for all link geometry)
private slotSpatialIndex: SpatialIndexManager<string> // For slots
private rerouteSpatialIndex: SpatialIndexManager<string> // For reroutes
private spatialIndex: SpatialIndexManager // For nodes
private linkSegmentSpatialIndex: SpatialIndexManager // For link segments (single index for all link geometry)
private slotSpatialIndex: SpatialIndexManager // For slots
private rerouteSpatialIndex: SpatialIndexManager // For reroutes
// Vue dragging state for selection toolbox (public ref for direct mutation)
public isDraggingVueNodes = ref(false)
@@ -173,10 +173,10 @@ class LayoutStoreImpl implements LayoutStore {
this.yoperations = this.ydoc.getArray('operations')
// Initialize spatial index managers
this.spatialIndex = new SpatialIndexManager<NodeId>()
this.linkSegmentSpatialIndex = new SpatialIndexManager<string>() // Single index for all link geometry
this.slotSpatialIndex = new SpatialIndexManager<string>()
this.rerouteSpatialIndex = new SpatialIndexManager<string>()
this.spatialIndex = new SpatialIndexManager()
this.linkSegmentSpatialIndex = new SpatialIndexManager() // Single index for all link geometry
this.slotSpatialIndex = new SpatialIndexManager()
this.rerouteSpatialIndex = new SpatialIndexManager()
// Listen for Yjs changes and trigger Vue reactivity
this.ynodes.observe((event: Y.YMapEvent<NodeLayoutMap>) => {
@@ -230,24 +230,24 @@ class LayoutStoreImpl implements LayoutStore {
* Get or create a customRef for a node layout
*/
getNodeLayoutRef(nodeId: NodeId): Ref<NodeLayout | null> {
const nodeKey = String(nodeId)
let nodeRef = this.nodeRefs.get(nodeKey)
let nodeRef = this.nodeRefs.get(nodeId)
if (!nodeRef) {
nodeRef = customRef<NodeLayout | null>((track, trigger) => {
this.nodeTriggers.set(nodeKey, trigger)
// Store the trigger so we can call it when Yjs changes
this.nodeTriggers.set(nodeId, trigger)
return {
get: () => {
track()
const ynode = this.ynodes.get(nodeKey)
const ynode = this.ynodes.get(nodeId)
const layout = ynode ? yNodeToLayout(ynode) : null
return layout
},
set: (newLayout: NodeLayout | null) => {
if (newLayout === null) {
// Delete operation
const existing = this.ynodes.get(nodeKey)
const existing = this.ynodes.get(nodeId)
if (existing) {
this.applyOperation({
type: 'deleteNode',
@@ -261,7 +261,7 @@ class LayoutStoreImpl implements LayoutStore {
}
} else {
// Update operation - detect what changed
const existing = this.ynodes.get(nodeKey)
const existing = this.ynodes.get(nodeId)
if (!existing) {
// Create operation
this.applyOperation({
@@ -326,7 +326,7 @@ class LayoutStoreImpl implements LayoutStore {
}
})
this.nodeRefs.set(nodeKey, nodeRef)
this.nodeRefs.set(nodeId, nodeRef)
}
return nodeRef
@@ -341,10 +341,13 @@ class LayoutStoreImpl implements LayoutStore {
void this.version
const result: NodeId[] = []
for (const [nodeId, ynode] of this.ynodes) {
const layout = yNodeToLayout(ynode)
if (boundsIntersect(layout.bounds, bounds)) {
result.push(nodeId)
for (const [nodeId] of this.ynodes) {
const ynode = this.ynodes.get(nodeId)
if (ynode) {
const layout = yNodeToLayout(ynode)
if (layout && boundsIntersect(layout.bounds, bounds)) {
result.push(nodeId)
}
}
}
return result
@@ -360,9 +363,14 @@ class LayoutStoreImpl implements LayoutStore {
void this.version
const result = new Map<NodeId, NodeLayout>()
for (const [nodeId, ynode] of this.ynodes) {
const layout = yNodeToLayout(ynode)
result.set(nodeId, layout)
for (const [nodeId] of this.ynodes) {
const ynode = this.ynodes.get(nodeId)
if (ynode) {
const layout = yNodeToLayout(ynode)
if (layout) {
result.set(nodeId, layout)
}
}
}
return result
})
@@ -381,9 +389,14 @@ class LayoutStoreImpl implements LayoutStore {
queryNodeAtPoint(point: Point): NodeId | null {
const nodes: Array<[NodeId, NodeLayout]> = []
for (const [nodeId, ynode] of this.ynodes) {
const layout = yNodeToLayout(ynode)
nodes.push([nodeId, layout])
for (const [nodeId] of this.ynodes) {
const ynode = this.ynodes.get(nodeId)
if (ynode) {
const layout = yNodeToLayout(ynode)
if (layout) {
nodes.push([nodeId, layout])
}
}
}
// Sort by zIndex (top to bottom)
@@ -433,7 +446,17 @@ class LayoutStoreImpl implements LayoutStore {
deleteLinkLayout(linkId: LinkId): void {
const deleted = this.linkLayouts.delete(linkId)
if (deleted) {
this.cleanupLinkSegments(linkId)
// Clean up any segment layouts for this link
const keysToDelete: string[] = []
for (const [key] of this.linkSegmentLayouts) {
if (key.startsWith(`${linkId}:`)) {
keysToDelete.push(key)
}
}
for (const key of keysToDelete) {
this.linkSegmentLayouts.delete(key)
this.linkSegmentSpatialIndex.remove(key)
}
}
}
@@ -513,7 +536,6 @@ class LayoutStoreImpl implements LayoutStore {
* Update reroute layout data
*/
updateRerouteLayout(rerouteId: RerouteId, layout: RerouteLayout): void {
const rerouteKey = String(rerouteId)
const existing = this.rerouteLayouts.get(rerouteId)
if (!existing) {
@@ -526,10 +548,10 @@ class LayoutStoreImpl implements LayoutStore {
if (existing) {
// Update spatial index
this.rerouteSpatialIndex.update(rerouteKey, layout.bounds)
this.rerouteSpatialIndex.update(String(rerouteId), layout.bounds) // Spatial index uses strings
} else {
// Insert into spatial index
this.rerouteSpatialIndex.insert(rerouteKey, layout.bounds)
this.rerouteSpatialIndex.insert(String(rerouteId), layout.bounds) // Spatial index uses strings
}
this.rerouteLayouts.set(rerouteId, layout)
@@ -542,8 +564,7 @@ class LayoutStoreImpl implements LayoutStore {
const deleted = this.rerouteLayouts.delete(rerouteId)
if (deleted) {
// Remove from spatial index
const rerouteKey = String(rerouteId)
this.rerouteSpatialIndex.remove(rerouteKey)
this.rerouteSpatialIndex.remove(String(rerouteId)) // Spatial index uses strings
}
}
@@ -896,7 +917,7 @@ class LayoutStoreImpl implements LayoutStore {
// Manually trigger affected node refs after transaction
// This is needed because Yjs observers don't fire for property changes
change.nodeIds.forEach((nodeId) => {
const trigger = this.nodeTriggers.get(String(nodeId))
const trigger = this.nodeTriggers.get(nodeId)
if (trigger) {
trigger()
}
@@ -968,9 +989,8 @@ class LayoutStoreImpl implements LayoutStore {
* This should be called from the component's onUnmounted hook.
*/
cleanupNodeRef(nodeId: NodeId): void {
const nodeKey = String(nodeId)
this.nodeRefs.delete(nodeKey)
this.nodeTriggers.delete(nodeKey)
this.nodeRefs.delete(nodeId)
this.nodeTriggers.delete(nodeId)
}
/**
@@ -998,9 +1018,8 @@ class LayoutStoreImpl implements LayoutStore {
this.isGlobalDispatchQueued = false
nodes.forEach((node, index) => {
const nodeId = String(node.id)
const layout: NodeLayout = {
id: nodeId,
id: node.id.toString(),
position: { x: node.pos[0], y: node.pos[1] },
size: { width: node.size[0], height: node.size[1] },
zIndex: index,
@@ -1013,10 +1032,10 @@ class LayoutStoreImpl implements LayoutStore {
}
}
this.ynodes.set(nodeId, layoutToYNode(layout))
this.ynodes.set(layout.id, layoutToYNode(layout))
// Add to spatial index
this.spatialIndex.insert(nodeId, layout.bounds)
this.spatialIndex.insert(layout.id, layout.bounds)
})
// Trigger all existing refs to notify Vue of the new data
@@ -1029,7 +1048,7 @@ class LayoutStoreImpl implements LayoutStore {
operation: MoveNodeOperation,
change: LayoutChange
): void {
const ynode = this.ynodes.get(String(operation.nodeId))
const ynode = this.ynodes.get(operation.nodeId)
if (!ynode) {
return
}
@@ -1057,7 +1076,7 @@ class LayoutStoreImpl implements LayoutStore {
operation: ResizeNodeOperation,
change: LayoutChange
): void {
const ynode = this.ynodes.get(String(operation.nodeId))
const ynode = this.ynodes.get(operation.nodeId)
if (!ynode) return
const position = yNodeToLayout(ynode).position
@@ -1083,7 +1102,7 @@ class LayoutStoreImpl implements LayoutStore {
operation: SetNodeZIndexOperation,
change: LayoutChange
): void {
const ynode = this.ynodes.get(String(operation.nodeId))
const ynode = this.ynodes.get(operation.nodeId)
if (!ynode) return
ynode.set('zIndex', operation.zIndex)
@@ -1095,7 +1114,7 @@ class LayoutStoreImpl implements LayoutStore {
change: LayoutChange
): void {
const ynode = layoutToYNode(operation.layout)
this.ynodes.set(String(operation.nodeId), ynode)
this.ynodes.set(operation.nodeId, ynode)
// Add to spatial index
this.spatialIndex.insert(operation.nodeId, operation.layout.bounds)
@@ -1108,10 +1127,9 @@ class LayoutStoreImpl implements LayoutStore {
operation: DeleteNodeOperation,
change: LayoutChange
): void {
const nodeKey = String(operation.nodeId)
if (!this.ynodes.has(nodeKey)) return
if (!this.ynodes.has(operation.nodeId)) return
this.ynodes.delete(nodeKey)
this.ynodes.delete(operation.nodeId)
// Note: We intentionally do NOT delete nodeRefs and nodeTriggers here.
// During undo/redo, Vue components may still hold references to the old ref.
// If we delete the trigger, Vue won't be notified when the node is re-created.
@@ -1125,8 +1143,7 @@ class LayoutStoreImpl implements LayoutStore {
// Delete the associated links
for (const linkId of linksToDelete) {
const linkKey = String(linkId)
this.ylinks.delete(linkKey)
this.ylinks.delete(String(linkId))
this.linkLayouts.delete(linkId)
// Clean up link segment layouts
@@ -1145,7 +1162,7 @@ class LayoutStoreImpl implements LayoutStore {
for (const nodeId of operation.nodeIds) {
const data = operation.bounds[nodeId]
const ynode = this.ynodes.get(String(nodeId))
const ynode = this.ynodes.get(nodeId)
if (!ynode || !data) continue
ynode.set('position', { x: data.bounds.x, y: data.bounds.y })
@@ -1180,8 +1197,7 @@ class LayoutStoreImpl implements LayoutStore {
linkData.set('targetNodeId', operation.targetNodeId)
linkData.set('targetSlot', operation.targetSlot)
const linkKey = String(operation.linkId)
this.ylinks.set(linkKey, linkData)
this.ylinks.set(String(operation.linkId), linkData)
// Link geometry will be computed separately when nodes move
// This just tracks that the link exists
@@ -1192,10 +1208,9 @@ class LayoutStoreImpl implements LayoutStore {
operation: DeleteLinkOperation,
change: LayoutChange
): void {
const linkKey = String(operation.linkId)
if (!this.ylinks.has(linkKey)) return
if (!this.ylinks.has(String(operation.linkId))) return
this.ylinks.delete(linkKey)
this.ylinks.delete(String(operation.linkId))
this.linkLayouts.delete(operation.linkId)
// Clean up any segment layouts for this link
this.cleanupLinkSegments(operation.linkId)
@@ -1213,8 +1228,7 @@ class LayoutStoreImpl implements LayoutStore {
rerouteData.set('parentId', operation.parentId)
rerouteData.set('linkIds', operation.linkIds)
const rerouteKey = String(operation.rerouteId)
this.yreroutes.set(rerouteKey, rerouteData)
this.yreroutes.set(String(operation.rerouteId), rerouteData) // Yjs Map keys must be strings
// The observer will automatically update the spatial index
change.type = 'create'
@@ -1224,12 +1238,11 @@ class LayoutStoreImpl implements LayoutStore {
operation: DeleteRerouteOperation,
change: LayoutChange
): void {
const rerouteKey = String(operation.rerouteId)
if (!this.yreroutes.has(rerouteKey)) return
if (!this.yreroutes.has(String(operation.rerouteId))) return // Yjs Map keys are strings
this.yreroutes.delete(rerouteKey)
this.yreroutes.delete(String(operation.rerouteId)) // Yjs Map keys are strings
this.rerouteLayouts.delete(operation.rerouteId) // Layout map uses numeric ID
this.rerouteSpatialIndex.remove(rerouteKey)
this.rerouteSpatialIndex.remove(String(operation.rerouteId)) // Spatial index uses strings
change.type = 'delete'
}
@@ -1238,8 +1251,7 @@ class LayoutStoreImpl implements LayoutStore {
operation: MoveRerouteOperation,
change: LayoutChange
): void {
const rerouteKey = String(operation.rerouteId)
const yreroute = this.yreroutes.get(rerouteKey)
const yreroute = this.yreroutes.get(String(operation.rerouteId)) // Yjs Map keys are strings
if (!yreroute) return
yreroute.set('position', operation.position)
@@ -1319,10 +1331,9 @@ class LayoutStoreImpl implements LayoutStore {
* Clean up all segment layouts for a link
*/
private cleanupLinkSegments(linkId: LinkId): void {
const linkPrefix = `${linkId}:`
const keysToDelete: string[] = []
for (const [key] of this.linkSegmentLayouts) {
if (key.startsWith(linkPrefix)) {
if (key.startsWith(`${linkId}:`)) {
keysToDelete.push(key)
}
}
@@ -1353,17 +1364,15 @@ class LayoutStoreImpl implements LayoutStore {
* Handle reroute deletion
*/
private handleRerouteDelete(rerouteId: RerouteId): void {
const rerouteKey = String(rerouteId)
this.rerouteLayouts.delete(rerouteId)
this.rerouteSpatialIndex.remove(rerouteKey)
this.rerouteSpatialIndex.remove(String(rerouteId))
}
/**
* Handle reroute upsert (update if exists, create if not)
*/
private handleRerouteUpsert(rerouteId: RerouteId): void {
const rerouteKey = String(rerouteId)
const rerouteData = this.yreroutes.get(rerouteKey)
const rerouteData = this.yreroutes.get(String(rerouteId))
if (!rerouteData) return
const position = this.getRerouteField(rerouteData, 'position')
@@ -1500,7 +1509,7 @@ class LayoutStoreImpl implements LayoutStore {
const boundsRecord: BatchUpdateBoundsOperation['bounds'] = {}
for (const { nodeId, bounds } of updates) {
const ynode = this.ynodes.get(String(nodeId))
const ynode = this.ynodes.get(nodeId)
if (!ynode) continue
const currentLayout = yNodeToLayout(ynode)

View File

@@ -110,7 +110,7 @@ export function useLayoutSync() {
if (change.nodeIds.length === 0) return
for (const nodeId of change.nodeIds) {
pendingNodeIds.add(String(nodeId))
pendingNodeIds.add(nodeId)
}
scheduleFlush(change.source, canvas)
})

View File

@@ -117,6 +117,34 @@ describe('useTransformState', () => {
transformState.syncWithCanvas(mockCanvas as LGraphCanvas)
})
describe('canvasToScreen', () => {
it('should convert canvas coordinates to screen coordinates', () => {
const { canvasToScreen } = transformState
const canvasPoint = { x: 10, y: 20 }
const screenPoint = canvasToScreen(canvasPoint)
// screen = (canvas + offset) * scale
// x: (10 + 100) * 2 = 220
// y: (20 + 50) * 2 = 140
expect(screenPoint).toEqual({ x: 220, y: 140 })
})
it('should handle zero coordinates', () => {
const { canvasToScreen } = transformState
const screenPoint = canvasToScreen({ x: 0, y: 0 })
expect(screenPoint).toEqual({ x: 200, y: 100 })
})
it('should handle negative coordinates', () => {
const { canvasToScreen } = transformState
const screenPoint = canvasToScreen({ x: -10, y: -20 })
expect(screenPoint).toEqual({ x: 180, y: 60 })
})
})
describe('screenToCanvas', () => {
it('should convert screen coordinates to canvas coordinates', () => {
const { screenToCanvas } = transformState
@@ -129,10 +157,186 @@ describe('useTransformState', () => {
// y: 140 / 2 - 50 = 20
expect(canvasPoint).toEqual({ x: 10, y: 20 })
})
it('should be inverse of canvasToScreen', () => {
const { canvasToScreen, screenToCanvas } = transformState
const originalPoint = { x: 25, y: 35 }
const screenPoint = canvasToScreen(originalPoint)
const backToCanvas = screenToCanvas(screenPoint)
expect(backToCanvas.x).toBeCloseTo(originalPoint.x)
expect(backToCanvas.y).toBeCloseTo(originalPoint.y)
})
})
})
describe('getNodeScreenBounds', () => {
beforeEach(() => {
const mockCanvas = createMockCanvasContext()
mockCanvas.ds.offset = [100, 50]
mockCanvas.ds.scale = 2
transformState.syncWithCanvas(mockCanvas as LGraphCanvas)
})
it('should calculate correct screen bounds for a node', () => {
const { getNodeScreenBounds } = transformState
const nodePos: [number, number] = [10, 20]
const nodeSize: [number, number] = [200, 100]
const bounds = getNodeScreenBounds(nodePos, nodeSize)
// Top-left: canvasToScreen(10, 20) = (220, 140)
// Width: 200 * 2 = 400
// Height: 100 * 2 = 200
expect(bounds.x).toBe(220)
expect(bounds.y).toBe(140)
expect(bounds.width).toBe(400)
expect(bounds.height).toBe(200)
})
})
describe('isNodeInViewport', () => {
beforeEach(() => {
const mockCanvas = createMockCanvasContext()
mockCanvas.ds.offset = [0, 0]
mockCanvas.ds.scale = 1
transformState.syncWithCanvas(mockCanvas as LGraphCanvas)
})
const viewport = { width: 1000, height: 600 }
it('should return true for nodes inside viewport', () => {
const { isNodeInViewport } = transformState
const nodePos: [number, number] = [100, 100]
const nodeSize: [number, number] = [200, 100]
expect(isNodeInViewport(nodePos, nodeSize, viewport)).toBe(true)
})
it('should return false for nodes completely outside viewport', () => {
const { isNodeInViewport } = transformState
// Node far to the right
expect(isNodeInViewport([2000, 100], [200, 100], viewport)).toBe(false)
// Node far to the left
expect(isNodeInViewport([-500, 100], [200, 100], viewport)).toBe(false)
// Node far below
expect(isNodeInViewport([100, 1000], [200, 100], viewport)).toBe(false)
// Node far above
expect(isNodeInViewport([100, -500], [200, 100], viewport)).toBe(false)
})
it('should return true for nodes partially in viewport with margin', () => {
const { isNodeInViewport } = transformState
// Node slightly outside but within margin
const nodePos: [number, number] = [-50, -50]
const nodeSize: [number, number] = [100, 100]
expect(isNodeInViewport(nodePos, nodeSize, viewport, 0.2)).toBe(true)
})
it('should return false for tiny nodes (size culling)', () => {
const { isNodeInViewport } = transformState
// Node is in viewport but too small
const nodePos: [number, number] = [100, 100]
const nodeSize: [number, number] = [3, 3] // Less than 4 pixels
expect(isNodeInViewport(nodePos, nodeSize, viewport)).toBe(false)
})
it('should adjust margin based on zoom level', () => {
const { isNodeInViewport, syncWithCanvas } = transformState
const mockCanvas = createMockCanvasContext()
// Test with very low zoom
mockCanvas.ds.scale = 0.05
syncWithCanvas(mockCanvas as LGraphCanvas)
// Node at edge should still be visible due to increased margin
expect(isNodeInViewport([1100, 100], [200, 100], viewport)).toBe(true)
// Test with high zoom
mockCanvas.ds.scale = 4
syncWithCanvas(mockCanvas as LGraphCanvas)
// Margin should be tighter
expect(isNodeInViewport([1100, 100], [200, 100], viewport)).toBe(false)
})
})
describe('getViewportBounds', () => {
beforeEach(() => {
const mockCanvas = createMockCanvasContext()
mockCanvas.ds.offset = [100, 50]
mockCanvas.ds.scale = 2
transformState.syncWithCanvas(mockCanvas as LGraphCanvas)
})
it('should calculate viewport bounds in canvas coordinates', () => {
const { getViewportBounds } = transformState
const viewport = { width: 1000, height: 600 }
const bounds = getViewportBounds(viewport, 0.2)
// With 20% margin:
// marginX = 1000 * 0.2 = 200
// marginY = 600 * 0.2 = 120
// topLeft in screen: (-200, -120)
// bottomRight in screen: (1200, 720)
// Convert to canvas coordinates (canvas = screen / scale - offset):
// topLeft: (-200 / 2 - 100, -120 / 2 - 50) = (-200, -110)
// bottomRight: (1200 / 2 - 100, 720 / 2 - 50) = (500, 310)
expect(bounds.x).toBe(-200)
expect(bounds.y).toBe(-110)
expect(bounds.width).toBe(700) // 500 - (-200)
expect(bounds.height).toBe(420) // 310 - (-110)
})
it('should handle zero margin', () => {
const { getViewportBounds } = transformState
const viewport = { width: 1000, height: 600 }
const bounds = getViewportBounds(viewport, 0)
// No margin, so viewport bounds are exact
expect(bounds.x).toBe(-100) // 0 / 2 - 100
expect(bounds.y).toBe(-50) // 0 / 2 - 50
expect(bounds.width).toBe(500) // 1000 / 2
expect(bounds.height).toBe(300) // 600 / 2
})
})
describe('edge cases', () => {
it('should handle extreme zoom levels', () => {
const { syncWithCanvas, canvasToScreen } = transformState
const mockCanvas = createMockCanvasContext()
// Very small zoom
mockCanvas.ds.scale = 0.001
syncWithCanvas(mockCanvas as LGraphCanvas)
const point1 = canvasToScreen({ x: 1000, y: 1000 })
expect(point1.x).toBeCloseTo(1)
expect(point1.y).toBeCloseTo(1)
// Very large zoom
mockCanvas.ds.scale = 100
syncWithCanvas(mockCanvas as LGraphCanvas)
const point2 = canvasToScreen({ x: 1, y: 1 })
expect(point2.x).toBe(100)
expect(point2.y).toBe(100)
})
it('should handle zero scale in screenToCanvas', () => {
const { syncWithCanvas, screenToCanvas } = transformState
const mockCanvas = createMockCanvasContext()

View File

@@ -104,6 +104,24 @@ function useTransformStateIndividual() {
camera.z = canvas.ds.scale || 1
}
/**
* Converts canvas coordinates to screen coordinates
*
* Applies the same transform that LiteGraph uses for rendering.
* Essential for positioning Vue components to align with canvas elements.
*
* Formula: screen = (canvas + offset) * scale
*
* @param point - Point in canvas coordinate system
* @returns Point in screen coordinate system
*/
function canvasToScreen(point: Point): Point {
return {
x: (point.x + camera.x) * camera.z,
y: (point.y + camera.y) * camera.z
}
}
/**
* Converts screen coordinates to canvas coordinates
*
@@ -122,11 +140,111 @@ function useTransformStateIndividual() {
}
}
// Get node's screen bounds for culling
function getNodeScreenBounds(
pos: [number, number],
size: [number, number]
): DOMRect {
const topLeft = canvasToScreen({ x: pos[0], y: pos[1] })
const width = size[0] * camera.z
const height = size[1] * camera.z
return new DOMRect(topLeft.x, topLeft.y, width, height)
}
// Helper: Calculate zoom-adjusted margin for viewport culling
function calculateAdjustedMargin(baseMargin: number): number {
if (camera.z < 0.1) return Math.min(baseMargin * 5, 2.0)
if (camera.z > 3.0) return Math.max(baseMargin * 0.5, 0.05)
return baseMargin
}
// Helper: Check if node is too small to be visible at current zoom
function isNodeTooSmall(nodeSize: [number, number]): boolean {
const nodeScreenSize = Math.max(nodeSize[0], nodeSize[1]) * camera.z
return nodeScreenSize < 4
}
// Helper: Calculate expanded viewport bounds with margin
function getExpandedViewportBounds(
viewport: { width: number; height: number },
margin: number
) {
const marginX = viewport.width * margin
const marginY = viewport.height * margin
return {
left: -marginX,
right: viewport.width + marginX,
top: -marginY,
bottom: viewport.height + marginY
}
}
// Helper: Test if node intersects with viewport bounds
function testViewportIntersection(
screenPos: { x: number; y: number },
nodeSize: [number, number],
bounds: { left: number; right: number; top: number; bottom: number }
): boolean {
const nodeRight = screenPos.x + nodeSize[0] * camera.z
const nodeBottom = screenPos.y + nodeSize[1] * camera.z
return !(
nodeRight < bounds.left ||
screenPos.x > bounds.right ||
nodeBottom < bounds.top ||
screenPos.y > bounds.bottom
)
}
// Check if node is within viewport with frustum and size-based culling
function isNodeInViewport(
nodePos: [number, number],
nodeSize: [number, number],
viewport: { width: number; height: number },
margin: number = 0.2
): boolean {
// Early exit for tiny nodes
if (isNodeTooSmall(nodeSize)) return false
const screenPos = canvasToScreen({ x: nodePos[0], y: nodePos[1] })
const adjustedMargin = calculateAdjustedMargin(margin)
const bounds = getExpandedViewportBounds(viewport, adjustedMargin)
return testViewportIntersection(screenPos, nodeSize, bounds)
}
// Get viewport bounds in canvas coordinates (for spatial index queries)
function getViewportBounds(
viewport: { width: number; height: number },
margin: number = 0.2
) {
const marginX = viewport.width * margin
const marginY = viewport.height * margin
const topLeft = screenToCanvas({ x: -marginX, y: -marginY })
const bottomRight = screenToCanvas({
x: viewport.width + marginX,
y: viewport.height + marginY
})
return {
x: topLeft.x,
y: topLeft.y,
width: bottomRight.x - topLeft.x,
height: bottomRight.y - topLeft.y
}
}
return {
camera: readonly(camera),
transformStyle,
syncWithCanvas,
screenToCanvas
canvasToScreen,
screenToCanvas,
getNodeScreenBounds,
isNodeInViewport,
getViewportBounds
}
}

View File

@@ -6,8 +6,6 @@
*/
import type { ComputedRef, Ref } from 'vue'
import type { NodeId as WorkflowNodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
// Enum for layout source types
export enum LayoutSource {
Canvas = 'canvas',
@@ -39,7 +37,7 @@ export interface NodeBoundsUpdate {
bounds: Bounds
}
export type NodeId = WorkflowNodeId
export type NodeId = string
export type LinkId = number
export type RerouteId = number

View File

@@ -15,21 +15,21 @@ import { QuadTree } from './QuadTree'
/**
* Cache entry for spatial queries
*/
interface CacheEntry<TId> {
result: TId[]
interface CacheEntry {
result: NodeId[]
timestamp: number
}
/**
* Spatial index manager using QuadTree
*/
export class SpatialIndexManager<TId extends string | number = NodeId> {
private quadTree: QuadTree<TId>
private queryCache: Map<string, CacheEntry<TId>>
export class SpatialIndexManager {
private quadTree: QuadTree<NodeId>
private queryCache: Map<string, CacheEntry>
private cacheSize = 0
constructor(bounds?: Bounds) {
this.quadTree = new QuadTree<TId>(
this.quadTree = new QuadTree<NodeId>(
bounds ?? QUADTREE_CONFIG.DEFAULT_BOUNDS,
{
maxDepth: QUADTREE_CONFIG.MAX_DEPTH,
@@ -42,16 +42,16 @@ export class SpatialIndexManager<TId extends string | number = NodeId> {
/**
* Insert a node into the spatial index
*/
insert(nodeId: TId, bounds: Bounds): void {
this.quadTree.insert(String(nodeId), bounds, nodeId)
insert(nodeId: NodeId, bounds: Bounds): void {
this.quadTree.insert(nodeId, bounds, nodeId)
this.invalidateCache()
}
/**
* Update a node's bounds in the spatial index
*/
update(nodeId: TId, bounds: Bounds): void {
this.quadTree.update(String(nodeId), bounds)
update(nodeId: NodeId, bounds: Bounds): void {
this.quadTree.update(nodeId, bounds)
this.invalidateCache()
}
@@ -59,9 +59,9 @@ export class SpatialIndexManager<TId extends string | number = NodeId> {
* Batch update multiple nodes' bounds in the spatial index
* More efficient than calling update() multiple times as it only invalidates cache once
*/
batchUpdate(updates: Array<{ nodeId: TId; bounds: Bounds }>): void {
batchUpdate(updates: Array<{ nodeId: NodeId; bounds: Bounds }>): void {
for (const { nodeId, bounds } of updates) {
this.quadTree.update(String(nodeId), bounds)
this.quadTree.update(nodeId, bounds)
}
this.invalidateCache()
}
@@ -69,15 +69,15 @@ export class SpatialIndexManager<TId extends string | number = NodeId> {
/**
* Remove a node from the spatial index
*/
remove(nodeId: TId): void {
this.quadTree.remove(String(nodeId))
remove(nodeId: NodeId): void {
this.quadTree.remove(nodeId)
this.invalidateCache()
}
/**
* Query nodes within the given bounds
*/
query(bounds: Bounds): TId[] {
query(bounds: Bounds): NodeId[] {
const cacheKey = this.getCacheKey(bounds)
const cached = this.queryCache.get(cacheKey)
@@ -137,7 +137,7 @@ export class SpatialIndexManager<TId extends string | number = NodeId> {
/**
* Add result to cache with LRU eviction
*/
private addToCache(key: string, result: TId[]): void {
private addToCache(key: string, result: NodeId[]): void {
// Evict oldest entries if cache is full
if (this.cacheSize >= PERFORMANCE_CONFIG.SPATIAL_CACHE_MAX_SIZE) {
const oldestKey = this.findOldestCacheEntry()

View File

@@ -1,6 +1,5 @@
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useExecutionStore } from '@/stores/executionStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import type { MinimapNodeData } from '../types'
import { AbstractMinimapDataSource } from './AbstractMinimapDataSource'
@@ -26,8 +25,7 @@ export class LayoutStoreDataSource extends AbstractMinimapDataSource {
// Find corresponding LiteGraph node for additional properties
const graphNode = this.graph?._nodes?.find((n) => String(n.id) === nodeId)
const executionState =
nodeProgressStates[createNodeLocatorId(null, nodeId)]?.state ?? null
const executionState = nodeProgressStates[nodeId]?.state ?? null
nodes.push({
id: nodeId,

View File

@@ -15,10 +15,6 @@ vi.mock('@/base/common/downloadUtil', () => ({
downloadFile: vi.fn()
}))
vi.mock('@/services/hdrViewerService', () => ({
openHdrViewer: vi.fn()
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -40,10 +36,6 @@ const i18n = createI18n({
loading: 'Loading',
viewGrid: 'Grid view',
galleryThumbnail: 'Gallery thumbnail'
},
hdrViewer: {
hdrImage: 'HDR image',
openInHdrViewer: 'Open in HDR Viewer'
}
}
}
@@ -94,15 +86,6 @@ describe('ImagePreview', () => {
expect(container.querySelector('.image-preview')).not.toBeInTheDocument()
})
it('offers the HDR viewer instead of an <img> for exr outputs', () => {
renderImagePreview({
imageUrls: ['/api/view?filename=out.exr&type=output']
})
expect(screen.getByTestId('hdr-open-button')).toBeInTheDocument()
expect(screen.queryByTestId('main-image')).not.toBeInTheDocument()
})
it('displays calculating dimensions text in gallery mode', async () => {
renderImagePreview({
imageUrls: [defaultProps.imageUrls[0]]

View File

@@ -23,23 +23,15 @@
total: imageUrls.length
})
"
@click="handleGridClick(index)"
@click="openImageInGallery(index)"
>
<img
v-if="!isHdrImageUrl(imageUrls[index])"
:src="url"
:alt="`${$t('g.galleryThumbnail')} ${index + 1}`"
draggable="false"
class="pointer-events-none size-full object-contain"
@load="updateAspectRatio($event, index)"
/>
<div
v-else
class="flex size-full flex-col items-center justify-center gap-1 text-base-foreground"
>
<i class="icon-[lucide--sun] size-6" />
<span class="text-xs">{{ $t('hdrViewer.hdrImage') }}</span>
</div>
</Button>
</div>
@@ -69,30 +61,12 @@
</p>
</div>
<!-- Loading State -->
<div
v-if="showLoader && !imageError && !currentImageIsHdr"
class="size-full"
>
<div v-if="showLoader && !imageError" class="size-full">
<Skeleton class="size-full rounded-sm" />
</div>
<button
v-if="!imageError && currentImageIsHdr"
type="button"
data-testid="hdr-open-button"
class="absolute inset-0 flex cursor-pointer flex-col items-center justify-center gap-3 border-0 bg-transparent text-base-foreground"
@click="openHdrViewer(currentImageUrl)"
>
<i class="icon-[lucide--sun] size-12" />
<span class="text-sm">{{ $t('hdrViewer.hdrImage') }}</span>
<span
class="rounded-md bg-base-foreground px-3 py-1.5 text-sm text-base-background"
>
{{ $t('hdrViewer.openInHdrViewer') }}
</span>
</button>
<!-- Main Image -->
<img
v-if="!imageError && !currentImageIsHdr"
v-if="!imageError"
data-testid="main-image"
:src="currentImageUrl"
:alt="imageAltText"
@@ -108,7 +82,7 @@
>
<!-- Mask/Edit Button -->
<button
v-if="!hasMultipleImages && !imageError && !currentImageIsHdr"
v-if="!hasMultipleImages && !imageError"
:class="actionButtonClass"
:title="$t('g.editOrMaskImage')"
:aria-label="$t('g.editOrMaskImage')"
@@ -143,7 +117,7 @@
<!-- Image Dimensions (gallery mode only) -->
<div
v-if="viewMode === 'gallery' && !currentImageIsHdr"
v-if="viewMode === 'gallery'"
class="pt-2 text-center text-xs text-base-foreground"
>
<span
@@ -204,9 +178,7 @@ import Button from '@/components/ui/button/Button.vue'
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { openHdrViewer } from '@/services/hdrViewerService'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { isHdrImageUrl } from '@/utils/hdrFormatUtil'
import { getGridThumbnailUrl } from '@/utils/imageUtil'
import { resolveNode } from '@/utils/litegraphUtil'
import { cn } from '@comfyorg/tailwind-utils'
@@ -256,7 +228,6 @@ const { start: startDelayedLoader, stop: stopDelayedLoader } = useTimeoutFn(
)
const currentImageUrl = computed(() => imageUrls[currentIndex.value] ?? '')
const currentImageIsHdr = computed(() => isHdrImageUrl(currentImageUrl.value))
const gridImageUrls = computed(() => imageUrls.map(getGridThumbnailUrl))
const hasMultipleImages = computed(() => imageUrls.length > 1)
const imageAltText = computed(() =>
@@ -362,15 +333,6 @@ async function openImageInGallery(index: number) {
galleryPanelEl.value?.focus()
}
function handleGridClick(index: number) {
const url = imageUrls[index]
if (isHdrImageUrl(url)) {
openHdrViewer(url)
return
}
void openImageInGallery(index)
}
function getNavigationDotClass(index: number) {
return cn(
'size-2 cursor-pointer rounded-full border-0 p-0 transition-all duration-200',

View File

@@ -48,7 +48,7 @@
nodeData.mode === LGraphEventMode.ALWAYS &&
!nodeData.hasErrors
"
:id="nodeId"
:id="nodeData.id"
/>
<div
v-if="isSelected || executing"
@@ -340,13 +340,11 @@ const { handleNodeCollapse, handleNodeTitleUpdate, handleNodeRightClick } =
useNodeEventHandlers()
const { bringNodeToFront } = useNodeZIndex()
const nodeId = computed(() => String(nodeData.id))
useVueElementTracking(nodeId.value, 'node')
useVueElementTracking(String(nodeData.id), 'node')
const { selectedNodeIds, isGhostPlacing } = storeToRefs(useCanvasStore())
const isSelected = computed(() => {
return selectedNodeIds.value.has(nodeId.value)
return selectedNodeIds.value.has(nodeData.id)
})
const nodeLocatorId = computed(() => getLocatorIdFromNodeData(nodeData))
@@ -355,7 +353,7 @@ const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
const missingNodesErrorStore = useMissingNodesErrorStore()
const hasExecutionError = computed(
() => executionErrorStore.lastExecutionErrorNodeId === nodeId.value
() => executionErrorStore.lastExecutionErrorNodeId === nodeData.id
)
const hasAnyError = computed((): boolean => {

View File

@@ -16,7 +16,6 @@ import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type { NodeId as VueNodeId } from '@/renderer/core/layout/types'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { createNodeExecutionId } from '@/types/nodeIdentification'
import { seedRequiredInputMissingNodeError } from '@/utils/__tests__/executionErrorTestUtils'
import {
createMockNodeInputSlot,
@@ -271,7 +270,7 @@ describe('NodeSlots.vue', () => {
const { container } = renderSlots(nodeData)
seedRequiredInputMissingNodeError(
useExecutionErrorStore(),
createNodeExecutionId([nodeData.id]),
nodeData.id,
'model'
)
await nextTick()
@@ -301,7 +300,7 @@ describe('NodeSlots.vue', () => {
const { container } = renderSlots(nodeData)
seedRequiredInputMissingNodeError(
useExecutionErrorStore(),
createNodeExecutionId([65, 70]),
'65:70',
'model'
)
await nextTick()
@@ -338,7 +337,7 @@ describe('NodeSlots.vue', () => {
const { container } = renderSlots(nodeData)
seedRequiredInputMissingNodeError(
useExecutionErrorStore(),
createNodeExecutionId([65, 70, 63]),
'65:70:63',
'mask'
)
await nextTick()

View File

@@ -12,8 +12,6 @@ import type {
} from '@/composables/graph/useGraphNodeManager'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { createNodeExecutionId } from '@/types/nodeIdentification'
import { widgetId } from '@/types/widgetId'
const GRAPH_ID = 'graph-test'
@@ -66,7 +64,7 @@ describe('NodeWidgets', () => {
const createMockNodeData = (
nodeType: string = 'TestNode',
widgets: SafeWidgetData[] = [],
id: NodeId = 1
id: string = '1'
): VueNodeData => ({
id,
type: nodeType,
@@ -233,13 +231,13 @@ describe('NodeWidgets', () => {
nodeId: undefined,
name: 'string_a',
type: 'text',
sourceExecutionId: createNodeExecutionId([65, 18])
sourceExecutionId: '65:18'
})
const secondTransientEntry = createMockWidget({
nodeId: undefined,
name: 'string_a',
type: 'text',
sourceExecutionId: createNodeExecutionId([65, 19])
sourceExecutionId: '65:19'
})
const nodeData = createMockNodeData('SubgraphNode', [
firstTransientEntry,

View File

@@ -23,10 +23,6 @@ function useNodeEventHandlersIndividual() {
const { bringNodeToFront } = useNodeZIndex()
const { shouldHandleNodePointerEvents } = useCanvasInteractions()
function getNode(nodeId: NodeId) {
return nodeManager.value?.getNode(String(nodeId))
}
/**
* Handle node selection events
* Supports single selection and multi-select with Ctrl/Cmd
@@ -34,9 +30,9 @@ function useNodeEventHandlersIndividual() {
function handleNodeSelect(event: PointerEvent, nodeId: NodeId) {
if (!shouldHandleNodePointerEvents.value) return
if (!canvasStore.canvas) return
if (!canvasStore.canvas || !nodeManager.value) return
const node = getNode(nodeId)
const node = nodeManager.value.getNode(nodeId)
if (!node) return
const multiSelect = isMultiSelectKey(event)
@@ -71,7 +67,9 @@ function useNodeEventHandlersIndividual() {
function handleNodeCollapse(nodeId: NodeId, collapsed: boolean) {
if (!shouldHandleNodePointerEvents.value) return
const node = getNode(nodeId)
if (!nodeManager.value) return
const node = nodeManager.value.getNode(nodeId)
if (!node) return
// Use LiteGraph's collapse method if the state needs to change
@@ -88,7 +86,9 @@ function useNodeEventHandlersIndividual() {
function handleNodeTitleUpdate(nodeId: NodeId, newTitle: string) {
if (!shouldHandleNodePointerEvents.value) return
const node = getNode(nodeId)
if (!nodeManager.value) return
const node = nodeManager.value.getNode(nodeId)
if (!node) return
// Update the node title in LiteGraph for persistence
@@ -107,9 +107,9 @@ function useNodeEventHandlersIndividual() {
function handleNodeRightClick(event: PointerEvent, nodeId: NodeId) {
if (!shouldHandleNodePointerEvents.value) return
if (!canvasStore.canvas) return
if (!canvasStore.canvas || !nodeManager.value) return
const node = getNode(nodeId)
const node = nodeManager.value.getNode(nodeId)
if (!node) return
// Prevent default context menu
@@ -130,9 +130,9 @@ function useNodeEventHandlersIndividual() {
) {
if (!shouldHandleNodePointerEvents.value) return
if (!canvasStore.canvas) return
if (!canvasStore.canvas || !nodeManager.value) return
const node = getNode(nodeId)
const node = nodeManager.value.getNode(nodeId)
if (!node) return
if (!multiSelect) {

View File

@@ -10,13 +10,12 @@ import { useClickDragGuard } from '@/composables/useClickDragGuard'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { NodeId } from '@/renderer/core/layout/types'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { isMultiSelectKey } from '@/renderer/extensions/vueNodes/utils/selectionUtils'
import { useNodeDrag } from '@/renderer/extensions/vueNodes/layout/useNodeDrag'
export function useNodePointerInteractions(
nodeIdRef: MaybeRefOrGetter<NodeId>
nodeIdRef: MaybeRefOrGetter<string>
) {
const { startDrag, endDrag, handleDrag } = useNodeDrag()
// Use canvas interactions for proper wheel event handling and pointer event capture control
@@ -26,10 +25,6 @@ export function useNodePointerInteractions(
useNodeEventHandlers()
const { nodeManager } = useVueNodeLifecycle()
function isPinnedNode(nodeId: NodeId): boolean {
return nodeManager.value?.getNode(String(nodeId))?.flags?.pinned ?? false
}
const forwardMiddlePointerIfNeeded = (
event: PointerEvent,
isMiddleInput: (event: PointerEvent) => boolean
@@ -64,7 +59,7 @@ export function useNodePointerInteractions(
}
// IMPORTANT: Read from actual LGraphNode to get correct state
if (isPinnedNode(nodeId)) {
if (nodeManager.value?.getNode(nodeId)?.flags?.pinned) {
return
}
@@ -81,7 +76,7 @@ export function useNodePointerInteractions(
const nodeId = toValue(nodeIdRef)
if (isPinnedNode(nodeId)) {
if (nodeManager.value?.getNode(nodeId)?.flags?.pinned) {
return
}
@@ -116,7 +111,7 @@ export function useNodePointerInteractions(
layoutStore.isDraggingVueNodes.value = false
}
function safeDragStart(event: PointerEvent, nodeId: NodeId) {
function safeDragStart(event: PointerEvent, nodeId: string) {
try {
startDrag(event, nodeId)
} finally {

Some files were not shown because too many files have changed in this diff Show More