diff --git a/browser_tests/fixtures/components/ComfyNodeSearchBoxV2.ts b/browser_tests/fixtures/components/ComfyNodeSearchBoxV2.ts index eab275c88a1..43b85e29adf 100644 --- a/browser_tests/fixtures/components/ComfyNodeSearchBoxV2.ts +++ b/browser_tests/fixtures/components/ComfyNodeSearchBoxV2.ts @@ -5,12 +5,14 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage' export class ComfyNodeSearchBoxV2 { readonly dialog: Locator readonly input: Locator + readonly filterSearch: Locator readonly results: Locator readonly filterOptions: Locator constructor(readonly page: Page) { this.dialog = page.getByRole('search') - this.input = this.dialog.locator('input[type="text"]') + this.input = this.dialog.getByRole('combobox') + this.filterSearch = this.dialog.getByRole('textbox', { name: 'Search' }) this.results = this.dialog.getByTestId('result-item') this.filterOptions = this.dialog.getByTestId('filter-option') } diff --git a/browser_tests/fixtures/helpers/NodeOperationsHelper.ts b/browser_tests/fixtures/helpers/NodeOperationsHelper.ts index 3a2199f3dd3..6e0befa1b9b 100644 --- a/browser_tests/fixtures/helpers/NodeOperationsHelper.ts +++ b/browser_tests/fixtures/helpers/NodeOperationsHelper.ts @@ -1,6 +1,10 @@ import type { Locator } from '@playwright/test' -import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph' +import type { + GraphAddOptions, + LGraph, + LGraphNode +} from '@/lib/litegraph/src/litegraph' import type { ComfyWorkflowJSON, NodeId @@ -39,6 +43,45 @@ export class NodeOperationsHelper { }) } + async getSelectedNodeIds(): Promise { + return await this.page.evaluate(() => { + const selected = window.app?.canvas?.selected_nodes + if (!selected) return [] + return Object.keys(selected).map(Number) + }) + } + + /** + * Add a node to the graph by type. + * @param type - The node type (e.g. 'KSampler', 'VAEDecode') + * @param options - GraphAddOptions (ghost, skipComputeOrder). When ghost is + * true and cursorPosition is provided, a synthetic MouseEvent is created + * as the dragEvent. + * @param cursorPosition - Client coordinates for ghost placement dragEvent + */ + async addNode( + type: string, + options?: Omit, + cursorPosition?: Position + ): Promise { + const id = await this.page.evaluate( + ([nodeType, opts, cursor]) => { + const node = window.LiteGraph!.createNode(nodeType)! + const addOpts: Record = { ...opts } + if (opts?.ghost && cursor) { + addOpts.dragEvent = new MouseEvent('click', { + clientX: cursor.x, + clientY: cursor.y + }) + } + window.app!.graph.add(node, addOpts as GraphAddOptions) + return node.id + }, + [type, options ?? {}, cursorPosition ?? null] as const + ) + return new NodeReference(id, this.comfyPage) + } + /** Remove all nodes from the graph and clean. */ async clearGraph() { await this.comfyPage.settings.setSetting('Comfy.ConfirmClear', false) diff --git a/browser_tests/tests/nodeGhostPlacement.spec.ts b/browser_tests/tests/nodeGhostPlacement.spec.ts index 6e4392f21b7..a528dbd9a15 100644 --- a/browser_tests/tests/nodeGhostPlacement.spec.ts +++ b/browser_tests/tests/nodeGhostPlacement.spec.ts @@ -22,18 +22,14 @@ async function addGhostAtCenter(comfyPage: ComfyPage) { await comfyPage.page.mouse.move(centerX, centerY) await comfyPage.nextFrame() - const nodeId = await comfyPage.page.evaluate( - ([clientX, clientY]) => { - const node = window.LiteGraph!.createNode('VAEDecode')! - const event = new MouseEvent('click', { clientX, clientY }) - window.app!.graph.add(node, { ghost: true, dragEvent: event }) - return node.id - }, - [centerX, centerY] as const + const nodeRef = await comfyPage.nodeOps.addNode( + 'VAEDecode', + { ghost: true }, + { x: centerX, y: centerY } ) await comfyPage.nextFrame() - return { nodeId, centerX, centerY } + return { nodeId: nodeRef.id, centerX, centerY } } function getNodeById(comfyPage: ComfyPage, nodeId: number | string) { @@ -80,7 +76,6 @@ for (const mode of ['litegraph', 'vue'] as const) { }, [centerX, centerY] as const ) - await comfyPage.nextFrame() expect(Math.abs(result.diffX)).toBeLessThan(5) expect(Math.abs(result.diffY)).toBeLessThan(5) @@ -153,5 +148,127 @@ for (const mode of ['litegraph', 'vue'] as const) { const after = await getNodeById(comfyPage, nodeId) expect(after).toBeNull() }) + + test('moving ghost onto existing node and clicking places correctly', async ({ + comfyPage + }) => { + // Get existing KSampler node from the default workflow + const [ksamplerRef] = + await comfyPage.nodeOps.getNodeRefsByTitle('KSampler') + const ksamplerPos = await ksamplerRef.getPosition() + const ksamplerSize = await ksamplerRef.getSize() + const targetX = Math.round(ksamplerPos.x + ksamplerSize.width / 2) + const targetY = Math.round(ksamplerPos.y + ksamplerSize.height / 2) + + // Start ghost placement away from the existing node + const startX = 50 + const startY = 50 + await comfyPage.page.mouse.move(startX, startY, { steps: 20 }) + await comfyPage.nextFrame() + + const ghostRef = await comfyPage.nodeOps.addNode( + 'VAEDecode', + { ghost: true }, + { x: startX, y: startY } + ) + await comfyPage.nextFrame() + + // Move ghost onto the existing node + await comfyPage.page.mouse.move(targetX, targetY, { steps: 20 }) + await comfyPage.nextFrame() + + // Click to finalize — on top of the existing node + await comfyPage.page.mouse.click(targetX, targetY) + await comfyPage.nextFrame() + + // Ghost should be placed (no longer ghost) + const ghostResult = await getNodeById(comfyPage, ghostRef.id) + expect(ghostResult).not.toBeNull() + expect(ghostResult!.ghost).toBe(false) + + // Ghost node should have moved from its start position toward where we clicked + const ghostPos = await ghostRef.getPosition() + expect( + Math.abs(ghostPos.x - startX) > 20 || Math.abs(ghostPos.y - startY) > 20 + ).toBe(true) + + // Existing node should NOT be selected + const selectedIds = await comfyPage.nodeOps.getSelectedNodeIds() + expect(selectedIds).not.toContain(ksamplerRef.id) + }) + + test( + 'subgraph blueprint added from search box enters ghost mode', + { tag: ['@subgraph'] }, + async ({ comfyPage }) => { + await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled') + await comfyPage.settings.setSetting( + 'Comfy.NodeSearchBoxImpl', + 'default' + ) + await comfyPage.searchBoxV2.reload(comfyPage) + + // Convert a node to a subgraph and publish it as a blueprint + const nodeRef = await comfyPage.nodeOps.getNodeRefById('3') + await nodeRef.click('title') + await comfyPage.nextFrame() + await comfyPage.command.executeCommand('Comfy.Graph.ConvertToSubgraph') + await comfyPage.nextFrame() + await comfyPage.nextFrame() + const subgraphNodes = + await comfyPage.nodeOps.getNodeRefsByTitle('New Subgraph') + expect(subgraphNodes).toHaveLength(1) + const subgraphNode = subgraphNodes[0] + + const blueprintName = `ghost-test-${Date.now()}` + await subgraphNode.click('title') + await comfyPage.command.executeCommand('Comfy.PublishSubgraph', { + name: blueprintName + }) + await expect(comfyPage.visibleToasts).toHaveCount(1, { timeout: 5000 }) + await comfyPage.toast.closeToasts(1) + + const nodeCountBefore = await comfyPage.nodeOps.getGraphNodesCount() + + // Open v2 search box and search for the published blueprint + await comfyPage.canvasOps.doubleClick() + const { searchBoxV2 } = comfyPage + await expect(searchBoxV2.input).toBeVisible() + + await searchBoxV2.input.fill(blueprintName) + await expect(searchBoxV2.results.first()).toBeVisible() + + // Click the result to add the node (v2 search box uses ghost mode) + await searchBoxV2.results.first().click() + await comfyPage.nextFrame() + + // A new node should exist on the graph in ghost mode + const nodeCountAfter = await comfyPage.nodeOps.getGraphNodesCount() + expect(nodeCountAfter).toBe(nodeCountBefore + 1) + + const ghostNodeId = await comfyPage.page.evaluate(() => { + return window.app!.canvas.state.ghostNodeId + }) + expect(ghostNodeId).not.toBeNull() + + const ghostState = await getNodeById(comfyPage, ghostNodeId!) + expect(ghostState).not.toBeNull() + expect(ghostState!.ghost).toBe(true) + + // Wait for search box to close, then click to confirm placement + await expect(searchBoxV2.input).toBeHidden() + await comfyPage.nextFrame() + const viewport = comfyPage.page.viewportSize()! + await comfyPage.page.mouse.click( + Math.round(viewport.width / 2), + Math.round(viewport.height / 2) + ) + await comfyPage.nextFrame() + + const afterPlace = await getNodeById(comfyPage, ghostNodeId!) + expect(afterPlace).not.toBeNull() + expect(afterPlace!.ghost).toBe(false) + } + ) }) } diff --git a/browser_tests/tests/nodeSearchBoxV2.spec.ts b/browser_tests/tests/nodeSearchBoxV2.spec.ts index ee0571a4d41..627e74cdbfc 100644 --- a/browser_tests/tests/nodeSearchBoxV2.spec.ts +++ b/browser_tests/tests/nodeSearchBoxV2.spec.ts @@ -56,7 +56,9 @@ test.describe('Node search box V2', { tag: '@node' }, () => { }) test.describe('Category navigation', () => { - test('Favorites shows only bookmarked nodes', async ({ comfyPage }) => { + test('Bookmarked filter shows only bookmarked nodes', async ({ + comfyPage + }) => { const { searchBoxV2 } = comfyPage await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [ 'KSampler' @@ -66,7 +68,7 @@ test.describe('Node search box V2', { tag: '@node' }, () => { await comfyPage.canvasOps.doubleClick() await expect(searchBoxV2.input).toBeVisible() - await searchBoxV2.categoryButton('favorites').click() + await searchBoxV2.filterBarButton('Bookmarked').click() await expect(searchBoxV2.results).toHaveCount(1) await expect(searchBoxV2.results.first()).toContainText('KSampler') @@ -101,7 +103,7 @@ test.describe('Node search box V2', { tag: '@node' }, () => { await expect(searchBoxV2.filterOptions.first()).toBeVisible() // Type to narrow and select MODEL - await searchBoxV2.input.fill('MODEL') + await searchBoxV2.filterSearch.fill('MODEL') await searchBoxV2.filterOptions .filter({ hasText: 'MODEL' }) .first() diff --git a/browser_tests/tests/nodeSearchBoxV2Extended.spec.ts b/browser_tests/tests/nodeSearchBoxV2Extended.spec.ts index 9d2abbf6702..d4bcd3452b7 100644 --- a/browser_tests/tests/nodeSearchBoxV2Extended.spec.ts +++ b/browser_tests/tests/nodeSearchBoxV2Extended.spec.ts @@ -97,7 +97,7 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => { // Apply Input filter with MODEL type await searchBoxV2.filterBarButton('Input').click() await expect(searchBoxV2.filterOptions.first()).toBeVisible() - await searchBoxV2.input.fill('MODEL') + await searchBoxV2.filterSearch.fill('MODEL') await searchBoxV2.filterOptions .filter({ hasText: 'MODEL' }) .first() diff --git a/packages/design-system/src/css/style.css b/packages/design-system/src/css/style.css index 27179eda128..86e7d8298bb 100644 --- a/packages/design-system/src/css/style.css +++ b/packages/design-system/src/css/style.css @@ -589,8 +589,6 @@ background-color: color-mix(in srgb, currentColor 20%, transparent); font-weight: 700; border-radius: 0.25rem; - padding: 0 0.125rem; - margin: -0.125rem 0.125rem; } @utility scrollbar-hide { diff --git a/packages/shared-frontend-utils/src/formatUtil.test.ts b/packages/shared-frontend-utils/src/formatUtil.test.ts index 70dc8a6a108..3a1e6c877d4 100644 --- a/packages/shared-frontend-utils/src/formatUtil.test.ts +++ b/packages/shared-frontend-utils/src/formatUtil.test.ts @@ -202,6 +202,28 @@ describe('formatUtil', () => { 'foo bar foo' ) }) + + it('should highlight cross-word matches', () => { + const result = highlightQuery('convert image to mask', 'geto', false) + expect(result).toBe( + 'convert image to mask' + ) + }) + + it('should not match across line breaks', () => { + const result = highlightQuery('ge\nto', 'geto', false) + expect(result).toBe('ge\nto') + }) + + it('should not match across tabs', () => { + const result = highlightQuery('ge\tto', 'geto', false) + expect(result).toBe('ge\tto') + }) + + it('should not match across multiple spaces', () => { + const result = highlightQuery('ge to', 'geto', false) + expect(result).toBe('ge to') + }) }) describe('getFilenameDetails', () => { diff --git a/packages/shared-frontend-utils/src/formatUtil.ts b/packages/shared-frontend-utils/src/formatUtil.ts index 53eec9aff04..3e521900925 100644 --- a/packages/shared-frontend-utils/src/formatUtil.ts +++ b/packages/shared-frontend-utils/src/formatUtil.ts @@ -74,10 +74,14 @@ export function highlightQuery( text = DOMPurify.sanitize(text) } - // Escape special regex characters in the query string - const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - - const regex = new RegExp(`(${escapedQuery})`, 'gi') + // Escape special regex characters, then join with an optional single + // space so cross-word matches (e.g. "geto" → "imaGE TO") are + // highlighted without spanning tabs, newlines, or multi-space gaps. + const pattern = Array.from(query) + .map((ch) => ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('[ ]?') + + const regex = new RegExp(`(${pattern})`, 'gi') return text.replace(regex, '$1') } diff --git a/packages/tailwind-utils/src/index.ts b/packages/tailwind-utils/src/index.ts index 732aa4c60b3..13017144027 100644 --- a/packages/tailwind-utils/src/index.ts +++ b/packages/tailwind-utils/src/index.ts @@ -1,9 +1,17 @@ import { clsx } from 'clsx' import type { ClassArray } from 'clsx' -import { twMerge } from 'tailwind-merge' +import { extendTailwindMerge } from 'tailwind-merge' export type { ClassValue } from 'clsx' +const twMerge = extendTailwindMerge({ + extend: { + classGroups: { + 'font-size': ['text-xxs', 'text-xxxs'] + } + } +}) + export function cn(...inputs: ClassArray) { return twMerge(clsx(inputs)) } diff --git a/src/components/node/CreditBadge.vue b/src/components/node/CreditBadge.vue new file mode 100644 index 00000000000..dc109e354f2 --- /dev/null +++ b/src/components/node/CreditBadge.vue @@ -0,0 +1,28 @@ + + + diff --git a/src/components/node/NodePreviewCard.vue b/src/components/node/NodePreviewCard.vue index da9306f31ea..d90f3b33160 100644 --- a/src/components/node/NodePreviewCard.vue +++ b/src/components/node/NodePreviewCard.vue @@ -1,9 +1,14 @@