From 2ab4fb79ee4994e1e9bf98991531ad78523aba02 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Wed, 6 Aug 2025 11:51:41 -0700 Subject: [PATCH] [feat] TransformPane - Viewport synchronization layer for Vue nodes (#4304) Co-authored-by: Claude Co-authored-by: Benjamin Lu Co-authored-by: github-actions --- .../fixtures/utils/vueNodeFixtures.ts | 131 +++ .../tests/vueNodes/NodeHeader.spec.ts | 138 ++++ package-lock.json | 19 + package.json | 1 + src/assets/css/style.css | 272 ++++++ src/components/common/EditableText.spec.ts | 69 ++ src/components/common/EditableText.vue | 30 +- src/components/graph/DomWidgets.vue | 4 + src/components/graph/GraphCanvas.vue | 401 ++++++++- src/components/graph/TransformPane.spec.ts | 438 ++++++++++ src/components/graph/TransformPane.vue | 133 +++ .../graph/debug/QuadTreeDebugSection.vue | 112 +++ .../graph/debug/QuadTreeVisualization.vue | 112 +++ .../graph/debug/VueNodeDebugPanel.vue | 165 ++++ src/components/graph/vueNodes/InputSlot.vue | 84 ++ src/components/graph/vueNodes/LGraphNode.vue | 204 +++++ src/components/graph/vueNodes/NodeContent.vue | 42 + src/components/graph/vueNodes/NodeHeader.vue | 149 ++++ src/components/graph/vueNodes/NodeSlots.vue | 140 ++++ src/components/graph/vueNodes/NodeWidgets.vue | 167 ++++ src/components/graph/vueNodes/OutputSlot.vue | 87 ++ .../widgets/LOD_IMPLEMENTATION_GUIDE.md | 295 +++++++ .../graph/vueWidgets/WidgetButton.vue | 7 +- .../graph/vueWidgets/WidgetChart.vue | 107 +-- .../graph/vueWidgets/WidgetColorPicker.vue | 25 +- .../graph/vueWidgets/WidgetFileUpload.vue | 340 +++++++- .../graph/vueWidgets/WidgetGalleria.vue | 54 +- .../graph/vueWidgets/WidgetImageCompare.vue | 178 +--- .../graph/vueWidgets/WidgetInputText.vue | 28 +- .../graph/vueWidgets/WidgetMarkdown.vue | 95 +++ .../graph/vueWidgets/WidgetMultiSelect.vue | 32 +- .../graph/vueWidgets/WidgetSelect.vue | 52 +- .../graph/vueWidgets/WidgetSelectButton.vue | 43 +- .../graph/vueWidgets/WidgetSlider.vue | 150 +++- .../graph/vueWidgets/WidgetTextarea.vue | 30 +- .../graph/vueWidgets/WidgetToggleSwitch.vue | 36 +- .../graph/vueWidgets/WidgetTreeSelect.vue | 29 +- .../graph/vueWidgets/widgetRegistry.ts | 7 +- src/composables/element/useTransformState.ts | 242 ++++++ .../graph/useCanvasTransformSync.ts | 115 +++ src/composables/graph/useEventForwarding.ts | 186 +++++ src/composables/graph/useGraphNodeManager.ts | 779 ++++++++++++++++++ src/composables/graph/useLOD.ts | 186 +++++ src/composables/graph/useSpatialIndex.ts | 212 +++++ src/composables/graph/useTransformSettling.ts | 151 ++++ src/composables/graph/useWidgetRenderer.ts | 117 +++ src/composables/graph/useWidgetValue.ts | 155 ++++ src/composables/useFeatureFlags.ts | 82 ++ src/composables/widgets/useChartWidget.ts | 29 + src/composables/widgets/useColorWidget.ts | 21 + .../widgets/useFileUploadWidget.ts | 21 + src/composables/widgets/useGalleriaWidget.ts | 27 + .../widgets/useImageCompareWidget.ts | 21 + src/composables/widgets/useImageWidget.ts | 21 + .../widgets/useMultiSelectWidget.ts | 22 + .../widgets/useSelectButtonWidget.ts | 29 + src/composables/widgets/useTextareaWidget.ts | 29 + .../widgets/useTreeSelectWidget.ts | 25 + src/constants/coreSettings.ts | 21 + src/constants/slotColors.ts | 30 + src/extensions/core/groupNodeManage.ts | 2 +- src/lib/litegraph/CLAUDE.md | 36 - src/lib/litegraph/src/LGraphNode.ts | 155 +++- src/lib/litegraph/src/LGraphNodeProperties.ts | 176 ++++ src/lib/litegraph/src/LLink.ts | 11 - src/lib/litegraph/src/LiteGraphGlobal.ts | 45 + src/lib/litegraph/src/interfaces.ts | 2 + src/lib/litegraph/src/litegraph.ts | 8 +- src/lib/litegraph/src/node/NodeSlot.ts | 2 +- src/lib/litegraph/src/types/widgets.ts | 86 ++ src/lib/litegraph/src/widgets/ChartWidget.ts | 50 ++ src/lib/litegraph/src/widgets/ColorWidget.ts | 50 ++ .../litegraph/src/widgets/FileUploadWidget.ts | 50 ++ .../litegraph/src/widgets/GalleriaWidget.ts | 50 ++ .../src/widgets/ImageCompareWidget.ts | 50 ++ src/lib/litegraph/src/widgets/ImageWidget.ts | 50 ++ .../litegraph/src/widgets/MarkdownWidget.ts | 50 ++ .../src/widgets/MultiSelectWidget.ts | 50 ++ .../src/widgets/SelectButtonWidget.ts | 50 ++ .../litegraph/src/widgets/TextareaWidget.ts | 50 ++ .../litegraph/src/widgets/TreeSelectWidget.ts | 50 ++ src/lib/litegraph/src/widgets/widgetMap.ts | 126 +++ .../test/LGraphNodeProperties.test.ts | 163 ++++ .../__snapshots__/ConfigureGraph.test.ts.snap | 328 ++++++++ .../test/__snapshots__/LGraph.test.ts.snap | 3 + .../LGraph_constructor.test.ts.snap | 3 + src/locales/en/commands.json | 16 +- src/locales/en/main.json | 12 +- src/locales/en/settings.json | 8 + src/locales/es/main.json | 7 +- src/locales/es/settings.json | 8 + src/locales/fr/main.json | 7 +- src/locales/fr/settings.json | 8 + src/locales/ja/main.json | 11 +- src/locales/ja/settings.json | 8 + src/locales/ko/main.json | 11 +- src/locales/ko/settings.json | 8 + src/locales/ru/main.json | 7 +- src/locales/ru/settings.json | 8 + src/locales/zh/main.json | 7 +- src/locales/zh/settings.json | 8 + src/schemas/nodeDef/nodeDefSchemaV2.ts | 201 +++++ src/scripts/widgets.ts | 22 +- src/types/simplifiedWidget.ts | 16 +- src/types/spatialIndex.ts | 23 + src/utils/spatial/QuadTree.ts | 302 +++++++ src/utils/typeGuardUtil.ts | 40 +- .../element/useTransformState.test.ts | 329 ++++++++ .../graph/useCanvasTransformSync.test.ts | 240 ++++++ .../tests/composables/graph/useLOD.test.ts | 270 ++++++ .../composables/graph/useSpatialIndex.test.ts | 483 +++++++++++ .../graph/useTransformSettling.test.ts | 277 +++++++ .../graph/useWidgetRenderer.test.ts | 141 ++++ .../composables/graph/useWidgetValue.test.ts | 503 +++++++++++ tests-ui/tests/helpers/nodeTestHelpers.ts | 76 ++ .../tests/performance/QuadTreeBenchmark.ts | 225 +++++ .../spatialIndexPerformance.test.ts | 402 +++++++++ .../performance/transformPerformance.test.ts | 479 +++++++++++ tests-ui/tests/utils/spatial/QuadTree.test.ts | 269 ++++++ 119 files changed, 12658 insertions(+), 397 deletions(-) create mode 100644 browser_tests/fixtures/utils/vueNodeFixtures.ts create mode 100644 browser_tests/tests/vueNodes/NodeHeader.spec.ts create mode 100644 src/components/graph/TransformPane.spec.ts create mode 100644 src/components/graph/TransformPane.vue create mode 100644 src/components/graph/debug/QuadTreeDebugSection.vue create mode 100644 src/components/graph/debug/QuadTreeVisualization.vue create mode 100644 src/components/graph/debug/VueNodeDebugPanel.vue create mode 100644 src/components/graph/vueNodes/InputSlot.vue create mode 100644 src/components/graph/vueNodes/LGraphNode.vue create mode 100644 src/components/graph/vueNodes/NodeContent.vue create mode 100644 src/components/graph/vueNodes/NodeHeader.vue create mode 100644 src/components/graph/vueNodes/NodeSlots.vue create mode 100644 src/components/graph/vueNodes/NodeWidgets.vue create mode 100644 src/components/graph/vueNodes/OutputSlot.vue create mode 100644 src/components/graph/vueNodes/widgets/LOD_IMPLEMENTATION_GUIDE.md create mode 100644 src/components/graph/vueWidgets/WidgetMarkdown.vue create mode 100644 src/composables/element/useTransformState.ts create mode 100644 src/composables/graph/useCanvasTransformSync.ts create mode 100644 src/composables/graph/useEventForwarding.ts create mode 100644 src/composables/graph/useGraphNodeManager.ts create mode 100644 src/composables/graph/useLOD.ts create mode 100644 src/composables/graph/useSpatialIndex.ts create mode 100644 src/composables/graph/useTransformSettling.ts create mode 100644 src/composables/graph/useWidgetRenderer.ts create mode 100644 src/composables/graph/useWidgetValue.ts create mode 100644 src/composables/useFeatureFlags.ts create mode 100644 src/composables/widgets/useChartWidget.ts create mode 100644 src/composables/widgets/useColorWidget.ts create mode 100644 src/composables/widgets/useFileUploadWidget.ts create mode 100644 src/composables/widgets/useGalleriaWidget.ts create mode 100644 src/composables/widgets/useImageCompareWidget.ts create mode 100644 src/composables/widgets/useImageWidget.ts create mode 100644 src/composables/widgets/useMultiSelectWidget.ts create mode 100644 src/composables/widgets/useSelectButtonWidget.ts create mode 100644 src/composables/widgets/useTextareaWidget.ts create mode 100644 src/composables/widgets/useTreeSelectWidget.ts create mode 100644 src/constants/slotColors.ts create mode 100644 src/lib/litegraph/src/LGraphNodeProperties.ts create mode 100644 src/lib/litegraph/src/widgets/ChartWidget.ts create mode 100644 src/lib/litegraph/src/widgets/ColorWidget.ts create mode 100644 src/lib/litegraph/src/widgets/FileUploadWidget.ts create mode 100644 src/lib/litegraph/src/widgets/GalleriaWidget.ts create mode 100644 src/lib/litegraph/src/widgets/ImageCompareWidget.ts create mode 100644 src/lib/litegraph/src/widgets/ImageWidget.ts create mode 100644 src/lib/litegraph/src/widgets/MarkdownWidget.ts create mode 100644 src/lib/litegraph/src/widgets/MultiSelectWidget.ts create mode 100644 src/lib/litegraph/src/widgets/SelectButtonWidget.ts create mode 100644 src/lib/litegraph/src/widgets/TextareaWidget.ts create mode 100644 src/lib/litegraph/src/widgets/TreeSelectWidget.ts create mode 100644 src/lib/litegraph/test/LGraphNodeProperties.test.ts create mode 100644 src/types/spatialIndex.ts create mode 100644 src/utils/spatial/QuadTree.ts create mode 100644 tests-ui/tests/composables/element/useTransformState.test.ts create mode 100644 tests-ui/tests/composables/graph/useCanvasTransformSync.test.ts create mode 100644 tests-ui/tests/composables/graph/useLOD.test.ts create mode 100644 tests-ui/tests/composables/graph/useSpatialIndex.test.ts create mode 100644 tests-ui/tests/composables/graph/useTransformSettling.test.ts create mode 100644 tests-ui/tests/composables/graph/useWidgetRenderer.test.ts create mode 100644 tests-ui/tests/composables/graph/useWidgetValue.test.ts create mode 100644 tests-ui/tests/helpers/nodeTestHelpers.ts create mode 100644 tests-ui/tests/performance/QuadTreeBenchmark.ts create mode 100644 tests-ui/tests/performance/spatialIndexPerformance.test.ts create mode 100644 tests-ui/tests/performance/transformPerformance.test.ts create mode 100644 tests-ui/tests/utils/spatial/QuadTree.test.ts diff --git a/browser_tests/fixtures/utils/vueNodeFixtures.ts b/browser_tests/fixtures/utils/vueNodeFixtures.ts new file mode 100644 index 000000000..5c4541b92 --- /dev/null +++ b/browser_tests/fixtures/utils/vueNodeFixtures.ts @@ -0,0 +1,131 @@ +import type { Locator, Page } from '@playwright/test' + +import type { NodeReference } from './litegraphUtils' + +/** + * VueNodeFixture provides Vue-specific testing utilities for interacting with + * Vue node components. It bridges the gap between litegraph node references + * and Vue UI components. + */ +export class VueNodeFixture { + constructor( + private readonly nodeRef: NodeReference, + private readonly page: Page + ) {} + + /** + * Get the node's header element using data-testid + */ + async getHeader(): Promise { + const nodeId = this.nodeRef.id + return this.page.locator(`[data-testid="node-header-${nodeId}"]`) + } + + /** + * Get the node's title element + */ + async getTitleElement(): Promise { + const header = await this.getHeader() + return header.locator('[data-testid="node-title"]') + } + + /** + * Get the current title text + */ + async getTitle(): Promise { + const titleElement = await this.getTitleElement() + return (await titleElement.textContent()) || '' + } + + /** + * Set a new title by double-clicking and entering text + */ + async setTitle(newTitle: string): Promise { + const titleElement = await this.getTitleElement() + await titleElement.dblclick() + + const input = (await this.getHeader()).locator( + '[data-testid="node-title-input"]' + ) + await input.fill(newTitle) + await input.press('Enter') + } + + /** + * Cancel title editing + */ + async cancelTitleEdit(): Promise { + const titleElement = await this.getTitleElement() + await titleElement.dblclick() + + const input = (await this.getHeader()).locator( + '[data-testid="node-title-input"]' + ) + await input.press('Escape') + } + + /** + * Check if the title is currently being edited + */ + async isEditingTitle(): Promise { + const header = await this.getHeader() + const input = header.locator('[data-testid="node-title-input"]') + return await input.isVisible() + } + + /** + * Get the collapse/expand button + */ + async getCollapseButton(): Promise { + const header = await this.getHeader() + return header.locator('[data-testid="node-collapse-button"]') + } + + /** + * Toggle the node's collapsed state + */ + async toggleCollapse(): Promise { + const button = await this.getCollapseButton() + await button.click() + } + + /** + * Get the collapse icon element + */ + async getCollapseIcon(): Promise { + const button = await this.getCollapseButton() + return button.locator('i') + } + + /** + * Get the collapse icon's CSS classes + */ + async getCollapseIconClass(): Promise { + const icon = await this.getCollapseIcon() + return (await icon.getAttribute('class')) || '' + } + + /** + * Check if the collapse button is visible + */ + async isCollapseButtonVisible(): Promise { + const button = await this.getCollapseButton() + return await button.isVisible() + } + + /** + * Get the node's body/content element + */ + async getBody(): Promise { + const nodeId = this.nodeRef.id + return this.page.locator(`[data-testid="node-body-${nodeId}"]`) + } + + /** + * Check if the node body is visible (not collapsed) + */ + async isBodyVisible(): Promise { + const body = await this.getBody() + return await body.isVisible() + } +} diff --git a/browser_tests/tests/vueNodes/NodeHeader.spec.ts b/browser_tests/tests/vueNodes/NodeHeader.spec.ts new file mode 100644 index 000000000..fd98c0434 --- /dev/null +++ b/browser_tests/tests/vueNodes/NodeHeader.spec.ts @@ -0,0 +1,138 @@ +import { + comfyExpect as expect, + comfyPageFixture as test +} from '../../fixtures/ComfyPage' +import { VueNodeFixture } from '../../fixtures/utils/vueNodeFixtures' + +test.describe('NodeHeader', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Enabled') + await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false) + await comfyPage.setSetting('Comfy.EnableTooltips', true) + await comfyPage.setup() + // Load single SaveImage node workflow (positioned below menu bar) + await comfyPage.loadWorkflow('single_save_image_node') + }) + + test('displays node title', async ({ comfyPage }) => { + // Get the single SaveImage node from the workflow + const nodes = await comfyPage.getNodeRefsByType('SaveImage') + expect(nodes.length).toBeGreaterThanOrEqual(1) + + const node = nodes[0] + const vueNode = new VueNodeFixture(node, comfyPage.page) + + const title = await vueNode.getTitle() + expect(title).toBe('Save Image') + + // Verify title is visible in the header + const header = await vueNode.getHeader() + await expect(header).toContainText('Save Image') + }) + + test('allows title renaming', async ({ comfyPage }) => { + // Get the single SaveImage node from the workflow + const nodes = await comfyPage.getNodeRefsByType('SaveImage') + const node = nodes[0] + const vueNode = new VueNodeFixture(node, comfyPage.page) + + // Test renaming with Enter + await vueNode.setTitle('My Custom Sampler') + const newTitle = await vueNode.getTitle() + expect(newTitle).toBe('My Custom Sampler') + + // Verify the title is displayed + const header = await vueNode.getHeader() + await expect(header).toContainText('My Custom Sampler') + + // Test cancel with Escape + const titleElement = await vueNode.getTitleElement() + await titleElement.dblclick() + await comfyPage.nextFrame() + + // Type a different value but cancel + const input = (await vueNode.getHeader()).locator( + '[data-testid="node-title-input"]' + ) + await input.fill('This Should Be Cancelled') + await input.press('Escape') + await comfyPage.nextFrame() + + // Title should remain as the previously saved value + const titleAfterCancel = await vueNode.getTitle() + expect(titleAfterCancel).toBe('My Custom Sampler') + }) + + test('handles node collapsing', async ({ comfyPage }) => { + // Get the single SaveImage node from the workflow + const nodes = await comfyPage.getNodeRefsByType('SaveImage') + const node = nodes[0] + const vueNode = new VueNodeFixture(node, comfyPage.page) + + // Initially should not be collapsed + expect(await node.isCollapsed()).toBe(false) + const body = await vueNode.getBody() + await expect(body).toBeVisible() + + // Collapse the node + await vueNode.toggleCollapse() + expect(await node.isCollapsed()).toBe(true) + + // Verify node content is hidden + const collapsedSize = await node.getSize() + await expect(body).not.toBeVisible() + + // Expand again + await vueNode.toggleCollapse() + expect(await node.isCollapsed()).toBe(false) + await expect(body).toBeVisible() + + // Size should be restored + const expandedSize = await node.getSize() + expect(expandedSize.height).toBeGreaterThanOrEqual(collapsedSize.height) + }) + + test('shows collapse/expand icon state', async ({ comfyPage }) => { + // Get the single SaveImage node from the workflow + const nodes = await comfyPage.getNodeRefsByType('SaveImage') + const node = nodes[0] + const vueNode = new VueNodeFixture(node, comfyPage.page) + + // Check initial expanded state icon + let iconClass = await vueNode.getCollapseIconClass() + expect(iconClass).toContain('pi-chevron-down') + + // Collapse and check icon + await vueNode.toggleCollapse() + iconClass = await vueNode.getCollapseIconClass() + expect(iconClass).toContain('pi-chevron-right') + + // Expand and check icon + await vueNode.toggleCollapse() + iconClass = await vueNode.getCollapseIconClass() + expect(iconClass).toContain('pi-chevron-down') + }) + + test('preserves title when collapsing/expanding', async ({ comfyPage }) => { + // Get the single SaveImage node from the workflow + const nodes = await comfyPage.getNodeRefsByType('SaveImage') + const node = nodes[0] + const vueNode = new VueNodeFixture(node, comfyPage.page) + + // Set custom title + await vueNode.setTitle('Test Sampler') + expect(await vueNode.getTitle()).toBe('Test Sampler') + + // Collapse + await vueNode.toggleCollapse() + expect(await vueNode.getTitle()).toBe('Test Sampler') + + // Expand + await vueNode.toggleCollapse() + expect(await vueNode.getTitle()).toBe('Test Sampler') + + // Verify title is still displayed + const header = await vueNode.getHeader() + await expect(header).toContainText('Test Sampler') + }) +}) diff --git a/package-lock.json b/package-lock.json index 14402e412..d2dcae5ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "@xterm/xterm": "^5.5.0", "algoliasearch": "^5.21.0", "axios": "^1.8.2", + "chart.js": "^4.5.0", "dompurify": "^3.2.5", "dotenv": "^16.4.5", "extendable-media-recorder": "^9.2.27", @@ -2745,6 +2746,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@langchain/core": { "version": "0.2.36", "resolved": "https://registry.npmjs.org/@langchain/core/-/core-0.2.36.tgz", @@ -6321,6 +6328,18 @@ "node": "*" } }, + "node_modules/chart.js": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz", + "integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/check-error": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", diff --git a/package.json b/package.json index 666d73ca2..c188015cc 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "@xterm/xterm": "^5.5.0", "algoliasearch": "^5.21.0", "axios": "^1.8.2", + "chart.js": "^4.5.0", "dompurify": "^3.2.5", "dotenv": "^16.4.5", "extendable-media-recorder": "^9.2.27", diff --git a/src/assets/css/style.css b/src/assets/css/style.css index 289392447..9fe38ec95 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -5,6 +5,7 @@ @tailwind utilities; } + :root { --fg-color: #000; --bg-color: #fff; @@ -134,6 +135,188 @@ body { border: thin solid; } +/* Shared markdown content styling for consistent rendering across components */ +.comfy-markdown-content { + /* Typography */ + font-size: 0.875rem; /* text-sm */ + line-height: 1.6; + word-wrap: break-word; +} + +/* Headings */ +.comfy-markdown-content h1 { + font-size: 22px; /* text-[22px] */ + font-weight: 700; /* font-bold */ + margin-top: 2rem; /* mt-8 */ + margin-bottom: 1rem; /* mb-4 */ +} + +.comfy-markdown-content h1:first-child { + margin-top: 0; /* first:mt-0 */ +} + +.comfy-markdown-content h2 { + font-size: 18px; /* text-[18px] */ + font-weight: 700; /* font-bold */ + margin-top: 2rem; /* mt-8 */ + margin-bottom: 1rem; /* mb-4 */ +} + +.comfy-markdown-content h2:first-child { + margin-top: 0; /* first:mt-0 */ +} + +.comfy-markdown-content h3 { + font-size: 16px; /* text-[16px] */ + font-weight: 700; /* font-bold */ + margin-top: 2rem; /* mt-8 */ + margin-bottom: 1rem; /* mb-4 */ +} + +.comfy-markdown-content h3:first-child { + margin-top: 0; /* first:mt-0 */ +} + +.comfy-markdown-content h4, +.comfy-markdown-content h5, +.comfy-markdown-content h6 { + margin-top: 2rem; /* mt-8 */ + margin-bottom: 1rem; /* mb-4 */ +} + +.comfy-markdown-content h4:first-child, +.comfy-markdown-content h5:first-child, +.comfy-markdown-content h6:first-child { + margin-top: 0; /* first:mt-0 */ +} + +/* Paragraphs */ +.comfy-markdown-content p { + margin: 0 0 0.5em; +} + +.comfy-markdown-content p:last-child { + margin-bottom: 0; +} + +/* First child reset */ +.comfy-markdown-content *:first-child { + margin-top: 0; /* mt-0 */ +} + +/* Lists */ +.comfy-markdown-content ul, +.comfy-markdown-content ol { + padding-left: 2rem; /* pl-8 */ + margin: 0.5rem 0; /* my-2 */ +} + +/* Nested lists */ +.comfy-markdown-content ul ul, +.comfy-markdown-content ol ol, +.comfy-markdown-content ul ol, +.comfy-markdown-content ol ul { + padding-left: 1.5rem; /* pl-6 */ + margin: 0.5rem 0; /* my-2 */ +} + +.comfy-markdown-content li { + margin: 0.5rem 0; /* my-2 */ +} + +/* Code */ +.comfy-markdown-content code { + color: var(--code-text-color); + background-color: var(--code-bg-color); + border-radius: 0.25rem; /* rounded */ + padding: 0.125rem 0.375rem; /* px-1.5 py-0.5 */ + font-family: monospace; +} + +.comfy-markdown-content pre { + background-color: var(--code-block-bg-color); + border-radius: 0.25rem; /* rounded */ + padding: 1rem; /* p-4 */ + margin: 1rem 0; /* my-4 */ + overflow-x: auto; /* overflow-x-auto */ +} + +.comfy-markdown-content pre code { + background-color: transparent; /* bg-transparent */ + padding: 0; /* p-0 */ + color: var(--p-text-color); +} + +/* Tables */ +.comfy-markdown-content table { + width: 100%; /* w-full */ + border-collapse: collapse; /* border-collapse */ +} + +.comfy-markdown-content th, +.comfy-markdown-content td { + padding: 0.5rem; /* px-2 py-2 */ +} + +.comfy-markdown-content th { + color: var(--fg-color); +} + +.comfy-markdown-content td { + color: var(--drag-text); +} + +.comfy-markdown-content tr { + border-bottom: 1px solid var(--content-bg); +} + +.comfy-markdown-content tr:last-child { + border-bottom: none; +} + +.comfy-markdown-content thead { + border-bottom: 1px solid var(--p-text-color); +} + +/* Links */ +.comfy-markdown-content a { + color: var(--drag-text); + text-decoration: underline; +} + +/* Media */ +.comfy-markdown-content img, +.comfy-markdown-content video { + max-width: 100%; /* max-w-full */ + height: auto; /* h-auto */ + display: block; /* block */ + margin-bottom: 1rem; /* mb-4 */ +} + +/* Blockquotes */ +.comfy-markdown-content blockquote { + border-left: 3px solid var(--p-primary-color, var(--primary-bg)); + padding-left: 0.75em; + margin: 0.5em 0; + opacity: 0.8; +} + +/* Horizontal rule */ +.comfy-markdown-content hr { + border: none; + border-top: 1px solid var(--p-border-color, var(--border-color)); + margin: 1em 0; +} + +/* Strong and emphasis */ +.comfy-markdown-content strong { + font-weight: bold; +} + +.comfy-markdown-content em { + font-style: italic; +} + .comfy-modal { display: none; /* Hidden by default */ position: fixed; /* Stay in place */ @@ -637,3 +820,92 @@ audio.comfy-audio.empty-audio-widget { width: calc(100vw - env(titlebar-area-width, 100vw)); } /* End of [Desktop] Electron window specific styles */ + +/* Vue Node LOD (Level of Detail) System */ +/* These classes control rendering detail based on zoom level */ + +/* Minimal LOD (zoom <= 0.4) - Title only for performance */ +.lg-node--lod-minimal { + min-height: 32px; + transition: min-height 0.2s ease; + /* Performance optimizations */ + text-shadow: none; + backdrop-filter: none; +} + +.lg-node--lod-minimal .lg-node-body { + display: none !important; +} + +/* Reduced LOD (0.4 < zoom <= 0.8) - Essential widgets, simplified styling */ +.lg-node--lod-reduced { + transition: opacity 0.1s ease; + /* Performance optimizations */ + text-shadow: none; +} + +.lg-node--lod-reduced .lg-widget-label, +.lg-node--lod-reduced .lg-slot-label { + display: none; +} + +.lg-node--lod-reduced .lg-slot { + opacity: 0.6; + font-size: 0.75rem; +} + +.lg-node--lod-reduced .lg-widget { + margin: 2px 0; + font-size: 0.875rem; +} + +/* Full LOD (zoom > 0.8) - Complete detail rendering */ +.lg-node--lod-full { + /* 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; + -moz-user-select: none; + -ms-user-select: none; +} + +.lg-node .lg-slot, +.lg-node .lg-widget { + transition: opacity 0.1s ease, font-size 0.1s ease; +} + +/* Performance optimization during canvas interaction */ +.transform-pane--interacting .lg-node * { + transition: none !important; +} + +.transform-pane--interacting .lg-node { + will-change: transform; +} + +/* Global performance optimizations for LOD */ +.lg-node--lod-minimal, +.lg-node--lod-reduced { + /* Remove ALL expensive paint effects */ + box-shadow: none !important; + filter: none !important; + backdrop-filter: none !important; + text-shadow: none !important; + -webkit-mask-image: none !important; + mask-image: none !important; + clip-path: none !important; +} + +/* Reduce paint complexity for minimal LOD */ +.lg-node--lod-minimal { + /* Skip complex borders */ + border-radius: 0 !important; + /* Use solid colors only */ + background-image: none !important; +} + diff --git a/src/components/common/EditableText.spec.ts b/src/components/common/EditableText.spec.ts index 2e7b036b5..2d31123b9 100644 --- a/src/components/common/EditableText.spec.ts +++ b/src/components/common/EditableText.spec.ts @@ -68,4 +68,73 @@ describe('EditableText', () => { // @ts-expect-error fixme ts strict error expect(wrapper.emitted('edit')[0]).toEqual(['Test Text']) }) + + it('cancels editing on escape key', async () => { + const wrapper = mountComponent({ + modelValue: 'Original Text', + isEditing: true + }) + + // Change the input value + await wrapper.findComponent(InputText).setValue('Modified Text') + + // Press escape + await wrapper.findComponent(InputText).trigger('keyup.escape') + + // Should emit cancel event + expect(wrapper.emitted('cancel')).toBeTruthy() + + // Should NOT emit edit event + expect(wrapper.emitted('edit')).toBeFalsy() + + // Input value should be reset to original + expect(wrapper.findComponent(InputText).props()['modelValue']).toBe( + 'Original Text' + ) + }) + + it('does not save changes when escape is pressed and blur occurs', async () => { + const wrapper = mountComponent({ + modelValue: 'Original Text', + isEditing: true + }) + + // Change the input value + await wrapper.findComponent(InputText).setValue('Modified Text') + + // Press escape (which triggers blur internally) + await wrapper.findComponent(InputText).trigger('keyup.escape') + + // Manually trigger blur to simulate the blur that happens after escape + await wrapper.findComponent(InputText).trigger('blur') + + // Should emit cancel but not edit + expect(wrapper.emitted('cancel')).toBeTruthy() + expect(wrapper.emitted('edit')).toBeFalsy() + }) + + it('saves changes on enter but not on escape', async () => { + // Test Enter key saves changes + const enterWrapper = mountComponent({ + modelValue: 'Original Text', + isEditing: true + }) + await enterWrapper.findComponent(InputText).setValue('Saved Text') + await enterWrapper.findComponent(InputText).trigger('keyup.enter') + // Trigger blur that happens after enter + await enterWrapper.findComponent(InputText).trigger('blur') + expect(enterWrapper.emitted('edit')).toBeTruthy() + // @ts-expect-error fixme ts strict error + expect(enterWrapper.emitted('edit')[0]).toEqual(['Saved Text']) + + // Test Escape key cancels changes with a fresh wrapper + const escapeWrapper = mountComponent({ + modelValue: 'Original Text', + isEditing: true + }) + await escapeWrapper.findComponent(InputText).setValue('Cancelled Text') + await escapeWrapper.findComponent(InputText).trigger('keyup.escape') + expect(escapeWrapper.emitted('cancel')).toBeTruthy() + expect(escapeWrapper.emitted('edit')).toBeFalsy() + }) }) diff --git a/src/components/common/EditableText.vue b/src/components/common/EditableText.vue index 16510d3fd..c6fa18a8d 100644 --- a/src/components/common/EditableText.vue +++ b/src/components/common/EditableText.vue @@ -14,10 +14,12 @@ fluid :pt="{ root: { - onBlur: finishEditing + onBlur: finishEditing, + ...inputAttrs } }" @keyup.enter="blurInputElement" + @keyup.escape="cancelEditing" @click.stop /> @@ -27,21 +29,41 @@ import InputText from 'primevue/inputtext' import { nextTick, ref, watch } from 'vue' -const { modelValue, isEditing = false } = defineProps<{ +const { + modelValue, + isEditing = false, + inputAttrs = {} +} = defineProps<{ modelValue: string isEditing?: boolean + inputAttrs?: Record }>() -const emit = defineEmits(['update:modelValue', 'edit']) +const emit = defineEmits(['update:modelValue', 'edit', 'cancel']) const inputValue = ref(modelValue) const inputRef = ref | undefined>() +const isCanceling = ref(false) const blurInputElement = () => { // @ts-expect-error - $el is an internal property of the InputText component inputRef.value?.$el.blur() } const finishEditing = () => { - emit('edit', inputValue.value) + // Don't save if we're canceling + if (!isCanceling.value) { + emit('edit', inputValue.value) + } + isCanceling.value = false +} +const cancelEditing = () => { + // Set canceling flag to prevent blur from saving + isCanceling.value = true + // Reset to original value + inputValue.value = modelValue + // Emit cancel event + emit('cancel') + // Blur the input to exit edit mode + blurInputElement() } watch( () => isEditing, diff --git a/src/components/graph/DomWidgets.vue b/src/components/graph/DomWidgets.vue index 7edca3f5e..25136b887 100644 --- a/src/components/graph/DomWidgets.vue +++ b/src/components/graph/DomWidgets.vue @@ -16,6 +16,7 @@ import { computed } from 'vue' import DomWidget from '@/components/graph/widgets/DomWidget.vue' import { useChainCallback } from '@/composables/functional/useChainCallback' +import { LiteGraph } from '@/lib/litegraph/src/litegraph' import { useDomWidgetStore } from '@/stores/domWidgetStore' import { useCanvasStore } from '@/stores/graphStore' @@ -27,6 +28,9 @@ const updateWidgets = () => { const lgCanvas = canvasStore.canvas if (!lgCanvas) return + // Skip updating DOM widgets when Vue nodes mode is enabled + if (LiteGraph.vueNodesMode) return + const lowQuality = lgCanvas.low_quality const currentGraph = lgCanvas.graph diff --git a/src/components/graph/GraphCanvas.vue b/src/components/graph/GraphCanvas.vue index 1b0cf55ee..b2c59bc26 100644 --- a/src/components/graph/GraphCanvas.vue +++ b/src/components/graph/GraphCanvas.vue @@ -34,6 +34,54 @@ class="w-full h-full touch-none" /> + + + + + + + + + @@ -50,7 +98,16 @@ diff --git a/src/components/graph/TransformPane.spec.ts b/src/components/graph/TransformPane.spec.ts new file mode 100644 index 000000000..ca4aab9db --- /dev/null +++ b/src/components/graph/TransformPane.spec.ts @@ -0,0 +1,438 @@ +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' + +// Mock the transform state composable +const mockTransformState = { + camera: ref({ x: 0, y: 0, z: 1 }), + transformStyle: ref({ + transform: 'scale(1) translate(0px, 0px)', + transformOrigin: '0 0' + }), + syncWithCanvas: vi.fn(), + canvasToScreen: vi.fn(), + screenToCanvas: vi.fn(), + isNodeInViewport: vi.fn() +} + +vi.mock('@/composables/element/useTransformState', () => ({ + useTransformState: () => mockTransformState +})) + +// Mock requestAnimationFrame/cancelAnimationFrame +global.requestAnimationFrame = vi.fn((cb) => { + setTimeout(cb, 16) + return 1 +}) +global.cancelAnimationFrame = vi.fn() + +describe('TransformPane', () => { + let wrapper: ReturnType + let mockCanvas: any + + beforeEach(() => { + vi.clearAllMocks() + + // Create mock canvas with LiteGraph interface + mockCanvas = { + canvas: { + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }, + ds: { + offset: [0, 0], + scale: 1 + } + } + + // Reset mock transform state + mockTransformState.camera.value = { x: 0, y: 0, z: 1 } + mockTransformState.transformStyle.value = { + transform: 'scale(1) translate(0px, 0px)', + transformOrigin: '0 0' + } + }) + + afterEach(() => { + if (wrapper) { + wrapper.unmount() + } + }) + + describe('component mounting', () => { + it('should mount successfully with minimal props', () => { + wrapper = mount(TransformPane, { + props: { + canvas: mockCanvas + } + }) + + expect(wrapper.exists()).toBe(true) + expect(wrapper.find('.transform-pane').exists()).toBe(true) + }) + + it('should apply transform style from composable', () => { + mockTransformState.transformStyle.value = { + transform: 'scale(2) translate(100px, 50px)', + transformOrigin: '0 0' + } + + wrapper = mount(TransformPane, { + props: { + canvas: mockCanvas + } + }) + + const transformPane = wrapper.find('.transform-pane') + const style = transformPane.attributes('style') + expect(style).toContain('transform: scale(2) translate(100px, 50px)') + }) + + it('should render slot content', () => { + wrapper = mount(TransformPane, { + props: { + canvas: mockCanvas + }, + slots: { + default: '
Test Node
' + } + }) + + expect(wrapper.find('.test-content').exists()).toBe(true) + expect(wrapper.find('.test-content').text()).toBe('Test Node') + }) + }) + + describe('debug overlay', () => { + it('should not show debug overlay by default', () => { + wrapper = mount(TransformPane, { + props: { + canvas: mockCanvas + } + }) + + expect(wrapper.find('.viewport-debug-overlay').exists()).toBe(false) + }) + + it('should show debug overlay when enabled', () => { + wrapper = mount(TransformPane, { + props: { + canvas: mockCanvas, + viewport: { width: 1920, height: 1080 }, + showDebugOverlay: true + } + }) + + expect(wrapper.find('.viewport-debug-overlay').exists()).toBe(true) + }) + + it('should display viewport dimensions in debug overlay', () => { + wrapper = mount(TransformPane, { + props: { + canvas: mockCanvas, + viewport: { width: 1280, height: 720 }, + showDebugOverlay: true + } + }) + + const debugOverlay = wrapper.find('.viewport-debug-overlay') + expect(debugOverlay.text()).toContain('Viewport: 1280x720') + }) + + it('should include device pixel ratio in debug overlay', () => { + // Mock device pixel ratio + Object.defineProperty(window, 'devicePixelRatio', { + writable: true, + value: 2 + }) + + wrapper = mount(TransformPane, { + props: { + canvas: mockCanvas, + viewport: { width: 1920, height: 1080 }, + showDebugOverlay: true + } + }) + + const debugOverlay = wrapper.find('.viewport-debug-overlay') + expect(debugOverlay.text()).toContain('DPR: 2') + }) + }) + + describe('RAF synchronization', () => { + it('should start RAF sync on mount', async () => { + wrapper = mount(TransformPane, { + props: { + canvas: mockCanvas + } + }) + + await nextTick() + + // Should emit RAF status change to true + expect(wrapper.emitted('rafStatusChange')).toBeTruthy() + expect(wrapper.emitted('rafStatusChange')?.[0]).toEqual([true]) + }) + + it('should call syncWithCanvas during RAF updates', async () => { + wrapper = mount(TransformPane, { + props: { + canvas: mockCanvas + } + }) + + await nextTick() + + // Allow RAF to execute + await new Promise((resolve) => setTimeout(resolve, 20)) + + expect(mockTransformState.syncWithCanvas).toHaveBeenCalledWith(mockCanvas) + }) + + it('should emit transform update timing', async () => { + wrapper = mount(TransformPane, { + props: { + canvas: mockCanvas + } + }) + + await nextTick() + + // Allow RAF to execute + await new Promise((resolve) => setTimeout(resolve, 20)) + + expect(wrapper.emitted('transformUpdate')).toBeTruthy() + const updateEvent = wrapper.emitted('transformUpdate')?.[0] + expect(typeof updateEvent?.[0]).toBe('number') + expect(updateEvent?.[0]).toBeGreaterThanOrEqual(0) + }) + + it('should stop RAF sync on unmount', async () => { + wrapper = mount(TransformPane, { + props: { + canvas: mockCanvas + } + }) + + await nextTick() + wrapper.unmount() + + expect(wrapper.emitted('rafStatusChange')).toBeTruthy() + const events = wrapper.emitted('rafStatusChange') as any[] + expect(events[events.length - 1]).toEqual([false]) + expect(global.cancelAnimationFrame).toHaveBeenCalled() + }) + }) + + describe('canvas event listeners', () => { + it('should add event listeners to canvas on mount', async () => { + wrapper = mount(TransformPane, { + props: { + canvas: mockCanvas + } + }) + + await nextTick() + + expect(mockCanvas.canvas.addEventListener).toHaveBeenCalledWith( + 'wheel', + expect.any(Function), + expect.any(Object) + ) + expect(mockCanvas.canvas.addEventListener).toHaveBeenCalledWith( + 'pointerdown', + expect.any(Function), + expect.any(Object) + ) + expect(mockCanvas.canvas.addEventListener).toHaveBeenCalledWith( + 'pointerup', + expect.any(Function), + expect.any(Object) + ) + expect(mockCanvas.canvas.addEventListener).toHaveBeenCalledWith( + 'pointercancel', + expect.any(Function), + expect.any(Object) + ) + }) + + it('should remove event listeners on unmount', async () => { + wrapper = mount(TransformPane, { + props: { + canvas: mockCanvas + } + }) + + await nextTick() + wrapper.unmount() + + expect(mockCanvas.canvas.removeEventListener).toHaveBeenCalledWith( + 'wheel', + expect.any(Function), + expect.any(Object) + ) + expect(mockCanvas.canvas.removeEventListener).toHaveBeenCalledWith( + 'pointerdown', + expect.any(Function), + expect.any(Object) + ) + expect(mockCanvas.canvas.removeEventListener).toHaveBeenCalledWith( + 'pointerup', + expect.any(Function), + expect.any(Object) + ) + expect(mockCanvas.canvas.removeEventListener).toHaveBeenCalledWith( + 'pointercancel', + expect.any(Function), + expect.any(Object) + ) + }) + }) + + describe('interaction state management', () => { + it('should apply interacting class during interactions', async () => { + wrapper = mount(TransformPane, { + props: { + canvas: mockCanvas + } + }) + + // Simulate interaction start by checking internal state + // Note: This tests the CSS class application logic + const transformPane = wrapper.find('.transform-pane') + + // Initially should not have interacting class + expect(transformPane.classes()).not.toContain( + 'transform-pane--interacting' + ) + }) + + it('should handle pointer events for node delegation', async () => { + wrapper = mount(TransformPane, { + props: { + canvas: mockCanvas + } + }) + + const transformPane = wrapper.find('.transform-pane') + + // Simulate pointer down - we can't test the exact delegation logic + // in unit tests due to vue-test-utils limitations, but we can verify + // the event handler is set up correctly + await transformPane.trigger('pointerdown') + + // The test passes if no errors are thrown during event handling + expect(transformPane.exists()).toBe(true) + }) + }) + + describe('transform state integration', () => { + it('should provide transform utilities to child components', () => { + wrapper = mount(TransformPane, { + props: { + canvas: mockCanvas + } + }) + + // The component should provide transform state via Vue's provide/inject + // This is tested indirectly through the composable integration + expect(mockTransformState.syncWithCanvas).toBeDefined() + expect(mockTransformState.canvasToScreen).toBeDefined() + expect(mockTransformState.screenToCanvas).toBeDefined() + }) + }) + + describe('error handling', () => { + it('should handle null canvas gracefully', () => { + wrapper = mount(TransformPane, { + props: { + canvas: undefined + } + }) + + expect(wrapper.exists()).toBe(true) + expect(wrapper.find('.transform-pane').exists()).toBe(true) + }) + + it('should handle missing canvas properties', () => { + const incompleteCanvas = {} as any + + wrapper = mount(TransformPane, { + props: { + canvas: incompleteCanvas + } + }) + + expect(wrapper.exists()).toBe(true) + // Should not throw errors during mount + }) + }) + + describe('performance optimizations', () => { + it('should use contain CSS property for layout optimization', () => { + wrapper = mount(TransformPane, { + props: { + canvas: mockCanvas + } + }) + + const transformPane = wrapper.find('.transform-pane') + + // This test verifies the CSS contains the performance optimization + // Note: In JSDOM, computed styles might not reflect all CSS properties + expect(transformPane.element.className).toContain('transform-pane') + }) + + it('should disable pointer events on container but allow on children', () => { + wrapper = mount(TransformPane, { + props: { + canvas: mockCanvas + }, + slots: { + default: '
Test Node
' + } + }) + + const transformPane = wrapper.find('.transform-pane') + + // The CSS should handle pointer events optimization + // This is primarily a CSS concern, but we verify the structure + expect(transformPane.exists()).toBe(true) + expect(wrapper.find('[data-node-id="test"]').exists()).toBe(true) + }) + }) + + describe('viewport prop handling', () => { + it('should handle missing viewport prop', () => { + wrapper = mount(TransformPane, { + props: { + canvas: mockCanvas, + showDebugOverlay: true + } + }) + + // Should not crash when viewport is undefined + expect(wrapper.exists()).toBe(true) + }) + + it('should update debug overlay when viewport changes', async () => { + wrapper = mount(TransformPane, { + props: { + canvas: mockCanvas, + viewport: { width: 800, height: 600 }, + showDebugOverlay: true + } + }) + + expect(wrapper.text()).toContain('800x600') + + await wrapper.setProps({ + viewport: { width: 1920, height: 1080 } + }) + + expect(wrapper.text()).toContain('1920x1080') + }) + }) +}) diff --git a/src/components/graph/TransformPane.vue b/src/components/graph/TransformPane.vue new file mode 100644 index 000000000..a754fa40b --- /dev/null +++ b/src/components/graph/TransformPane.vue @@ -0,0 +1,133 @@ + + + + + + diff --git a/src/components/graph/debug/QuadTreeDebugSection.vue b/src/components/graph/debug/QuadTreeDebugSection.vue new file mode 100644 index 000000000..74d26e946 --- /dev/null +++ b/src/components/graph/debug/QuadTreeDebugSection.vue @@ -0,0 +1,112 @@ + + + + diff --git a/src/components/graph/debug/QuadTreeVisualization.vue b/src/components/graph/debug/QuadTreeVisualization.vue new file mode 100644 index 000000000..28ade900d --- /dev/null +++ b/src/components/graph/debug/QuadTreeVisualization.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/src/components/graph/debug/VueNodeDebugPanel.vue b/src/components/graph/debug/VueNodeDebugPanel.vue new file mode 100644 index 000000000..50c5b9cc4 --- /dev/null +++ b/src/components/graph/debug/VueNodeDebugPanel.vue @@ -0,0 +1,165 @@ + + + + diff --git a/src/components/graph/vueNodes/InputSlot.vue b/src/components/graph/vueNodes/InputSlot.vue new file mode 100644 index 000000000..80a78b069 --- /dev/null +++ b/src/components/graph/vueNodes/InputSlot.vue @@ -0,0 +1,84 @@ + + + diff --git a/src/components/graph/vueNodes/LGraphNode.vue b/src/components/graph/vueNodes/LGraphNode.vue new file mode 100644 index 000000000..b9e195853 --- /dev/null +++ b/src/components/graph/vueNodes/LGraphNode.vue @@ -0,0 +1,204 @@ + + + diff --git a/src/components/graph/vueNodes/NodeContent.vue b/src/components/graph/vueNodes/NodeContent.vue new file mode 100644 index 000000000..41ae8df34 --- /dev/null +++ b/src/components/graph/vueNodes/NodeContent.vue @@ -0,0 +1,42 @@ + + + diff --git a/src/components/graph/vueNodes/NodeHeader.vue b/src/components/graph/vueNodes/NodeHeader.vue new file mode 100644 index 000000000..3eaaa774a --- /dev/null +++ b/src/components/graph/vueNodes/NodeHeader.vue @@ -0,0 +1,149 @@ + + + diff --git a/src/components/graph/vueNodes/NodeSlots.vue b/src/components/graph/vueNodes/NodeSlots.vue new file mode 100644 index 000000000..527c88d8a --- /dev/null +++ b/src/components/graph/vueNodes/NodeSlots.vue @@ -0,0 +1,140 @@ + + + diff --git a/src/components/graph/vueNodes/NodeWidgets.vue b/src/components/graph/vueNodes/NodeWidgets.vue new file mode 100644 index 000000000..10bc978c1 --- /dev/null +++ b/src/components/graph/vueNodes/NodeWidgets.vue @@ -0,0 +1,167 @@ + + + diff --git a/src/components/graph/vueNodes/OutputSlot.vue b/src/components/graph/vueNodes/OutputSlot.vue new file mode 100644 index 000000000..2edd3f096 --- /dev/null +++ b/src/components/graph/vueNodes/OutputSlot.vue @@ -0,0 +1,87 @@ + + + diff --git a/src/components/graph/vueNodes/widgets/LOD_IMPLEMENTATION_GUIDE.md b/src/components/graph/vueNodes/widgets/LOD_IMPLEMENTATION_GUIDE.md new file mode 100644 index 000000000..95c8b40a3 --- /dev/null +++ b/src/components/graph/vueNodes/widgets/LOD_IMPLEMENTATION_GUIDE.md @@ -0,0 +1,295 @@ +# Level of Detail (LOD) Implementation Guide for Widgets + +## What is Level of Detail (LOD)? + +Level of Detail is a technique used to optimize performance by showing different amounts of detail based on how zoomed in the user is. Think of it like Google Maps - when you're zoomed out looking at the whole country, you only see major cities and highways. When you zoom in close, you see street names, building details, and restaurants. + +For ComfyUI nodes, this means: +- **Zoomed out** (viewing many nodes): Show only essential controls, hide labels and descriptions +- **Zoomed in** (focusing on specific nodes): Show all details, labels, help text, and visual polish + +## Why LOD Matters + +Without LOD optimization: +- 1000+ nodes with full detail = browser lag and poor performance +- Text that's too small to read still gets rendered (wasted work) +- Visual effects that are invisible at distance still consume GPU + +With LOD optimization: +- Smooth performance even with large node graphs +- Battery life improvement on laptops +- Better user experience across different zoom levels + +## How to Implement LOD in Your Widget + +### Step 1: Get the LOD Context + +Every widget component gets a `zoomLevel` prop. Use this to determine how much detail to show: + +```vue + +``` + +**Primary API:** Use `lodScore` (0-1) for granular control and smooth transitions +**Convenience API:** Use `lodLevel` ('minimal'|'reduced'|'full') for simple on/off decisions + +### Step 2: Choose What to Show at Different Zoom Levels + +#### Understanding the LOD Score +- `lodScore` is a number from 0 to 1 +- 0 = completely zoomed out (show minimal detail) +- 1 = fully zoomed in (show everything) +- 0.5 = medium zoom (show some details) + +#### Understanding LOD Levels +- `'minimal'` = zoom level 0.4 or below (very zoomed out) +- `'reduced'` = zoom level 0.4 to 0.8 (medium zoom) +- `'full'` = zoom level 0.8 or above (zoomed in close) + +### Step 3: Implement Your Widget's LOD Strategy + +Here's a complete example of a slider widget with LOD: + +```vue + + + + + +``` + +## Common LOD Patterns + +### Pattern 1: Essential vs. Nice-to-Have +```typescript +// Always show the main functionality +const showMainControl = computed(() => true) + +// Granular control with lodScore +const showLabels = computed(() => lodScore.value > 0.4) +const labelOpacity = computed(() => Math.max(0.3, lodScore.value)) + +// Simple control with lodLevel +const showExtras = computed(() => lodLevel.value === 'full') +``` + +### Pattern 2: Smooth Opacity Transitions +```typescript +// Gradually fade elements based on zoom +const labelOpacity = computed(() => { + // Fade in from zoom 0.3 to 0.6 + return Math.max(0, Math.min(1, (lodScore.value - 0.3) / 0.3)) +}) +``` + +### Pattern 3: Progressive Detail +```typescript +const detailLevel = computed(() => { + if (lodScore.value < 0.3) return 'none' + if (lodScore.value < 0.6) return 'basic' + if (lodScore.value < 0.8) return 'standard' + return 'full' +}) +``` + +## LOD Guidelines by Widget Type + +### Text Input Widgets +- **Always show**: The input field itself +- **Medium zoom**: Show label +- **High zoom**: Show placeholder text, validation messages +- **Full zoom**: Show character count, format hints + +### Button Widgets +- **Always show**: The button +- **Medium zoom**: Show button text +- **High zoom**: Show button description +- **Full zoom**: Show keyboard shortcuts, tooltips + +### Selection Widgets (Dropdown, Radio) +- **Always show**: The current selection +- **Medium zoom**: Show option labels +- **High zoom**: Show all options when expanded +- **Full zoom**: Show option descriptions, icons + +### Complex Widgets (Color Picker, File Browser) +- **Always show**: Simplified representation (color swatch, filename) +- **Medium zoom**: Show basic controls +- **High zoom**: Show full interface +- **Full zoom**: Show advanced options, previews + +## Design Collaboration Guidelines + +### For Designers +When designing widgets, consider creating variants for different zoom levels: + +1. **Minimal Design** (far away view) + - Essential elements only + - Higher contrast for visibility + - Simplified shapes and fewer details + +2. **Standard Design** (normal view) + - Balanced detail and simplicity + - Clear labels and readable text + - Good for most use cases + +3. **Full Detail Design** (close-up view) + - All labels, descriptions, and help text + - Rich visual effects and polish + - Maximum information density + +### Design Handoff Checklist +- [ ] Specify which elements are essential vs. nice-to-have +- [ ] Define minimum readable sizes for text elements +- [ ] Provide simplified versions for distant viewing +- [ ] Consider color contrast at different opacity levels +- [ ] Test designs at multiple zoom levels + +## Testing Your LOD Implementation + +### Manual Testing +1. Create a workflow with your widget +2. Zoom out until nodes are very small +3. Verify essential functionality still works +4. Zoom in gradually and check that details appear smoothly +5. Test performance with 50+ nodes containing your widget + +### Performance Considerations +- Avoid complex calculations in LOD computed properties +- Use `v-if` instead of `v-show` for elements that won't render +- Consider using `v-memo` for expensive widget content +- Test on lower-end devices + +### Common Mistakes +❌ **Don't**: Hide the main widget functionality at any zoom level +❌ **Don't**: Use complex animations that trigger at every zoom change +❌ **Don't**: Make LOD thresholds too sensitive (causes flickering) +❌ **Don't**: Forget to test with real content and edge cases + +✅ **Do**: Keep essential functionality always visible +✅ **Do**: Use smooth transitions between LOD levels +✅ **Do**: Test with varying content lengths and types +✅ **Do**: Consider accessibility at all zoom levels + +## Getting Help + +- Check existing widgets in `src/components/graph/vueNodes/widgets/` for examples +- Ask in the ComfyUI frontend Discord for LOD implementation questions +- Test your changes with the LOD debug panel (top-right in GraphCanvas) +- Profile performance impact using browser dev tools \ No newline at end of file diff --git a/src/components/graph/vueWidgets/WidgetButton.vue b/src/components/graph/vueWidgets/WidgetButton.vue index 34f7eb51f..ae8fb7567 100644 --- a/src/components/graph/vueWidgets/WidgetButton.vue +++ b/src/components/graph/vueWidgets/WidgetButton.vue @@ -3,7 +3,12 @@ -