mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-25 09:09:14 +00:00
Compare commits
6 Commits
DynamicGro
...
feat/ephem
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a66f9afcd5 | ||
|
|
083317f378 | ||
|
|
e9047bf610 | ||
|
|
3852b69b19 | ||
|
|
ae343602b5 | ||
|
|
ce6ca4dafd |
3
.github/workflows/cla.yml
vendored
3
.github/workflows/cla.yml
vendored
@@ -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: |
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -8,7 +8,6 @@
|
||||
v-model="filters['global'].value"
|
||||
class="max-w-96"
|
||||
size="lg"
|
||||
autofocus
|
||||
:placeholder="
|
||||
$t('g.searchPlaceholder', { subject: $t('g.keybindings') })
|
||||
"
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
}))
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
101
src/config/comfyApi.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
62
src/config/firebase.test.ts
Normal file
62
src/config/firebase.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 ?? {}
|
||||
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -2,7 +2,6 @@ import { isCloud, isNightly } from '@/platform/distribution/types'
|
||||
|
||||
import './clipspace'
|
||||
import './contextMenuFilter'
|
||||
import './createBoundingBoxes'
|
||||
import './customWidgets'
|
||||
import './dynamicPrompts'
|
||||
import './editAttention'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
11
src/main.ts
11
src/main.ts
@@ -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')
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<div
|
||||
data-testid="asset-card"
|
||||
data-component-id="AssetCard"
|
||||
:data-asset-id="asset.id"
|
||||
:aria-labelledby="titleId"
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
size="md"
|
||||
:placeholder="$t('g.searchSettings') + '...'"
|
||||
:debounce-time="128"
|
||||
:autofocus="activeCategoryKey !== 'keybinding'"
|
||||
autofocus
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()]
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user