diff --git a/.github/workflows/update-electron-types.yaml b/.github/workflows/update-electron-types.yaml index 0dfcdea34..96f85f6b0 100644 --- a/.github/workflows/update-electron-types.yaml +++ b/.github/workflows/update-electron-types.yaml @@ -40,7 +40,7 @@ jobs: - name: Get new version id: get-version run: | - NEW_VERSION=$(pnpm list @comfyorg/comfyui-electron-types --json --depth=0 | jq -r '.[0].version') + NEW_VERSION=$(pnpm list @comfyorg/comfyui-electron-types --json --depth=0 | jq -r '.[0].dependencies."@comfyorg/comfyui-electron-types".version') echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT - name: Create Pull Request diff --git a/browser_tests/fixtures/ComfyPage.ts b/browser_tests/fixtures/ComfyPage.ts index 185cfe643..9ead7ef29 100644 --- a/browser_tests/fixtures/ComfyPage.ts +++ b/browser_tests/fixtures/ComfyPage.ts @@ -13,6 +13,7 @@ import { ComfyActionbar } from '../helpers/actionbar' import { LocationMock } from '../helpers/locationMock' import { ComfyTemplates } from '../helpers/templates' import { ComfyMouse } from './ComfyMouse' +import { VueNodeHelpers } from './VueNodeHelpers' import { ComfyNodeSearchBox } from './components/ComfyNodeSearchBox' import { SettingDialog } from './components/SettingDialog' import { @@ -146,6 +147,7 @@ export class ComfyPage { public readonly settingDialog: SettingDialog public readonly confirmDialog: ConfirmDialog public readonly locationMock: LocationMock + public readonly vueNodes: VueNodeHelpers /** Worker index to test user ID */ public readonly userIds: string[] = [] @@ -175,6 +177,7 @@ export class ComfyPage { this.settingDialog = new SettingDialog(page, this) this.confirmDialog = new ConfirmDialog(page) this.locationMock = new LocationMock(page) + this.vueNodes = new VueNodeHelpers(page) } convertLeafToContent(structure: FolderStructure): FolderStructure { @@ -1433,7 +1436,7 @@ export class ComfyPage { } async closeDialog() { - await this.page.locator('.p-dialog-close-button').click() + await this.page.locator('.p-dialog-close-button').click({ force: true }) await expect(this.page.locator('.p-dialog')).toBeHidden() } diff --git a/browser_tests/fixtures/VueNodeHelpers.ts b/browser_tests/fixtures/VueNodeHelpers.ts new file mode 100644 index 000000000..e3b3de542 --- /dev/null +++ b/browser_tests/fixtures/VueNodeHelpers.ts @@ -0,0 +1,110 @@ +/** + * Vue Node Test Helpers + */ +import type { Locator, Page } from '@playwright/test' + +export class VueNodeHelpers { + constructor(private page: Page) {} + + /** + * Get locator for all Vue node components in the DOM + */ + get nodes(): Locator { + return this.page.locator('[data-node-id]') + } + + /** + * Get locator for selected Vue node components (using visual selection indicators) + */ + get selectedNodes(): Locator { + return this.page.locator( + '[data-node-id].outline-black, [data-node-id].outline-white' + ) + } + + /** + * Get total count of Vue nodes in the DOM + */ + async getNodeCount(): Promise { + return await this.nodes.count() + } + + /** + * Get count of selected Vue nodes + */ + async getSelectedNodeCount(): Promise { + return await this.selectedNodes.count() + } + + /** + * Get all Vue node IDs currently in the DOM + */ + async getNodeIds(): Promise { + return await this.nodes.evaluateAll((nodes) => + nodes + .map((n) => n.getAttribute('data-node-id')) + .filter((id): id is string => id !== null) + ) + } + + /** + * Select a specific Vue node by ID + */ + async selectNode(nodeId: string): Promise { + await this.page.locator(`[data-node-id="${nodeId}"]`).click() + } + + /** + * Select multiple Vue nodes by IDs using Ctrl+click + */ + async selectNodes(nodeIds: string[]): Promise { + if (nodeIds.length === 0) return + + // Select first node normally + await this.selectNode(nodeIds[0]) + + // Add additional nodes with Ctrl+click + for (let i = 1; i < nodeIds.length; i++) { + await this.page.locator(`[data-node-id="${nodeIds[i]}"]`).click({ + modifiers: ['Control'] + }) + } + } + + /** + * Clear all selections by clicking empty space + */ + async clearSelection(): Promise { + await this.page.mouse.click(50, 50) + } + + /** + * Delete selected Vue nodes using Delete key + */ + async deleteSelected(): Promise { + await this.page.locator('#graph-canvas').focus() + await this.page.keyboard.press('Delete') + } + + /** + * Delete selected Vue nodes using Backspace key + */ + async deleteSelectedWithBackspace(): Promise { + await this.page.locator('#graph-canvas').focus() + await this.page.keyboard.press('Backspace') + } + + /** + * Wait for Vue nodes to be rendered + */ + async waitForNodes(expectedCount?: number): Promise { + if (expectedCount !== undefined) { + await this.page.waitForFunction( + (count) => document.querySelectorAll('[data-node-id]').length >= count, + expectedCount + ) + } else { + await this.page.waitForSelector('[data-node-id]') + } + } +} diff --git a/browser_tests/tests/nodeHelp.spec.ts b/browser_tests/tests/nodeHelp.spec.ts index 68ce7b8d5..764849286 100644 --- a/browser_tests/tests/nodeHelp.spec.ts +++ b/browser_tests/tests/nodeHelp.spec.ts @@ -46,7 +46,7 @@ test.describe('Node Help', () => { // Click the help button in the selection toolbox const helpButton = comfyPage.selectionToolbox.locator( - 'button:has(.pi-question-circle)' + 'button[data-testid="info-button"]' ) await expect(helpButton).toBeVisible() await helpButton.click() @@ -164,7 +164,7 @@ test.describe('Node Help', () => { // Click help button const helpButton = comfyPage.page.locator( - '.selection-toolbox button:has(.pi-question-circle)' + '.selection-toolbox button[data-testid="info-button"]' ) await helpButton.click() @@ -194,7 +194,7 @@ test.describe('Node Help', () => { // Click help button const helpButton = comfyPage.page.locator( - '.selection-toolbox button:has(.pi-question-circle)' + '.selection-toolbox button[data-testid="info-button"]' ) await helpButton.click() @@ -228,7 +228,7 @@ test.describe('Node Help', () => { await selectNodeWithPan(comfyPage, ksamplerNodes[0]) const helpButton = comfyPage.page.locator( - '.selection-toolbox button:has(.pi-question-circle)' + '.selection-toolbox button[data-testid="info-button"]' ) await helpButton.click() @@ -276,7 +276,7 @@ test.describe('Node Help', () => { await selectNodeWithPan(comfyPage, ksamplerNodes[0]) const helpButton = comfyPage.page.locator( - '.selection-toolbox button:has(.pi-question-circle)' + '.selection-toolbox button[data-testid="info-button"]' ) await helpButton.click() @@ -348,7 +348,7 @@ This is documentation for a custom node. } const helpButton = comfyPage.page.locator( - '.selection-toolbox button:has(.pi-question-circle)' + '.selection-toolbox button[data-testid="info-button"]' ) if (await helpButton.isVisible()) { await helpButton.click() @@ -389,7 +389,7 @@ This is documentation for a custom node. await selectNodeWithPan(comfyPage, ksamplerNodes[0]) const helpButton = comfyPage.page.locator( - '.selection-toolbox button:has(.pi-question-circle)' + '.selection-toolbox button[data-testid="info-button"]' ) await helpButton.click() @@ -456,7 +456,7 @@ This is English documentation. await selectNodeWithPan(comfyPage, ksamplerNodes[0]) const helpButton = comfyPage.page.locator( - '.selection-toolbox button:has(.pi-question-circle)' + '.selection-toolbox button[data-testid="info-button"]' ) await helpButton.click() @@ -479,7 +479,7 @@ This is English documentation. await selectNodeWithPan(comfyPage, ksamplerNodes[0]) const helpButton = comfyPage.page.locator( - '.selection-toolbox button:has(.pi-question-circle)' + '.selection-toolbox button[data-testid="info-button"]' ) await helpButton.click() @@ -522,7 +522,7 @@ This is English documentation. await selectNodeWithPan(comfyPage, ksamplerNodes[0]) const helpButton = comfyPage.page.locator( - '.selection-toolbox button:has(.pi-question-circle)' + '.selection-toolbox button[data-testid="info-button"]' ) await helpButton.click() @@ -538,7 +538,7 @@ This is English documentation. // Click help button again const helpButton2 = comfyPage.page.locator( - '.selection-toolbox button:has(.pi-question-circle)' + '.selection-toolbox button[data-testid="info-button"]' ) await helpButton2.click() diff --git a/browser_tests/tests/remoteWidgets.spec.ts b/browser_tests/tests/remoteWidgets.spec.ts index 4a390af96..05bb578df 100644 --- a/browser_tests/tests/remoteWidgets.spec.ts +++ b/browser_tests/tests/remoteWidgets.spec.ts @@ -190,7 +190,9 @@ test.describe('Remote COMBO Widget', () => { await comfyPage.page.keyboard.press('Control+A') await expect( - comfyPage.page.locator('.selection-toolbox .pi-refresh') + comfyPage.page.locator( + '.selection-toolbox button[data-testid="refresh-button"]' + ) ).toBeVisible() }) diff --git a/browser_tests/tests/rightClickMenu.spec.ts-snapshots/add-node-node-added-chromium-linux.png b/browser_tests/tests/rightClickMenu.spec.ts-snapshots/add-node-node-added-chromium-linux.png index 9443182e3..2755d74c5 100644 Binary files a/browser_tests/tests/rightClickMenu.spec.ts-snapshots/add-node-node-added-chromium-linux.png and b/browser_tests/tests/rightClickMenu.spec.ts-snapshots/add-node-node-added-chromium-linux.png differ diff --git a/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-multiple-nodes-border-chromium-linux.png b/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-multiple-nodes-border-chromium-linux.png index 7aa22906b..96f6507e1 100644 Binary files a/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-multiple-nodes-border-chromium-linux.png and b/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-multiple-nodes-border-chromium-linux.png differ diff --git a/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-multiple-selections-border-chromium-linux.png b/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-multiple-selections-border-chromium-linux.png index 41bb283d9..af92221f3 100644 Binary files a/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-multiple-selections-border-chromium-linux.png and b/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-multiple-selections-border-chromium-linux.png differ diff --git a/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-single-node-no-border-chromium-linux.png b/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-single-node-no-border-chromium-linux.png index a9d9bafce..f9b9b012c 100644 Binary files a/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-single-node-no-border-chromium-linux.png and b/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-single-node-no-border-chromium-linux.png differ diff --git a/browser_tests/tests/selectionToolboxSubmenus.spec.ts b/browser_tests/tests/selectionToolboxSubmenus.spec.ts new file mode 100644 index 000000000..a7311c15a --- /dev/null +++ b/browser_tests/tests/selectionToolboxSubmenus.spec.ts @@ -0,0 +1,177 @@ +import { expect } from '@playwright/test' + +import { comfyPageFixture as test } from '../fixtures/ComfyPage' + +test.describe('Selection Toolbox - More Options Submenus', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true) + await comfyPage.loadWorkflow('nodes/single_ksampler') + await comfyPage.nextFrame() + await comfyPage.selectNodes(['KSampler']) + await comfyPage.nextFrame() + }) + + const openMoreOptions = async (comfyPage: any) => { + const ksamplerNodes = await comfyPage.getNodeRefsByTitle('KSampler') + if (ksamplerNodes.length === 0) { + throw new Error('No KSampler nodes found') + } + + // Drag the KSampler to the center of the screen + const nodePos = await ksamplerNodes[0].getPosition() + const viewportSize = comfyPage.page.viewportSize() + const centerX = viewportSize.width / 3 + const centerY = viewportSize.height / 2 + await comfyPage.dragAndDrop( + { x: nodePos.x, y: nodePos.y }, + { x: centerX, y: centerY } + ) + await comfyPage.nextFrame() + + await ksamplerNodes[0].click('title') + await comfyPage.nextFrame() + await comfyPage.page.waitForTimeout(500) + + await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible({ + timeout: 5000 + }) + + const moreOptionsBtn = comfyPage.page.locator( + '[data-testid="more-options-button"]' + ) + await expect(moreOptionsBtn).toBeVisible({ timeout: 3000 }) + + await comfyPage.page.click('[data-testid="more-options-button"]') + + await comfyPage.nextFrame() + + const menuOptionsVisible = await comfyPage.page + .getByText('Rename') + .isVisible({ timeout: 2000 }) + .catch(() => false) + if (menuOptionsVisible) { + return + } + + await moreOptionsBtn.click({ force: true }) + await comfyPage.nextFrame() + await comfyPage.page.waitForTimeout(2000) + + const menuOptionsVisibleAfterClick = await comfyPage.page + .getByText('Rename') + .isVisible({ timeout: 2000 }) + .catch(() => false) + if (menuOptionsVisibleAfterClick) { + return + } + + throw new Error('Could not open More Options menu - popover not showing') + } + + test('opens Node Info from More Options menu', async ({ comfyPage }) => { + await openMoreOptions(comfyPage) + const nodeInfoButton = comfyPage.page.getByText('Node Info', { + exact: true + }) + await expect(nodeInfoButton).toBeVisible() + await nodeInfoButton.click() + await comfyPage.nextFrame() + }) + + test('changes node shape via Shape submenu', async ({ comfyPage }) => { + const nodeRef = (await comfyPage.getNodeRefsByTitle('KSampler'))[0] + const initialShape = await nodeRef.getProperty('shape') + + await openMoreOptions(comfyPage) + await comfyPage.page.getByText('Shape', { exact: true }).click() + await expect(comfyPage.page.getByText('Box', { exact: true })).toBeVisible({ + timeout: 5000 + }) + await comfyPage.page.getByText('Box', { exact: true }).click() + await comfyPage.nextFrame() + + const newShape = await nodeRef.getProperty('shape') + expect(newShape).not.toBe(initialShape) + expect(newShape).toBe(1) + }) + + test('changes node color via Color submenu swatch', async ({ comfyPage }) => { + const nodeRef = (await comfyPage.getNodeRefsByTitle('KSampler'))[0] + const initialColor = await nodeRef.getProperty('color') + + await openMoreOptions(comfyPage) + await comfyPage.page.getByText('Color', { exact: true }).click() + const blueSwatch = comfyPage.page.locator('[title="Blue"]') + await expect(blueSwatch.first()).toBeVisible({ timeout: 5000 }) + await blueSwatch.first().click() + await comfyPage.nextFrame() + + const newColor = await nodeRef.getProperty('color') + expect(newColor).toBe('#223') + if (initialColor) { + expect(newColor).not.toBe(initialColor) + } + }) + + test('renames a node using Rename action', async ({ comfyPage }) => { + const nodeRef = (await comfyPage.getNodeRefsByTitle('KSampler'))[0] + await openMoreOptions(comfyPage) + await comfyPage.page + .getByText('Rename', { exact: true }) + .click({ force: true }) + const input = comfyPage.page.locator( + '.group-title-editor.node-title-editor .editable-text input' + ) + await expect(input).toBeVisible() + await input.fill('RenamedNode') + await input.press('Enter') + await comfyPage.nextFrame() + const newTitle = await nodeRef.getProperty('title') + expect(newTitle).toBe('RenamedNode') + }) + + test('closes More Options menu when clicking outside', async ({ + comfyPage + }) => { + await openMoreOptions(comfyPage) + await expect( + comfyPage.page.getByText('Rename', { exact: true }) + ).toBeVisible({ timeout: 5000 }) + + await comfyPage.page + .locator('#graph-canvas') + .click({ position: { x: 0, y: 50 }, force: true }) + await comfyPage.nextFrame() + await expect( + comfyPage.page.getByText('Rename', { exact: true }) + ).not.toBeVisible() + }) + + test('closes More Options menu when clicking the button again (toggle)', async ({ + comfyPage + }) => { + await openMoreOptions(comfyPage) + await expect( + comfyPage.page.getByText('Rename', { exact: true }) + ).toBeVisible({ timeout: 5000 }) + + await comfyPage.page.evaluate(() => { + const btn = document.querySelector('[data-testid="more-options-button"]') + if (btn) { + const event = new MouseEvent('click', { + bubbles: true, + cancelable: true, + view: window, + detail: 1 + }) + btn.dispatchEvent(event) + } + }) + await comfyPage.nextFrame() + await comfyPage.page.waitForTimeout(500) + + await expect( + comfyPage.page.getByText('Rename', { exact: true }) + ).not.toBeVisible() + }) +}) diff --git a/browser_tests/tests/vueNodes/deleteKeyInteraction.spec.ts b/browser_tests/tests/vueNodes/deleteKeyInteraction.spec.ts new file mode 100644 index 000000000..a00d93eb0 --- /dev/null +++ b/browser_tests/tests/vueNodes/deleteKeyInteraction.spec.ts @@ -0,0 +1,141 @@ +import { expect } from '@playwright/test' + +import { comfyPageFixture as test } from '../../fixtures/ComfyPage' + +test.describe('Vue Nodes - Delete Key Interaction', () => { + test.beforeEach(async ({ comfyPage }) => { + // Enable Vue nodes rendering + await comfyPage.setSetting('Comfy.VueNodes.Enabled', true) + await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false) + await comfyPage.setup() + }) + + test('Can select all and delete Vue nodes with Delete key', async ({ + comfyPage + }) => { + await comfyPage.vueNodes.waitForNodes() + + // Get initial Vue node count + const initialNodeCount = await comfyPage.vueNodes.getNodeCount() + expect(initialNodeCount).toBeGreaterThan(0) + + // Select all Vue nodes + await comfyPage.ctrlA() + + // Verify all Vue nodes are selected + const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount() + expect(selectedCount).toBe(initialNodeCount) + + // Delete with Delete key + await comfyPage.vueNodes.deleteSelected() + + // Verify all Vue nodes were deleted + const finalNodeCount = await comfyPage.vueNodes.getNodeCount() + expect(finalNodeCount).toBe(0) + }) + + test('Can select specific Vue node and delete it', async ({ comfyPage }) => { + await comfyPage.vueNodes.waitForNodes() + + // Get initial Vue node count + const initialNodeCount = await comfyPage.vueNodes.getNodeCount() + expect(initialNodeCount).toBeGreaterThan(0) + + // Get first Vue node ID and select it + const nodeIds = await comfyPage.vueNodes.getNodeIds() + await comfyPage.vueNodes.selectNode(nodeIds[0]) + + // Verify selection + const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount() + expect(selectedCount).toBe(1) + + // Delete with Delete key + await comfyPage.vueNodes.deleteSelected() + + // Verify one Vue node was deleted + const finalNodeCount = await comfyPage.vueNodes.getNodeCount() + expect(finalNodeCount).toBe(initialNodeCount - 1) + }) + + test('Can select and delete Vue node with Backspace key', async ({ + comfyPage + }) => { + await comfyPage.vueNodes.waitForNodes() + + const initialNodeCount = await comfyPage.vueNodes.getNodeCount() + + // Select first Vue node + const nodeIds = await comfyPage.vueNodes.getNodeIds() + await comfyPage.vueNodes.selectNode(nodeIds[0]) + + // Delete with Backspace key instead of Delete + await comfyPage.vueNodes.deleteSelectedWithBackspace() + + // Verify Vue node was deleted + const finalNodeCount = await comfyPage.vueNodes.getNodeCount() + expect(finalNodeCount).toBe(initialNodeCount - 1) + }) + + test('Delete key does not delete node when typing in Vue node widgets', async ({ + comfyPage + }) => { + const initialNodeCount = await comfyPage.getGraphNodesCount() + + // Find a text input widget in a Vue node + const textWidget = comfyPage.page + .locator('input[type="text"], textarea') + .first() + + // Click on text widget to focus it + await textWidget.click() + await textWidget.fill('test text') + + // Press Delete while focused on widget - should delete text, not node + await textWidget.press('Delete') + + // Node count should remain the same + const finalNodeCount = await comfyPage.getGraphNodesCount() + expect(finalNodeCount).toBe(initialNodeCount) + }) + + test('Delete key does not delete node when nothing is selected', async ({ + comfyPage + }) => { + await comfyPage.vueNodes.waitForNodes() + + // Ensure no Vue nodes are selected + await comfyPage.vueNodes.clearSelection() + const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount() + expect(selectedCount).toBe(0) + + // Press Delete key - should not crash and should handle gracefully + await comfyPage.page.keyboard.press('Delete') + + // Vue node count should remain the same + const nodeCount = await comfyPage.vueNodes.getNodeCount() + expect(nodeCount).toBeGreaterThan(0) + }) + + test('Can multi-select with Ctrl+click and delete multiple Vue nodes', async ({ + comfyPage + }) => { + await comfyPage.vueNodes.waitForNodes() + const initialNodeCount = await comfyPage.vueNodes.getNodeCount() + + // Multi-select first two Vue nodes using Ctrl+click + const nodeIds = await comfyPage.vueNodes.getNodeIds() + const nodesToSelect = nodeIds.slice(0, 2) + await comfyPage.vueNodes.selectNodes(nodesToSelect) + + // Verify expected nodes are selected + const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount() + expect(selectedCount).toBe(nodesToSelect.length) + + // Delete selected Vue nodes + await comfyPage.vueNodes.deleteSelected() + + // Verify expected nodes were deleted + const finalNodeCount = await comfyPage.vueNodes.getNodeCount() + expect(finalNodeCount).toBe(initialNodeCount - nodesToSelect.length) + }) +}) diff --git a/browser_tests/tests/widget.spec.ts b/browser_tests/tests/widget.spec.ts index b23faabfc..728b5d028 100644 --- a/browser_tests/tests/widget.spec.ts +++ b/browser_tests/tests/widget.spec.ts @@ -264,7 +264,13 @@ test.describe('Animated image widget', () => { expect(filename).toContain('animated_webp.webp') }) - test('Can preview saved animated webp image', async ({ comfyPage }) => { + // FIXME: This test keeps flip-flopping because it relies on animated webp timing, + // which is inherently unreliable in CI environments. The test asset is an animated + // webp with 2 frames, and the test depends on animation frame timing to verify that + // animated webp images are properly displayed (as opposed to being treated as static webp). + // While the underlying functionality works (animated webp are correctly distinguished + // from static webp), the test is flaky due to timing dependencies with webp animation frames. + test.fixme('Can preview saved animated webp image', async ({ comfyPage }) => { await comfyPage.loadWorkflow('widgets/save_animated_webp') // Get position of the load animated webp node diff --git a/browser_tests/tests/widget.spec.ts-snapshots/animated-image-preview-saved-webp-chromium-linux.png b/browser_tests/tests/widget.spec.ts-snapshots/animated-image-preview-saved-webp-chromium-linux.png deleted file mode 100644 index 3f3d831bb..000000000 Binary files a/browser_tests/tests/widget.spec.ts-snapshots/animated-image-preview-saved-webp-chromium-linux.png and /dev/null differ diff --git a/package.json b/package.json index c501ad230..fdae536f3 100644 --- a/package.json +++ b/package.json @@ -114,7 +114,7 @@ "dependencies": { "@alloc/quick-lru": "^5.2.0", "@atlaskit/pragmatic-drag-and-drop": "^1.3.1", - "@comfyorg/comfyui-electron-types": "^0.4.69", + "@comfyorg/comfyui-electron-types": "^0.4.72", "@iconify/json": "^2.2.380", "@primeuix/forms": "0.0.2", "@primeuix/styled": "0.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21c04dd0e..ce2b3a676 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: ^1.3.1 version: 1.3.1 '@comfyorg/comfyui-electron-types': - specifier: ^0.4.69 - version: 0.4.69 + specifier: ^0.4.72 + version: 0.4.72 '@iconify/json': specifier: ^2.2.380 version: 2.2.380 @@ -1038,8 +1038,8 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} - '@comfyorg/comfyui-electron-types@0.4.69': - resolution: {integrity: sha512-emEapJvbbx8lXiJ/84gmk+fYU73MmqkQKgBDQkyDwctcOb+eNe347PaH/+0AIjX8A/DtFHfnwgh9J8k3RVdqZA==} + '@comfyorg/comfyui-electron-types@0.4.72': + resolution: {integrity: sha512-Ecf0XYOKDqqIcnjSWL8GHLo6MOsuwqs0I1QgWc3Hv+BZm+qUE4vzOXCyhfFoTIGHLZFTwe37gnygPPKFzMu00Q==} '@csstools/color-helpers@5.1.0': resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} @@ -6741,8 +6741,8 @@ packages: vue-component-type-helpers@2.2.12: resolution: {integrity: sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==} - vue-component-type-helpers@3.0.6: - resolution: {integrity: sha512-6CRM8X7EJqWCJOiKPvSLQG+hJPb/Oy2gyJx3pLjUEhY7PuaCthQu3e0zAGI1lqUBobrrk9IT0K8sG2GsCluxoQ==} + vue-component-type-helpers@3.0.7: + resolution: {integrity: sha512-TvyUcFXmjZcXUvU+r1MOyn4/vv4iF+tPwg5Ig33l/FJ3myZkxeQpzzQMLMFWcQAjr6Xs7BRwVy/TwbmNZUA/4w==} vue-demi@0.14.10: resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} @@ -7908,7 +7908,7 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} - '@comfyorg/comfyui-electron-types@0.4.69': {} + '@comfyorg/comfyui-electron-types@0.4.72': {} '@csstools/color-helpers@5.1.0': {} @@ -9331,7 +9331,7 @@ snapshots: storybook: 9.1.1(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)) type-fest: 2.19.0 vue: 3.5.13(typescript@5.9.2) - vue-component-type-helpers: 3.0.6 + vue-component-type-helpers: 3.0.7 '@swc/helpers@0.5.17': dependencies: @@ -14598,7 +14598,7 @@ snapshots: vue-component-type-helpers@2.2.12: {} - vue-component-type-helpers@3.0.6: {} + vue-component-type-helpers@3.0.7: {} vue-demi@0.14.10(vue@3.5.13(typescript@5.9.2)): dependencies: diff --git a/src/assets/css/style.css b/src/assets/css/style.css index 9c49e1704..4520f15b6 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -961,9 +961,7 @@ audio.comfy-audio.empty-audio-widget { /* Uses default styling - no overrides needed */ } -/* Smooth transitions between LOD levels */ .lg-node { - transition: min-height 0.2s ease; /* Disable text selection on all nodes */ user-select: none; -webkit-user-select: none; diff --git a/src/assets/icons/custom/mask.svg b/src/assets/icons/custom/mask.svg new file mode 100644 index 000000000..1e1a6d97c --- /dev/null +++ b/src/assets/icons/custom/mask.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/base/common/downloadUtil.ts b/src/base/common/downloadUtil.ts new file mode 100644 index 000000000..307a3e35b --- /dev/null +++ b/src/base/common/downloadUtil.ts @@ -0,0 +1,41 @@ +/** + * Utility functions for downloading files + */ + +// Constants +const DEFAULT_DOWNLOAD_FILENAME = 'download.png' + +/** + * Download a file from a URL by creating a temporary anchor element + * @param url - The URL of the file to download (must be a valid URL string) + * @param filename - Optional filename override (will use URL filename or default if not provided) + * @throws {Error} If the URL is invalid or empty + */ +export const downloadFile = (url: string, filename?: string): void => { + if (!url || typeof url !== 'string' || url.trim().length === 0) { + throw new Error('Invalid URL provided for download') + } + const link = document.createElement('a') + link.href = url + link.download = + filename || extractFilenameFromUrl(url) || DEFAULT_DOWNLOAD_FILENAME + + // Trigger download + document.body.appendChild(link) + link.click() + document.body.removeChild(link) +} + +/** + * Extract filename from a URL's query parameters + * @param url - The URL to extract filename from + * @returns The extracted filename or null if not found + */ +const extractFilenameFromUrl = (url: string): string | null => { + try { + const urlObj = new URL(url, window.location.origin) + return urlObj.searchParams.get('filename') + } catch { + return null + } +} diff --git a/src/components/breadcrumb/SubgraphBreadcrumb.vue b/src/components/breadcrumb/SubgraphBreadcrumb.vue index e710f6c82..6e9c59af9 100644 --- a/src/components/breadcrumb/SubgraphBreadcrumb.vue +++ b/src/components/breadcrumb/SubgraphBreadcrumb.vue @@ -38,7 +38,7 @@ import { computed, onUpdated, ref, watch } from 'vue' import SubgraphBreadcrumbItem from '@/components/breadcrumb/SubgraphBreadcrumbItem.vue' import { useOverflowObserver } from '@/composables/element/useOverflowObserver' -import { useCanvasStore } from '@/stores/graphStore' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore' import { useSubgraphStore } from '@/stores/subgraphStore' import { useWorkflowStore } from '@/stores/workflowStore' diff --git a/src/components/common/FormItem.vue b/src/components/common/FormItem.vue index 6488354ac..e8b76ad19 100644 --- a/src/components/common/FormItem.vue +++ b/src/components/common/FormItem.vue @@ -40,6 +40,7 @@ import BackgroundImageUpload from '@/components/common/BackgroundImageUpload.vue import CustomFormValue from '@/components/common/CustomFormValue.vue' import FormColorPicker from '@/components/common/FormColorPicker.vue' import FormImageUpload from '@/components/common/FormImageUpload.vue' +import FormRadioGroup from '@/components/common/FormRadioGroup.vue' import InputKnob from '@/components/common/InputKnob.vue' import InputSlider from '@/components/common/InputSlider.vue' import UrlInput from '@/components/common/UrlInput.vue' @@ -66,6 +67,7 @@ function getFormAttrs(item: FormItem) { } switch (item.type) { case 'combo': + case 'radio': attrs['options'] = typeof item.options === 'function' ? // @ts-expect-error: Audit and deprecate usage of legacy options type: @@ -97,6 +99,8 @@ function getFormComponent(item: FormItem): Component { return InputKnob case 'combo': return Select + case 'radio': + return FormRadioGroup case 'image': return FormImageUpload case 'color': diff --git a/src/components/common/FormRadioGroup.spec.ts b/src/components/common/FormRadioGroup.spec.ts new file mode 100644 index 000000000..c1d7a0483 --- /dev/null +++ b/src/components/common/FormRadioGroup.spec.ts @@ -0,0 +1,245 @@ +import { mount } from '@vue/test-utils' +import PrimeVue from 'primevue/config' +import RadioButton from 'primevue/radiobutton' +import { beforeAll, describe, expect, it } from 'vitest' +import { createApp } from 'vue' + +import type { SettingOption } from '@/types/settingTypes' + +import FormRadioGroup from './FormRadioGroup.vue' + +describe('FormRadioGroup', () => { + beforeAll(() => { + const app = createApp({}) + app.use(PrimeVue) + }) + + const mountComponent = (props: any, options = {}) => { + return mount(FormRadioGroup, { + global: { + plugins: [PrimeVue], + components: { RadioButton } + }, + props, + ...options + }) + } + + describe('normalizedOptions computed property', () => { + it('handles string array options', () => { + const wrapper = mountComponent({ + modelValue: 'option1', + options: ['option1', 'option2', 'option3'], + id: 'test-radio' + }) + + const radioButtons = wrapper.findAllComponents(RadioButton) + expect(radioButtons).toHaveLength(3) + + expect(radioButtons[0].props('value')).toBe('option1') + expect(radioButtons[1].props('value')).toBe('option2') + expect(radioButtons[2].props('value')).toBe('option3') + + const labels = wrapper.findAll('label') + expect(labels[0].text()).toBe('option1') + expect(labels[1].text()).toBe('option2') + expect(labels[2].text()).toBe('option3') + }) + + it('handles SettingOption array', () => { + const options: SettingOption[] = [ + { text: 'Small', value: 'sm' }, + { text: 'Medium', value: 'md' }, + { text: 'Large', value: 'lg' } + ] + + const wrapper = mountComponent({ + modelValue: 'md', + options, + id: 'test-radio' + }) + + const radioButtons = wrapper.findAllComponents(RadioButton) + expect(radioButtons).toHaveLength(3) + + expect(radioButtons[0].props('value')).toBe('sm') + expect(radioButtons[1].props('value')).toBe('md') + expect(radioButtons[2].props('value')).toBe('lg') + + const labels = wrapper.findAll('label') + expect(labels[0].text()).toBe('Small') + expect(labels[1].text()).toBe('Medium') + expect(labels[2].text()).toBe('Large') + }) + + it('handles SettingOption with undefined value (uses text as value)', () => { + const options: SettingOption[] = [ + { text: 'Option A', value: undefined }, + { text: 'Option B' } + ] + + const wrapper = mountComponent({ + modelValue: 'Option A', + options, + id: 'test-radio' + }) + + const radioButtons = wrapper.findAllComponents(RadioButton) + + expect(radioButtons[0].props('value')).toBe('Option A') + expect(radioButtons[1].props('value')).toBe('Option B') + }) + + it('handles custom object with optionLabel and optionValue', () => { + const options = [ + { name: 'First Option', id: 1 }, + { name: 'Second Option', id: 2 }, + { name: 'Third Option', id: 3 } + ] + + const wrapper = mountComponent({ + modelValue: 2, + options, + optionLabel: 'name', + optionValue: 'id', + id: 'test-radio' + }) + + const radioButtons = wrapper.findAllComponents(RadioButton) + expect(radioButtons).toHaveLength(3) + + expect(radioButtons[0].props('value')).toBe(1) + expect(radioButtons[1].props('value')).toBe(2) + expect(radioButtons[2].props('value')).toBe(3) + + const labels = wrapper.findAll('label') + expect(labels[0].text()).toBe('First Option') + expect(labels[1].text()).toBe('Second Option') + expect(labels[2].text()).toBe('Third Option') + }) + + it('handles mixed array with strings and SettingOptions', () => { + const options: (string | SettingOption)[] = [ + 'Simple String', + { text: 'Complex Option', value: 'complex' }, + 'Another String' + ] + + const wrapper = mountComponent({ + modelValue: 'complex', + options, + id: 'test-radio' + }) + + const radioButtons = wrapper.findAllComponents(RadioButton) + expect(radioButtons).toHaveLength(3) + + expect(radioButtons[0].props('value')).toBe('Simple String') + expect(radioButtons[1].props('value')).toBe('complex') + expect(radioButtons[2].props('value')).toBe('Another String') + + const labels = wrapper.findAll('label') + expect(labels[0].text()).toBe('Simple String') + expect(labels[1].text()).toBe('Complex Option') + expect(labels[2].text()).toBe('Another String') + }) + + it('handles empty options array', () => { + const wrapper = mountComponent({ + modelValue: null, + options: [], + id: 'test-radio' + }) + + const radioButtons = wrapper.findAllComponents(RadioButton) + expect(radioButtons).toHaveLength(0) + }) + + it('handles undefined options gracefully', () => { + const wrapper = mountComponent({ + modelValue: null, + options: undefined, + id: 'test-radio' + }) + + const radioButtons = wrapper.findAllComponents(RadioButton) + expect(radioButtons).toHaveLength(0) + }) + + it('handles object with missing properties gracefully', () => { + const options = [ + { label: 'Option 1', val: 'opt1' }, + { text: 'Option 2', value: 'opt2' } + ] + + const wrapper = mountComponent({ + modelValue: 'opt1', + options, + id: 'test-radio' + }) + + const radioButtons = wrapper.findAllComponents(RadioButton) + expect(radioButtons).toHaveLength(2) + + const labels = wrapper.findAll('label') + expect(labels[0].text()).toBe('Unknown') + expect(labels[1].text()).toBe('Option 2') + }) + }) + + describe('component functionality', () => { + it('sets correct input-id and name attributes', () => { + const options = ['A', 'B'] + + const wrapper = mountComponent({ + modelValue: 'A', + options, + id: 'my-radio-group' + }) + + const radioButtons = wrapper.findAllComponents(RadioButton) + + expect(radioButtons[0].props('inputId')).toBe('my-radio-group-A') + expect(radioButtons[0].props('name')).toBe('my-radio-group') + expect(radioButtons[1].props('inputId')).toBe('my-radio-group-B') + expect(radioButtons[1].props('name')).toBe('my-radio-group') + }) + + it('associates labels with radio buttons correctly', () => { + const options = ['Yes', 'No'] + + const wrapper = mountComponent({ + modelValue: 'Yes', + options, + id: 'confirm-radio' + }) + + const labels = wrapper.findAll('label') + + expect(labels[0].attributes('for')).toBe('confirm-radio-Yes') + expect(labels[1].attributes('for')).toBe('confirm-radio-No') + }) + + it('sets aria-describedby attribute correctly', () => { + const options: SettingOption[] = [ + { text: 'Option 1', value: 'opt1' }, + { text: 'Option 2', value: 'opt2' } + ] + + const wrapper = mountComponent({ + modelValue: 'opt1', + options, + id: 'test-radio' + }) + + const radioButtons = wrapper.findAllComponents(RadioButton) + + expect(radioButtons[0].attributes('aria-describedby')).toBe( + 'Option 1-label' + ) + expect(radioButtons[1].attributes('aria-describedby')).toBe( + 'Option 2-label' + ) + }) + }) +}) diff --git a/src/components/common/FormRadioGroup.vue b/src/components/common/FormRadioGroup.vue new file mode 100644 index 000000000..ca18569b8 --- /dev/null +++ b/src/components/common/FormRadioGroup.vue @@ -0,0 +1,62 @@ + + + diff --git a/src/components/dialog/content/SettingDialogContent.vue b/src/components/dialog/content/SettingDialogContent.vue index 3954186ac..818cbafb6 100644 --- a/src/components/dialog/content/SettingDialogContent.vue +++ b/src/components/dialog/content/SettingDialogContent.vue @@ -113,7 +113,12 @@ const sortedGroups = (category: SettingTreeNode): ISettingGroup[] => { .sort((a, b) => a.label.localeCompare(b.label)) .map((group) => ({ label: group.label, - settings: flattenTree(group) + settings: flattenTree(group).sort((a, b) => { + const sortOrderA = a.sortOrder ?? 0 + const sortOrderB = b.sortOrder ?? 0 + + return sortOrderB - sortOrderA + }) })) } diff --git a/src/components/graph/DomWidgets.vue b/src/components/graph/DomWidgets.vue index 7edca3f5e..da174cf22 100644 --- a/src/components/graph/DomWidgets.vue +++ b/src/components/graph/DomWidgets.vue @@ -16,8 +16,8 @@ import { computed } from 'vue' import DomWidget from '@/components/graph/widgets/DomWidget.vue' import { useChainCallback } from '@/composables/functional/useChainCallback' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { useDomWidgetStore } from '@/stores/domWidgetStore' -import { useCanvasStore } from '@/stores/graphStore' const domWidgetStore = useDomWidgetStore() diff --git a/src/components/graph/GraphCanvas.vue b/src/components/graph/GraphCanvas.vue index 264bdef1f..4c3b59c28 100644 --- a/src/components/graph/GraphCanvas.vue +++ b/src/components/graph/GraphCanvas.vue @@ -40,13 +40,12 @@ > { + return mount(SelectionToolbox, { + props, + global: { + plugins: [i18n, PrimeVue], + provide: { + [Symbol.for('SelectionOverlay')]: mockProvide + }, + stubs: { + Panel: { + template: + '
', + props: ['pt', 'style', 'class'] + }, + InfoButton: { template: '
' }, + ColorPickerButton: { + template: + '', + props: ['severity', 'text', 'class'], + emits: ['click'] + } + } + } + }) + } + + it('should handle click without errors', async () => { + const mockNodeDef = { + nodePath: 'test/node', + display_name: 'Test Node' + } + canvasStore.selectedItems = [mockLGraphNode] as any + vi.spyOn(nodeDefStore, 'fromLGraphNode').mockReturnValue(mockNodeDef as any) + const wrapper = mountComponent() + const button = wrapper.find('button') + await button.trigger('click') + expect(button.exists()).toBe(true) + }) + + it('should have correct CSS classes', () => { + const mockNodeDef = { + nodePath: 'test/node', + display_name: 'Test Node' + } + canvasStore.selectedItems = [mockLGraphNode] as any + vi.spyOn(nodeDefStore, 'fromLGraphNode').mockReturnValue(mockNodeDef as any) + + const wrapper = mountComponent() + const button = wrapper.find('button') + + expect(button.classes()).toContain('help-button') + expect(button.attributes('severity')).toBe('secondary') + }) + + it('should have correct tooltip', () => { + const mockNodeDef = { + nodePath: 'test/node', + display_name: 'Test Node' + } + canvasStore.selectedItems = [mockLGraphNode] as any + vi.spyOn(nodeDefStore, 'fromLGraphNode').mockReturnValue(mockNodeDef as any) + + const wrapper = mountComponent() + const button = wrapper.find('button') + + expect(button.exists()).toBe(true) + }) +}) diff --git a/src/components/graph/selectionToolbox/InfoButton.vue b/src/components/graph/selectionToolbox/InfoButton.vue new file mode 100644 index 000000000..3fd159d89 --- /dev/null +++ b/src/components/graph/selectionToolbox/InfoButton.vue @@ -0,0 +1,22 @@ + + + diff --git a/src/components/graph/selectionToolbox/Load3DViewerButton.vue b/src/components/graph/selectionToolbox/Load3DViewerButton.vue index b207e5018..5187f0c02 100644 --- a/src/components/graph/selectionToolbox/Load3DViewerButton.vue +++ b/src/components/graph/selectionToolbox/Load3DViewerButton.vue @@ -1,6 +1,5 @@ diff --git a/src/components/graph/selectionToolbox/MoreOptions.vue b/src/components/graph/selectionToolbox/MoreOptions.vue new file mode 100644 index 000000000..f40a49b60 --- /dev/null +++ b/src/components/graph/selectionToolbox/MoreOptions.vue @@ -0,0 +1,316 @@ + + + diff --git a/src/components/graph/selectionToolbox/PinButton.vue b/src/components/graph/selectionToolbox/PinButton.vue deleted file mode 100644 index 86598339b..000000000 --- a/src/components/graph/selectionToolbox/PinButton.vue +++ /dev/null @@ -1,25 +0,0 @@ - - - diff --git a/src/components/graph/selectionToolbox/RefreshSelectionButton.vue b/src/components/graph/selectionToolbox/RefreshSelectionButton.vue index 786fe511f..0da7364a0 100644 --- a/src/components/graph/selectionToolbox/RefreshSelectionButton.vue +++ b/src/components/graph/selectionToolbox/RefreshSelectionButton.vue @@ -1,17 +1,22 @@ diff --git a/src/components/graph/selectionToolbox/SaveToSubgraphLibrary.vue b/src/components/graph/selectionToolbox/SaveToSubgraphLibrary.vue index 8fa1b94d6..7f76d2eab 100644 --- a/src/components/graph/selectionToolbox/SaveToSubgraphLibrary.vue +++ b/src/components/graph/selectionToolbox/SaveToSubgraphLibrary.vue @@ -21,8 +21,8 @@ import { computed } from 'vue' import { useI18n } from 'vue-i18n' import { SubgraphNode } from '@/lib/litegraph/src/litegraph' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { useCommandStore } from '@/stores/commandStore' -import { useCanvasStore } from '@/stores/graphStore' const { t } = useI18n() const commandStore = useCommandStore() diff --git a/src/components/graph/selectionToolbox/SubmenuPopover.vue b/src/components/graph/selectionToolbox/SubmenuPopover.vue new file mode 100644 index 000000000..056f0f90b --- /dev/null +++ b/src/components/graph/selectionToolbox/SubmenuPopover.vue @@ -0,0 +1,127 @@ + + + diff --git a/src/components/graph/selectionToolbox/VerticalDivider.vue b/src/components/graph/selectionToolbox/VerticalDivider.vue new file mode 100644 index 000000000..dc6876a3e --- /dev/null +++ b/src/components/graph/selectionToolbox/VerticalDivider.vue @@ -0,0 +1,3 @@ + diff --git a/src/components/graph/widgets/DomWidget.vue b/src/components/graph/widgets/DomWidget.vue index 5eb7a6ed5..cacca6d47 100644 --- a/src/components/graph/widgets/DomWidget.vue +++ b/src/components/graph/widgets/DomWidget.vue @@ -30,9 +30,9 @@ import { import { useAbsolutePosition } from '@/composables/element/useAbsolutePosition' import { useDomClipping } from '@/composables/element/useDomClipping' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { isComponentWidget, isDOMWidget } from '@/scripts/domWidget' -import { type DomWidgetState } from '@/stores/domWidgetStore' -import { useCanvasStore } from '@/stores/graphStore' +import type { DomWidgetState } from '@/stores/domWidgetStore' import { useSettingStore } from '@/stores/settingStore' const { widgetState } = defineProps<{ diff --git a/src/components/searchbox/NodeSearchBoxPopover.vue b/src/components/searchbox/NodeSearchBoxPopover.vue index 8086b07fe..92a68798c 100644 --- a/src/components/searchbox/NodeSearchBoxPopover.vue +++ b/src/components/searchbox/NodeSearchBoxPopover.vue @@ -45,8 +45,8 @@ import { type LiteGraphCanvasEvent } from '@/lib/litegraph/src/litegraph' import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { useLitegraphService } from '@/services/litegraphService' -import { useCanvasStore } from '@/stores/graphStore' import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore' import { useSettingStore } from '@/stores/settingStore' import { useWorkflowStore } from '@/stores/workflowStore' diff --git a/src/composables/canvas/useCanvasTransformSync.ts b/src/composables/canvas/useCanvasTransformSync.ts index fe0d2b457..f2fc480cb 100644 --- a/src/composables/canvas/useCanvasTransformSync.ts +++ b/src/composables/canvas/useCanvasTransformSync.ts @@ -1,7 +1,7 @@ import { onUnmounted, ref } from 'vue' import { LGraphCanvas } from '@/lib/litegraph/src/litegraph' -import { useCanvasStore } from '@/stores/graphStore' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' interface CanvasTransformSyncOptions { /** diff --git a/src/composables/canvas/useSelectedLiteGraphItems.ts b/src/composables/canvas/useSelectedLiteGraphItems.ts index 8cd8d310e..cc1ad6619 100644 --- a/src/composables/canvas/useSelectedLiteGraphItems.ts +++ b/src/composables/canvas/useSelectedLiteGraphItems.ts @@ -4,8 +4,8 @@ import { type Positionable, Reroute } from '@/lib/litegraph/src/litegraph' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { app } from '@/scripts/app' -import { useCanvasStore } from '@/stores/graphStore' import { collectFromNodes, traverseNodesDepthFirst diff --git a/src/composables/canvas/useSelectionToolboxPosition.ts b/src/composables/canvas/useSelectionToolboxPosition.ts index efb8d5ca7..0ea92b92e 100644 --- a/src/composables/canvas/useSelectionToolboxPosition.ts +++ b/src/composables/canvas/useSelectionToolboxPosition.ts @@ -1,4 +1,4 @@ -import { ref, watch } from 'vue' +import { onUnmounted, ref, watch } from 'vue' import type { Ref } from 'vue' import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync' @@ -6,14 +6,44 @@ import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteG import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags' import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces' import { LGraphNode } from '@/lib/litegraph/src/litegraph' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { layoutStore } from '@/renderer/core/layout/store/layoutStore' -import { useCanvasStore } from '@/stores/graphStore' +import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil' import { computeUnionBounds } from '@/utils/mathUtil' /** * Manages the position of the selection toolbox independently. * Uses CSS custom properties for performant transform updates. */ + +// Shared signals for auxiliary UI (e.g., MoreOptions) to coordinate hide/restore +export const moreOptionsOpen = ref(false) +export const forceCloseMoreOptionsSignal = ref(0) +export const restoreMoreOptionsSignal = ref(0) +export const moreOptionsRestorePending = ref(false) +let moreOptionsWasOpenBeforeDrag = false +let moreOptionsSelectionSignature: string | null = null + +function buildSelectionSignature( + store: ReturnType +): string | null { + const c = store.canvas + if (!c) return null + const items = Array.from(c.selectedItems) + if (items.length !== 1) return null + const item = items[0] + if (isLGraphNode(item)) return `N:${item.id}` + if (isLGraphGroup(item)) return `G:${item.id}` + return null +} + +function currentSelectionMatchesSignature( + store: ReturnType +) { + if (!moreOptionsSelectionSignature) return false + return buildSelectionSignature(store) === moreOptionsSelectionSignature +} + export function useSelectionToolboxPosition( toolboxRef: Ref ) { @@ -105,10 +135,17 @@ export function useSelectionToolboxPosition( () => canvasStore.getCanvas().state.selectionChanged, (changed) => { if (changed) { + if (moreOptionsRestorePending.value || moreOptionsSelectionSignature) { + moreOptionsRestorePending.value = false + moreOptionsWasOpenBeforeDrag = false + if (!moreOptionsOpen.value) { + moreOptionsSelectionSignature = null + } else { + moreOptionsSelectionSignature = buildSelectionSignature(canvasStore) + } + } updateSelectionBounds() canvasStore.getCanvas().state.selectionChanged = false - - // Start transform sync if we have selection if (visible.value) { startSync() } else { @@ -118,24 +155,77 @@ export function useSelectionToolboxPosition( }, { immediate: true } ) + watch( + () => moreOptionsOpen.value, + (v) => { + if (v) { + moreOptionsSelectionSignature = buildSelectionSignature(canvasStore) + } else if (!canvasStore.canvas?.state?.draggingItems) { + moreOptionsSelectionSignature = null + if (moreOptionsRestorePending.value) + moreOptionsRestorePending.value = false + } + } + ) // Watch for dragging state watch( () => canvasStore.canvas?.state?.draggingItems, (dragging) => { if (dragging) { - // Hide during node dragging visible.value = false + + if (moreOptionsOpen.value) { + const currentSig = buildSelectionSignature(canvasStore) + if (currentSig !== moreOptionsSelectionSignature) { + moreOptionsSelectionSignature = null + } + moreOptionsWasOpenBeforeDrag = true + moreOptionsOpen.value = false + moreOptionsRestorePending.value = !!moreOptionsSelectionSignature + if (moreOptionsRestorePending.value) { + forceCloseMoreOptionsSignal.value++ + } else { + moreOptionsWasOpenBeforeDrag = false + } + } else { + moreOptionsRestorePending.value = false + moreOptionsWasOpenBeforeDrag = false + } } else { - // Update after dragging ends requestAnimationFrame(() => { updateSelectionBounds() + const selectionMatches = currentSelectionMatchesSignature(canvasStore) + const shouldRestore = + moreOptionsWasOpenBeforeDrag && + visible.value && + moreOptionsRestorePending.value && + selectionMatches + + if (shouldRestore) { + restoreMoreOptionsSignal.value++ + } else { + moreOptionsRestorePending.value = false + } + moreOptionsWasOpenBeforeDrag = false }) } } ) + onUnmounted(() => { + resetMoreOptionsState() + }) + return { visible } } + +// External cleanup utility to be called when SelectionToolbox component unmounts +function resetMoreOptionsState() { + moreOptionsOpen.value = false + moreOptionsRestorePending.value = false + moreOptionsWasOpenBeforeDrag = false + moreOptionsSelectionSignature = null +} diff --git a/src/composables/element/useAbsolutePosition.ts b/src/composables/element/useAbsolutePosition.ts index 209deb6c6..8b8e9f302 100644 --- a/src/composables/element/useAbsolutePosition.ts +++ b/src/composables/element/useAbsolutePosition.ts @@ -2,7 +2,7 @@ import { type CSSProperties, ref, watch } from 'vue' import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion' import type { Size, Vector2 } from '@/lib/litegraph/src/litegraph' -import { useCanvasStore } from '@/stores/graphStore' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { useSettingStore } from '@/stores/settingStore' export interface PositionConfig { diff --git a/src/composables/graph/useCanvasInteractions.ts b/src/composables/graph/useCanvasInteractions.ts index 05d0b7e28..f28f902ae 100644 --- a/src/composables/graph/useCanvasInteractions.ts +++ b/src/composables/graph/useCanvasInteractions.ts @@ -1,7 +1,7 @@ import { computed } from 'vue' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { app } from '@/scripts/app' -import { useCanvasStore } from '@/stores/graphStore' import { useSettingStore } from '@/stores/settingStore' /** diff --git a/src/composables/graph/useCanvasRefresh.ts b/src/composables/graph/useCanvasRefresh.ts new file mode 100644 index 000000000..ab9240f88 --- /dev/null +++ b/src/composables/graph/useCanvasRefresh.ts @@ -0,0 +1,22 @@ +// call nextTick on all changeTracker +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' +import { useWorkflowStore } from '@/stores/workflowStore' + +/** + * Composable for refreshing nodes in the graph + * */ +export function useCanvasRefresh() { + const canvasStore = useCanvasStore() + const workflowStore = useWorkflowStore() + const refreshCanvas = () => { + canvasStore.canvas?.emitBeforeChange() + canvasStore.canvas?.setDirty(true, true) + canvasStore.canvas?.graph?.afterChange() + canvasStore.canvas?.emitAfterChange() + workflowStore.activeWorkflow?.changeTracker?.checkState() + } + + return { + refreshCanvas + } +} diff --git a/src/composables/graph/useFrameNodes.ts b/src/composables/graph/useFrameNodes.ts new file mode 100644 index 000000000..f8ad026d6 --- /dev/null +++ b/src/composables/graph/useFrameNodes.ts @@ -0,0 +1,30 @@ +import { computed } from 'vue' + +import { useSelectionState } from '@/composables/graph/useSelectionState' +import { LGraphGroup } from '@/lib/litegraph/src/litegraph' +import { useTitleEditorStore } from '@/renderer/core/canvas/canvasStore' +import { app } from '@/scripts/app' +import { useSettingStore } from '@/stores/settingStore' + +/** + * Composable encapsulating logic for framing currently selected nodes into a group. + */ +export function useFrameNodes() { + const settingStore = useSettingStore() + const titleEditorStore = useTitleEditorStore() + const { hasMultipleSelection } = useSelectionState() + + const canFrame = computed(() => hasMultipleSelection.value) + + const frameNodes = () => { + const { canvas } = app + if (!canvas.selectedItems?.size) return + const group = new LGraphGroup() + const padding = settingStore.get('Comfy.GroupSelectedNodes.Padding') + group.resizeTo(canvas.selectedItems, padding) + canvas.graph?.add(group) + titleEditorStore.titleEditorTarget = group + } + + return { frameNodes, canFrame } +} diff --git a/src/composables/graph/useGraphNodeManager.ts b/src/composables/graph/useGraphNodeManager.ts index 262d37639..ffa64ca2c 100644 --- a/src/composables/graph/useGraphNodeManager.ts +++ b/src/composables/graph/useGraphNodeManager.ts @@ -53,9 +53,11 @@ export interface VueNodeData { mode: number selected: boolean executing: boolean + subgraphId?: string | null widgets?: SafeWidgetData[] inputs?: unknown[] outputs?: unknown[] + hasErrors?: boolean flags?: { collapsed?: boolean } @@ -166,6 +168,11 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => { // Extract safe data from LiteGraph node for Vue consumption const extractVueNodeData = (node: LGraphNode): VueNodeData => { + // Determine subgraph ID - null for root graph, string for subgraphs + const subgraphId = + node.graph && 'id' in node.graph && node.graph !== node.graph.rootGraph + ? String(node.graph.id) + : null // Extract safe widget data const safeWidgets = node.widgets?.map((widget) => { try { @@ -201,13 +208,22 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => { } }) + const nodeType = + node.type || + node.constructor?.comfyClass || + node.constructor?.title || + node.constructor?.name || + 'Unknown' + return { id: String(node.id), - title: node.title || 'Untitled', - type: node.type || 'Unknown', + title: typeof node.title === 'string' ? node.title : '', + type: nodeType, mode: node.mode || 0, selected: node.selected || false, executing: false, // Will be updated separately based on execution state + subgraphId, + hasErrors: !!node.has_errors, widgets: safeWidgets, inputs: node.inputs ? [...node.inputs] : undefined, outputs: node.outputs ? [...node.outputs] : undefined, @@ -610,7 +626,7 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => { // Set up widget callbacks BEFORE extracting data (critical order) setupNodeWidgetCallbacks(node) - // Extract safe data for Vue + // Extract initial data for Vue (may be incomplete during graph configure) vueNodeData.set(id, extractVueNodeData(node)) // Set up reactive tracking state @@ -655,7 +671,11 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => { // Chain our callback with any existing onAfterGraphConfigured callback node.onAfterGraphConfigured = useChainCallback( node.onAfterGraphConfigured, - initializeVueNodeLayout + () => { + // Re-extract data now that configure() has populated title/slots/widgets/etc. + vueNodeData.set(id, extractVueNodeData(node)) + initializeVueNodeLayout() + } ) } else { // Not during workflow loading - initialize layout immediately diff --git a/src/composables/graph/useGroupMenuOptions.ts b/src/composables/graph/useGroupMenuOptions.ts new file mode 100644 index 000000000..11e3b84d1 --- /dev/null +++ b/src/composables/graph/useGroupMenuOptions.ts @@ -0,0 +1,199 @@ +import { useI18n } from 'vue-i18n' + +import { + LGraphEventMode, + type LGraphGroup, + type LGraphNode +} from '@/lib/litegraph/src/litegraph' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' +import { useSettingStore } from '@/stores/settingStore' +import { useWorkflowStore } from '@/stores/workflowStore' + +import { useCanvasRefresh } from './useCanvasRefresh' +import type { MenuOption } from './useMoreOptionsMenu' +import { useNodeCustomization } from './useNodeCustomization' + +/** + * Composable for group-related menu operations + */ +export function useGroupMenuOptions() { + const { t } = useI18n() + const canvasStore = useCanvasStore() + const workflowStore = useWorkflowStore() + const settingStore = useSettingStore() + const canvasRefresh = useCanvasRefresh() + const { shapeOptions, colorOptions, isLightTheme } = useNodeCustomization() + + const getFitGroupToNodesOption = (groupContext: LGraphGroup): MenuOption => ({ + label: 'Fit Group To Nodes', + icon: 'icon-[lucide--move-diagonal-2]', + action: () => { + try { + groupContext.recomputeInsideNodes() + } catch (e) { + console.warn('Failed to recompute group nodes:', e) + return + } + + const padding = settingStore.get('Comfy.GroupSelectedNodes.Padding') + groupContext.resizeTo(groupContext.children, padding) + groupContext.graph?.change() + canvasStore.canvas?.setDirty(true, true) + workflowStore.activeWorkflow?.changeTracker?.checkState() + } + }) + + const getGroupShapeOptions = ( + groupContext: LGraphGroup, + bump: () => void + ): MenuOption => ({ + label: t('contextMenu.Shape'), + icon: 'icon-[lucide--box]', + hasSubmenu: true, + submenu: shapeOptions.map((shape) => ({ + label: shape.localizedName, + action: () => { + const nodes = (groupContext.nodes || []) as LGraphNode[] + nodes.forEach((node) => (node.shape = shape.value)) + canvasRefresh.refreshCanvas() + bump() + } + })) + }) + + const getGroupColorOptions = ( + groupContext: LGraphGroup, + bump: () => void + ): MenuOption => ({ + label: t('contextMenu.Color'), + icon: 'icon-[lucide--palette]', + hasSubmenu: true, + submenu: colorOptions.map((colorOption) => ({ + label: colorOption.localizedName, + color: isLightTheme.value + ? colorOption.value.light + : colorOption.value.dark, + action: () => { + groupContext.color = isLightTheme.value + ? colorOption.value.light + : colorOption.value.dark + canvasRefresh.refreshCanvas() + bump() + } + })) + }) + + const getGroupModeOptions = ( + groupContext: LGraphGroup, + bump: () => void + ): MenuOption[] => { + const options: MenuOption[] = [] + + try { + groupContext.recomputeInsideNodes() + } catch (e) { + console.warn('Failed to recompute group nodes for mode options:', e) + return options + } + + const groupNodes = (groupContext.nodes || []) as LGraphNode[] + if (!groupNodes.length) return options + + // Check if all nodes have the same mode + let allSame = true + for (let i = 1; i < groupNodes.length; i++) { + if (groupNodes[i].mode !== groupNodes[0].mode) { + allSame = false + break + } + } + + const createModeAction = (label: string, mode: LGraphEventMode) => ({ + label: t(`selectionToolbox.${label}`), + icon: + mode === LGraphEventMode.BYPASS + ? 'icon-[lucide--ban]' + : mode === LGraphEventMode.NEVER + ? 'icon-[lucide--zap-off]' + : 'icon-[lucide--play]', + action: () => { + groupNodes.forEach((n) => { + n.mode = mode + }) + canvasStore.canvas?.setDirty(true, true) + groupContext.graph?.change() + workflowStore.activeWorkflow?.changeTracker?.checkState() + bump() + } + }) + + if (allSame) { + const current = groupNodes[0].mode + switch (current) { + case LGraphEventMode.ALWAYS: + options.push( + createModeAction('Set Group Nodes to Never', LGraphEventMode.NEVER) + ) + options.push( + createModeAction('Bypass Group Nodes', LGraphEventMode.BYPASS) + ) + break + case LGraphEventMode.NEVER: + options.push( + createModeAction( + 'Set Group Nodes to Always', + LGraphEventMode.ALWAYS + ) + ) + options.push( + createModeAction('Bypass Group Nodes', LGraphEventMode.BYPASS) + ) + break + case LGraphEventMode.BYPASS: + options.push( + createModeAction( + 'Set Group Nodes to Always', + LGraphEventMode.ALWAYS + ) + ) + options.push( + createModeAction('Set Group Nodes to Never', LGraphEventMode.NEVER) + ) + break + default: + options.push( + createModeAction( + 'Set Group Nodes to Always', + LGraphEventMode.ALWAYS + ) + ) + options.push( + createModeAction('Set Group Nodes to Never', LGraphEventMode.NEVER) + ) + options.push( + createModeAction('Bypass Group Nodes', LGraphEventMode.BYPASS) + ) + break + } + } else { + options.push( + createModeAction('Set Group Nodes to Always', LGraphEventMode.ALWAYS) + ) + options.push( + createModeAction('Set Group Nodes to Never', LGraphEventMode.NEVER) + ) + options.push( + createModeAction('Bypass Group Nodes', LGraphEventMode.BYPASS) + ) + } + + return options + } + + return { + getFitGroupToNodesOption, + getGroupShapeOptions, + getGroupColorOptions, + getGroupModeOptions + } +} diff --git a/src/composables/graph/useImageMenuOptions.ts b/src/composables/graph/useImageMenuOptions.ts new file mode 100644 index 000000000..3956edefa --- /dev/null +++ b/src/composables/graph/useImageMenuOptions.ts @@ -0,0 +1,108 @@ +import { useI18n } from 'vue-i18n' + +import { downloadFile } from '@/base/common/downloadUtil' +import { useCommandStore } from '@/stores/commandStore' + +import type { MenuOption } from './useMoreOptionsMenu' + +/** + * Composable for image-related menu operations + */ +export function useImageMenuOptions() { + const { t } = useI18n() + + const openMaskEditor = () => { + const commandStore = useCommandStore() + void commandStore.execute('Comfy.MaskEditor.OpenMaskEditor') + } + + const openImage = (node: any) => { + if (!node?.imgs?.length) return + const img = node.imgs[node.imageIndex ?? 0] + if (!img) return + const url = new URL(img.src) + url.searchParams.delete('preview') + window.open(url.toString(), '_blank') + } + + const copyImage = async (node: any) => { + if (!node?.imgs?.length) return + const img = node.imgs[node.imageIndex ?? 0] + if (!img) return + + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + if (!ctx) return + + canvas.width = img.naturalWidth + canvas.height = img.naturalHeight + ctx.drawImage(img, 0, 0) + + try { + const blob = await new Promise((resolve) => { + canvas.toBlob(resolve, 'image/png') + }) + + if (!blob) { + console.warn('Failed to create image blob') + return + } + + // Check if clipboard API is available + if (!navigator.clipboard?.write) { + console.warn('Clipboard API not available') + return + } + + await navigator.clipboard.write([ + new ClipboardItem({ 'image/png': blob }) + ]) + } catch (error) { + console.error('Failed to copy image to clipboard:', error) + } + } + + const saveImage = (node: any) => { + if (!node?.imgs?.length) return + const img = node.imgs[node.imageIndex ?? 0] + if (!img) return + + try { + const url = new URL(img.src) + url.searchParams.delete('preview') + downloadFile(url.toString()) + } catch (error) { + console.error('Failed to save image:', error) + } + } + + const getImageMenuOptions = (node: any): MenuOption[] => { + if (!node?.imgs?.length) return [] + + return [ + { + label: t('contextMenu.Open in Mask Editor'), + action: () => openMaskEditor() + }, + { + label: t('contextMenu.Open Image'), + icon: 'icon-[lucide--external-link]', + action: () => openImage(node) + }, + { + label: t('contextMenu.Copy Image'), + icon: 'icon-[lucide--copy]', + action: () => copyImage(node) + }, + { + label: t('contextMenu.Save Image'), + icon: 'icon-[lucide--download]', + action: () => saveImage(node) + } + ] + } + + return { + getImageMenuOptions + } +} diff --git a/src/composables/graph/useMoreOptionsMenu.ts b/src/composables/graph/useMoreOptionsMenu.ts new file mode 100644 index 000000000..71c4a9254 --- /dev/null +++ b/src/composables/graph/useMoreOptionsMenu.ts @@ -0,0 +1,186 @@ +import { computed, ref } from 'vue' + +import { type LGraphGroup } from '@/lib/litegraph/src/litegraph' +import { isLGraphGroup } from '@/utils/litegraphUtil' + +import { useGroupMenuOptions } from './useGroupMenuOptions' +import { useImageMenuOptions } from './useImageMenuOptions' +import { useNodeMenuOptions } from './useNodeMenuOptions' +import { useSelectionMenuOptions } from './useSelectionMenuOptions' +import { useSelectionState } from './useSelectionState' + +export interface MenuOption { + label?: string + icon?: string + shortcut?: string + hasSubmenu?: boolean + type?: 'divider' + action?: () => void + submenu?: SubMenuOption[] + badge?: BadgeVariant +} + +export interface SubMenuOption { + label: string + icon?: string + action: () => void + color?: string +} + +export enum BadgeVariant { + NEW = 'new', + DEPRECATED = 'deprecated' +} + +/** + * Composable for managing the More Options menu configuration + * Refactored to use smaller, focused composables for better maintainability + */ +export function useMoreOptionsMenu() { + const { + selectedItems, + selectedNodes, + nodeDef, + showNodeHelp, + hasSubgraphs: hasSubgraphsComputed, + hasImageNode, + hasOutputNodesSelected, + hasMultipleSelection, + computeSelectionFlags + } = useSelectionState() + + const { getImageMenuOptions } = useImageMenuOptions() + const { + getNodeInfoOption, + getAdjustSizeOption, + getNodeVisualOptions, + getPinOption, + getBypassOption, + getRunBranchOption + } = useNodeMenuOptions() + const { + getFitGroupToNodesOption, + getGroupShapeOptions, + getGroupColorOptions, + getGroupModeOptions + } = useGroupMenuOptions() + const { + getBasicSelectionOptions, + getSubgraphOptions, + getMultipleNodesOptions, + getDeleteOption, + getAlignmentOptions + } = useSelectionMenuOptions() + + const hasSubgraphs = hasSubgraphsComputed + const hasMultipleNodes = hasMultipleSelection + + // Internal version to force menu rebuild after state mutations + const optionsVersion = ref(0) + const bump = () => { + optionsVersion.value++ + } + + const menuOptions = computed((): MenuOption[] => { + // Reference selection flags to ensure re-computation when they change + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + optionsVersion.value + const states = computeSelectionFlags() + + // Detect single group selection context (and no nodes explicitly selected) + const selectedGroups = selectedItems.value.filter( + isLGraphGroup + ) as LGraphGroup[] + const groupContext: LGraphGroup | null = + selectedGroups.length === 1 && selectedNodes.value.length === 0 + ? selectedGroups[0] + : null + const hasSubgraphsSelected = hasSubgraphs.value + const options: MenuOption[] = [] + + // Section 1: Basic selection operations (Rename, Copy, Duplicate) + options.push(...getBasicSelectionOptions()) + options.push({ type: 'divider' }) + + // Section 2: Node Info & Size Adjustment + if (nodeDef.value) { + options.push(getNodeInfoOption(showNodeHelp)) + } + + if (groupContext) { + options.push(getFitGroupToNodesOption(groupContext)) + } else { + options.push(getAdjustSizeOption()) + } + + // Section 3: Collapse/Shape/Color + if (groupContext) { + // Group context: Shape, Color, Divider + options.push(getGroupShapeOptions(groupContext, bump)) + options.push(getGroupColorOptions(groupContext, bump)) + options.push({ type: 'divider' }) + } else { + // Node context: Expand/Minimize, Shape, Color, Divider + options.push(...getNodeVisualOptions(states, bump)) + options.push({ type: 'divider' }) + } + + // Section 4: Image operations (if image node) + if (hasImageNode.value && selectedNodes.value.length > 0) { + options.push(...getImageMenuOptions(selectedNodes.value[0])) + } + + // Section 5: Subgraph operations + options.push(...getSubgraphOptions(hasSubgraphsSelected)) + + // Section 6: Multiple nodes operations + if (hasMultipleNodes.value) { + options.push(...getMultipleNodesOptions()) + } + + // Section 7: Divider + options.push({ type: 'divider' }) + + // Section 8: Pin/Unpin (non-group only) + if (!groupContext) { + options.push(getPinOption(states, bump)) + } + + // Section 9: Alignment (if multiple nodes) + if (hasMultipleNodes.value) { + options.push(...getAlignmentOptions()) + } + + // Section 10: Mode operations + if (groupContext) { + // Group mode operations + options.push(...getGroupModeOptions(groupContext, bump)) + } else { + // Bypass option for nodes + options.push(getBypassOption(states, bump)) + } + + // Section 11: Run Branch (if output nodes) + if (hasOutputNodesSelected.value) { + options.push(getRunBranchOption()) + } + + // Section 12: Final divider and Delete + options.push({ type: 'divider' }) + options.push(getDeleteOption()) + + return options + }) + + // Computed property to get only menu items with submenus + const menuOptionsWithSubmenu = computed(() => + menuOptions.value.filter((option) => option.hasSubmenu && option.submenu) + ) + + return { + menuOptions, + menuOptionsWithSubmenu, + bump, + hasSubgraphs + } +} diff --git a/src/composables/graph/useNodeArrangement.ts b/src/composables/graph/useNodeArrangement.ts new file mode 100644 index 000000000..df9568b19 --- /dev/null +++ b/src/composables/graph/useNodeArrangement.ts @@ -0,0 +1,106 @@ +import { useI18n } from 'vue-i18n' + +import type { Direction } from '@/lib/litegraph/src/interfaces' +import { alignNodes, distributeNodes } from '@/lib/litegraph/src/utils/arrange' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' +import { isLGraphNode } from '@/utils/litegraphUtil' + +import { useCanvasRefresh } from './useCanvasRefresh' + +interface AlignOption { + name: string + localizedName: string + value: Direction + icon: string +} + +interface DistributeOption { + name: string + localizedName: string + value: boolean // true for horizontal, false for vertical + icon: string +} + +/** + * Composable for handling node alignment and distribution + */ +export function useNodeArrangement() { + const { t } = useI18n() + const canvasStore = useCanvasStore() + const canvasRefresh = useCanvasRefresh() + const alignOptions: AlignOption[] = [ + { + name: 'top', + localizedName: t('contextMenu.Top'), + value: 'top', + icon: 'icon-[lucide--align-start-vertical]' + }, + { + name: 'bottom', + localizedName: t('contextMenu.Bottom'), + value: 'bottom', + icon: 'icon-[lucide--align-end-vertical]' + }, + { + name: 'left', + localizedName: t('contextMenu.Left'), + value: 'left', + icon: 'icon-[lucide--align-start-horizontal]' + }, + { + name: 'right', + localizedName: t('contextMenu.Right'), + value: 'right', + icon: 'icon-[lucide--align-end-horizontal]' + } + ] + + const distributeOptions: DistributeOption[] = [ + { + name: 'horizontal', + localizedName: t('contextMenu.Horizontal'), + value: true, + icon: 'icon-[lucide--align-center-horizontal]' + }, + { + name: 'vertical', + localizedName: t('contextMenu.Vertical'), + value: false, + icon: 'icon-[lucide--align-center-vertical]' + } + ] + + const applyAlign = (alignOption: AlignOption) => { + const selectedNodes = Array.from(canvasStore.selectedItems).filter((item) => + isLGraphNode(item) + ) + + if (selectedNodes.length === 0) { + return + } + + alignNodes(selectedNodes, alignOption.value) + + canvasRefresh.refreshCanvas() + } + + const applyDistribute = (distributeOption: DistributeOption) => { + const selectedNodes = Array.from(canvasStore.selectedItems).filter((item) => + isLGraphNode(item) + ) + + if (selectedNodes.length < 2) { + return + } + + distributeNodes(selectedNodes, distributeOption.value) + canvasRefresh.refreshCanvas() + } + + return { + alignOptions, + distributeOptions, + applyAlign, + applyDistribute + } +} diff --git a/src/composables/graph/useNodeCustomization.ts b/src/composables/graph/useNodeCustomization.ts new file mode 100644 index 000000000..5a8032657 --- /dev/null +++ b/src/composables/graph/useNodeCustomization.ts @@ -0,0 +1,167 @@ +import { computed } from 'vue' +import { useI18n } from 'vue-i18n' + +import { + LGraphCanvas, + LGraphNode, + LiteGraph, + RenderShape, + isColorable +} from '@/lib/litegraph/src/litegraph' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' +import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore' +import { adjustColor } from '@/utils/colorUtil' + +import { useCanvasRefresh } from './useCanvasRefresh' + +interface ColorOption { + name: string + localizedName: string + value: { + dark: string + light: string + } +} + +interface ShapeOption { + name: string + localizedName: string + value: RenderShape +} + +/** + * Composable for handling node color and shape customization + */ +export function useNodeCustomization() { + const { t } = useI18n() + const canvasStore = useCanvasStore() + const colorPaletteStore = useColorPaletteStore() + const canvasRefresh = useCanvasRefresh() + const isLightTheme = computed( + () => colorPaletteStore.completedActivePalette.light_theme + ) + + const toLightThemeColor = (color: string) => + adjustColor(color, { lightness: 0.5 }) + + // Color options + const NO_COLOR_OPTION: ColorOption = { + name: 'noColor', + localizedName: t('color.noColor'), + value: { + dark: LiteGraph.NODE_DEFAULT_BGCOLOR, + light: toLightThemeColor(LiteGraph.NODE_DEFAULT_BGCOLOR) + } + } + + const colorOptions: ColorOption[] = [ + NO_COLOR_OPTION, + ...Object.entries(LGraphCanvas.node_colors).map(([name, color]) => ({ + name, + localizedName: t(`color.${name}`), + value: { + dark: color.bgcolor, + light: toLightThemeColor(color.bgcolor) + } + })) + ] + + // Shape options + const shapeOptions: ShapeOption[] = [ + { + name: 'default', + localizedName: t('shape.default'), + value: RenderShape.ROUND + }, + { + name: 'box', + localizedName: t('shape.box'), + value: RenderShape.BOX + }, + { + name: 'card', + localizedName: t('shape.CARD'), + value: RenderShape.CARD + } + ] + + const applyColor = (colorOption: ColorOption | null) => { + const colorName = colorOption?.name ?? NO_COLOR_OPTION.name + const canvasColorOption = + colorName === NO_COLOR_OPTION.name + ? null + : LGraphCanvas.node_colors[colorName] + + for (const item of canvasStore.selectedItems) { + if (isColorable(item)) { + item.setColorOption(canvasColorOption) + } + } + + canvasRefresh.refreshCanvas() + } + + const applyShape = (shapeOption: ShapeOption) => { + const selectedNodes = Array.from(canvasStore.selectedItems).filter( + (item): item is LGraphNode => item instanceof LGraphNode + ) + + if (selectedNodes.length === 0) { + return + } + + selectedNodes.forEach((node) => { + node.shape = shapeOption.value + }) + + canvasRefresh.refreshCanvas() + } + + const getCurrentColor = (): ColorOption | null => { + const selectedItems = Array.from(canvasStore.selectedItems) + if (selectedItems.length === 0) return null + + // Get color from first colorable item + const firstColorableItem = selectedItems.find((item) => isColorable(item)) + if (!firstColorableItem || !isColorable(firstColorableItem)) return null + + // Get the current color option from the colorable item + const currentColorOption = firstColorableItem.getColorOption() + const currentBgColor = currentColorOption?.bgcolor ?? null + + // Find matching color option + return ( + colorOptions.find( + (option) => + option.value.dark === currentBgColor || + option.value.light === currentBgColor + ) ?? NO_COLOR_OPTION + ) + } + + const getCurrentShape = (): ShapeOption | null => { + const selectedNodes = Array.from(canvasStore.selectedItems).filter( + (item): item is LGraphNode => item instanceof LGraphNode + ) + + if (selectedNodes.length === 0) return null + + const firstNode = selectedNodes[0] + const currentShape = firstNode.shape ?? RenderShape.ROUND + + return ( + shapeOptions.find((option) => option.value === currentShape) ?? + shapeOptions[0] + ) + } + + return { + colorOptions, + shapeOptions, + applyColor, + applyShape, + getCurrentColor, + getCurrentShape, + isLightTheme + } +} diff --git a/src/composables/graph/useNodeMenuOptions.ts b/src/composables/graph/useNodeMenuOptions.ts new file mode 100644 index 000000000..c1d291a4d --- /dev/null +++ b/src/composables/graph/useNodeMenuOptions.ts @@ -0,0 +1,128 @@ +import { computed } from 'vue' +import { useI18n } from 'vue-i18n' + +import type { MenuOption } from './useMoreOptionsMenu' +import { useNodeCustomization } from './useNodeCustomization' +import { useSelectedNodeActions } from './useSelectedNodeActions' +import type { NodeSelectionState } from './useSelectionState' + +/** + * Composable for node-related menu operations + */ +export function useNodeMenuOptions() { + const { t } = useI18n() + const { shapeOptions, applyShape, applyColor, colorOptions, isLightTheme } = + useNodeCustomization() + const { + adjustNodeSize, + toggleNodeCollapse, + toggleNodePin, + toggleNodeBypass, + runBranch + } = useSelectedNodeActions() + + const shapeSubmenu = computed(() => + shapeOptions.map((shape) => ({ + label: shape.localizedName, + action: () => applyShape(shape) + })) + ) + + const colorSubmenu = computed(() => { + return colorOptions.map((colorOption) => ({ + label: colorOption.localizedName, + color: isLightTheme.value + ? colorOption.value.light + : colorOption.value.dark, + action: () => + applyColor(colorOption.name === 'noColor' ? null : colorOption) + })) + }) + + const getAdjustSizeOption = (): MenuOption => ({ + label: t('contextMenu.Adjust Size'), + icon: 'icon-[lucide--move-diagonal-2]', + action: adjustNodeSize + }) + + const getNodeVisualOptions = ( + states: NodeSelectionState, + bump: () => void + ): MenuOption[] => [ + { + label: states.collapsed + ? t('contextMenu.Expand Node') + : t('contextMenu.Minimize Node'), + icon: states.collapsed + ? 'icon-[lucide--maximize-2]' + : 'icon-[lucide--minimize-2]', + action: () => { + toggleNodeCollapse() + bump() + } + }, + { + label: t('contextMenu.Shape'), + icon: 'icon-[lucide--box]', + hasSubmenu: true, + submenu: shapeSubmenu.value, + action: () => {} + }, + { + label: t('contextMenu.Color'), + icon: 'icon-[lucide--palette]', + hasSubmenu: true, + submenu: colorSubmenu.value, + action: () => {} + } + ] + + const getPinOption = ( + states: NodeSelectionState, + bump: () => void + ): MenuOption => ({ + label: states.pinned ? t('contextMenu.Unpin') : t('contextMenu.Pin'), + icon: states.pinned ? 'icon-[lucide--pin-off]' : 'icon-[lucide--pin]', + action: () => { + toggleNodePin() + bump() + } + }) + + const getBypassOption = ( + states: NodeSelectionState, + bump: () => void + ): MenuOption => ({ + label: states.bypassed + ? t('contextMenu.Remove Bypass') + : t('contextMenu.Bypass'), + icon: states.bypassed ? 'icon-[lucide--zap-off]' : 'icon-[lucide--ban]', + shortcut: 'Ctrl+B', + action: () => { + toggleNodeBypass() + bump() + } + }) + + const getRunBranchOption = (): MenuOption => ({ + label: t('contextMenu.Run Branch'), + icon: 'icon-[lucide--play]', + action: runBranch + }) + + const getNodeInfoOption = (showNodeHelp: () => void): MenuOption => ({ + label: t('contextMenu.Node Info'), + icon: 'icon-[lucide--info]', + action: showNodeHelp + }) + + return { + getNodeInfoOption, + getAdjustSizeOption, + getNodeVisualOptions, + getPinOption, + getBypassOption, + getRunBranchOption, + colorSubmenu + } +} diff --git a/src/composables/graph/useSelectedNodeActions.ts b/src/composables/graph/useSelectedNodeActions.ts new file mode 100644 index 000000000..9c37800f6 --- /dev/null +++ b/src/composables/graph/useSelectedNodeActions.ts @@ -0,0 +1,68 @@ +import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems' +import { LGraphEventMode } from '@/lib/litegraph/src/litegraph' +import { app } from '@/scripts/app' +import { useCommandStore } from '@/stores/commandStore' +import { useWorkflowStore } from '@/stores/workflowStore' +import { filterOutputNodes } from '@/utils/nodeFilterUtil' + +/** + * Composable for handling node information and utility operations + */ +export function useSelectedNodeActions() { + const { getSelectedNodes, toggleSelectedNodesMode } = + useSelectedLiteGraphItems() + const commandStore = useCommandStore() + const workflowStore = useWorkflowStore() + + const adjustNodeSize = () => { + const selectedNodes = getSelectedNodes() + + selectedNodes.forEach((node) => { + const optimalSize = node.computeSize() + node.setSize([optimalSize[0], optimalSize[1]]) + }) + + app.canvas.setDirty(true, true) + workflowStore.activeWorkflow?.changeTracker?.checkState() + } + + const toggleNodeCollapse = () => { + const selectedNodes = getSelectedNodes() + selectedNodes.forEach((node) => { + node.collapse() + }) + + app.canvas.setDirty(true, true) + workflowStore.activeWorkflow?.changeTracker?.checkState() + } + + const toggleNodePin = () => { + const selectedNodes = getSelectedNodes() + selectedNodes.forEach((node) => { + node.pin(!node.pinned) + }) + + app.canvas.setDirty(true, true) + workflowStore.activeWorkflow?.changeTracker?.checkState() + } + + const toggleNodeBypass = () => { + toggleSelectedNodesMode(LGraphEventMode.BYPASS) + app.canvas.setDirty(true, true) + } + + const runBranch = async () => { + const selectedNodes = getSelectedNodes() + const selectedOutputNodes = filterOutputNodes(selectedNodes) + if (selectedOutputNodes.length === 0) return + await commandStore.execute('Comfy.QueueSelectedOutputNodes') + } + + return { + adjustNodeSize, + toggleNodeCollapse, + toggleNodePin, + toggleNodeBypass, + runBranch + } +} diff --git a/src/composables/graph/useSelectionMenuOptions.ts b/src/composables/graph/useSelectionMenuOptions.ts new file mode 100644 index 000000000..0a90df1f8 --- /dev/null +++ b/src/composables/graph/useSelectionMenuOptions.ts @@ -0,0 +1,147 @@ +import { computed } from 'vue' +import { useI18n } from 'vue-i18n' + +import { useCommandStore } from '@/stores/commandStore' + +import { useFrameNodes } from './useFrameNodes' +import { BadgeVariant, type MenuOption } from './useMoreOptionsMenu' +import { useNodeArrangement } from './useNodeArrangement' +import { useSelectionOperations } from './useSelectionOperations' +import { useSubgraphOperations } from './useSubgraphOperations' + +/** + * Composable for selection-related menu operations + */ +export function useSelectionMenuOptions() { + const { t } = useI18n() + const { + copySelection, + duplicateSelection, + deleteSelection, + renameSelection + } = useSelectionOperations() + + const { alignOptions, distributeOptions, applyAlign, applyDistribute } = + useNodeArrangement() + + const { convertToSubgraph, unpackSubgraph, addSubgraphToLibrary } = + useSubgraphOperations() + + const { frameNodes } = useFrameNodes() + + const alignSubmenu = computed(() => + alignOptions.map((align) => ({ + label: align.localizedName, + icon: align.icon, + action: () => applyAlign(align) + })) + ) + + const distributeSubmenu = computed(() => + distributeOptions.map((distribute) => ({ + label: distribute.localizedName, + icon: distribute.icon, + action: () => applyDistribute(distribute) + })) + ) + + const getBasicSelectionOptions = (): MenuOption[] => [ + { + label: t('contextMenu.Rename'), + action: renameSelection + }, + { + label: t('contextMenu.Copy'), + shortcut: 'Ctrl+C', + action: copySelection + }, + { + label: t('contextMenu.Duplicate'), + shortcut: 'Ctrl+D', + action: duplicateSelection + } + ] + + const getSubgraphOptions = (hasSubgraphs: boolean): MenuOption[] => { + if (hasSubgraphs) { + return [ + { + label: t('contextMenu.Add Subgraph to Library'), + icon: 'icon-[lucide--folder-plus]', + action: addSubgraphToLibrary + }, + { + label: t('contextMenu.Unpack Subgraph'), + icon: 'icon-[lucide--expand]', + action: unpackSubgraph + } + ] + } else { + return [ + { + label: t('contextMenu.Convert to Subgraph'), + icon: 'icon-[lucide--shrink]', + action: convertToSubgraph, + badge: BadgeVariant.NEW + } + ] + } + } + + const getMultipleNodesOptions = (): MenuOption[] => { + const convertToGroupNodes = () => { + const commandStore = useCommandStore() + void commandStore.execute( + 'Comfy.GroupNode.ConvertSelectedNodesToGroupNode' + ) + } + + return [ + { + label: t('contextMenu.Convert to Group Node'), + icon: 'icon-[lucide--group]', + action: convertToGroupNodes, + badge: BadgeVariant.DEPRECATED + }, + { + label: t('g.frameNodes'), + icon: 'icon-[lucide--frame]', + action: frameNodes + } + ] + } + + const getAlignmentOptions = (): MenuOption[] => [ + { + label: t('contextMenu.Align Selected To'), + icon: 'icon-[lucide--align-start-horizontal]', + hasSubmenu: true, + submenu: alignSubmenu.value, + action: () => {} + }, + { + label: t('contextMenu.Distribute Nodes'), + icon: 'icon-[lucide--align-center-horizontal]', + hasSubmenu: true, + submenu: distributeSubmenu.value, + action: () => {} + } + ] + + const getDeleteOption = (): MenuOption => ({ + label: t('contextMenu.Delete'), + icon: 'icon-[lucide--trash-2]', + shortcut: 'Delete', + action: deleteSelection + }) + + return { + getBasicSelectionOptions, + getSubgraphOptions, + getMultipleNodesOptions, + getDeleteOption, + getAlignmentOptions, + alignSubmenu, + distributeSubmenu + } +} diff --git a/src/composables/graph/useSelectionOperations.ts b/src/composables/graph/useSelectionOperations.ts new file mode 100644 index 000000000..945d05409 --- /dev/null +++ b/src/composables/graph/useSelectionOperations.ts @@ -0,0 +1,168 @@ +// import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems' // Unused for now +import { t } from '@/i18n' +import { LGraphNode } from '@/lib/litegraph/src/litegraph' +import { + useCanvasStore, + useTitleEditorStore +} from '@/renderer/core/canvas/canvasStore' +import { app } from '@/scripts/app' +import { useDialogService } from '@/services/dialogService' +import { useToastStore } from '@/stores/toastStore' +import { useWorkflowStore } from '@/stores/workflowStore' + +/** + * Composable for handling basic selection operations like copy, paste, duplicate, delete, rename + */ +export function useSelectionOperations() { + // const { getSelectedNodes } = useSelectedLiteGraphItems() // Unused for now + const canvasStore = useCanvasStore() + const toastStore = useToastStore() + const dialogService = useDialogService() + const titleEditorStore = useTitleEditorStore() + const workflowStore = useWorkflowStore() + + const copySelection = () => { + const canvas = app.canvas + if (!canvas.selectedItems || canvas.selectedItems.size === 0) { + toastStore.add({ + severity: 'warn', + summary: t('g.nothingToCopy'), + detail: t('g.selectItemsToCopy'), + life: 3000 + }) + return + } + + canvas.copyToClipboard() + toastStore.add({ + severity: 'success', + summary: t('g.copied'), + detail: t('g.itemsCopiedToClipboard'), + life: 2000 + }) + } + + const pasteSelection = () => { + const canvas = app.canvas + canvas.pasteFromClipboard({ connectInputs: false }) + + // Trigger change tracking + workflowStore.activeWorkflow?.changeTracker?.checkState() + } + + const duplicateSelection = () => { + const canvas = app.canvas + if (!canvas.selectedItems || canvas.selectedItems.size === 0) { + toastStore.add({ + severity: 'warn', + summary: t('g.nothingToDuplicate'), + detail: t('g.selectItemsToDuplicate'), + life: 3000 + }) + return + } + + // Copy current selection + canvas.copyToClipboard() + + // Clear selection to avoid confusion + canvas.selectedItems.clear() + canvasStore.updateSelectedItems() + + // Paste to create duplicates + canvas.pasteFromClipboard({ connectInputs: false }) + + // Trigger change tracking + workflowStore.activeWorkflow?.changeTracker?.checkState() + } + + const deleteSelection = () => { + const canvas = app.canvas + if (!canvas.selectedItems || canvas.selectedItems.size === 0) { + toastStore.add({ + severity: 'warn', + summary: t('g.nothingToDelete'), + detail: t('g.selectItemsToDelete'), + life: 3000 + }) + return + } + + canvas.deleteSelected() + canvas.setDirty(true, true) + + // Trigger change tracking + workflowStore.activeWorkflow?.changeTracker?.checkState() + } + + const renameSelection = async () => { + const selectedItems = Array.from(canvasStore.selectedItems) + + // Handle single node selection + if (selectedItems.length === 1) { + const item = selectedItems[0] + + // For nodes, use the title editor + if (item instanceof LGraphNode) { + titleEditorStore.titleEditorTarget = item + return + } + + // For other items like groups, use prompt dialog + const currentTitle = 'title' in item ? (item.title as string) : '' + const newTitle = await dialogService.prompt({ + title: t('g.rename'), + message: t('g.enterNewName'), + defaultValue: currentTitle + }) + + if (newTitle && newTitle !== currentTitle) { + if ('title' in item) { + // Type-safe assignment for items with title property + const titledItem = item as { title: string } + titledItem.title = newTitle + app.canvas.setDirty(true, true) + workflowStore.activeWorkflow?.changeTracker?.checkState() + } + } + return + } + + // Handle multiple selections - batch rename + if (selectedItems.length > 1) { + const baseTitle = await dialogService.prompt({ + title: t('g.batchRename'), + message: t('g.enterBaseName'), + defaultValue: 'Item' + }) + + if (baseTitle) { + selectedItems.forEach((item, index) => { + if ('title' in item) { + // Type-safe assignment for items with title property + const titledItem = item as { title: string } + titledItem.title = `${baseTitle} ${index + 1}` + } + }) + app.canvas.setDirty(true, true) + workflowStore.activeWorkflow?.changeTracker?.checkState() + } + return + } + + toastStore.add({ + severity: 'warn', + summary: t('g.nothingToRename'), + detail: t('g.selectItemsToRename'), + life: 3000 + }) + } + + return { + copySelection, + pasteSelection, + duplicateSelection, + deleteSelection, + renameSelection + } +} diff --git a/src/composables/graph/useSelectionState.ts b/src/composables/graph/useSelectionState.ts new file mode 100644 index 000000000..0ff3d0c5c --- /dev/null +++ b/src/composables/graph/useSelectionState.ts @@ -0,0 +1,146 @@ +import { storeToRefs } from 'pinia' +import { computed } from 'vue' + +import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab' +import { + LGraphEventMode, + LGraphNode, + SubgraphNode +} from '@/lib/litegraph/src/litegraph' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' +import { useNodeDefStore } from '@/stores/nodeDefStore' +import { useSettingStore } from '@/stores/settingStore' +import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore' +import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore' +import { isImageNode, isLGraphNode, isLoad3dNode } from '@/utils/litegraphUtil' +import { filterOutputNodes } from '@/utils/nodeFilterUtil' + +export interface NodeSelectionState { + collapsed: boolean + pinned: boolean + bypassed: boolean +} + +/** + * Centralized computed selection state + shared helper actions to avoid duplication + * between selection toolbox, context menus, and other UI affordances. + */ +export function useSelectionState() { + const canvasStore = useCanvasStore() + const nodeDefStore = useNodeDefStore() + const sidebarTabStore = useSidebarTabStore() + const nodeHelpStore = useNodeHelpStore() + const { id: nodeLibraryTabId } = useNodeLibrarySidebarTab() + + const { selectedItems } = storeToRefs(canvasStore) + + const selectedNodes = computed(() => { + return selectedItems.value.filter((i: unknown) => + isLGraphNode(i) + ) as LGraphNode[] + }) + + const nodeDef = computed(() => { + if (selectedNodes.value.length !== 1) return null + return nodeDefStore.fromLGraphNode(selectedNodes.value[0]) + }) + + const hasAnySelection = computed(() => selectedItems.value.length > 0) + const hasSingleSelection = computed(() => selectedItems.value.length === 1) + const hasMultipleSelection = computed(() => selectedItems.value.length > 1) + + const isSingleNode = computed( + () => hasSingleSelection.value && isLGraphNode(selectedItems.value[0]) + ) + const isSingleSubgraph = computed( + () => + isSingleNode.value && + (selectedItems.value[0] as LGraphNode)?.isSubgraphNode?.() + ) + const isSingleImageNode = computed( + () => + isSingleNode.value && isImageNode(selectedItems.value[0] as LGraphNode) + ) + + const hasSubgraphs = computed(() => + selectedItems.value.some((i: unknown) => i instanceof SubgraphNode) + ) + + const hasAny3DNodeSelected = computed(() => { + const enable3DViewer = useSettingStore().get('Comfy.Load3D.3DViewerEnable') + return ( + selectedNodes.value.length === 1 && + selectedNodes.value.some(isLoad3dNode) && + enable3DViewer + ) + }) + + const hasImageNode = computed(() => isSingleImageNode.value) + const hasOutputNodesSelected = computed( + () => filterOutputNodes(selectedNodes.value).length > 0 + ) + + // Helper function to compute selection flags (reused by both computed and function) + const computeSelectionStatesFromNodes = ( + nodes: LGraphNode[] + ): NodeSelectionState => { + if (!nodes.length) + return { collapsed: false, pinned: false, bypassed: false } + return { + collapsed: nodes.some((n) => n.flags?.collapsed), + pinned: nodes.some((n) => n.pinned), + bypassed: nodes.some((n) => n.mode === LGraphEventMode.BYPASS) + } + } + + const selectedNodesStates = computed(() => + computeSelectionStatesFromNodes(selectedNodes.value) + ) + + // On-demand computation (non-reactive) so callers can fetch fresh flags + const computeSelectionFlags = (): NodeSelectionState => + computeSelectionStatesFromNodes(selectedNodes.value) + + /** Toggle node help sidebar/panel for the single selected node (if any). */ + const showNodeHelp = () => { + const def = nodeDef.value + if (!def) return + + const isSidebarActive = + sidebarTabStore.activeSidebarTabId === nodeLibraryTabId + const currentHelpNode: any = nodeHelpStore.currentHelpNode + const isSameNodeHelpOpen = + isSidebarActive && + nodeHelpStore.isHelpOpen && + currentHelpNode && + currentHelpNode.nodePath === def.nodePath + + if (isSameNodeHelpOpen) { + nodeHelpStore.closeHelp() + sidebarTabStore.toggleSidebarTab(nodeLibraryTabId) + return + } + + if (!isSidebarActive) sidebarTabStore.toggleSidebarTab(nodeLibraryTabId) + nodeHelpStore.openHelp(def) + } + + return { + selectedItems, + selectedNodes, + nodeDef, + showNodeHelp, + hasAny3DNodeSelected, + hasAnySelection, + hasSingleSelection, + hasMultipleSelection, + isSingleNode, + isSingleSubgraph, + isSingleImageNode, + hasSubgraphs, + hasImageNode, + hasOutputNodesSelected, + selectedNodesStates, + computeSelectionFlags + } +} diff --git a/src/composables/graph/useSubgraphOperations.ts b/src/composables/graph/useSubgraphOperations.ts new file mode 100644 index 000000000..47ebe983d --- /dev/null +++ b/src/composables/graph/useSubgraphOperations.ts @@ -0,0 +1,131 @@ +import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems' +import { SubgraphNode } from '@/lib/litegraph/src/litegraph' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' +import { useNodeOutputStore } from '@/stores/imagePreviewStore' +import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore' +import { useNodeDefStore } from '@/stores/nodeDefStore' +import { useWorkflowStore } from '@/stores/workflowStore' +import { isLGraphNode } from '@/utils/litegraphUtil' + +/** + * Composable for handling subgraph-related operations + */ +export function useSubgraphOperations() { + const { getSelectedNodes } = useSelectedLiteGraphItems() + const canvasStore = useCanvasStore() + const workflowStore = useWorkflowStore() + const nodeOutputStore = useNodeOutputStore() + const nodeDefStore = useNodeDefStore() + const nodeBookmarkStore = useNodeBookmarkStore() + + const convertToSubgraph = () => { + const canvas = canvasStore.getCanvas() + const graph = canvas.subgraph ?? canvas.graph + if (!graph) { + return null + } + + const res = graph.convertToSubgraph(canvas.selectedItems) + if (!res) { + return + } + + const { node } = res + canvas.select(node) + canvasStore.updateSelectedItems() + // Trigger change tracking + workflowStore.activeWorkflow?.changeTracker?.checkState() + } + + const unpackSubgraph = () => { + const canvas = canvasStore.getCanvas() + const graph = canvas.subgraph ?? canvas.graph + + if (!graph) { + return + } + + const selectedItems = Array.from(canvas.selectedItems) + const subgraphNodes = selectedItems.filter( + (item): item is SubgraphNode => item instanceof SubgraphNode + ) + + if (subgraphNodes.length === 0) { + return + } + + subgraphNodes.forEach((subgraphNode) => { + // Revoke any image previews for the subgraph + nodeOutputStore.revokeSubgraphPreviews(subgraphNode) + + // Unpack the subgraph + graph.unpackSubgraph(subgraphNode) + }) + + // Trigger change tracking + workflowStore.activeWorkflow?.changeTracker?.checkState() + } + + const addSubgraphToLibrary = async () => { + const selectedItems = Array.from(canvasStore.selectedItems) + + // Handle single node selection like BookmarkButton.vue + if (selectedItems.length === 1) { + const item = selectedItems[0] + if (isLGraphNode(item)) { + const nodeDef = nodeDefStore.fromLGraphNode(item) + if (nodeDef) { + await nodeBookmarkStore.addBookmark(nodeDef.nodePath) + return + } + } + } + + // Handle multiple nodes - convert to subgraph first then bookmark + const selectedNodes = getSelectedNodes() + + if (selectedNodes.length === 0) { + return + } + + // Check if selection contains subgraph nodes + const hasSubgraphs = selectedNodes.some( + (node) => node instanceof SubgraphNode + ) + + if (!hasSubgraphs) { + // Convert regular nodes to subgraph first + convertToSubgraph() + return + } + + // For subgraph nodes, bookmark them + let bookmarkedCount = 0 + for (const node of selectedNodes) { + if (node instanceof SubgraphNode) { + const nodeDef = nodeDefStore.fromLGraphNode(node) + if (nodeDef) { + await nodeBookmarkStore.addBookmark(nodeDef.nodePath) + bookmarkedCount++ + } + } + } + } + + const isSubgraphSelected = (): boolean => { + const selectedItems = Array.from(canvasStore.selectedItems) + return selectedItems.some((item) => item instanceof SubgraphNode) + } + + const hasSelectableNodes = (): boolean => { + return getSelectedNodes().length > 0 + } + + return { + convertToSubgraph, + unpackSubgraph, + addSubgraphToLibrary, + isSubgraphSelected, + hasSelectableNodes + } +} diff --git a/src/composables/graph/useSubmenuPositioning.ts b/src/composables/graph/useSubmenuPositioning.ts new file mode 100644 index 000000000..2dda2bd1c --- /dev/null +++ b/src/composables/graph/useSubmenuPositioning.ts @@ -0,0 +1,163 @@ +import { nextTick } from 'vue' + +import type { MenuOption } from './useMoreOptionsMenu' + +/** + * Composable for handling submenu positioning logic + */ +export function useSubmenuPositioning() { + /** + * Toggle submenu visibility with proper positioning + * @param option - Menu option with submenu + * @param event - Click event + * @param submenu - PrimeVue Popover reference + * @param currentSubmenu - Currently open submenu name + * @param menuOptionsWithSubmenu - All menu options with submenus + * @param submenuRefs - References to all submenu popovers + */ + const toggleSubmenu = async ( + option: MenuOption, + event: Event, + submenu: any, // Component instance with show/hide methods + currentSubmenu: { value: string | null }, + menuOptionsWithSubmenu: MenuOption[], + submenuRefs: Record // Component instances + ): Promise => { + if (!option.label || !option.hasSubmenu) return + + // Check if this submenu is currently open + const isCurrentlyOpen = currentSubmenu.value === option.label + + // Hide all submenus first + menuOptionsWithSubmenu.forEach((opt) => { + const sm = submenuRefs[`submenu-${opt.label}`] + if (sm) { + sm.hide() + } + }) + currentSubmenu.value = null + + // If it wasn't open before, show it now + if (!isCurrentlyOpen) { + currentSubmenu.value = option.label + await nextTick() + + const menuItem = event.currentTarget as HTMLElement + const menuItemRect = menuItem.getBoundingClientRect() + + // Find the parent popover content element that contains this menu item + const mainPopoverContent = menuItem.closest( + '[data-pc-section="content"]' + ) as HTMLElement + + if (mainPopoverContent) { + const mainPopoverRect = mainPopoverContent.getBoundingClientRect() + + // Create a temporary positioned element as the target + const tempTarget = createPositionedTarget( + mainPopoverRect.right + 8, + menuItemRect.top, + `submenu-target-${option.label}` + ) + + // Create event using the temp target + const tempEvent = createMouseEvent( + mainPopoverRect.right + 8, + menuItemRect.top + ) + + // Show submenu relative to temp target + submenu.show(tempEvent, tempTarget) + + // Clean up temp target after a delay + cleanupTempTarget(tempTarget, 100) + } else { + // Fallback: position to the right of the menu item + const tempTarget = createPositionedTarget( + menuItemRect.right + 8, + menuItemRect.top, + `submenu-fallback-target-${option.label}` + ) + + // Create event using the temp target + const tempEvent = createMouseEvent( + menuItemRect.right + 8, + menuItemRect.top + ) + + // Show submenu relative to temp target + submenu.show(tempEvent, tempTarget) + + // Clean up temp target after a delay + cleanupTempTarget(tempTarget, 100) + } + } + } + + /** + * Create a temporary positioned DOM element for submenu targeting + */ + const createPositionedTarget = ( + left: number, + top: number, + id: string + ): HTMLElement => { + const tempTarget = document.createElement('div') + tempTarget.style.position = 'absolute' + tempTarget.style.left = `${left}px` + tempTarget.style.top = `${top}px` + tempTarget.style.width = '1px' + tempTarget.style.height = '1px' + tempTarget.style.pointerEvents = 'none' + tempTarget.style.visibility = 'hidden' + tempTarget.id = id + + document.body.appendChild(tempTarget) + return tempTarget + } + + /** + * Create a mouse event with specific coordinates + */ + const createMouseEvent = (clientX: number, clientY: number): MouseEvent => { + return new MouseEvent('click', { + bubbles: true, + cancelable: true, + clientX, + clientY + }) + } + + /** + * Clean up temporary target element after delay + */ + const cleanupTempTarget = (target: HTMLElement, delay: number): void => { + setTimeout(() => { + if (target.parentNode) { + target.parentNode.removeChild(target) + } + }, delay) + } + + /** + * Hide all submenus + */ + const hideAllSubmenus = ( + menuOptionsWithSubmenu: MenuOption[], + submenuRefs: Record, // Component instances + currentSubmenu: { value: string | null } + ): void => { + menuOptionsWithSubmenu.forEach((option) => { + const submenu = submenuRefs[`submenu-${option.label}`] + if (submenu) { + submenu.hide() + } + }) + currentSubmenu.value = null + } + + return { + toggleSubmenu, + hideAllSubmenus + } +} diff --git a/src/composables/graph/useViewportCulling.ts b/src/composables/graph/useViewportCulling.ts index b5c996f93..6fc835e7e 100644 --- a/src/composables/graph/useViewportCulling.ts +++ b/src/composables/graph/useViewportCulling.ts @@ -1,18 +1,16 @@ /** - * Viewport Culling Composable + * Vue Nodes Viewport Culling * - * Handles viewport culling optimization for Vue nodes including: - * - Transform state synchronization - * - Visible node calculation with screen space transforms - * - Adaptive margin computation based on zoom level - * - Performance optimizations for large graphs + * Principles: + * 1. Query DOM directly using data attributes (no cache to maintain) + * 2. Set display none on element to avoid cascade resolution overhead + * 3. Only run when transform changes (event driven) */ -import { type Ref, computed, readonly, ref } from 'vue' +import { type Ref, computed } from 'vue' import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' -import { useTransformState } from '@/renderer/core/layout/useTransformState' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { app as comfyApp } from '@/scripts/app' -import { useCanvasStore } from '@/stores/graphStore' interface NodeManager { getNode: (id: string) => any @@ -25,188 +23,84 @@ export function useViewportCulling( nodeManager: Ref ) { const canvasStore = useCanvasStore() - const { syncWithCanvas } = useTransformState() - // Transform tracking for performance optimization - const lastScale = ref(1) - const lastOffsetX = ref(0) - const lastOffsetY = ref(0) - - // Current transform state - const currentTransformState = computed(() => ({ - scale: lastScale.value, - offsetX: lastOffsetX.value, - offsetY: lastOffsetY.value - })) - - /** - * Computed property that returns nodes visible in the current viewport - * Implements sophisticated culling algorithm with adaptive margins - */ - const nodesToRender = computed(() => { - if (!isVueNodesEnabled.value) { - return [] - } - - // Access trigger to force re-evaluation after nodeManager initialization - void nodeDataTrigger.value - - if (!comfyApp.graph) { - return [] - } - - const allNodes = Array.from(vueNodeData.value.values()) - - // Apply viewport culling - check if node bounds intersect with viewport - // TODO: use quadtree - if (nodeManager.value && canvasStore.canvas && comfyApp.canvas) { - const canvas = canvasStore.canvas - const manager = nodeManager.value - - // Ensure transform is synced before checking visibility - syncWithCanvas(comfyApp.canvas) - - const ds = canvas.ds - - // Work in screen space - viewport is simply the canvas element size - const viewport_width = canvas.canvas.width - const viewport_height = canvas.canvas.height - - // Add margin that represents a constant distance in canvas space - // Convert canvas units to screen pixels by multiplying by scale - const canvasMarginDistance = 200 // Fixed margin in canvas units - const margin_x = canvasMarginDistance * ds.scale - const margin_y = canvasMarginDistance * ds.scale - - const filtered = allNodes.filter((nodeData) => { - const node = manager.getNode(nodeData.id) - if (!node) return false - - // Transform node position to screen space (same as DOM widgets) - const screen_x = (node.pos[0] + ds.offset[0]) * ds.scale - const screen_y = (node.pos[1] + ds.offset[1]) * ds.scale - const screen_width = node.size[0] * ds.scale - const screen_height = node.size[1] * ds.scale - - // Check if node bounds intersect with expanded viewport (in screen space) - const isVisible = !( - screen_x + screen_width < -margin_x || - screen_x > viewport_width + margin_x || - screen_y + screen_height < -margin_y || - screen_y > viewport_height + margin_y - ) - - return isVisible - }) - - return filtered - } - - return allNodes + const allNodes = computed(() => { + if (!isVueNodesEnabled.value) return [] + void nodeDataTrigger.value // Force re-evaluation when nodeManager initializes + return Array.from(vueNodeData.value.values()) }) /** - * Handle transform updates with performance optimization - * Only syncs when transform actually changes to avoid unnecessary reflows + * Update visibility of all nodes based on viewport + * Queries DOM directly - no cache maintenance needed */ - const handleTransformUpdate = (detectChangesInRAF: () => void) => { - // Skip all work if Vue nodes are disabled - if (!isVueNodesEnabled.value) { - return - } - - // Sync transform state only when it changes (avoids reflows) - if (comfyApp.canvas?.ds) { - const currentScale = comfyApp.canvas.ds.scale - const currentOffsetX = comfyApp.canvas.ds.offset[0] - const currentOffsetY = comfyApp.canvas.ds.offset[1] - - if ( - currentScale !== lastScale.value || - currentOffsetX !== lastOffsetX.value || - currentOffsetY !== lastOffsetY.value - ) { - syncWithCanvas(comfyApp.canvas) - lastScale.value = currentScale - lastOffsetX.value = currentOffsetX - lastOffsetY.value = currentOffsetY - } - } - - // Detect node changes during transform updates - detectChangesInRAF() - - // Trigger reactivity for nodesToRender - void nodesToRender.value.length - } - - /** - * Calculate if a specific node is visible in viewport - * Useful for individual node visibility checks - */ - const isNodeVisible = (nodeData: VueNodeData): boolean => { - if (!nodeManager.value || !canvasStore.canvas || !comfyApp.canvas) { - return true // Default to visible if culling not available - } + const updateVisibility = () => { + if (!nodeManager.value || !canvasStore.canvas || !comfyApp.canvas) return const canvas = canvasStore.canvas - const node = nodeManager.value.getNode(nodeData.id) - if (!node) return false - - syncWithCanvas(comfyApp.canvas) + const manager = nodeManager.value const ds = canvas.ds + // Viewport bounds const viewport_width = canvas.canvas.width const viewport_height = canvas.canvas.height - const canvasMarginDistance = 200 - const margin_x = canvasMarginDistance * ds.scale - const margin_y = canvasMarginDistance * ds.scale + const margin = 500 * ds.scale - const screen_x = (node.pos[0] + ds.offset[0]) * ds.scale - const screen_y = (node.pos[1] + ds.offset[1]) * ds.scale - const screen_width = node.size[0] * ds.scale - const screen_height = node.size[1] * ds.scale + // Get all node elements at once + const nodeElements = document.querySelectorAll('[data-node-id]') - return !( - screen_x + screen_width < -margin_x || - screen_x > viewport_width + margin_x || - screen_y + screen_height < -margin_y || - screen_y > viewport_height + margin_y - ) + // Update each element's visibility + for (const element of nodeElements) { + const nodeId = element.getAttribute('data-node-id') + if (!nodeId) continue + + const node = manager.getNode(nodeId) + if (!node) continue + + // Calculate if node is outside viewport + const screen_x = (node.pos[0] + ds.offset[0]) * ds.scale + const screen_y = (node.pos[1] + ds.offset[1]) * ds.scale + const screen_width = node.size[0] * ds.scale + const screen_height = node.size[1] * ds.scale + + const isNodeOutsideViewport = + screen_x + screen_width < -margin || + screen_x > viewport_width + margin || + screen_y + screen_height < -margin || + screen_y > viewport_height + margin + + // Setting display none directly avoid potential cascade resolution + if (element instanceof HTMLElement) { + element.style.display = isNodeOutsideViewport ? 'none' : '' + } + } } + // RAF throttling for smooth updates during continuous panning + let rafId: number | null = null + /** - * Get viewport bounds information for debugging + * Handle transform update - called by TransformPane event + * Uses RAF to batch updates for smooth performance */ - const getViewportInfo = () => { - if (!canvasStore.canvas || !comfyApp.canvas) { - return null + const handleTransformUpdate = () => { + if (!isVueNodesEnabled.value) return + + // Cancel previous RAF if still pending + if (rafId !== null) { + cancelAnimationFrame(rafId) } - const canvas = canvasStore.canvas - const ds = canvas.ds - - return { - viewport_width: canvas.canvas.width, - viewport_height: canvas.canvas.height, - scale: ds.scale, - offset: [ds.offset[0], ds.offset[1]], - margin_distance: 200, - margin_x: 200 * ds.scale, - margin_y: 200 * ds.scale - } + // Schedule update in next animation frame + rafId = requestAnimationFrame(() => { + updateVisibility() + rafId = null + }) } return { - nodesToRender, + allNodes, handleTransformUpdate, - isNodeVisible, - getViewportInfo, - - // Transform state - currentTransformState: readonly(currentTransformState), - lastScale: readonly(lastScale), - lastOffsetX: readonly(lastOffsetX), - lastOffsetY: readonly(lastOffsetY) + updateVisibility } } diff --git a/src/composables/graph/useVueNodeLifecycle.ts b/src/composables/graph/useVueNodeLifecycle.ts index ea275e220..54296b900 100644 --- a/src/composables/graph/useVueNodeLifecycle.ts +++ b/src/composables/graph/useVueNodeLifecycle.ts @@ -16,13 +16,13 @@ import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations' import { layoutStore } from '@/renderer/core/layout/store/layoutStore' import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync' import { useLinkLayoutSync } from '@/renderer/core/layout/sync/useLinkLayoutSync' import { useSlotLayoutSync } from '@/renderer/core/layout/sync/useSlotLayoutSync' import { app as comfyApp } from '@/scripts/app' -import { useCanvasStore } from '@/stores/graphStore' export function useVueNodeLifecycle(isVueNodesEnabled: Ref) { const canvasStore = useCanvasStore() diff --git a/src/composables/useCopy.ts b/src/composables/useCopy.ts index 15f6183de..d24e7603c 100644 --- a/src/composables/useCopy.ts +++ b/src/composables/useCopy.ts @@ -1,6 +1,6 @@ import { useEventListener } from '@vueuse/core' -import { useCanvasStore } from '@/stores/graphStore' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' /** * Adds a handler on copy that serializes selected nodes to JSON diff --git a/src/composables/useCoreCommands.ts b/src/composables/useCoreCommands.ts index 3ea42cd20..e75d0f6bf 100644 --- a/src/composables/useCoreCommands.ts +++ b/src/composables/useCoreCommands.ts @@ -12,9 +12,13 @@ import { LGraphGroup, LGraphNode, LiteGraph, - SubgraphNode + SubgraphNode, + type Point } from '@/lib/litegraph/src/litegraph' -import { type Point } from '@/lib/litegraph/src/litegraph' +import { + useCanvasStore, + useTitleEditorStore +} from '@/renderer/core/canvas/canvasStore' import { api } from '@/scripts/api' import { app } from '@/scripts/app' import { useDialogService } from '@/services/dialogService' @@ -22,7 +26,6 @@ import { useLitegraphService } from '@/services/litegraphService' import { useWorkflowService } from '@/services/workflowService' import type { ComfyCommand } from '@/stores/commandStore' import { useExecutionStore } from '@/stores/executionStore' -import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore' import { useHelpCenterStore } from '@/stores/helpCenterStore' import { useNodeOutputStore } from '@/stores/imagePreviewStore' import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore' diff --git a/src/composables/useLitegraphSettings.ts b/src/composables/useLitegraphSettings.ts index fd340bb09..56d4d74b3 100644 --- a/src/composables/useLitegraphSettings.ts +++ b/src/composables/useLitegraphSettings.ts @@ -5,7 +5,7 @@ import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph' -import { useCanvasStore } from '@/stores/graphStore' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { useSettingStore } from '@/stores/settingStore' /** diff --git a/src/composables/usePaste.ts b/src/composables/usePaste.ts index 84476ec8b..b2a956d3b 100644 --- a/src/composables/usePaste.ts +++ b/src/composables/usePaste.ts @@ -2,9 +2,9 @@ import { useEventListener } from '@vueuse/core' import { LiteGraph } from '@/lib/litegraph/src/litegraph' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' -import { type ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' +import type { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema' import { app } from '@/scripts/app' -import { useCanvasStore } from '@/stores/graphStore' import { useWorkspaceStore } from '@/stores/workspaceStore' import { isAudioNode, isImageNode, isVideoNode } from '@/utils/litegraphUtil' diff --git a/src/composables/useRefreshableSelection.ts b/src/composables/useRefreshableSelection.ts index bf2a6660f..8e1db9fc5 100644 --- a/src/composables/useRefreshableSelection.ts +++ b/src/composables/useRefreshableSelection.ts @@ -1,7 +1,7 @@ import { computed, ref, watchEffect } from 'vue' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' -import { useCanvasStore } from '@/stores/graphStore' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { isLGraphNode } from '@/utils/litegraphUtil' interface RefreshableItem { diff --git a/src/locales/en/main.json b/src/locales/en/main.json index b7ed574f1..6eb6ab457 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -6,9 +6,20 @@ "noWorkflowsFound": "No workflows found.", "comingSoon": "Coming Soon", "download": "Download", + "downloadImage": "Download image", + "editOrMaskImage": "Edit or mask image", + "removeImage": "Remove image", + "viewImageOfTotal": "View image {index} of {total}", + "imagePreview": "Image preview - Use arrow keys to navigate between images", + "galleryImage": "Gallery image", + "galleryThumbnail": "Gallery thumbnail", + "errorLoadingImage": "Error loading image", + "failedToDownloadImage": "Failed to download image", + "calculatingDimensions": "Calculating dimensions", "import": "Import", "loadAllFolders": "Load All Folders", "refresh": "Refresh", + "refreshNode": "Refresh Node", "terminal": "Terminal", "logs": "Logs", "videoFailedToLoad": "Video failed to load", @@ -30,7 +41,9 @@ "icon": "Icon", "color": "Color", "error": "Error", - "help": "Help", + "info": "Node Info", + "bookmark": "Save to Library", + "moreOptions": "More Options", "loading": "Loading", "loadingPanel": "Loading {panel} panel...", "preview": "PREVIEW", @@ -157,7 +170,8 @@ "nodeContentError": "Node Content Error", "nodeHeaderError": "Node Header Error", "nodeSlotsError": "Node Slots Error", - "nodeWidgetsError": "Node Widgets Error" + "nodeWidgetsError": "Node Widgets Error", + "frameNodes": "Frame Nodes" }, "manager": { "title": "Custom Nodes Manager", @@ -311,7 +325,37 @@ "Save Selected as Template": "Save Selected as Template", "Node Templates": "Node Templates", "Manage": "Manage", - "Search": "Search" + "Search": "Search", + "Open in Mask Editor": "Open in Mask Editor", + "Open Image": "Open Image", + "Copy Image": "Copy Image", + "Save Image": "Save Image", + "Rename": "Rename", + "Copy": "Copy", + "Duplicate": "Duplicate", + "Paste": "Paste", + "Node Info": "Node Info", + "Adjust Size": "Adjust Size", + "Minimize Node": "Minimize Node", + "Expand Node": "Expand Node", + "Shape": "Shape", + "Color": "Color", + "Add Subgraph to Library": "Add Subgraph to Library", + "Unpack Subgraph": "Unpack Subgraph", + "Convert to Subgraph": "Convert to Subgraph", + "Align Selected To": "Align Selected To", + "Distribute Nodes": "Distribute Nodes", + "Remove Bypass": "Remove Bypass", + "Run Branch": "Run Branch", + "Delete": "Delete", + "Top": "Top", + "Bottom": "Bottom", + "Left": "Left", + "Right": "Right", + "Horizontal": "Horizontal", + "Vertical": "Vertical", + "new": "new", + "deprecated": "deprecated" }, "icon": { "bookmark": "Bookmark", @@ -462,6 +506,14 @@ "revertChanges": "Revert Changes", "restart": "Restart" }, + "shape": { + "default": "Default", + "round": "Round", + "CARD": "Card", + "circle": "Circle", + "arrow": "Arrow", + "box": "Box" + }, "sideToolbar": { "themeToggle": "Toggle Theme", "helpCenter": "Help Center", @@ -1758,7 +1810,10 @@ "executeButton": { "tooltip": "Execute to selected output nodes (Highlighted with orange border)", "disabledTooltip": "No output nodes selected" - } + }, + "Set Group Nodes to Never": "Set Group Nodes to Never", + "Bypass Group Nodes": "Bypass Group Nodes", + "Set Group Nodes to Always": "Set Group Nodes to Always" }, "chatHistory": { "cancelEdit": "Cancel", diff --git a/src/stores/graphStore.ts b/src/renderer/core/canvas/canvasStore.ts similarity index 100% rename from src/stores/graphStore.ts rename to src/renderer/core/canvas/canvasStore.ts diff --git a/src/renderer/core/canvas/injectionKeys.ts b/src/renderer/core/canvas/injectionKeys.ts index 4134846c3..5c850c100 100644 --- a/src/renderer/core/canvas/injectionKeys.ts +++ b/src/renderer/core/canvas/injectionKeys.ts @@ -1,8 +1,25 @@ import type { InjectionKey, Ref } from 'vue' +import type { NodeProgressState } from '@/schemas/apiSchema' + /** * Injection key for providing selected node IDs to Vue node components. * Contains a reactive Set of selected node IDs (as strings). */ export const SelectedNodeIdsKey: InjectionKey>> = Symbol('selectedNodeIds') + +/** + * Injection key for providing executing node IDs to Vue node components. + * Contains a reactive Set of currently executing node IDs (as strings). + */ +export const ExecutingNodeIdsKey: InjectionKey>> = + Symbol('executingNodeIds') + +/** + * Injection key for providing node progress states to Vue node components. + * Contains a reactive Record of node IDs to their current progress state. + */ +export const NodeProgressStatesKey: InjectionKey< + Ref> +> = Symbol('nodeProgressStates') diff --git a/src/renderer/core/layout/__tests__/TransformPane.spec.ts b/src/renderer/core/layout/__tests__/TransformPane.spec.ts index f6fe7126c..e3519afb5 100644 --- a/src/renderer/core/layout/__tests__/TransformPane.spec.ts +++ b/src/renderer/core/layout/__tests__/TransformPane.spec.ts @@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { nextTick, ref } from 'vue' -import TransformPane from '../TransformPane.vue' +import TransformPane from '../transform/TransformPane.vue' // Mock the transform state composable const mockTransformState = { diff --git a/src/renderer/core/layout/store/layoutStore.ts b/src/renderer/core/layout/store/layoutStore.ts index ca5d67120..673344086 100644 --- a/src/renderer/core/layout/store/layoutStore.ts +++ b/src/renderer/core/layout/store/layoutStore.ts @@ -36,9 +36,19 @@ import { type Point, type RerouteId, type RerouteLayout, - type Size, type SlotLayout } from '@/renderer/core/layout/types' +import { + REROUTE_RADIUS, + boundsIntersect, + pointInBounds +} from '@/renderer/core/layout/utils/layoutMath' +import { makeLinkSegmentKey } from '@/renderer/core/layout/utils/layoutUtils' +import { + type NodeLayoutMap, + layoutToYNode, + yNodeToLayout +} from '@/renderer/core/layout/utils/mappers' import { SpatialIndexManager } from '@/renderer/core/spatial/SpatialIndex' type YEventChange = { @@ -48,9 +58,6 @@ type YEventChange = { const logger = log.getLogger('LayoutStore') -// Constants -const REROUTE_RADIUS = 8 - // Utility functions function asRerouteId(id: string | number): RerouteId { return Number(id) @@ -60,15 +67,6 @@ function asLinkId(id: string | number): LinkId { return Number(id) } -interface NodeLayoutData { - id: NodeId - position: Point - size: Size - zIndex: number - visible: boolean - bounds: Bounds -} - interface LinkData { id: LinkId sourceNodeId: NodeId @@ -91,15 +89,6 @@ interface TypedYMap { } class LayoutStoreImpl implements LayoutStore { - private static readonly NODE_DEFAULTS: NodeLayoutData = { - id: 'unknown-node', - position: { x: 0, y: 0 }, - size: { width: 100, height: 50 }, - zIndex: 0, - visible: true, - bounds: { x: 0, y: 0, width: 100, height: 50 } - } - private static readonly REROUTE_DEFAULTS: RerouteData = { id: 0, position: { x: 0, y: 0 }, @@ -109,7 +98,7 @@ class LayoutStoreImpl implements LayoutStore { // Yjs document and shared data structures private ydoc = new Y.Doc() - private ynodes: Y.Map> // Maps nodeId -> Y.Map containing NodeLayout data + private ynodes: Y.Map // Maps nodeId -> NodeLayoutMap containing NodeLayout data private ylinks: Y.Map> // Maps linkId -> Y.Map containing link data private yreroutes: Y.Map> // Maps rerouteId -> Y.Map containing reroute data private yoperations: Y.Array // Operation log @@ -155,7 +144,7 @@ class LayoutStoreImpl implements LayoutStore { this.rerouteSpatialIndex = new SpatialIndexManager() // Listen for Yjs changes and trigger Vue reactivity - this.ynodes.observe((event: Y.YMapEvent>) => { + this.ynodes.observe((event: Y.YMapEvent) => { this.version++ // Trigger all affected node refs @@ -184,16 +173,6 @@ class LayoutStoreImpl implements LayoutStore { }) } - private getNodeField( - ynode: Y.Map, - field: K, - defaultValue: NodeLayoutData[K] = LayoutStoreImpl.NODE_DEFAULTS[field] - ): NodeLayoutData[K] { - const typedNode = ynode as TypedYMap - const value = typedNode.get(field) - return value ?? defaultValue - } - private getLinkField( ylink: Y.Map, field: K @@ -227,7 +206,7 @@ class LayoutStoreImpl implements LayoutStore { get: () => { track() const ynode = this.ynodes.get(nodeId) - const layout = ynode ? this.yNodeToLayout(ynode) : null + const layout = ynode ? yNodeToLayout(ynode) : null return layout }, set: (newLayout: NodeLayout | null) => { @@ -242,7 +221,7 @@ class LayoutStoreImpl implements LayoutStore { timestamp: Date.now(), source: this.currentSource, actor: this.currentActor, - previousLayout: this.yNodeToLayout(existing) + previousLayout: yNodeToLayout(existing) }) } } else { @@ -260,7 +239,7 @@ class LayoutStoreImpl implements LayoutStore { actor: this.currentActor }) } else { - const existingLayout = this.yNodeToLayout(existing) + const existingLayout = yNodeToLayout(existing) // Check what properties changed if ( @@ -330,8 +309,8 @@ class LayoutStoreImpl implements LayoutStore { for (const [nodeId] of this.ynodes) { const ynode = this.ynodes.get(nodeId) if (ynode) { - const layout = this.yNodeToLayout(ynode) - if (layout && this.boundsIntersect(layout.bounds, bounds)) { + const layout = yNodeToLayout(ynode) + if (layout && boundsIntersect(layout.bounds, bounds)) { result.push(nodeId) } } @@ -352,7 +331,7 @@ class LayoutStoreImpl implements LayoutStore { for (const [nodeId] of this.ynodes) { const ynode = this.ynodes.get(nodeId) if (ynode) { - const layout = this.yNodeToLayout(ynode) + const layout = yNodeToLayout(ynode) if (layout) { result.set(nodeId, layout) } @@ -378,7 +357,7 @@ class LayoutStoreImpl implements LayoutStore { for (const [nodeId] of this.ynodes) { const ynode = this.ynodes.get(nodeId) if (ynode) { - const layout = this.yNodeToLayout(ynode) + const layout = yNodeToLayout(ynode) if (layout) { nodes.push([nodeId, layout]) } @@ -389,7 +368,7 @@ class LayoutStoreImpl implements LayoutStore { nodes.sort(([, a], [, b]) => b.zIndex - a.zIndex) for (const [nodeId, layout] of nodes) { - if (this.pointInBounds(point, layout.bounds)) { + if (pointInBounds(point, layout.bounds)) { return nodeId } } @@ -561,16 +540,6 @@ class LayoutStoreImpl implements LayoutStore { return this.rerouteLayouts.get(rerouteId) || null } - /** - * Helper to create internal key for link segment - */ - private makeLinkSegmentKey( - linkId: LinkId, - rerouteId: RerouteId | null - ): string { - return `${linkId}:${rerouteId ?? 'final'}` - } - /** * Update link segment layout data */ @@ -579,7 +548,7 @@ class LayoutStoreImpl implements LayoutStore { rerouteId: RerouteId | null, layout: Omit ): void { - const key = this.makeLinkSegmentKey(linkId, rerouteId) + const key = makeLinkSegmentKey(linkId, rerouteId) const existing = this.linkSegmentLayouts.get(key) // Short-circuit if bounds and centerPos unchanged (prevents spatial index churn) @@ -629,7 +598,7 @@ class LayoutStoreImpl implements LayoutStore { * Delete link segment layout data */ deleteLinkSegmentLayout(linkId: LinkId, rerouteId: RerouteId | null): void { - const key = this.makeLinkSegmentKey(linkId, rerouteId) + const key = makeLinkSegmentKey(linkId, rerouteId) const deleted = this.linkSegmentLayouts.delete(key) if (deleted) { // Remove from spatial index @@ -693,7 +662,7 @@ class LayoutStoreImpl implements LayoutStore { rerouteId: segmentLayout.rerouteId } } - } else if (this.pointInBounds(point, segmentLayout.bounds)) { + } else if (pointInBounds(point, segmentLayout.bounds)) { // Fallback to bounding box test return { linkId: segmentLayout.linkId, @@ -733,7 +702,7 @@ class LayoutStoreImpl implements LayoutStore { // Check precise bounds for candidates for (const key of candidateSlotKeys) { const slotLayout = this.slotLayouts.get(key) - if (slotLayout && this.pointInBounds(point, slotLayout.bounds)) { + if (slotLayout && pointInBounds(point, slotLayout.bounds)) { return slotLayout } } @@ -969,7 +938,7 @@ class LayoutStoreImpl implements LayoutStore { } } - this.ynodes.set(layout.id, this.layoutToYNode(layout)) + this.ynodes.set(layout.id, layoutToYNode(layout)) // Add to spatial index this.spatialIndex.insert(layout.id, layout.bounds) @@ -987,7 +956,7 @@ class LayoutStoreImpl implements LayoutStore { return } - const size = this.getNodeField(ynode, 'size') + const size = yNodeToLayout(ynode).size const newBounds = { x: operation.position.x, y: operation.position.y, @@ -1016,7 +985,7 @@ class LayoutStoreImpl implements LayoutStore { const ynode = this.ynodes.get(operation.nodeId) if (!ynode) return - const position = this.getNodeField(ynode, 'position') + const position = yNodeToLayout(ynode).position const newBounds = { x: position.x, y: position.y, @@ -1053,7 +1022,7 @@ class LayoutStoreImpl implements LayoutStore { operation: CreateNodeOperation, change: LayoutChange ): void { - const ynode = this.layoutToYNode(operation.layout) + const ynode = layoutToYNode(operation.layout) this.ynodes.set(operation.nodeId, ynode) // Add to spatial index @@ -1187,7 +1156,7 @@ class LayoutStoreImpl implements LayoutStore { * Update node bounds helper */ private updateNodeBounds( - ynode: Y.Map, + ynode: NodeLayoutMap, position: Point, size: { width: number; height: number } ): void { @@ -1335,27 +1304,6 @@ class LayoutStoreImpl implements LayoutStore { } // Helper methods - private layoutToYNode(layout: NodeLayout): Y.Map { - const ynode = new Y.Map() - ynode.set('id', layout.id) - ynode.set('position', layout.position) - ynode.set('size', layout.size) - ynode.set('zIndex', layout.zIndex) - ynode.set('visible', layout.visible) - ynode.set('bounds', layout.bounds) - return ynode - } - - private yNodeToLayout(ynode: Y.Map): NodeLayout { - return { - id: this.getNodeField(ynode, 'id'), - position: this.getNodeField(ynode, 'position'), - size: this.getNodeField(ynode, 'size'), - zIndex: this.getNodeField(ynode, 'zIndex'), - visible: this.getNodeField(ynode, 'visible'), - bounds: this.getNodeField(ynode, 'bounds') - } - } private notifyChange(change: LayoutChange): void { this.changeListeners.forEach((listener) => { @@ -1367,24 +1315,6 @@ class LayoutStoreImpl implements LayoutStore { }) } - private pointInBounds(point: Point, bounds: Bounds): boolean { - return ( - point.x >= bounds.x && - point.x <= bounds.x + bounds.width && - point.y >= bounds.y && - point.y <= bounds.y + bounds.height - ) - } - - private boundsIntersect(a: Bounds, b: Bounds): boolean { - return !( - a.x + a.width < b.x || - b.x + b.width < a.x || - a.y + a.height < b.y || - b.y + b.height < a.y - ) - } - // CRDT-specific methods getOperationsSince(timestamp: number): LayoutOperation[] { const operations: LayoutOperation[] = [] diff --git a/src/renderer/core/layout/TransformPane.vue b/src/renderer/core/layout/transform/TransformPane.vue similarity index 87% rename from src/renderer/core/layout/TransformPane.vue rename to src/renderer/core/layout/transform/TransformPane.vue index 2f623257c..0f88b177d 100644 --- a/src/renderer/core/layout/TransformPane.vue +++ b/src/renderer/core/layout/transform/TransformPane.vue @@ -13,10 +13,10 @@ diff --git a/src/renderer/extensions/vueNodes/components/InputOutputSlot.test.ts b/src/renderer/extensions/vueNodes/components/InputOutputSlot.test.ts new file mode 100644 index 000000000..d10c9c382 --- /dev/null +++ b/src/renderer/extensions/vueNodes/components/InputOutputSlot.test.ts @@ -0,0 +1,86 @@ +import { type ComponentMountingOptions, mount } from '@vue/test-utils' +import { createPinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createI18n } from 'vue-i18n' + +import enMessages from '@/locales/en/main.json' +import { useDomSlotRegistration } from '@/renderer/core/layout/slots/useDomSlotRegistration' + +import InputSlot from './InputSlot.vue' +import OutputSlot from './OutputSlot.vue' + +// Mock composable used by InputSlot/OutputSlot so we can assert call params +vi.mock('@/renderer/core/layout/slots/useDomSlotRegistration', () => ({ + useDomSlotRegistration: vi.fn(() => ({ remeasure: vi.fn() })) +})) + +type InputSlotProps = ComponentMountingOptions['props'] +type OutputSlotProps = ComponentMountingOptions['props'] + +const mountInputSlot = (props: InputSlotProps) => + mount(InputSlot, { + global: { + plugins: [ + createI18n({ + legacy: false, + locale: 'en', + messages: { en: enMessages } + }), + createPinia() + ] + }, + props + }) + +const mountOutputSlot = (props: OutputSlotProps) => + mount(OutputSlot, { + global: { + plugins: [ + createI18n({ + legacy: false, + locale: 'en', + messages: { en: enMessages } + }), + createPinia() + ] + }, + props + }) + +describe('InputSlot/OutputSlot', () => { + beforeEach(() => { + vi.mocked(useDomSlotRegistration).mockClear() + }) + + it('InputSlot registers with correct options', () => { + mountInputSlot({ + nodeId: 'node-1', + index: 3, + slotData: { name: 'A', type: 'any', boundingRect: [0, 0, 0, 0] } + }) + + expect(useDomSlotRegistration).toHaveBeenLastCalledWith( + expect.objectContaining({ + nodeId: 'node-1', + slotIndex: 3, + isInput: true + }) + ) + }) + + it('OutputSlot registers with correct options', () => { + mountOutputSlot({ + nodeId: 'node-2', + index: 1, + slotData: { name: 'B', type: 'any', boundingRect: [0, 0, 0, 0] } + }) + + expect(useDomSlotRegistration).toHaveBeenLastCalledWith( + expect.objectContaining({ + nodeId: 'node-2', + slotIndex: 1, + isInput: false + }) + ) + }) +}) diff --git a/src/renderer/extensions/vueNodes/components/InputSlot.vue b/src/renderer/extensions/vueNodes/components/InputSlot.vue index 3b1aba232..d653b5c1f 100644 --- a/src/renderer/extensions/vueNodes/components/InputSlot.vue +++ b/src/renderer/extensions/vueNodes/components/InputSlot.vue @@ -21,7 +21,7 @@ {{ slotData.localized_name || slotData.name || `Input ${index}` }} diff --git a/src/renderer/extensions/vueNodes/components/LGraphNode.vue b/src/renderer/extensions/vueNodes/components/LGraphNode.vue index f88b4122c..754bd323a 100644 --- a/src/renderer/extensions/vueNodes/components/LGraphNode.vue +++ b/src/renderer/extensions/vueNodes/components/LGraphNode.vue @@ -8,20 +8,19 @@ :class=" cn( 'bg-white dark-theme:bg-charcoal-100', - 'min-w-[445px]', 'lg-node absolute rounded-2xl', // border 'border border-solid border-sand-100 dark-theme:border-charcoal-300', - !!executing && 'border-blue-500 dark-theme:border-blue-500', - !!error && 'border-red-700 dark-theme:border-red-300', + !!executing && 'border-blue-100 dark-theme:border-blue-100', + hasAnyError && 'border-error', // hover 'hover:ring-7 ring-gray-500/50 dark-theme:ring-gray-500/20', // Selected 'outline-transparent -outline-offset-2 outline-2', !!isSelected && 'outline-black dark-theme:outline-white', !!(isSelected && executing) && - 'outline-blue-500 dark-theme:outline-blue-500', - !!(isSelected && error) && 'outline-red-500 dark-theme:outline-red-500', + 'outline-blue-100 dark-theme:outline-blue-100', + isSelected && hasAnyError && 'outline-error', { 'animate-pulse': executing, 'opacity-50': nodeData.mode === 4, @@ -119,6 +118,7 @@ :node-data="nodeData" :readonly="readonly" :lod-level="lodLevel" + :image-urls="nodeImageUrls" />
@@ -126,14 +126,29 @@ diff --git a/src/renderer/extensions/vueNodes/components/NodeContent.vue b/src/renderer/extensions/vueNodes/components/NodeContent.vue index f99e10917..3df38bdf6 100644 --- a/src/renderer/extensions/vueNodes/components/NodeContent.vue +++ b/src/renderer/extensions/vueNodes/components/NodeContent.vue @@ -5,28 +5,42 @@
- - +
diff --git a/src/renderer/extensions/vueNodes/components/OutputSlot.vue b/src/renderer/extensions/vueNodes/components/OutputSlot.vue index e83019aa9..51dcc1d22 100644 --- a/src/renderer/extensions/vueNodes/components/OutputSlot.vue +++ b/src/renderer/extensions/vueNodes/components/OutputSlot.vue @@ -15,7 +15,7 @@ {{ slotData.name || `Output ${index}` }} diff --git a/src/renderer/extensions/vueNodes/composables/useNodeEventHandlers.ts b/src/renderer/extensions/vueNodes/composables/useNodeEventHandlers.ts index 8cd7e2e5a..844649676 100644 --- a/src/renderer/extensions/vueNodes/composables/useNodeEventHandlers.ts +++ b/src/renderer/extensions/vueNodes/composables/useNodeEventHandlers.ts @@ -11,8 +11,8 @@ import type { Ref } from 'vue' import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex' -import { useCanvasStore } from '@/stores/graphStore' interface NodeManager { getNode: (id: string) => any diff --git a/src/renderer/extensions/vueNodes/execution/useExecutionStateProvider.ts b/src/renderer/extensions/vueNodes/execution/useExecutionStateProvider.ts new file mode 100644 index 000000000..aae08298a --- /dev/null +++ b/src/renderer/extensions/vueNodes/execution/useExecutionStateProvider.ts @@ -0,0 +1,36 @@ +import { storeToRefs } from 'pinia' +import { computed, provide } from 'vue' + +import { + ExecutingNodeIdsKey, + NodeProgressStatesKey +} from '@/renderer/core/canvas/injectionKeys' +import { useExecutionStore } from '@/stores/executionStore' + +/** + * Composable for providing execution state to Vue node children + * + * This composable sets up the execution state providers that can be injected + * by child Vue nodes using useNodeExecutionState. + * + * Should be used in the parent component that manages Vue nodes (e.g., GraphCanvas). + */ +export const useExecutionStateProvider = () => { + const executionStore = useExecutionStore() + const { executingNodeIds: storeExecutingNodeIds, nodeProgressStates } = + storeToRefs(executionStore) + + // Convert execution store data to the format expected by Vue nodes + const executingNodeIds = computed( + () => new Set(storeExecutingNodeIds.value.map(String)) + ) + + // Provide the execution state to all child Vue nodes + provide(ExecutingNodeIdsKey, executingNodeIds) + provide(NodeProgressStatesKey, nodeProgressStates) + + return { + executingNodeIds, + nodeProgressStates + } +} diff --git a/src/renderer/extensions/vueNodes/execution/useNodeExecutionState.ts b/src/renderer/extensions/vueNodes/execution/useNodeExecutionState.ts new file mode 100644 index 000000000..8f03e29e1 --- /dev/null +++ b/src/renderer/extensions/vueNodes/execution/useNodeExecutionState.ts @@ -0,0 +1,54 @@ +import { computed, inject, ref } from 'vue' + +import { + ExecutingNodeIdsKey, + NodeProgressStatesKey +} from '@/renderer/core/canvas/injectionKeys' +import type { NodeProgressState } from '@/schemas/apiSchema' + +/** + * Composable for managing execution state of Vue-based nodes + * + * Provides reactive access to execution state and progress for a specific node + * by injecting execution data from the parent GraphCanvas provider. + * + * @param nodeId - The ID of the node to track execution state for + * @returns Object containing reactive execution state and progress + */ +export const useNodeExecutionState = (nodeId: string) => { + const executingNodeIds = inject(ExecutingNodeIdsKey, ref(new Set())) + const nodeProgressStates = inject( + NodeProgressStatesKey, + ref>({}) + ) + + const executing = computed(() => { + return executingNodeIds.value.has(nodeId) + }) + + const progress = computed(() => { + const state = nodeProgressStates.value[nodeId] + return state?.max > 0 ? state.value / state.max : undefined + }) + + const progressState = computed(() => nodeProgressStates.value[nodeId]) + + const progressPercentage = computed(() => { + const prog = progress.value + return prog !== undefined ? Math.round(prog * 100) : undefined + }) + + const executionState = computed(() => { + const state = progressState.value + if (!state) return 'idle' + return state.state + }) + + return { + executing, + progress, + progressPercentage, + progressState, + executionState + } +} diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetButton.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetButton.test.ts new file mode 100644 index 000000000..72d6726a5 --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetButton.test.ts @@ -0,0 +1,248 @@ +import { mount } from '@vue/test-utils' +import Button from 'primevue/button' +import type { ButtonProps } from 'primevue/button' +import PrimeVue from 'primevue/config' +import { describe, expect, it, vi } from 'vitest' + +import WidgetButton from '@/renderer/extensions/vueNodes/widgets/components/WidgetButton.vue' +import type { SimplifiedWidget } from '@/types/simplifiedWidget' + +describe('WidgetButton Interactions', () => { + const createMockWidget = ( + options: Partial = {}, + callback?: () => void, + name: string = 'test_button' + ): SimplifiedWidget => ({ + name, + type: 'button', + value: undefined, + options, + callback + }) + + const mountComponent = (widget: SimplifiedWidget, readonly = false) => { + return mount(WidgetButton, { + global: { + plugins: [PrimeVue], + components: { Button } + }, + props: { + widget, + readonly + } + }) + } + + const clickButton = async (wrapper: ReturnType) => { + const button = wrapper.findComponent({ name: 'Button' }) + await button.trigger('click') + return button + } + + describe('Click Handling', () => { + it('calls callback when button is clicked', async () => { + const mockCallback = vi.fn() + const widget = createMockWidget({}, mockCallback) + const wrapper = mountComponent(widget) + + await clickButton(wrapper) + + expect(mockCallback).toHaveBeenCalledTimes(1) + }) + + it('does not call callback when button is readonly', async () => { + const mockCallback = vi.fn() + const widget = createMockWidget({}, mockCallback) + const wrapper = mountComponent(widget, true) + + await clickButton(wrapper) + + expect(mockCallback).not.toHaveBeenCalled() + }) + + it('handles missing callback gracefully', async () => { + const widget = createMockWidget({}, undefined) + const wrapper = mountComponent(widget) + + // Should not throw error when clicking without callback + await expect(clickButton(wrapper)).resolves.toBeDefined() + }) + + it('calls callback multiple times when clicked multiple times', async () => { + const mockCallback = vi.fn() + const widget = createMockWidget({}, mockCallback) + const wrapper = mountComponent(widget) + + const numClicks = 8 + + await clickButton(wrapper) + for (let i = 0; i < numClicks; i++) { + await clickButton(wrapper) + } + + expect(mockCallback).toHaveBeenCalledTimes(numClicks) + }) + }) + + describe('Component Rendering', () => { + it('renders button component', () => { + const widget = createMockWidget() + const wrapper = mountComponent(widget) + + const button = wrapper.findComponent({ name: 'Button' }) + expect(button.exists()).toBe(true) + }) + + it('renders widget label when name is provided', () => { + const widget = createMockWidget() + const wrapper = mountComponent(widget) + + const label = wrapper.find('label') + expect(label.exists()).toBe(true) + expect(label.text()).toBe('test_button') + }) + + it('does not render label when widget name is empty', () => { + const widget = createMockWidget({}, undefined, '') + const wrapper = mountComponent(widget) + + const label = wrapper.find('label') + expect(label.exists()).toBe(false) + }) + + it('sets button size to small', () => { + const widget = createMockWidget() + const wrapper = mountComponent(widget) + + const button = wrapper.findComponent({ name: 'Button' }) + expect(button.props('size')).toBe('small') + }) + + it('passes widget options to button component', () => { + const buttonOptions = { + label: 'Custom Label', + icon: 'pi pi-check', + severity: 'success' as const + } + const widget = createMockWidget(buttonOptions) + const wrapper = mountComponent(widget) + + const button = wrapper.findComponent({ name: 'Button' }) + expect(button.props('label')).toBe('Custom Label') + expect(button.props('icon')).toBe('pi pi-check') + expect(button.props('severity')).toBe('success') + }) + }) + + describe('Readonly Mode', () => { + it('disables button when readonly', () => { + const widget = createMockWidget() + const wrapper = mountComponent(widget, true) + + // Test the actual DOM button element instead of the Vue component props + const buttonElement = wrapper.find('button') + expect(buttonElement.element.disabled).toBe(true) + }) + + it('enables button when not readonly', () => { + const widget = createMockWidget() + const wrapper = mountComponent(widget, false) + + // Test the actual DOM button element instead of the Vue component props + const buttonElement = wrapper.find('button') + expect(buttonElement.element.disabled).toBe(false) + }) + }) + + describe('Widget Options', () => { + it('handles button with text only', () => { + const widget = createMockWidget({ label: 'Click Me' }) + const wrapper = mountComponent(widget) + + const button = wrapper.findComponent({ name: 'Button' }) + expect(button.props('label')).toBe('Click Me') + expect(button.props('icon')).toBeNull() + }) + + it('handles button with icon only', () => { + const widget = createMockWidget({ icon: 'pi pi-star' }) + const wrapper = mountComponent(widget) + + const button = wrapper.findComponent({ name: 'Button' }) + expect(button.props('icon')).toBe('pi pi-star') + }) + + it('handles button with both text and icon', () => { + const widget = createMockWidget({ + label: 'Save', + icon: 'pi pi-save' + }) + const wrapper = mountComponent(widget) + + const button = wrapper.findComponent({ name: 'Button' }) + expect(button.props('label')).toBe('Save') + expect(button.props('icon')).toBe('pi pi-save') + }) + + it.for([ + 'secondary', + 'success', + 'info', + 'warning', + 'danger', + 'help', + 'contrast' + ] as const)('handles button severity: %s', (severity) => { + const widget = createMockWidget({ severity }) + const wrapper = mountComponent(widget) + const button = wrapper.findComponent({ name: 'Button' }) + expect(button.props('severity')).toBe(severity) + }) + + it.for(['outlined', 'text'] as const)( + 'handles button variant: %s', + (variant) => { + const widget = createMockWidget({ variant }) + const wrapper = mountComponent(widget) + const button = wrapper.findComponent({ name: 'Button' }) + expect(button.props('variant')).toBe(variant) + } + ) + }) + + describe('Edge Cases', () => { + it('handles widget with no options', () => { + const widget = createMockWidget() + const wrapper = mountComponent(widget) + + const button = wrapper.findComponent({ name: 'Button' }) + expect(button.exists()).toBe(true) + }) + + it('handles callback that throws error', async () => { + const mockCallback = vi.fn(() => { + throw new Error('Callback error') + }) + const widget = createMockWidget({}, mockCallback) + const wrapper = mountComponent(widget) + + // Should not break the component when callback throws + await expect(clickButton(wrapper)).rejects.toThrow('Callback error') + expect(mockCallback).toHaveBeenCalledTimes(1) + }) + + it('handles rapid consecutive clicks', async () => { + const mockCallback = vi.fn() + const widget = createMockWidget({}, mockCallback) + const wrapper = mountComponent(widget) + + // Simulate rapid clicks + const clickPromises = Array.from({ length: 16 }, () => + clickButton(wrapper) + ) + await Promise.all(clickPromises) + + expect(mockCallback).toHaveBeenCalledTimes(16) + }) + }) +}) diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.test.ts new file mode 100644 index 000000000..96bb4ed3e --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.test.ts @@ -0,0 +1,304 @@ +import { mount } from '@vue/test-utils' +import ColorPicker from 'primevue/colorpicker' +import type { ColorPickerProps } from 'primevue/colorpicker' +import PrimeVue from 'primevue/config' +import { describe, expect, it } from 'vitest' + +import type { SimplifiedWidget } from '@/types/simplifiedWidget' + +import WidgetColorPicker from './WidgetColorPicker.vue' +import WidgetLayoutField from './layout/WidgetLayoutField.vue' + +describe('WidgetColorPicker Value Binding', () => { + const createMockWidget = ( + value: string = '#000000', + options: Partial = {}, + callback?: (value: string) => void + ): SimplifiedWidget => ({ + name: 'test_color_picker', + type: 'color', + value, + options, + callback + }) + + const mountComponent = ( + widget: SimplifiedWidget, + modelValue: string, + readonly = false + ) => { + return mount(WidgetColorPicker, { + global: { + plugins: [PrimeVue], + components: { + ColorPicker, + WidgetLayoutField + } + }, + props: { + widget, + modelValue, + readonly + } + }) + } + + const setColorPickerValue = async ( + wrapper: ReturnType, + value: unknown + ) => { + const colorPicker = wrapper.findComponent({ name: 'ColorPicker' }) + await colorPicker.setValue(value) + return wrapper.emitted('update:modelValue') + } + + describe('Vue Event Emission', () => { + it('emits Vue event when color changes', async () => { + const widget = createMockWidget('#ff0000') + const wrapper = mountComponent(widget, '#ff0000') + + const emitted = await setColorPickerValue(wrapper, '#00ff00') + + expect(emitted).toBeDefined() + expect(emitted![0]).toContain('#00ff00') + }) + + it('handles different color formats', async () => { + const widget = createMockWidget('#ffffff') + const wrapper = mountComponent(widget, '#ffffff') + + const emitted = await setColorPickerValue(wrapper, '#123abc') + + expect(emitted).toBeDefined() + expect(emitted![0]).toContain('#123abc') + }) + + it('handles missing callback gracefully', async () => { + const widget = createMockWidget('#000000', {}, undefined) + const wrapper = mountComponent(widget, '#000000') + + const emitted = await setColorPickerValue(wrapper, '#ff00ff') + + // Should still emit Vue event + expect(emitted).toBeDefined() + expect(emitted![0]).toContain('#ff00ff') + }) + + it('normalizes bare hex without # to #hex on emit', async () => { + const widget = createMockWidget('ff0000') + const wrapper = mountComponent(widget, 'ff0000') + + const emitted = await setColorPickerValue(wrapper, '00ff00') + expect(emitted).toBeDefined() + expect(emitted![0]).toContain('#00ff00') + }) + + it('normalizes rgb() strings to #hex on emit', async () => { + const widget = createMockWidget('#000000') + const wrapper = mountComponent(widget, '#000000') + + const emitted = await setColorPickerValue(wrapper, 'rgb(255, 0, 0)') + expect(emitted).toBeDefined() + expect(emitted![0]).toContain('#ff0000') + }) + + it('normalizes hsb() strings to #hex on emit', async () => { + const widget = createMockWidget('#000000', { format: 'hsb' }) + const wrapper = mountComponent(widget, '#000000') + + const emitted = await setColorPickerValue(wrapper, 'hsb(120, 100, 100)') + expect(emitted).toBeDefined() + expect(emitted![0]).toContain('#00ff00') + }) + + it('normalizes HSB object values to #hex on emit', async () => { + const widget = createMockWidget('#000000', { format: 'hsb' }) + const wrapper = mountComponent(widget, '#000000') + + const emitted = await setColorPickerValue(wrapper, { + h: 240, + s: 100, + b: 100 + }) + expect(emitted).toBeDefined() + expect(emitted![0]).toContain('#0000ff') + }) + }) + + describe('Component Rendering', () => { + it('renders color picker component', () => { + const widget = createMockWidget('#ff0000') + const wrapper = mountComponent(widget, '#ff0000') + + const colorPicker = wrapper.findComponent({ name: 'ColorPicker' }) + expect(colorPicker.exists()).toBe(true) + }) + + it('normalizes display to a single leading #', () => { + // Case 1: model value already includes '#' + let widget = createMockWidget('#ff0000') + let wrapper = mountComponent(widget, '#ff0000') + let colorText = wrapper.find('[data-testid="widget-color-text"]') + expect.soft(colorText.text()).toBe('#ff0000') + + // Case 2: model value missing '#' + widget = createMockWidget('ff0000') + wrapper = mountComponent(widget, 'ff0000') + colorText = wrapper.find('[data-testid="widget-color-text"]') + expect.soft(colorText.text()).toBe('#ff0000') + }) + + it('renders layout field wrapper', () => { + const widget = createMockWidget('#ff0000') + const wrapper = mountComponent(widget, '#ff0000') + + const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' }) + expect(layoutField.exists()).toBe(true) + }) + + it('displays current color value as text', () => { + const widget = createMockWidget('#ff0000') + const wrapper = mountComponent(widget, '#ff0000') + + const colorText = wrapper.find('[data-testid="widget-color-text"]') + expect(colorText.text()).toBe('#ff0000') + }) + + it('updates color text when value changes', async () => { + const widget = createMockWidget('#ff0000') + const wrapper = mountComponent(widget, '#ff0000') + + await setColorPickerValue(wrapper, '#00ff00') + + // Need to check the local state update + const colorText = wrapper.find('[data-testid="widget-color-text"]') + // Be specific about the displayed value including the leading '#' + expect.soft(colorText.text()).toBe('#00ff00') + }) + + it('uses default color when no value provided', () => { + const widget = createMockWidget('') + const wrapper = mountComponent(widget, '') + + const colorPicker = wrapper.findComponent({ name: 'ColorPicker' }) + // Should use the default value from the composable + expect(colorPicker.exists()).toBe(true) + }) + }) + + describe('Readonly Mode', () => { + it('disables color picker when readonly', () => { + const widget = createMockWidget('#ff0000') + const wrapper = mountComponent(widget, '#ff0000', true) + + const colorPicker = wrapper.findComponent({ name: 'ColorPicker' }) + expect(colorPicker.props('disabled')).toBe(true) + }) + + it('enables color picker when not readonly', () => { + const widget = createMockWidget('#ff0000') + const wrapper = mountComponent(widget, '#ff0000', false) + + const colorPicker = wrapper.findComponent({ name: 'ColorPicker' }) + expect(colorPicker.props('disabled')).toBe(false) + }) + }) + + describe('Color Formats', () => { + it('handles valid hex colors', async () => { + const validHexColors = [ + '#000000', + '#ffffff', + '#ff0000', + '#00ff00', + '#0000ff', + '#123abc' + ] + + for (const color of validHexColors) { + const widget = createMockWidget(color) + const wrapper = mountComponent(widget, color) + + const colorText = wrapper.find('[data-testid="widget-color-text"]') + expect.soft(colorText.text()).toBe(color) + } + }) + + it('handles short hex colors', () => { + const widget = createMockWidget('#fff') + const wrapper = mountComponent(widget, '#fff') + + const colorText = wrapper.find('[data-testid="widget-color-text"]') + expect(colorText.text()).toBe('#fff') + }) + + it('passes widget options to color picker', () => { + const colorOptions = { + format: 'hex' as const, + inline: true + } + const widget = createMockWidget('#ff0000', colorOptions) + const wrapper = mountComponent(widget, '#ff0000') + + const colorPicker = wrapper.findComponent({ name: 'ColorPicker' }) + expect(colorPicker.props('format')).toBe('hex') + expect(colorPicker.props('inline')).toBe(true) + }) + }) + + describe('Widget Layout Integration', () => { + it('passes widget to layout field', () => { + const widget = createMockWidget('#ff0000') + const wrapper = mountComponent(widget, '#ff0000') + + const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' }) + expect(layoutField.props('widget')).toEqual(widget) + }) + + it('maintains proper component structure', () => { + const widget = createMockWidget('#ff0000') + const wrapper = mountComponent(widget, '#ff0000') + + // Should have layout field containing label with color picker and text + const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' }) + const label = wrapper.find('label') + const colorPicker = wrapper.findComponent({ name: 'ColorPicker' }) + const colorText = wrapper.find('span') + + expect(layoutField.exists()).toBe(true) + expect(label.exists()).toBe(true) + expect(colorPicker.exists()).toBe(true) + expect(colorText.exists()).toBe(true) + }) + }) + + describe('Edge Cases', () => { + it('handles empty color value', () => { + const widget = createMockWidget('') + const wrapper = mountComponent(widget, '') + + const colorPicker = wrapper.findComponent({ name: 'ColorPicker' }) + expect(colorPicker.exists()).toBe(true) + }) + + it('handles invalid color formats gracefully', async () => { + const widget = createMockWidget('invalid-color') + const wrapper = mountComponent(widget, 'invalid-color') + + const colorText = wrapper.find('[data-testid="widget-color-text"]') + expect(colorText.text()).toBe('#000000') + + const emitted = await setColorPickerValue(wrapper, 'invalid-color') + expect(emitted).toBeDefined() + expect(emitted![0]).toContain('#000000') + }) + + it('handles widget with no options', () => { + const widget = createMockWidget('#ff0000') + const wrapper = mountComponent(widget, '#ff0000') + + const colorPicker = wrapper.findComponent({ name: 'ColorPicker' }) + expect(colorPicker.exists()).toBe(true) + }) + }) +}) diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.vue index ed5f2b0ec..a3fcb1725 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.vue @@ -14,19 +14,26 @@ :pt="{ preview: '!w-full !h-full !border-none' }" - @update:model-value="onChange" + @update:model-value="onPickerUpdate" /> - #{{ localValue }} + {{ + toHexFromFormat(localValue, format) + }} diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectButton.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectButton.test.ts new file mode 100644 index 000000000..5e5494a09 --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectButton.test.ts @@ -0,0 +1,433 @@ +import { mount } from '@vue/test-utils' +import PrimeVue from 'primevue/config' +import { describe, expect, it, vi } from 'vitest' + +import type { SimplifiedWidget } from '@/types/simplifiedWidget' + +import WidgetSelectButton from './WidgetSelectButton.vue' + +function createMockWidget( + value: string = 'option1', + options: SimplifiedWidget['options'] = {}, + callback?: (value: string) => void +): SimplifiedWidget { + return { + name: 'test_selectbutton', + type: 'string', + value, + options, + callback + } +} + +function mountComponent( + widget: SimplifiedWidget, + modelValue: string, + readonly = false +) { + return mount(WidgetSelectButton, { + global: { + plugins: [PrimeVue] + }, + props: { + widget, + modelValue, + readonly + } + }) +} + +async function clickSelectButton( + wrapper: ReturnType, + optionText: string +) { + const buttons = wrapper.findAll('button') + const targetButton = buttons.find((button) => + button.text().includes(optionText) + ) + + if (!targetButton) { + throw new Error(`Button with text "${optionText}" not found`) + } + + await targetButton.trigger('click') + return targetButton +} + +describe('WidgetSelectButton Button Selection', () => { + describe('Basic Rendering', () => { + it('renders FormSelectButton component', () => { + const widget = createMockWidget('option1', { + values: ['option1', 'option2', 'option3'] + }) + const wrapper = mountComponent(widget, 'option1') + + const formSelectButton = wrapper.findComponent({ + name: 'FormSelectButton' + }) + expect(formSelectButton.exists()).toBe(true) + }) + + it('renders buttons for each option', () => { + const options = ['first', 'second', 'third'] + const widget = createMockWidget('first', { values: options }) + const wrapper = mountComponent(widget, 'first') + + const buttons = wrapper.findAll('button') + expect(buttons).toHaveLength(3) + expect(buttons[0].text()).toBe('first') + expect(buttons[1].text()).toBe('second') + expect(buttons[2].text()).toBe('third') + }) + + it('handles empty options array', () => { + const widget = createMockWidget('', { values: [] }) + const wrapper = mountComponent(widget, '') + + const buttons = wrapper.findAll('button') + expect(buttons).toHaveLength(0) + }) + + it('handles missing values option', () => { + const widget = createMockWidget('') + const wrapper = mountComponent(widget, '') + + const buttons = wrapper.findAll('button') + expect(buttons).toHaveLength(0) + }) + }) + + describe('Selection State', () => { + it('highlights selected option', () => { + const options = ['apple', 'banana', 'cherry'] + const widget = createMockWidget('banana', { values: options }) + const wrapper = mountComponent(widget, 'banana') + + const buttons = wrapper.findAll('button') + const selectedButton = buttons[1] // 'banana' + const unselectedButton = buttons[0] // 'apple' + + expect(selectedButton.classes()).toContain('bg-white') + expect(selectedButton.classes()).toContain('text-neutral-900') + expect(unselectedButton.classes()).not.toContain('bg-white') + expect(unselectedButton.classes()).not.toContain('text-neutral-900') + }) + + it('handles no selection gracefully', () => { + const options = ['option1', 'option2'] + const widget = createMockWidget('nonexistent', { values: options }) + const wrapper = mountComponent(widget, 'nonexistent') + + const buttons = wrapper.findAll('button') + buttons.forEach((button) => { + expect(button.classes()).not.toContain('bg-white') + expect(button.classes()).not.toContain('text-neutral-900') + }) + }) + + it('updates selection when modelValue changes', async () => { + const options = ['first', 'second', 'third'] + const widget = createMockWidget('first', { values: options }) + const wrapper = mountComponent(widget, 'first') + + // Initially 'first' is selected + let buttons = wrapper.findAll('button') + expect(buttons[0].classes()).toContain('bg-white') + + // Update to 'second' + await wrapper.setProps({ modelValue: 'second' }) + buttons = wrapper.findAll('button') + expect(buttons[0].classes()).not.toContain('bg-white') + expect(buttons[1].classes()).toContain('bg-white') + }) + }) + + describe('User Interactions', () => { + it('emits update:modelValue when button is clicked', async () => { + const options = ['first', 'second', 'third'] + const widget = createMockWidget('first', { values: options }) + const wrapper = mountComponent(widget, 'first') + + await clickSelectButton(wrapper, 'second') + + const emitted = wrapper.emitted('update:modelValue') + expect(emitted).toBeDefined() + expect(emitted?.[0]).toEqual(['second']) + }) + + it('handles callback execution when provided', async () => { + const mockCallback = vi.fn() + const options = ['option1', 'option2'] + const widget = createMockWidget( + 'option1', + { values: options }, + mockCallback + ) + const wrapper = mountComponent(widget, 'option1') + + await clickSelectButton(wrapper, 'option2') + + expect(mockCallback).toHaveBeenCalledWith('option2') + }) + + it('handles missing callback gracefully', async () => { + const options = ['option1', 'option2'] + const widget = createMockWidget('option1', { values: options }, undefined) + const wrapper = mountComponent(widget, 'option1') + + await clickSelectButton(wrapper, 'option2') + + // Should still emit Vue event + const emitted = wrapper.emitted('update:modelValue') + expect(emitted).toBeDefined() + expect(emitted?.[0]).toEqual(['option2']) + }) + + it('allows clicking same option again', async () => { + const options = ['option1', 'option2'] + const widget = createMockWidget('option1', { values: options }) + const wrapper = mountComponent(widget, 'option1') + + await clickSelectButton(wrapper, 'option1') + + const emitted = wrapper.emitted('update:modelValue') + expect(emitted).toBeDefined() + expect(emitted?.[0]).toEqual(['option1']) + }) + }) + + describe('Readonly Mode', () => { + it('disables all buttons when readonly', () => { + const options = ['option1', 'option2', 'option3'] + const widget = createMockWidget('option1', { values: options }) + const wrapper = mountComponent(widget, 'option1', true) + + const formSelectButton = wrapper.findComponent({ + name: 'FormSelectButton' + }) + expect(formSelectButton.props('disabled')).toBe(true) + + const buttons = wrapper.findAll('button') + buttons.forEach((button) => { + expect(button.element.disabled).toBe(true) + expect(button.classes()).toContain('cursor-not-allowed') + expect(button.classes()).toContain('opacity-50') + }) + }) + + it('does not emit changes in readonly mode', async () => { + const options = ['option1', 'option2'] + const widget = createMockWidget('option1', { values: options }) + const wrapper = mountComponent(widget, 'option1', true) + + await clickSelectButton(wrapper, 'option2') + + const emitted = wrapper.emitted('update:modelValue') + expect(emitted).toBeUndefined() + }) + + it('does not change visual state in readonly mode', () => { + const options = ['option1', 'option2'] + const widget = createMockWidget('option1', { values: options }) + const wrapper = mountComponent(widget, 'option1', true) + + const buttons = wrapper.findAll('button') + buttons.forEach((button) => { + expect(button.classes()).not.toContain('hover:bg-zinc-200/50') + }) + }) + }) + + describe('Option Types', () => { + it('handles string options', () => { + const options = ['apple', 'banana', 'cherry'] + const widget = createMockWidget('banana', { values: options }) + const wrapper = mountComponent(widget, 'banana') + + const buttons = wrapper.findAll('button') + expect(buttons[0].text()).toBe('apple') + expect(buttons[1].text()).toBe('banana') + expect(buttons[2].text()).toBe('cherry') + }) + + it('handles number options', () => { + const options = [1, 2, 3] + const widget = createMockWidget('2', { values: options }) + const wrapper = mountComponent(widget, '2') + + const buttons = wrapper.findAll('button') + expect(buttons[0].text()).toBe('1') + expect(buttons[1].text()).toBe('2') + expect(buttons[2].text()).toBe('3') + + // The selected button should be the one with '2' + expect(buttons[1].classes()).toContain('bg-white') + }) + + it('handles object options with label and value', () => { + const options = [ + { label: 'First Option', value: 'first' }, + { label: 'Second Option', value: 'second' }, + { label: 'Third Option', value: 'third' } + ] + const widget = createMockWidget('second', { values: options }) + const wrapper = mountComponent(widget, 'second') + + const buttons = wrapper.findAll('button') + expect(buttons[0].text()).toBe('First Option') + expect(buttons[1].text()).toBe('Second Option') + expect(buttons[2].text()).toBe('Third Option') + + // 'second' should be selected + expect(buttons[1].classes()).toContain('bg-white') + }) + + it('emits correct values for object options', async () => { + const options = [ + { label: 'First', value: 'first_val' }, + { label: 'Second', value: 'second_val' } + ] + const widget = createMockWidget('first_val', { values: options }) + const wrapper = mountComponent(widget, 'first_val') + + await clickSelectButton(wrapper, 'Second') + + const emitted = wrapper.emitted('update:modelValue') + expect(emitted).toBeDefined() + expect(emitted?.[0]).toEqual(['second_val']) + }) + }) + + describe('Edge Cases', () => { + it('handles options with special characters', () => { + const options = ['@#$%^&*()', '{}[]|\\:";\'<>?,./'] + const widget = createMockWidget(options[0], { values: options }) + const wrapper = mountComponent(widget, options[0]) + + const buttons = wrapper.findAll('button') + expect(buttons[0].text()).toBe('@#$%^&*()') + expect(buttons[1].text()).toBe('{}[]|\\:";\'<>?,./') + }) + + it('handles empty string options', () => { + const options = ['', 'not empty', ' ', 'normal'] + const widget = createMockWidget('', { values: options }) + const wrapper = mountComponent(widget, '') + + const buttons = wrapper.findAll('button') + expect(buttons).toHaveLength(4) + expect(buttons[0].classes()).toContain('bg-white') // Empty string is selected + }) + + it('handles null/undefined in options', () => { + const options: (string | null | undefined)[] = [ + 'valid', + null, + undefined, + 'another' + ] + const widget = createMockWidget('valid', { values: options }) + const wrapper = mountComponent(widget, 'valid') + + const buttons = wrapper.findAll('button') + expect(buttons).toHaveLength(4) + expect(buttons[0].classes()).toContain('bg-white') + }) + + it('handles very long option text', () => { + const longText = + 'This is a very long option text that might cause layout issues if not handled properly' + const options = ['short', longText, 'normal'] + const widget = createMockWidget('short', { values: options }) + const wrapper = mountComponent(widget, 'short') + + const buttons = wrapper.findAll('button') + expect(buttons[1].text()).toBe(longText) + }) + + it('handles large number of options', () => { + const options = Array.from({ length: 20 }, (_, i) => `option${i + 1}`) + const widget = createMockWidget('option5', { values: options }) + const wrapper = mountComponent(widget, 'option5') + + const buttons = wrapper.findAll('button') + expect(buttons).toHaveLength(20) + expect(buttons[4].classes()).toContain('bg-white') // option5 is at index 4 + }) + + it('handles duplicate options', () => { + const options = ['duplicate', 'unique', 'duplicate', 'unique'] + const widget = createMockWidget('duplicate', { values: options }) + const wrapper = mountComponent(widget, 'duplicate') + + const buttons = wrapper.findAll('button') + expect(buttons).toHaveLength(4) + // Both 'duplicate' buttons should be highlighted (due to value matching) + expect(buttons[0].classes()).toContain('bg-white') + expect(buttons[2].classes()).toContain('bg-white') + }) + }) + + describe('Styling and Layout', () => { + it('applies proper button styling', () => { + const options = ['option1', 'option2'] + const widget = createMockWidget('option1', { values: options }) + const wrapper = mountComponent(widget, 'option1') + + const buttons = wrapper.findAll('button') + buttons.forEach((button) => { + expect(button.classes()).toContain('flex-1') + expect(button.classes()).toContain('h-6') + expect(button.classes()).toContain('px-5') + expect(button.classes()).toContain('rounded') + expect(button.classes()).toContain('text-center') + expect(button.classes()).toContain('text-xs') + }) + }) + + it('applies container styling', () => { + const options = ['option1', 'option2'] + const widget = createMockWidget('option1', { values: options }) + const wrapper = mountComponent(widget, 'option1') + + const container = wrapper.find('div').element + expect(container.className).toContain('p-1') + expect(container.className).toContain('inline-flex') + expect(container.className).toContain('justify-center') + expect(container.className).toContain('items-center') + expect(container.className).toContain('gap-1') + }) + + it('applies hover effects for non-selected options', () => { + const options = ['option1', 'option2'] + const widget = createMockWidget('option1', { values: options }) + const wrapper = mountComponent(widget, 'option1', false) + + const buttons = wrapper.findAll('button') + const unselectedButton = buttons[1] // 'option2' + + expect(unselectedButton.classes()).toContain('hover:bg-zinc-200/50') + expect(unselectedButton.classes()).toContain('cursor-pointer') + }) + }) + + describe('Integration with Layout', () => { + it('renders within WidgetLayoutField', () => { + const widget = createMockWidget('test', { values: ['test'] }) + const wrapper = mountComponent(widget, 'test') + + const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' }) + expect(layoutField.exists()).toBe(true) + expect(layoutField.props('widget')).toEqual(widget) + }) + + it('passes widget name to layout field', () => { + const widget = createMockWidget('test', { values: ['test'] }) + widget.name = 'custom_select_button' + const wrapper = mountComponent(widget, 'test') + + const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' }) + expect(layoutField.props('widget').name).toBe('custom_select_button') + }) + }) +}) diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.test.ts new file mode 100644 index 000000000..be16d31a9 --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.test.ts @@ -0,0 +1,260 @@ +import { mount } from '@vue/test-utils' +import PrimeVue from 'primevue/config' +import Textarea from 'primevue/textarea' +import { describe, expect, it } from 'vitest' + +import type { SimplifiedWidget } from '@/types/simplifiedWidget' + +import WidgetTextarea from './WidgetTextarea.vue' + +function createMockWidget( + value: string = 'default text', + options: SimplifiedWidget['options'] = {}, + callback?: (value: string) => void +): SimplifiedWidget { + return { + name: 'test_textarea', + type: 'string', + value, + options, + callback + } +} + +function mountComponent( + widget: SimplifiedWidget, + modelValue: string, + readonly = false, + placeholder?: string +) { + return mount(WidgetTextarea, { + global: { + plugins: [PrimeVue], + components: { Textarea } + }, + props: { + widget, + modelValue, + readonly, + placeholder + } + }) +} + +async function setTextareaValueAndTrigger( + wrapper: ReturnType, + value: string, + trigger: 'blur' | 'input' = 'blur' +) { + const textarea = wrapper.find('textarea') + if (!(textarea.element instanceof HTMLTextAreaElement)) { + throw new Error( + 'Textarea element not found or is not an HTMLTextAreaElement' + ) + } + await textarea.setValue(value) + await textarea.trigger(trigger) + return textarea +} + +describe('WidgetTextarea Value Binding', () => { + describe('Vue Event Emission', () => { + it('emits Vue event when textarea value changes on blur', async () => { + const widget = createMockWidget('hello') + const wrapper = mountComponent(widget, 'hello') + + await setTextareaValueAndTrigger(wrapper, 'world', 'blur') + + const emitted = wrapper.emitted('update:modelValue') + expect(emitted).toBeDefined() + expect(emitted?.[0]).toContain('world') + }) + + it('emits Vue event when textarea value changes on input', async () => { + const widget = createMockWidget('initial') + const wrapper = mountComponent(widget, 'initial') + + await setTextareaValueAndTrigger(wrapper, 'new content', 'input') + + const emitted = wrapper.emitted('update:modelValue') + expect(emitted).toBeDefined() + expect(emitted?.[0]).toContain('new content') + }) + + it('handles empty string values', async () => { + const widget = createMockWidget('something') + const wrapper = mountComponent(widget, 'something') + + await setTextareaValueAndTrigger(wrapper, '') + + const emitted = wrapper.emitted('update:modelValue') + expect(emitted).toBeDefined() + expect(emitted?.[0]).toContain('') + }) + + it('handles multiline text correctly', async () => { + const widget = createMockWidget('single line') + const wrapper = mountComponent(widget, 'single line') + + const multilineText = 'Line 1\nLine 2\nLine 3' + await setTextareaValueAndTrigger(wrapper, multilineText) + + const emitted = wrapper.emitted('update:modelValue') + expect(emitted).toBeDefined() + expect(emitted?.[0]).toContain(multilineText) + }) + + it('handles special characters correctly', async () => { + const widget = createMockWidget('normal') + const wrapper = mountComponent(widget, 'normal') + + const specialText = 'special @#$%^&*()[]{}|\\:";\'<>?,./' + await setTextareaValueAndTrigger(wrapper, specialText) + + const emitted = wrapper.emitted('update:modelValue') + expect(emitted).toBeDefined() + expect(emitted?.[0]).toContain(specialText) + }) + + it('handles missing callback gracefully', async () => { + const widget = createMockWidget('test', {}, undefined) + const wrapper = mountComponent(widget, 'test') + + await setTextareaValueAndTrigger(wrapper, 'new value') + + // Should still emit Vue event + const emitted = wrapper.emitted('update:modelValue') + expect(emitted).toBeDefined() + expect(emitted?.[0]).toContain('new value') + }) + }) + + describe('User Interactions', () => { + it('emits update:modelValue on blur', async () => { + const widget = createMockWidget('original') + const wrapper = mountComponent(widget, 'original') + + await setTextareaValueAndTrigger(wrapper, 'updated') + + const emitted = wrapper.emitted('update:modelValue') + expect(emitted).toBeDefined() + expect(emitted?.[0]).toContain('updated') + }) + + it('emits update:modelValue on input', async () => { + const widget = createMockWidget('start') + const wrapper = mountComponent(widget, 'start') + + await setTextareaValueAndTrigger(wrapper, 'finish', 'input') + + const emitted = wrapper.emitted('update:modelValue') + expect(emitted).toBeDefined() + expect(emitted?.[0]).toContain('finish') + }) + }) + + describe('Readonly Mode', () => { + it('disables textarea when readonly', () => { + const widget = createMockWidget('readonly test') + const wrapper = mountComponent(widget, 'readonly test', true) + + const textarea = wrapper.find('textarea') + if (!(textarea.element instanceof HTMLTextAreaElement)) { + throw new Error( + 'Textarea element not found or is not an HTMLTextAreaElement' + ) + } + expect(textarea.element.disabled).toBe(true) + }) + }) + + describe('Component Rendering', () => { + it('renders textarea component', () => { + const widget = createMockWidget('test value') + const wrapper = mountComponent(widget, 'test value') + + const textarea = wrapper.find('textarea') + expect(textarea.exists()).toBe(true) + }) + + it('displays initial value in textarea', () => { + const widget = createMockWidget('initial content') + const wrapper = mountComponent(widget, 'initial content') + + const textarea = wrapper.find('textarea') + if (!(textarea.element instanceof HTMLTextAreaElement)) { + throw new Error( + 'Textarea element not found or is not an HTMLTextAreaElement' + ) + } + expect(textarea.element.value).toBe('initial content') + }) + + it('uses widget name as placeholder when no placeholder provided', () => { + const widget = createMockWidget('test') + const wrapper = mountComponent(widget, 'test') + + const textarea = wrapper.find('textarea') + expect(textarea.attributes('placeholder')).toBe('test_textarea') + }) + + it('uses provided placeholder when specified', () => { + const widget = createMockWidget('test') + const wrapper = mountComponent( + widget, + 'test', + false, + 'Custom placeholder' + ) + + const textarea = wrapper.find('textarea') + expect(textarea.attributes('placeholder')).toBe('Custom placeholder') + }) + + it('sets default rows attribute', () => { + const widget = createMockWidget('test') + const wrapper = mountComponent(widget, 'test') + + const textarea = wrapper.find('textarea') + expect(textarea.attributes('rows')).toBe('3') + }) + }) + + describe('Edge Cases', () => { + it('handles very long text', async () => { + const widget = createMockWidget('short') + const wrapper = mountComponent(widget, 'short') + + const longText = 'a'.repeat(10000) + await setTextareaValueAndTrigger(wrapper, longText) + + const emitted = wrapper.emitted('update:modelValue') + expect(emitted).toBeDefined() + expect(emitted?.[0]).toContain(longText) + }) + + it('handles unicode characters', async () => { + const widget = createMockWidget('ascii') + const wrapper = mountComponent(widget, 'ascii') + + const unicodeText = '🎨 Unicode: αβγ 中文 العربية 🚀' + await setTextareaValueAndTrigger(wrapper, unicodeText) + + const emitted = wrapper.emitted('update:modelValue') + expect(emitted).toBeDefined() + expect(emitted?.[0]).toContain(unicodeText) + }) + + it('handles text with tabs and spaces', async () => { + const widget = createMockWidget('normal') + const wrapper = mountComponent(widget, 'normal') + + const formattedText = '\tIndented line\n Spaced line\n\t\tDouble indent' + await setTextareaValueAndTrigger(wrapper, formattedText) + + const emitted = wrapper.emitted('update:modelValue') + expect(emitted).toBeDefined() + expect(emitted?.[0]).toContain(formattedText) + }) + }) +}) diff --git a/src/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue b/src/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue index 3d8d254df..64aaf6b19 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue @@ -14,7 +14,7 @@ defineProps<{ >

{{ widget.name }}

diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useImagePreviewWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useImagePreviewWidget.ts index 0abd1b078..178deac73 100644 --- a/src/renderer/extensions/vueNodes/widgets/composables/useImagePreviewWidget.ts +++ b/src/renderer/extensions/vueNodes/widgets/composables/useImagePreviewWidget.ts @@ -8,11 +8,11 @@ import type { IBaseWidget, IWidgetOptions } from '@/lib/litegraph/src/types/widgets' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' import { app } from '@/scripts/app' import { calculateImageGrid } from '@/scripts/ui/imagePreview' -import { type ComfyWidgetConstructorV2 } from '@/scripts/widgets' -import { useCanvasStore } from '@/stores/graphStore' +import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets' import { useSettingStore } from '@/stores/settingStore' import { is_all_same_aspect_ratio } from '@/utils/imageUtil' diff --git a/src/renderer/extensions/vueNodes/widgets/testUtils.ts b/src/renderer/extensions/vueNodes/widgets/testUtils.ts new file mode 100644 index 000000000..c8d112528 --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/testUtils.ts @@ -0,0 +1,33 @@ +import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget' + +/** + * Creates a mock SimplifiedWidget for testing Vue Node widgets. + * This utility function is shared across widget component tests to ensure consistency. + */ +export function createMockWidget( + value: T = null as T, + options: Record = {}, + callback?: (value: T) => void, + overrides: Partial> = {} +): SimplifiedWidget { + return { + name: 'test_widget', + type: 'default', + value, + options, + callback, + ...overrides + } +} + +/** + * Creates a mock file for testing file upload widgets. + */ +export function createMockFile(name: string, type: string, size = 1024): File { + const file = new File(['mock content'], name, { type }) + Object.defineProperty(file, 'size', { + value: size, + writable: false + }) + return file +} diff --git a/src/renderer/thumbnail/graphThumbnailRenderer.ts b/src/renderer/thumbnail/graphThumbnailRenderer.ts index 5aac33bd2..25a1ac21c 100644 --- a/src/renderer/thumbnail/graphThumbnailRenderer.ts +++ b/src/renderer/thumbnail/graphThumbnailRenderer.ts @@ -1,9 +1,9 @@ import type { LGraph } from '@/lib/litegraph/src/litegraph' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { calculateMinimapScale, calculateNodeBounds } from '@/renderer/core/spatial/boundsCalculator' -import { useCanvasStore } from '@/stores/graphStore' import { useWorkflowStore } from '@/stores/workflowStore' import { renderMinimapToCanvas } from '../extensions/minimap/minimapCanvasRenderer' diff --git a/src/scripts/app.ts b/src/scripts/app.ts index 0a440e222..4ca75c5d1 100644 --- a/src/scripts/app.ts +++ b/src/scripts/app.ts @@ -652,7 +652,7 @@ export class ComfyApp { if (opacity) adjustments.opacity = opacity if (useColorPaletteStore().completedActivePalette.light_theme) { - adjustments.lightness = 0.5 + if (old_bgcolor) adjustments.lightness = 0.5 // Lighten title bar of colored nodes on light theme if (old_color) { diff --git a/src/services/keybindingService.ts b/src/services/keybindingService.ts index 7503bdb5a..e06bfbef4 100644 --- a/src/services/keybindingService.ts +++ b/src/services/keybindingService.ts @@ -1,4 +1,5 @@ import { CORE_KEYBINDINGS } from '@/constants/coreKeybindings' +import { app } from '@/scripts/app' import { useCommandStore } from '@/stores/commandStore' import { useDialogStore } from '@/stores/dialogStore' import { @@ -14,6 +15,19 @@ export const useKeybindingService = () => { const settingStore = useSettingStore() const dialogStore = useDialogStore() + // Helper function to determine if an event should be forwarded to canvas + const shouldForwardToCanvas = (event: KeyboardEvent): boolean => { + // Don't forward if modifier keys are pressed (except shift) + if (event.ctrlKey || event.altKey || event.metaKey) { + return false + } + + // Keys that LiteGraph handles but aren't in core keybindings + const canvasKeys = ['Delete', 'Backspace'] + + return canvasKeys.includes(event.key) + } + const keybindHandler = async function (event: KeyboardEvent) { const keyCombo = KeyComboImpl.fromEvent(event) if (keyCombo.isModifier) { @@ -26,6 +40,7 @@ export const useKeybindingService = () => { keyCombo.isReservedByTextInput && (target.tagName === 'TEXTAREA' || target.tagName === 'INPUT' || + target.contentEditable === 'true' || (target.tagName === 'SPAN' && target.classList.contains('property_value'))) ) { @@ -53,6 +68,20 @@ export const useKeybindingService = () => { return } + // Forward unhandled canvas-targeted events to LiteGraph + if (!keybinding && shouldForwardToCanvas(event)) { + const canvas = app.canvas + if ( + canvas && + canvas.processKey && + typeof canvas.processKey === 'function' + ) { + // Let LiteGraph handle the event + canvas.processKey(event) + return + } + } + // Only clear dialogs if not using modifiers if (event.ctrlKey || event.altKey || event.metaKey) { return diff --git a/src/services/litegraphService.ts b/src/services/litegraphService.ts index 89e33965d..892bdbe0a 100644 --- a/src/services/litegraphService.ts +++ b/src/services/litegraphService.ts @@ -24,6 +24,7 @@ import type { ISerialisableNodeOutput, ISerialisedNode } from '@/lib/litegraph/src/types/serialisation' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import type { NodeId } from '@/schemas/comfyWorkflowSchema' import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration' import type { @@ -37,7 +38,6 @@ import { isComponentWidget, isDOMWidget } from '@/scripts/domWidget' import { $el } from '@/scripts/ui' import { useDomWidgetStore } from '@/stores/domWidgetStore' import { useExecutionStore } from '@/stores/executionStore' -import { useCanvasStore } from '@/stores/graphStore' import { useNodeOutputStore } from '@/stores/imagePreviewStore' import { ComfyNodeDefImpl } from '@/stores/nodeDefStore' import { useSettingStore } from '@/stores/settingStore' diff --git a/src/stores/executionStore.ts b/src/stores/executionStore.ts index 44bb13d2d..d28e22978 100644 --- a/src/stores/executionStore.ts +++ b/src/stores/executionStore.ts @@ -5,6 +5,7 @@ import type ChatHistoryWidget from '@/components/graph/widgets/ChatHistoryWidget import { useNodeChatHistory } from '@/composables/node/useNodeChatHistory' import { useNodeProgressText } from '@/composables/node/useNodeProgressText' import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import type { DisplayComponentWsMessage, ExecutedWsMessage, @@ -28,7 +29,6 @@ import { useNodeOutputStore } from '@/stores/imagePreviewStore' import type { NodeLocatorId } from '@/types/nodeIdentification' import { createNodeLocatorId } from '@/types/nodeIdentification' -import { useCanvasStore } from './graphStore' import { ComfyWorkflow, useWorkflowStore } from './workflowStore' interface QueuedPrompt { @@ -132,7 +132,7 @@ export const useExecutionStore = defineStore('execution', () => { // Easily access all currently executing node IDs const executingNodeIds = computed(() => { - return Object.entries(nodeProgressStates) + return Object.entries(nodeProgressStates.value) .filter(([_, state]) => state.state === 'running') .map(([nodeId, _]) => nodeId) }) @@ -220,6 +220,19 @@ export const useExecutionStore = defineStore('execution', () => { return total > 0 ? done / total : 0 }) + const lastExecutionErrorNodeLocatorId = computed(() => { + const err = lastExecutionError.value + if (!err) return null + return executionIdToNodeLocatorId(String(err.node_id)) + }) + + const lastExecutionErrorNodeId = computed(() => { + const locator = lastExecutionErrorNodeLocatorId.value + if (!locator) return null + const localId = workflowStore.nodeLocatorIdToNodeId(locator) + return localId != null ? String(localId) : null + }) + function bindExecutionEvents() { api.addEventListener('execution_start', handleExecutionStart) api.addEventListener('execution_cached', handleExecutionCached) @@ -426,6 +439,10 @@ export const useExecutionStore = defineStore('execution', () => { * The error from the previous execution. */ lastExecutionError, + /** + * Local node ID for the most recent execution error. + */ + lastExecutionErrorNodeId, /** * The id of the node that is currently being executed (backward compatibility) */ diff --git a/src/stores/imagePreviewStore.ts b/src/stores/imagePreviewStore.ts index f0e3b4ab7..a4f778280 100644 --- a/src/stores/imagePreviewStore.ts +++ b/src/stores/imagePreviewStore.ts @@ -1,4 +1,5 @@ import { defineStore } from 'pinia' +import { ref } from 'vue' import { type LGraphNode, @@ -40,6 +41,8 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => { const { nodeIdToNodeLocatorId } = useWorkflowStore() const { executionIdToNodeLocatorId } = useExecutionStore() + const nodeOutputs = ref>({}) + function getNodeOutputs( node: LGraphNode ): ExecutedWsMessage['output'] | undefined { @@ -128,6 +131,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => { } app.nodeOutputs[nodeLocatorId] = outputs + nodeOutputs.value[nodeLocatorId] = outputs } function setNodeOutputs( @@ -271,17 +275,49 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => { } } + /** + * Remove node outputs for a specific node + * Clears both outputs and preview images + */ + function removeNodeOutputs(nodeId: number | string) { + const nodeLocatorId = nodeIdToNodeLocatorId(Number(nodeId)) + if (!nodeLocatorId) return false + + // Clear from app.nodeOutputs + const hadOutputs = !!app.nodeOutputs[nodeLocatorId] + delete app.nodeOutputs[nodeLocatorId] + + // Clear from reactive state + delete nodeOutputs.value[nodeLocatorId] + + // Clear preview images + if (app.nodePreviewImages[nodeLocatorId]) { + delete app.nodePreviewImages[nodeLocatorId] + } + + return hadOutputs + } + return { + // Getters getNodeOutputs, getNodeImageUrls, getNodePreviews, + getPreviewParam, + + // Setters setNodeOutputs, setNodeOutputsByExecutionId, setNodePreviewsByExecutionId, setNodePreviewsByNodeId, + + // Cleanup revokePreviewsByExecutionId, revokeAllPreviews, revokeSubgraphPreviews, - getPreviewParam + removeNodeOutputs, + + // State + nodeOutputs } }) diff --git a/src/stores/subgraphNavigationStore.ts b/src/stores/subgraphNavigationStore.ts index 0ff72f205..f8c6031c2 100644 --- a/src/stores/subgraphNavigationStore.ts +++ b/src/stores/subgraphNavigationStore.ts @@ -4,11 +4,11 @@ import { computed, ref, shallowRef, watch } from 'vue' import type { DragAndScaleState } from '@/lib/litegraph/src/DragAndScale' import type { Subgraph } from '@/lib/litegraph/src/litegraph' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { app } from '@/scripts/app' import { findSubgraphPathById } from '@/utils/graphTraversalUtil' import { isNonNullish } from '@/utils/typeGuardUtil' -import { useCanvasStore } from './graphStore' import { useWorkflowStore } from './workflowStore' /** diff --git a/src/stores/subgraphStore.ts b/src/stores/subgraphStore.ts index 68b2f5c25..c6b77fdd4 100644 --- a/src/stores/subgraphStore.ts +++ b/src/stores/subgraphStore.ts @@ -3,6 +3,7 @@ import { computed, ref } from 'vue' import { t } from '@/i18n' import { SubgraphNode } from '@/lib/litegraph/src/litegraph' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import type { NodeError } from '@/schemas/apiSchema' import type { ComfyNode, @@ -17,7 +18,6 @@ import { api } from '@/scripts/api' import { useDialogService } from '@/services/dialogService' import { useWorkflowService } from '@/services/workflowService' import { useExecutionStore } from '@/stores/executionStore' -import { useCanvasStore } from '@/stores/graphStore' import { ComfyNodeDefImpl } from '@/stores/nodeDefStore' import { useSettingStore } from '@/stores/settingStore' import { useToastStore } from '@/stores/toastStore' diff --git a/src/types/settingTypes.ts b/src/types/settingTypes.ts index 08453bde9..b1efbe9a1 100644 --- a/src/types/settingTypes.ts +++ b/src/types/settingTypes.ts @@ -6,6 +6,7 @@ type SettingInputType = | 'slider' | 'knob' | 'combo' + | 'radio' | 'text' | 'image' | 'color' @@ -43,6 +44,9 @@ export interface SettingParams extends FormItem { versionAdded?: string // Version of the setting when it was last modified versionModified?: string + // sortOrder for sorting settings within a group. Higher values appear first. + // Default is 0 if not specified. + sortOrder?: number } /** diff --git a/src/utils/colorUtil.ts b/src/utils/colorUtil.ts index e6df94953..020b83fbb 100644 --- a/src/utils/colorUtil.ts +++ b/src/utils/colorUtil.ts @@ -1,9 +1,20 @@ import { memoize } from 'es-toolkit/compat' type RGB = { r: number; g: number; b: number } +export interface HSB { + h: number + s: number + b: number +} type HSL = { h: number; s: number; l: number } type HSLA = { h: number; s: number; l: number; a: number } -type ColorFormat = 'hex' | 'rgb' | 'rgba' | 'hsl' | 'hsla' +type ColorFormatInternal = 'hex' | 'rgb' | 'rgba' | 'hsl' | 'hsla' +export type ColorFormat = 'hex' | 'rgb' | 'hsb' +interface HSV { + h: number + s: number + v: number +} export interface ColorAdjustOptions { lightness?: number @@ -59,6 +70,65 @@ export function hexToRgb(hex: string): RGB { return { r, g, b } } +export function rgbToHex({ r, g, b }: RGB): string { + const toHex = (n: number) => + Math.max(0, Math.min(255, Math.round(n))) + .toString(16) + .padStart(2, '0') + return `#${toHex(r)}${toHex(g)}${toHex(b)}` +} + +export function hsbToRgb({ h, s, b }: HSB): RGB { + // Normalize + const hh = ((h % 360) + 360) % 360 + const ss = Math.max(0, Math.min(100, s)) / 100 + const vv = Math.max(0, Math.min(100, b)) / 100 + + const c = vv * ss + const x = c * (1 - Math.abs(((hh / 60) % 2) - 1)) + const m = vv - c + + let rp = 0, + gp = 0, + bp = 0 + + if (hh < 60) { + rp = c + gp = x + bp = 0 + } else if (hh < 120) { + rp = x + gp = c + bp = 0 + } else if (hh < 180) { + rp = 0 + gp = c + bp = x + } else if (hh < 240) { + rp = 0 + gp = x + bp = c + } else if (hh < 300) { + rp = x + gp = 0 + bp = c + } else { + rp = c + gp = 0 + bp = x + } + + return { + r: Math.floor((rp + m) * 255), + g: Math.floor((gp + m) * 255), + b: Math.floor((bp + m) * 255) + } +} + +/** + * Normalize various color inputs (hex, rgb/rgba, hsl/hsla, hsb string/object) + * into lowercase #rrggbb. Falls back to #000000 on invalid inputs. + */ export function parseToRgb(color: string): RGB { const format = identifyColorFormat(color) if (!format) return { r: 0, g: 0, b: 0 } @@ -112,7 +182,7 @@ export function parseToRgb(color: string): RGB { } } -const identifyColorFormat = (color: string): ColorFormat | null => { +const identifyColorFormat = (color: string): ColorFormatInternal | null => { if (!color) return null if (color.startsWith('#') && (color.length === 4 || color.length === 7)) return 'hex' @@ -133,7 +203,73 @@ const isHSLA = (color: unknown): color is HSLA => { ) } -function parseToHSLA(color: string, format: ColorFormat): HSLA | null { +export function isColorFormat(v: unknown): v is ColorFormat { + return v === 'hex' || v === 'rgb' || v === 'hsb' +} + +function isHSBObject(v: unknown): v is HSB { + if (!v || typeof v !== 'object') return false + const rec = v as Record + return ( + typeof rec.h === 'number' && + Number.isFinite(rec.h) && + typeof rec.s === 'number' && + Number.isFinite(rec.s) && + typeof (rec as Record).b === 'number' && + Number.isFinite((rec as Record).b!) + ) +} + +function isHSVObject(v: unknown): v is HSV { + if (!v || typeof v !== 'object') return false + const rec = v as Record + return ( + typeof rec.h === 'number' && + Number.isFinite(rec.h) && + typeof rec.s === 'number' && + Number.isFinite(rec.s) && + typeof (rec as Record).v === 'number' && + Number.isFinite((rec as Record).v!) + ) +} + +export function toHexFromFormat(val: unknown, format: ColorFormat): string { + if (format === 'hex' && typeof val === 'string') { + const raw = val.trim().toLowerCase() + if (!raw) return '#000000' + if (/^[0-9a-f]{3}$/.test(raw)) return `#${raw}` + if (/^#[0-9a-f]{3}$/.test(raw)) return raw + if (/^[0-9a-f]{6}$/.test(raw)) return `#${raw}` + if (/^#[0-9a-f]{6}$/.test(raw)) return raw + return '#000000' + } + + if (format === 'rgb' && typeof val === 'string') { + const rgb = parseToRgb(val) + return rgbToHex(rgb).toLowerCase() + } + + if (format === 'hsb') { + if (isHSBObject(val)) { + return rgbToHex(hsbToRgb(val)).toLowerCase() + } + if (isHSVObject(val)) { + const { h, s, v } = val + return rgbToHex(hsbToRgb({ h, s, b: v })).toLowerCase() + } + if (typeof val === 'string') { + const nums = val.match(/\d+(?:\.\d+)?/g)?.map(Number) || [] + if (nums.length >= 3) { + return rgbToHex( + hsbToRgb({ h: nums[0], s: nums[1], b: nums[2] }) + ).toLowerCase() + } + } + } + return '#000000' +} + +function parseToHSLA(color: string, format: ColorFormatInternal): HSLA | null { let match: RegExpMatchArray | null switch (format) { diff --git a/src/views/ServerStartView.vue b/src/views/ServerStartView.vue index 27c3f7786..c0e13bd94 100644 --- a/src/views/ServerStartView.vue +++ b/src/views/ServerStartView.vue @@ -100,10 +100,3 @@ onMounted(async () => { electronVersion.value = await electron.getElectronVersion() }) - - diff --git a/tests-ui/tests/base/common/downloadUtil.test.ts b/tests-ui/tests/base/common/downloadUtil.test.ts new file mode 100644 index 000000000..7231b3620 --- /dev/null +++ b/tests-ui/tests/base/common/downloadUtil.test.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { downloadFile } from '@/base/common/downloadUtil' + +describe('downloadUtil', () => { + let mockLink: HTMLAnchorElement + + beforeEach(() => { + // Create a mock anchor element + mockLink = { + href: '', + download: '', + click: vi.fn() + } as unknown as HTMLAnchorElement + + // Spy on DOM methods + vi.spyOn(document, 'createElement').mockReturnValue(mockLink) + vi.spyOn(document.body, 'appendChild').mockImplementation(() => mockLink) + vi.spyOn(document.body, 'removeChild').mockImplementation(() => mockLink) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('downloadFile', () => { + it('should create and trigger download with basic URL', () => { + const testUrl = 'https://example.com/image.png' + + downloadFile(testUrl) + + expect(document.createElement).toHaveBeenCalledWith('a') + expect(mockLink.href).toBe(testUrl) + expect(mockLink.download).toBe('download.png') // Default filename + expect(document.body.appendChild).toHaveBeenCalledWith(mockLink) + expect(mockLink.click).toHaveBeenCalled() + expect(document.body.removeChild).toHaveBeenCalledWith(mockLink) + }) + + it('should use custom filename when provided', () => { + const testUrl = 'https://example.com/image.png' + const customFilename = 'my-custom-image.png' + + downloadFile(testUrl, customFilename) + + expect(mockLink.href).toBe(testUrl) + expect(mockLink.download).toBe(customFilename) + }) + + it('should extract filename from URL query parameters', () => { + const testUrl = + 'https://example.com/api/file?filename=extracted-image.jpg&other=param' + + downloadFile(testUrl) + + expect(mockLink.href).toBe(testUrl) + expect(mockLink.download).toBe('extracted-image.jpg') + }) + + it('should use default filename when URL has no filename parameter', () => { + const testUrl = 'https://example.com/api/file?other=param' + + downloadFile(testUrl) + + expect(mockLink.href).toBe(testUrl) + expect(mockLink.download).toBe('download.png') + }) + + it('should handle invalid URLs gracefully', () => { + const invalidUrl = 'not-a-valid-url' + + downloadFile(invalidUrl) + + expect(mockLink.href).toBe(invalidUrl) + expect(mockLink.download).toBe('download.png') + expect(mockLink.click).toHaveBeenCalled() + }) + + it('should prefer custom filename over extracted filename', () => { + const testUrl = + 'https://example.com/api/file?filename=extracted-image.jpg' + const customFilename = 'custom-override.png' + + downloadFile(testUrl, customFilename) + + expect(mockLink.download).toBe(customFilename) + }) + + it('should handle URLs with empty filename parameter', () => { + const testUrl = 'https://example.com/api/file?filename=' + + downloadFile(testUrl) + + expect(mockLink.download).toBe('download.png') + }) + + it('should handle relative URLs by using window.location.origin', () => { + const relativeUrl = '/api/file?filename=relative-image.png' + + downloadFile(relativeUrl) + + expect(mockLink.href).toBe(relativeUrl) + expect(mockLink.download).toBe('relative-image.png') + }) + + it('should clean up DOM elements after download', () => { + const testUrl = 'https://example.com/image.png' + + downloadFile(testUrl) + + // Verify the element was added and then removed + expect(document.body.appendChild).toHaveBeenCalledWith(mockLink) + expect(document.body.removeChild).toHaveBeenCalledWith(mockLink) + }) + }) +}) diff --git a/tests-ui/tests/colorUtil.test.ts b/tests-ui/tests/colorUtil.test.ts index daebacbb6..0f21d5d15 100644 --- a/tests-ui/tests/colorUtil.test.ts +++ b/tests-ui/tests/colorUtil.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it, vi } from 'vitest' -import { adjustColor } from '@/utils/colorUtil' +import { + adjustColor, + hexToRgb, + hsbToRgb, + parseToRgb, + rgbToHex +} from '@/utils/colorUtil' interface ColorTestCase { hex: string @@ -55,6 +61,74 @@ const colors: Record = { const formats: ColorFormat[] = ['hex', 'rgb', 'rgba', 'hsl', 'hsla'] +describe('colorUtil conversions', () => { + describe('hexToRgb / rgbToHex', () => { + it('converts 6-digit hex to RGB', () => { + expect(hexToRgb('#ff0000')).toEqual({ r: 255, g: 0, b: 0 }) + expect(hexToRgb('#00ff00')).toEqual({ r: 0, g: 255, b: 0 }) + expect(hexToRgb('#0000ff')).toEqual({ r: 0, g: 0, b: 255 }) + }) + + it('converts 3-digit hex to RGB', () => { + expect(hexToRgb('#f00')).toEqual({ r: 255, g: 0, b: 0 }) + expect(hexToRgb('#0f0')).toEqual({ r: 0, g: 255, b: 0 }) + expect(hexToRgb('#00f')).toEqual({ r: 0, g: 0, b: 255 }) + }) + + it('converts RGB to lowercase #hex and clamps values', () => { + expect(rgbToHex({ r: 255, g: 0, b: 0 })).toBe('#ff0000') + expect(rgbToHex({ r: 0, g: 255, b: 0 })).toBe('#00ff00') + expect(rgbToHex({ r: 0, g: 0, b: 255 })).toBe('#0000ff') + // out-of-range should clamp + expect(rgbToHex({ r: -10, g: 300, b: 16 })).toBe('#00ff10') + }) + + it('round-trips #hex -> rgb -> #hex', () => { + const hex = '#123abc' + expect(rgbToHex(hexToRgb(hex))).toBe('#123abc') + }) + }) + + describe('parseToRgb', () => { + it('parses #hex', () => { + expect(parseToRgb('#ff0000')).toEqual({ r: 255, g: 0, b: 0 }) + }) + + it('parses rgb()/rgba()', () => { + expect(parseToRgb('rgb(255, 0, 0)')).toEqual({ r: 255, g: 0, b: 0 }) + expect(parseToRgb('rgba(255,0,0,0.5)')).toEqual({ r: 255, g: 0, b: 0 }) + }) + + it('parses hsl()/hsla()', () => { + expect(parseToRgb('hsl(0, 100%, 50%)')).toEqual({ r: 255, g: 0, b: 0 }) + const green = parseToRgb('hsla(120, 100%, 50%, 0.7)') + expect(green.r).toBe(0) + expect(green.g).toBe(255) + expect(green.b).toBe(0) + }) + }) + + describe('hsbToRgb', () => { + it('converts HSB to primary RGB colors', () => { + expect(hsbToRgb({ h: 0, s: 100, b: 100 })).toEqual({ r: 255, g: 0, b: 0 }) + expect(hsbToRgb({ h: 120, s: 100, b: 100 })).toEqual({ + r: 0, + g: 255, + b: 0 + }) + expect(hsbToRgb({ h: 240, s: 100, b: 100 })).toEqual({ + r: 0, + g: 0, + b: 255 + }) + }) + + it('handles non-100 brightness and clamps/normalizes input', () => { + const rgb = hsbToRgb({ h: 360, s: 150, b: 50 }) + expect(rgbToHex(rgb)).toBe('#7f0000') + }) + }) +}) describe('colorUtil - adjustColor', () => { const runAdjustColorTests = ( color: ColorTestCase, diff --git a/tests-ui/tests/components/graph/ZoomControlsModal.spec.ts b/tests-ui/tests/components/graph/ZoomControlsModal.spec.ts index 910b70741..1bfdaaf26 100644 --- a/tests-ui/tests/components/graph/ZoomControlsModal.spec.ts +++ b/tests-ui/tests/components/graph/ZoomControlsModal.spec.ts @@ -28,7 +28,7 @@ vi.mock('@/stores/commandStore', () => ({ }) })) -vi.mock('@/stores/graphStore', () => ({ +vi.mock('@/renderer/core/canvas/canvasStore', () => ({ useCanvasStore: () => ({ appScalePercentage: 100, setAppZoomFromPercentage: mockSetAppZoom diff --git a/tests-ui/tests/composables/canvas/useCanvasTransformSync.test.ts b/tests-ui/tests/composables/canvas/useCanvasTransformSync.test.ts index 09eb10bca..1346c3e52 100644 --- a/tests-ui/tests/composables/canvas/useCanvasTransformSync.test.ts +++ b/tests-ui/tests/composables/canvas/useCanvasTransformSync.test.ts @@ -5,7 +5,7 @@ import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformS // Mock canvas store let mockGetCanvas = vi.fn() -vi.mock('@/stores/graphStore', () => ({ +vi.mock('@/renderer/core/canvas/canvasStore', () => ({ useCanvasStore: vi.fn(() => ({ getCanvas: mockGetCanvas })) diff --git a/tests-ui/tests/composables/canvas/useSelectedLiteGraphItems.test.ts b/tests-ui/tests/composables/canvas/useSelectedLiteGraphItems.test.ts index 38c488f65..40d129c52 100644 --- a/tests-ui/tests/composables/canvas/useSelectedLiteGraphItems.test.ts +++ b/tests-ui/tests/composables/canvas/useSelectedLiteGraphItems.test.ts @@ -8,8 +8,8 @@ import { LGraphNode, Reroute } from '@/lib/litegraph/src/litegraph' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { app } from '@/scripts/app' -import { useCanvasStore } from '@/stores/graphStore' // Mock the app module vi.mock('@/scripts/app', () => ({ diff --git a/tests-ui/tests/composables/element/useTransformState.test.ts b/tests-ui/tests/composables/element/useTransformState.test.ts index e22f34242..457b2fdd2 100644 --- a/tests-ui/tests/composables/element/useTransformState.test.ts +++ b/tests-ui/tests/composables/element/useTransformState.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest' -import { useTransformState } from '@/renderer/core/layout/useTransformState' +import { useTransformState } from '@/renderer/core/layout/transform/useTransformState' // Create a mock canvas context for transform testing function createMockCanvasContext() { diff --git a/tests-ui/tests/composables/graph/useCanvasInteractions.test.ts b/tests-ui/tests/composables/graph/useCanvasInteractions.test.ts index b2c670f3e..1f492a694 100644 --- a/tests-ui/tests/composables/graph/useCanvasInteractions.test.ts +++ b/tests-ui/tests/composables/graph/useCanvasInteractions.test.ts @@ -2,13 +2,19 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions' import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph' -import { useCanvasStore } from '@/stores/graphStore' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { useSettingStore } from '@/stores/settingStore' // Mock stores -vi.mock('@/stores/graphStore', () => { +vi.mock('@/renderer/core/canvas/canvasStore', () => { const getCanvas = vi.fn() - return { useCanvasStore: vi.fn(() => ({ getCanvas })) } + const setCursorStyle = vi.fn() + return { + useCanvasStore: vi.fn(() => ({ + getCanvas, + setCursorStyle + })) + } }) vi.mock('@/stores/settingStore', () => { const getFn = vi.fn() diff --git a/tests-ui/tests/composables/graph/useCanvasTransformSync.test.ts b/tests-ui/tests/composables/graph/useCanvasTransformSync.test.ts index 79503bb5f..c9c8831d5 100644 --- a/tests-ui/tests/composables/graph/useCanvasTransformSync.test.ts +++ b/tests-ui/tests/composables/graph/useCanvasTransformSync.test.ts @@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { nextTick } from 'vue' -import { useCanvasTransformSync } from '@/composables/graph/useCanvasTransformSync' +import { useCanvasTransformSync } from '@/renderer/core/layout/transform/useCanvasTransformSync' import type { LGraphCanvas } from '../../../../src/lib/litegraph/src/litegraph' diff --git a/tests-ui/tests/composables/graph/useSelectionState.test.ts b/tests-ui/tests/composables/graph/useSelectionState.test.ts new file mode 100644 index 000000000..ccab3054f --- /dev/null +++ b/tests-ui/tests/composables/graph/useSelectionState.test.ts @@ -0,0 +1,270 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, test, vi } from 'vitest' +import { type Ref, ref } from 'vue' + +import { useSelectionState } from '@/composables/graph/useSelectionState' +import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab' +import { LGraphEventMode } from '@/lib/litegraph/src/litegraph' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' +import { useNodeDefStore } from '@/stores/nodeDefStore' +import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore' +import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore' +import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil' +import { filterOutputNodes } from '@/utils/nodeFilterUtil' + +// Test interfaces +interface TestNodeConfig { + type?: string + mode?: LGraphEventMode + flags?: { collapsed?: boolean } + pinned?: boolean + removable?: boolean +} + +interface TestNode { + type: string + mode: LGraphEventMode + flags?: { collapsed?: boolean } + pinned?: boolean + removable?: boolean + isSubgraphNode: () => boolean +} + +type MockedItem = TestNode | { type: string; isNode: boolean } + +// Mock all stores +vi.mock('@/renderer/core/canvas/canvasStore', () => ({ + useCanvasStore: vi.fn() +})) + +vi.mock('@/stores/nodeDefStore', () => ({ + useNodeDefStore: vi.fn() +})) + +vi.mock('@/stores/workspace/sidebarTabStore', () => ({ + useSidebarTabStore: vi.fn() +})) + +vi.mock('@/stores/workspace/nodeHelpStore', () => ({ + useNodeHelpStore: vi.fn() +})) + +vi.mock('@/composables/sidebarTabs/useNodeLibrarySidebarTab', () => ({ + useNodeLibrarySidebarTab: vi.fn() +})) + +vi.mock('@/utils/litegraphUtil', () => ({ + isLGraphNode: vi.fn(), + isImageNode: vi.fn() +})) + +vi.mock('@/utils/nodeFilterUtil', () => ({ + filterOutputNodes: vi.fn() +})) + +const createTestNode = (config: TestNodeConfig = {}): TestNode => { + return { + type: config.type || 'TestNode', + mode: config.mode || LGraphEventMode.ALWAYS, + flags: config.flags, + pinned: config.pinned, + removable: config.removable, + isSubgraphNode: () => false + } +} + +// Mock comment/connection objects +const mockComment = { type: 'comment', isNode: false } +const mockConnection = { type: 'connection', isNode: false } + +describe('useSelectionState', () => { + // Mock store instances + let mockSelectedItems: Ref + + beforeEach(() => { + vi.clearAllMocks() + setActivePinia(createPinia()) + + // Setup mock canvas store with proper ref + mockSelectedItems = ref([]) + vi.mocked(useCanvasStore).mockReturnValue({ + selectedItems: mockSelectedItems, + // Add minimal required properties for the store + $id: 'canvas', + $state: {} as any, + $patch: vi.fn(), + $reset: vi.fn(), + $subscribe: vi.fn(), + $onAction: vi.fn(), + $dispose: vi.fn(), + _customProperties: new Set(), + _p: {} as any + } as any) + + // Setup mock node def store + vi.mocked(useNodeDefStore).mockReturnValue({ + fromLGraphNode: vi.fn((node: TestNode) => { + if (node?.type === 'TestNode') { + return { nodePath: 'test.TestNode', name: 'TestNode' } + } + return null + }), + // Add minimal required properties for the store + $id: 'nodeDef', + $state: {} as any, + $patch: vi.fn(), + $reset: vi.fn(), + $subscribe: vi.fn(), + $onAction: vi.fn(), + $dispose: vi.fn(), + _customProperties: new Set(), + _p: {} as any + } as any) + + // Setup mock sidebar tab store + const mockToggleSidebarTab = vi.fn() + vi.mocked(useSidebarTabStore).mockReturnValue({ + activeSidebarTabId: null, + toggleSidebarTab: mockToggleSidebarTab, + // Add minimal required properties for the store + $id: 'sidebarTab', + $state: {} as any, + $patch: vi.fn(), + $reset: vi.fn(), + $subscribe: vi.fn(), + $onAction: vi.fn(), + $dispose: vi.fn(), + _customProperties: new Set(), + _p: {} as any + } as any) + + // Setup mock node help store + const mockOpenHelp = vi.fn() + const mockCloseHelp = vi.fn() + const mockNodeHelpStore = { + isHelpOpen: false, + currentHelpNode: null, + openHelp: mockOpenHelp, + closeHelp: mockCloseHelp, + // Add minimal required properties for the store + $id: 'nodeHelp', + $state: {} as any, + $patch: vi.fn(), + $reset: vi.fn(), + $subscribe: vi.fn(), + $onAction: vi.fn(), + $dispose: vi.fn(), + _customProperties: new Set(), + _p: {} as any + } + vi.mocked(useNodeHelpStore).mockReturnValue(mockNodeHelpStore as any) + + // Setup mock composables + vi.mocked(useNodeLibrarySidebarTab).mockReturnValue({ + id: 'node-library-tab', + title: 'Node Library', + type: 'custom', + render: () => null + } as any) + + // Setup mock utility functions + vi.mocked(isLGraphNode).mockImplementation((item: unknown) => { + const typedItem = item as { isNode?: boolean } + return typedItem?.isNode !== false + }) + vi.mocked(isImageNode).mockImplementation((node: unknown) => { + const typedNode = node as { type?: string } + return typedNode?.type === 'ImageNode' + }) + vi.mocked(filterOutputNodes).mockImplementation( + (nodes: TestNode[]) => nodes.filter((n) => n.type === 'OutputNode') as any + ) + }) + + describe('Selection Detection', () => { + test('should return false when nothing selected', () => { + const { hasAnySelection } = useSelectionState() + expect(hasAnySelection.value).toBe(false) + }) + + test('should return true when items selected', () => { + // Update the mock data before creating the composable + const node1 = createTestNode() + const node2 = createTestNode() + mockSelectedItems.value = [node1, node2] + + const { hasAnySelection } = useSelectionState() + expect(hasAnySelection.value).toBe(true) + }) + }) + + describe('Node Type Filtering', () => { + test('should pick only LGraphNodes from mixed selections', () => { + // Update the mock data before creating the composable + const graphNode = createTestNode() + mockSelectedItems.value = [graphNode, mockComment, mockConnection] + + const { selectedNodes } = useSelectionState() + expect(selectedNodes.value).toHaveLength(1) + expect(selectedNodes.value[0]).toEqual(graphNode) + }) + }) + + describe('Node State Computation', () => { + test('should detect bypassed nodes', () => { + // Update the mock data before creating the composable + const bypassedNode = createTestNode({ mode: LGraphEventMode.BYPASS }) + mockSelectedItems.value = [bypassedNode] + + const { selectedNodes } = useSelectionState() + const isBypassed = selectedNodes.value.some( + (n) => n.mode === LGraphEventMode.BYPASS + ) + expect(isBypassed).toBe(true) + }) + + test('should detect pinned/collapsed states', () => { + // Update the mock data before creating the composable + const pinnedNode = createTestNode({ pinned: true }) + const collapsedNode = createTestNode({ flags: { collapsed: true } }) + mockSelectedItems.value = [pinnedNode, collapsedNode] + + const { selectedNodes } = useSelectionState() + const isPinned = selectedNodes.value.some((n) => n.pinned === true) + const isCollapsed = selectedNodes.value.some( + (n) => n.flags?.collapsed === true + ) + const isBypassed = selectedNodes.value.some( + (n) => n.mode === LGraphEventMode.BYPASS + ) + expect(isPinned).toBe(true) + expect(isCollapsed).toBe(true) + expect(isBypassed).toBe(false) + }) + + test('should provide non-reactive state computation', () => { + // Update the mock data before creating the composable + const node = createTestNode({ pinned: true }) + mockSelectedItems.value = [node] + + const { selectedNodes } = useSelectionState() + const isPinned = selectedNodes.value.some((n) => n.pinned === true) + const isCollapsed = selectedNodes.value.some( + (n) => n.flags?.collapsed === true + ) + const isBypassed = selectedNodes.value.some( + (n) => n.mode === LGraphEventMode.BYPASS + ) + + expect(isPinned).toBe(true) + expect(isCollapsed).toBe(false) + expect(isBypassed).toBe(false) + + // Test with empty selection using new composable instance + mockSelectedItems.value = [] + const { selectedNodes: newSelectedNodes } = useSelectionState() + const newIsPinned = newSelectedNodes.value.some((n) => n.pinned === true) + expect(newIsPinned).toBe(false) + }) + }) +}) diff --git a/tests-ui/tests/composables/graph/useTransformSettling.test.ts b/tests-ui/tests/composables/graph/useTransformSettling.test.ts index 2bc6342c8..6b2d106d5 100644 --- a/tests-ui/tests/composables/graph/useTransformSettling.test.ts +++ b/tests-ui/tests/composables/graph/useTransformSettling.test.ts @@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { nextTick, ref } from 'vue' -import { useTransformSettling } from '@/composables/graph/useTransformSettling' +import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling' describe('useTransformSettling', () => { let element: HTMLDivElement diff --git a/tests-ui/tests/composables/useMinimap.test.ts b/tests-ui/tests/composables/useMinimap.test.ts index f172178ad..91609931a 100644 --- a/tests-ui/tests/composables/useMinimap.test.ts +++ b/tests-ui/tests/composables/useMinimap.test.ts @@ -115,7 +115,7 @@ const defaultSettingStore = { set: vi.fn().mockResolvedValue(undefined) } -vi.mock('@/stores/graphStore', () => ({ +vi.mock('@/renderer/core/canvas/canvasStore', () => ({ useCanvasStore: vi.fn(() => defaultCanvasStore) })) diff --git a/tests-ui/tests/performance/transformPerformance.test.ts b/tests-ui/tests/performance/transformPerformance.test.ts index 41d8c12c6..e9f995e97 100644 --- a/tests-ui/tests/performance/transformPerformance.test.ts +++ b/tests-ui/tests/performance/transformPerformance.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest' -import { useTransformState } from '@/renderer/core/layout/useTransformState' +import { useTransformState } from '@/renderer/core/layout/transform/useTransformState' // Mock canvas context for testing const createMockCanvasContext = () => ({ diff --git a/tests-ui/tests/renderer/core/layout/utils/layoutMath.test.ts b/tests-ui/tests/renderer/core/layout/utils/layoutMath.test.ts new file mode 100644 index 000000000..3d768740e --- /dev/null +++ b/tests-ui/tests/renderer/core/layout/utils/layoutMath.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' + +import { + REROUTE_RADIUS, + boundsIntersect, + pointInBounds +} from '@/renderer/core/layout/utils/layoutMath' + +describe('layoutMath utils', () => { + describe('pointInBounds', () => { + it('detects inclusion correctly', () => { + const bounds = { x: 10, y: 10, width: 100, height: 50 } + expect(pointInBounds({ x: 10, y: 10 }, bounds)).toBe(true) + expect(pointInBounds({ x: 110, y: 60 }, bounds)).toBe(true) + expect(pointInBounds({ x: 9, y: 10 }, bounds)).toBe(false) + expect(pointInBounds({ x: 111, y: 10 }, bounds)).toBe(false) + expect(pointInBounds({ x: 10, y: 61 }, bounds)).toBe(false) + }) + + it('works with zero-size bounds', () => { + const zero = { x: 10, y: 20, width: 0, height: 0 } + expect(pointInBounds({ x: 10, y: 20 }, zero)).toBe(true) + expect(pointInBounds({ x: 10, y: 21 }, zero)).toBe(false) + expect(pointInBounds({ x: 9, y: 20 }, zero)).toBe(false) + }) + }) + + describe('boundsIntersect', () => { + it('detects intersection correctly', () => { + const a = { x: 0, y: 0, width: 10, height: 10 } + const b = { x: 5, y: 5, width: 10, height: 10 } + const c = { x: 11, y: 0, width: 5, height: 5 } + expect(boundsIntersect(a, b)).toBe(true) + expect(boundsIntersect(a, c)).toBe(false) + }) + + it('treats touching edges as intersecting', () => { + const a = { x: 0, y: 0, width: 10, height: 10 } + const d = { x: 10, y: 0, width: 5, height: 5 } // touches at right edge + expect(boundsIntersect(a, d)).toBe(true) + }) + }) + + describe('REROUTE_RADIUS', () => { + it('exports a sensible reroute radius', () => { + expect(REROUTE_RADIUS).toBeGreaterThan(0) + }) + }) +}) diff --git a/tests-ui/tests/renderer/core/layout/utils/layoutUtils.test.ts b/tests-ui/tests/renderer/core/layout/utils/layoutUtils.test.ts new file mode 100644 index 000000000..4c2dd87be --- /dev/null +++ b/tests-ui/tests/renderer/core/layout/utils/layoutUtils.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' + +import { makeLinkSegmentKey } from '@/renderer/core/layout/utils/layoutUtils' + +describe('layoutUtils', () => { + describe('makeLinkSegmentKey', () => { + it('creates stable keys for null reroute', () => { + expect(makeLinkSegmentKey(10, null)).toBe('10:final') + expect(makeLinkSegmentKey(42, null)).toBe('42:final') + }) + + it('creates stable keys for numeric reroute ids', () => { + expect(makeLinkSegmentKey(10, 3)).toBe('10:3') + expect(makeLinkSegmentKey(42, 0)).toBe('42:0') + expect(makeLinkSegmentKey(42, 7)).toBe('42:7') + }) + }) +}) diff --git a/tests-ui/tests/renderer/core/layout/utils/mappers.test.ts b/tests-ui/tests/renderer/core/layout/utils/mappers.test.ts new file mode 100644 index 000000000..a4ab0cd6b --- /dev/null +++ b/tests-ui/tests/renderer/core/layout/utils/mappers.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import * as Y from 'yjs' + +import { + NODE_LAYOUT_DEFAULTS, + type NodeLayoutMap, + yNodeToLayout +} from '@/renderer/core/layout/utils/mappers' + +describe('mappers', () => { + it('yNodeToLayout reads from Yjs-attached map', () => { + const layout = { + id: 'node-1', + position: { x: 12, y: 34 }, + size: { width: 111, height: 222 }, + zIndex: 5, + visible: true, + bounds: { x: 12, y: 34, width: 111, height: 222 } + } + + const doc = new Y.Doc() + const ynode = doc.getMap('node') as NodeLayoutMap + ynode.set('id', layout.id) + ynode.set('position', layout.position) + ynode.set('size', layout.size) + ynode.set('zIndex', layout.zIndex) + ynode.set('visible', layout.visible) + ynode.set('bounds', layout.bounds) + + const back = yNodeToLayout(ynode) + expect(back).toEqual(layout) + }) + + it('yNodeToLayout applies defaults for missing fields', () => { + const doc = new Y.Doc() + const ynode = doc.getMap('node') as NodeLayoutMap + // Don't set any fields - they should all use defaults + + const back = yNodeToLayout(ynode) + expect(back.id).toBe(NODE_LAYOUT_DEFAULTS.id) + expect(back.position).toEqual(NODE_LAYOUT_DEFAULTS.position) + expect(back.size).toEqual(NODE_LAYOUT_DEFAULTS.size) + expect(back.zIndex).toEqual(NODE_LAYOUT_DEFAULTS.zIndex) + expect(back.visible).toEqual(NODE_LAYOUT_DEFAULTS.visible) + expect(back.bounds).toEqual(NODE_LAYOUT_DEFAULTS.bounds) + }) +}) diff --git a/tests-ui/tests/renderer/extensions/vueNodes/components/ImagePreview.spec.ts b/tests-ui/tests/renderer/extensions/vueNodes/components/ImagePreview.spec.ts new file mode 100644 index 000000000..ccf61ebcf --- /dev/null +++ b/tests-ui/tests/renderer/extensions/vueNodes/components/ImagePreview.spec.ts @@ -0,0 +1,277 @@ +import { createTestingPinia } from '@pinia/testing' +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' +import { createI18n } from 'vue-i18n' + +import { downloadFile } from '@/base/common/downloadUtil' +import ImagePreview from '@/renderer/extensions/vueNodes/components/ImagePreview.vue' + +// Mock downloadFile to avoid DOM errors +vi.mock('@/base/common/downloadUtil', () => ({ + downloadFile: vi.fn() +})) + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + g: { + editOrMaskImage: 'Edit or mask image', + downloadImage: 'Download image', + removeImage: 'Remove image', + viewImageOfTotal: 'View image {index} of {total}', + imagePreview: + 'Image preview - Use arrow keys to navigate between images', + errorLoadingImage: 'Error loading image', + failedToDownloadImage: 'Failed to download image', + calculatingDimensions: 'Calculating dimensions', + imageFailedToLoad: 'Image failed to load', + loading: 'Loading' + } + } + } +}) + +describe('ImagePreview', () => { + const defaultProps = { + imageUrls: [ + '/api/view?filename=test1.png&type=output', + '/api/view?filename=test2.png&type=output' + ] + } + + const mountImagePreview = (props = {}) => { + return mount(ImagePreview, { + props: { ...defaultProps, ...props }, + global: { + plugins: [ + createTestingPinia({ + createSpy: vi.fn + }), + i18n + ], + stubs: { + 'i-lucide:venetian-mask': true, + 'i-lucide:download': true, + 'i-lucide:x': true, + 'i-lucide:image-off': true, + Skeleton: true + } + } + }) + } + + it('renders image preview when imageUrls provided', () => { + const wrapper = mountImagePreview() + + expect(wrapper.find('.image-preview').exists()).toBe(true) + expect(wrapper.find('img').exists()).toBe(true) + expect(wrapper.find('img').attributes('src')).toBe( + defaultProps.imageUrls[0] + ) + }) + + it('does not render when no imageUrls provided', () => { + const wrapper = mountImagePreview({ imageUrls: [] }) + + expect(wrapper.find('.image-preview').exists()).toBe(false) + }) + + it('displays calculating dimensions text initially', () => { + const wrapper = mountImagePreview() + + expect(wrapper.text()).toContain('Calculating dimensions') + }) + + it('shows navigation dots for multiple images', () => { + const wrapper = mountImagePreview() + + const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full') + expect(navigationDots).toHaveLength(2) + }) + + it('does not show navigation dots for single image', () => { + const wrapper = mountImagePreview({ + imageUrls: [defaultProps.imageUrls[0]] + }) + + const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full') + expect(navigationDots).toHaveLength(0) + }) + + it('shows action buttons on hover', async () => { + const wrapper = mountImagePreview() + + // Initially buttons should not be visible + expect(wrapper.find('.actions').exists()).toBe(false) + + // Trigger hover + await wrapper.trigger('mouseenter') + await nextTick() + + // Action buttons should now be visible + expect(wrapper.find('.actions').exists()).toBe(true) + expect(wrapper.findAll('.action-btn')).toHaveLength(2) // download, remove (no mask for multiple images) + }) + + it('hides action buttons when not hovering', async () => { + const wrapper = mountImagePreview() + + // Trigger hover + await wrapper.trigger('mouseenter') + await nextTick() + expect(wrapper.find('.actions').exists()).toBe(true) + + // Trigger mouse leave + await wrapper.trigger('mouseleave') + await nextTick() + expect(wrapper.find('.actions').exists()).toBe(false) + }) + + it('shows mask/edit button only for single images', async () => { + // Multiple images - should not show mask button + const multipleImagesWrapper = mountImagePreview() + await multipleImagesWrapper.trigger('mouseenter') + await nextTick() + + const maskButtonMultiple = multipleImagesWrapper.find( + '[aria-label="Edit or mask image"]' + ) + expect(maskButtonMultiple.exists()).toBe(false) + + // Single image - should show mask button + const singleImageWrapper = mountImagePreview({ + imageUrls: [defaultProps.imageUrls[0]] + }) + await singleImageWrapper.trigger('mouseenter') + await nextTick() + + const maskButtonSingle = singleImageWrapper.find( + '[aria-label="Edit or mask image"]' + ) + expect(maskButtonSingle.exists()).toBe(true) + }) + + it('handles action button clicks', async () => { + const wrapper = mountImagePreview({ + imageUrls: [defaultProps.imageUrls[0]] + }) + + await wrapper.trigger('mouseenter') + await nextTick() + + // Test Edit/Mask button - just verify it can be clicked without errors + const editButton = wrapper.find('[aria-label="Edit or mask image"]') + expect(editButton.exists()).toBe(true) + await editButton.trigger('click') + + // Test Remove button - just verify it can be clicked without errors + const removeButton = wrapper.find('[aria-label="Remove image"]') + expect(removeButton.exists()).toBe(true) + await removeButton.trigger('click') + }) + + it('handles download button click', async () => { + const wrapper = mountImagePreview({ + imageUrls: [defaultProps.imageUrls[0]] + }) + + await wrapper.trigger('mouseenter') + await nextTick() + + // Test Download button + const downloadButton = wrapper.find('[aria-label="Download image"]') + expect(downloadButton.exists()).toBe(true) + await downloadButton.trigger('click') + + // Verify the mocked downloadFile was called + expect(downloadFile).toHaveBeenCalledWith(defaultProps.imageUrls[0]) + }) + + it('switches images when navigation dots are clicked', async () => { + const wrapper = mountImagePreview() + + // Initially shows first image + expect(wrapper.find('img').attributes('src')).toBe( + defaultProps.imageUrls[0] + ) + + // Click second navigation dot + const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full') + await navigationDots[1].trigger('click') + await nextTick() + + // After clicking, component shows loading state (Skeleton), not img + expect(wrapper.find('skeleton-stub').exists()).toBe(true) + expect(wrapper.find('img').exists()).toBe(false) + + // Simulate image load event to clear loading state + const component = wrapper.vm as any + component.isLoading = false + await nextTick() + + // Now should show second image + const imgElement = wrapper.find('img') + expect(imgElement.exists()).toBe(true) + expect(imgElement.attributes('src')).toBe(defaultProps.imageUrls[1]) + }) + + it('applies correct classes to navigation dots based on current image', async () => { + const wrapper = mountImagePreview() + + const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full') + + // First dot should be active (has bg-white class) + expect(navigationDots[0].classes()).toContain('bg-white') + expect(navigationDots[1].classes()).toContain('bg-white/50') + + // Switch to second image + await navigationDots[1].trigger('click') + await nextTick() + + // Second dot should now be active + expect(navigationDots[0].classes()).toContain('bg-white/50') + expect(navigationDots[1].classes()).toContain('bg-white') + }) + + it('loads image without errors', async () => { + const wrapper = mountImagePreview() + + const img = wrapper.find('img') + expect(img.exists()).toBe(true) + + // Just verify the image element is properly set up + expect(img.attributes('src')).toBe(defaultProps.imageUrls[0]) + }) + + it('has proper accessibility attributes', () => { + const wrapper = mountImagePreview() + + const img = wrapper.find('img') + expect(img.attributes('alt')).toBe('Node output 1') + }) + + it('updates alt text when switching images', async () => { + const wrapper = mountImagePreview() + + // Initially first image + expect(wrapper.find('img').attributes('alt')).toBe('Node output 1') + + // Switch to second image + const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full') + await navigationDots[1].trigger('click') + await nextTick() + + // Simulate image load event to clear loading state + const component = wrapper.vm as any + component.isLoading = false + await nextTick() + + // Alt text should update + const imgElement = wrapper.find('img') + expect(imgElement.exists()).toBe(true) + expect(imgElement.attributes('alt')).toBe('Node output 2') + }) +}) diff --git a/tests-ui/tests/renderer/extensions/vueNodes/components/LGraphNode.spec.ts b/tests-ui/tests/renderer/extensions/vueNodes/components/LGraphNode.spec.ts index 861a301ab..6e34e2450 100644 --- a/tests-ui/tests/renderer/extensions/vueNodes/components/LGraphNode.spec.ts +++ b/tests-ui/tests/renderer/extensions/vueNodes/components/LGraphNode.spec.ts @@ -1,11 +1,14 @@ +import { createTestingPinia } from '@pinia/testing' import { mount } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ref } from 'vue' +import { computed, ref } from 'vue' +import { createI18n } from 'vue-i18n' import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys' import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue' import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking' +import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState' vi.mock( '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking', @@ -40,6 +43,29 @@ vi.mock('@/renderer/extensions/vueNodes/lod/useLOD', () => ({ LODLevel: { MINIMAL: 0 } })) +vi.mock( + '@/renderer/extensions/vueNodes/execution/useNodeExecutionState', + () => ({ + useNodeExecutionState: vi.fn(() => ({ + executing: computed(() => false), + progress: computed(() => undefined), + progressPercentage: computed(() => undefined), + progressState: computed(() => undefined as any), + executionState: computed(() => 'idle' as const) + })) + }) +) + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + 'Node Render Error': 'Node Render Error' + } + } +}) + describe('LGraphNode', () => { const mockNodeData: VueNodeData = { id: 'test-node-123', @@ -58,8 +84,21 @@ describe('LGraphNode', () => { return mount(LGraphNode, { props, global: { + plugins: [ + createTestingPinia({ + createSpy: vi.fn + }), + i18n + ], provide: { [SelectedNodeIdsKey as symbol]: ref(selectedNodeIds) + }, + stubs: { + NodeHeader: true, + NodeSlots: true, + NodeWidgets: true, + NodeContent: true, + SlotConnectionDot: true } } }) @@ -67,6 +106,14 @@ describe('LGraphNode', () => { beforeEach(() => { vi.clearAllMocks() + // Reset to default mock + vi.mocked(useNodeExecutionState).mockReturnValue({ + executing: computed(() => false), + progress: computed(() => undefined), + progressPercentage: computed(() => undefined), + progressState: computed(() => undefined as any), + executionState: computed(() => 'idle' as const) + }) }) it('should call resize tracking composable with node ID', () => { @@ -82,7 +129,27 @@ describe('LGraphNode', () => { }) it('should render node title', () => { - const wrapper = mountLGraphNode({ nodeData: mockNodeData }) + // Don't stub NodeHeader for this test so we can see the title + const wrapper = mount(LGraphNode, { + props: { nodeData: mockNodeData }, + global: { + plugins: [ + createTestingPinia({ + createSpy: vi.fn + }), + i18n + ], + provide: { + [SelectedNodeIdsKey as symbol]: ref(new Set()) + }, + stubs: { + NodeSlots: true, + NodeWidgets: true, + NodeContent: true, + SlotConnectionDot: true + } + } + }) expect(wrapper.text()).toContain('Test Node') }) @@ -98,7 +165,16 @@ describe('LGraphNode', () => { }) it('should apply executing animation when executing prop is true', () => { - const wrapper = mountLGraphNode({ nodeData: mockNodeData, executing: true }) + // Mock the execution state to return executing: true + vi.mocked(useNodeExecutionState).mockReturnValue({ + executing: computed(() => true), + progress: computed(() => undefined), + progressPercentage: computed(() => undefined), + progressState: computed(() => undefined as any), + executionState: computed(() => 'running' as const) + }) + + const wrapper = mountLGraphNode({ nodeData: mockNodeData }) expect(wrapper.classes()).toContain('animate-pulse') }) diff --git a/tests-ui/tests/renderer/extensions/vueNodes/composables/useNodeEventHandlers.test.ts b/tests-ui/tests/renderer/extensions/vueNodes/composables/useNodeEventHandlers.test.ts index 6d33ad9b7..57da3b080 100644 --- a/tests-ui/tests/renderer/extensions/vueNodes/composables/useNodeEventHandlers.test.ts +++ b/tests-ui/tests/renderer/extensions/vueNodes/composables/useNodeEventHandlers.test.ts @@ -4,11 +4,11 @@ import { ref } from 'vue' import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager' import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations' import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers' -import { useCanvasStore } from '@/stores/graphStore' -vi.mock('@/stores/graphStore', () => ({ +vi.mock('@/renderer/core/canvas/canvasStore', () => ({ useCanvasStore: vi.fn() })) diff --git a/tests-ui/tests/services/keybindingService.forwarding.test.ts b/tests-ui/tests/services/keybindingService.forwarding.test.ts new file mode 100644 index 000000000..afe4644db --- /dev/null +++ b/tests-ui/tests/services/keybindingService.forwarding.test.ts @@ -0,0 +1,176 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { app } from '@/scripts/app' +import { useKeybindingService } from '@/services/keybindingService' +import { useCommandStore } from '@/stores/commandStore' +import { useDialogStore } from '@/stores/dialogStore' + +// Mock the app and canvas using factory functions +vi.mock('@/scripts/app', () => { + return { + app: { + canvas: { + processKey: vi.fn() + } + } + } +}) + +// Mock stores +vi.mock('@/stores/settingStore', () => ({ + useSettingStore: vi.fn(() => ({ + get: vi.fn(() => []) + })) +})) + +vi.mock('@/stores/dialogStore', () => ({ + useDialogStore: vi.fn(() => ({ + dialogStack: [] + })) +})) + +// Test utility for creating keyboard events with mocked methods +function createTestKeyboardEvent( + key: string, + options: { + target?: Element + ctrlKey?: boolean + altKey?: boolean + metaKey?: boolean + } = {} +): KeyboardEvent { + const { + target = document.body, + ctrlKey = false, + altKey = false, + metaKey = false + } = options + + const event = new KeyboardEvent('keydown', { + key, + ctrlKey, + altKey, + metaKey, + bubbles: true, + cancelable: true + }) + + // Mock event methods + event.preventDefault = vi.fn() + event.composedPath = vi.fn(() => [target]) + + return event +} + +describe('keybindingService - Event Forwarding', () => { + let keybindingService: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + setActivePinia(createPinia()) + + // Mock command store execute + const commandStore = useCommandStore() + commandStore.execute = vi.fn() + + // Reset dialog store mock to empty + vi.mocked(useDialogStore).mockReturnValue({ + dialogStack: [] + } as any) + + keybindingService = useKeybindingService() + keybindingService.registerCoreKeybindings() + }) + + it('should forward Delete key to canvas when no keybinding exists', async () => { + const event = createTestKeyboardEvent('Delete') + + await keybindingService.keybindHandler(event) + + // Should forward to canvas processKey + expect(vi.mocked(app.canvas.processKey)).toHaveBeenCalledWith(event) + // Should not execute any command + expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled() + }) + + it('should forward Backspace key to canvas when no keybinding exists', async () => { + const event = createTestKeyboardEvent('Backspace') + + await keybindingService.keybindHandler(event) + + expect(vi.mocked(app.canvas.processKey)).toHaveBeenCalledWith(event) + expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled() + }) + + it('should not forward Delete key when typing in input field', async () => { + const inputElement = document.createElement('input') + const event = createTestKeyboardEvent('Delete', { target: inputElement }) + + await keybindingService.keybindHandler(event) + + // Should not forward to canvas when in input field + expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled() + expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled() + }) + + it('should not forward Delete key when typing in textarea', async () => { + const textareaElement = document.createElement('textarea') + const event = createTestKeyboardEvent('Delete', { target: textareaElement }) + + await keybindingService.keybindHandler(event) + + expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled() + expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled() + }) + + it('should not forward Delete key when canvas processKey is not available', async () => { + // Temporarily replace processKey with undefined + const originalProcessKey = vi.mocked(app.canvas).processKey + vi.mocked(app.canvas).processKey = undefined as any + + const event = createTestKeyboardEvent('Delete') + + await keybindingService.keybindHandler(event) + + expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled() + + // Restore processKey for other tests + vi.mocked(app.canvas).processKey = originalProcessKey + }) + + it('should not forward Delete key when canvas is not available', async () => { + // Temporarily set canvas to null + const originalCanvas = vi.mocked(app).canvas + vi.mocked(app).canvas = null as any + + const event = createTestKeyboardEvent('Delete') + + await keybindingService.keybindHandler(event) + + expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled() + + // Restore canvas for other tests + vi.mocked(app).canvas = originalCanvas + }) + + it('should not forward non-canvas keys', async () => { + const event = createTestKeyboardEvent('Enter') + + await keybindingService.keybindHandler(event) + + // Should not forward Enter key + expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled() + expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled() + }) + + it('should not forward when modifier keys are pressed', async () => { + const event = createTestKeyboardEvent('Delete', { ctrlKey: true }) + + await keybindingService.keybindHandler(event) + + // Should not forward when modifiers are pressed + expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled() + expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled() + }) +}) diff --git a/tests-ui/tests/store/subgraphNavigationStore.test.ts b/tests-ui/tests/store/subgraphNavigationStore.test.ts index b9d5537cd..e5834da52 100644 --- a/tests-ui/tests/store/subgraphNavigationStore.test.ts +++ b/tests-ui/tests/store/subgraphNavigationStore.test.ts @@ -34,7 +34,7 @@ vi.mock('@/scripts/app', () => { } }) -vi.mock('@/stores/graphStore', () => ({ +vi.mock('@/renderer/core/canvas/canvasStore', () => ({ useCanvasStore: () => ({ getCanvas: () => (app as any).canvas }) diff --git a/tests-ui/tests/store/subgraphNavigationStore.viewport.test.ts b/tests-ui/tests/store/subgraphNavigationStore.viewport.test.ts index db40609e0..63b023dd9 100644 --- a/tests-ui/tests/store/subgraphNavigationStore.viewport.test.ts +++ b/tests-ui/tests/store/subgraphNavigationStore.viewport.test.ts @@ -35,7 +35,7 @@ vi.mock('@/scripts/app', () => { }) // Mock canvasStore -vi.mock('@/stores/graphStore', () => ({ +vi.mock('@/renderer/core/canvas/canvasStore', () => ({ useCanvasStore: () => ({ getCanvas: () => (app as any).canvas }) diff --git a/tests-ui/tests/store/subgraphStore.test.ts b/tests-ui/tests/store/subgraphStore.test.ts index e8f844c59..d54d596d7 100644 --- a/tests-ui/tests/store/subgraphStore.test.ts +++ b/tests-ui/tests/store/subgraphStore.test.ts @@ -28,7 +28,7 @@ vi.mock('@/services/dialogService', () => ({ confirm: () => true })) })) -vi.mock('@/stores/graphStore', () => ({ +vi.mock('@/renderer/core/canvas/canvasStore', () => ({ useCanvasStore: vi.fn(() => ({ getCanvas: () => comfyApp.canvas }))