diff --git a/CLAUDE.md b/CLAUDE.md index 5e9b6174c1..e69de29bb2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,58 +0,0 @@ -- use `npm run` to see what commands are available -- For component communication, prefer Vue's event-based pattern (emit/@event-name) for state changes and notifications; use defineExpose with refs only for imperative operations that need direct control (like form.validate(), modal.open(), or editor.focus()); events promote loose coupling and are better for reusable components, while exposed methods are acceptable for tightly-coupled component pairs or when wrapping third-party libraries that require imperative APIs -- After making code changes, follow this general process: (1) Create unit tests, component tests, browser tests (if appropriate for each), (2) run unit tests, component tests, and browser tests until passing, (3) run typecheck, lint, format (with prettier) -- you can use `npm run` command to see the scripts available, (4) check if any READMEs (including nested) or documentation needs to be updated, (5) Decide whether the changes are worth adding new content to the external documentation for (or would requires changes to the external documentation) at https://docs.comfy.org, then present your suggestion -- When referencing PrimeVue, you can get all the docs here: https://primevue.org. Do this instead of making up or inferring names of Components -- When trying to set tailwind classes for dark theme, use "dark-theme:" prefix rather than "dark:" -- Never add lines to PR descriptions or commit messages that say "Generated with Claude Code" -- When making PR names and commit messages, if you are going to add a prefix like "docs:", "feat:", "bugfix:", use square brackets around the prefix term and do not use a colon (e.g., should be "[docs]" rather than "docs:"). -- When I reference GitHub Repos related to Comfy-Org, you should proactively fetch or read the associated information in the repo. To do so, you should exhaust all options: (1) Check if we have a local copy of the repo, (2) Use the GitHub API to fetch the information; you may want to do this IN ADDITION to the other options, especially for reading specific branches/PRs/comments/reviews/metadata, and (3) curl the GitHub website and parse the html or json responses -- For information about ComfyUI, ComfyUI_frontend, or ComfyUI-Manager, you can web search or download these wikis: https://deepwiki.com/Comfy-Org/ComfyUI-Manager, https://deepwiki.com/Comfy-Org/ComfyUI_frontend/1-overview, https://deepwiki.com/comfyanonymous/ComfyUI/2-core-architecture -- If a question/project is related to Comfy-Org, Comfy, or ComfyUI ecosystem, you should proactively use the Comfy docs to answer the question. The docs may be referenced with URLs like https://docs.comfy.org -- When operating inside a repo, check for README files at key locations in the repo detailing info about the contents of that folder. E.g., top-level key folders like tests-ui, browser_tests, composables, extensions/core, stores, services often have their own README.md files. When writing code, make sure to frequently reference these README files to understand the overall architecture and design of the project. Pay close attention to the snippets to learn particular patterns that seem to be there for a reason, as you should emulate those. -- Prefer running single tests, and not the whole test suite, for performance -- If using a lesser known or complex CLI tool, run the --help to see the documentation before deciding what to run, even if just for double-checking or verifying things. -- IMPORTANT: the most important goal when writing code is to create clean, best-practices, sustainable, and scalable public APIs and interfaces. Our app is used by thousands of users and we have thousands of mods/extensions that are constantly changing and updating; and we are also always updating. That's why it is IMPORTANT that we design systems and write code that follows practices of domain-driven design, object-oriented design, and design patterns (such that you can assure stability while allowing for all components around you to change and evolve). We ABSOLUTELY prioritize clean APIs and public interfaces that clearly define and restrict how/what the mods/extensions can access. -- If any of these technologies are referenced, you can proactively read their docs at these locations: https://primevue.org/theming, https://primevue.org/forms/, https://www.electronjs.org/docs/latest/api/browser-window, https://vitest.dev/guide/browser/, https://atlassian.design/components/pragmatic-drag-and-drop/core-package/drop-targets/, https://playwright.dev/docs/api/class-test, https://playwright.dev/docs/api/class-electron, https://www.algolia.com/doc/api-reference/rest-api/, https://pyav.org/docs/develop/cookbook/basics.html -- IMPORTANT: Never add Co-Authored by Claude or any reference to Claude or Claude Code in commit messages, PR descriptions, titles, or any documentation whatsoever -- The npm script to type check is called "typecheck" NOT "type check" -- Use the Vue 3 Composition API instead of the Options API when writing Vue components. An exception is when overriding or extending a PrimeVue component for compatibility, you may use the Options API. -- when we are solving an issue we know the link/number for, we should add "Fixes #n" (where n is the issue number) to the PR description. -- Never write css if you can accomplish the same thing with tailwind utility classes -- Utilize ref and reactive for reactive state -- Implement computed properties with computed() -- Use watch and watchEffect for side effects -- Implement lifecycle hooks with onMounted, onUpdated, etc. -- Utilize provide/inject for dependency injection -- Use vue 3.5 style of default prop declaration. Do not define a `props` variable; instead, destructure props. Since vue 3.5, destructuring props does not strip them of reactivity. -- Use Tailwind CSS for styling -- Leverage VueUse functions for performance-enhancing styles -- Use lodash for utility functions -- Implement proper props and emits definitions -- Utilize Vue 3's Teleport component when needed -- Use Suspense for async components -- Implement proper error handling -- Follow Vue 3 style guide and naming conventions -- IMPORTANT: Use vue-i18n for ALL user-facing strings - no hard-coded text in services/utilities. Place new translation entries in src/locales/en/main.json -- Avoid using `@ts-expect-error` to work around type issues. We needed to employ it to migrate to TypeScript, but it should not be viewed as an accepted practice or standard. -- DO NOT use deprecated PrimeVue components. Use these replacements instead: - * `Dropdown` → Use `Select` (import from 'primevue/select') - * `OverlayPanel` → Use `Popover` (import from 'primevue/popover') - * `Calendar` → Use `DatePicker` (import from 'primevue/datepicker') - * `InputSwitch` → Use `ToggleSwitch` (import from 'primevue/toggleswitch') - * `Sidebar` → Use `Drawer` (import from 'primevue/drawer') - * `Chips` → Use `AutoComplete` with multiple enabled and typeahead disabled - * `TabMenu` → Use `Tabs` without panels - * `Steps` → Use `Stepper` without panels - * `InlineMessage` → Use `Message` component -* Use `api.apiURL()` for all backend API calls and routes - - Actual API endpoints like /prompt, /queue, /view, etc. - - Image previews: `api.apiURL('/view?...')` - - Any backend-generated content or dynamic routes -* Use `api.fileURL()` for static files served from the public folder: - - Templates: `api.fileURL('/templates/default.json')` - - Extensions: `api.fileURL(extensionPath)` for loading JS modules - - Any static assets that exist in the public directory -- When implementing code that outputs raw HTML (e.g., using v-html directive), always ensure dynamic content has been properly sanitized with DOMPurify or validated through trusted sources. Prefer Vue templates over v-html when possible. -- For any async operations (API calls, timers, etc), implement cleanup/cancellation in component unmount to prevent memory leaks -- Extract complex template conditionals into separate components or computed properties -- Error messages should be actionable and user-friendly (e.g., "Failed to load data. Please refresh the page." instead of "Unknown error") diff --git a/browser_tests/fixtures/utils/vueNodeFixtures.ts b/browser_tests/fixtures/utils/vueNodeFixtures.ts new file mode 100644 index 0000000000..5c4541b926 --- /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 0000000000..fd98c04342 --- /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/eslint.config.js b/eslint.config.js index 9f212cfc1e..0a13203bcb 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -13,7 +13,8 @@ export default [ ignores: [ 'src/scripts/*', 'src/extensions/core/*', - 'src/types/vue-shim.d.ts' + 'src/types/vue-shim.d.ts', + 'src/lib/litegraph/**/*' ] }, { diff --git a/lint-staged.config.js b/lint-staged.config.js index 2d1a6f0511..699f13b274 100644 --- a/lint-staged.config.js +++ b/lint-staged.config.js @@ -3,7 +3,7 @@ export default { './**/*.{ts,tsx,vue,mts}': (stagedFiles) => [ ...formatAndEslint(stagedFiles), - 'vue-tsc --noEmit' + 'npm run typecheck' ] } diff --git a/package-lock.json b/package-lock.json index eee43ab87f..15f6235001 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,6 @@ "@alloc/quick-lru": "^5.2.0", "@atlaskit/pragmatic-drag-and-drop": "^1.3.1", "@comfyorg/comfyui-electron-types": "^0.4.43", - "@comfyorg/litegraph": "^0.16.3", "@primevue/forms": "^4.2.5", "@primevue/themes": "^4.2.5", "@sentry/vue": "^8.48.0", @@ -29,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", "firebase": "^11.6.0", @@ -947,12 +947,6 @@ "integrity": "sha512-o6WFbYn9yAkGbkOwvhPF7pbKDvN0occZ21Tfyhya8CIsIqKpTHLft0aOqo4yhSh+kTxN16FYjsfrTH5Olk4WuA==", "license": "GPL-3.0-only" }, - "node_modules/@comfyorg/litegraph": { - "version": "0.16.3", - "resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.16.3.tgz", - "integrity": "sha512-dst29g8+aZW8sWTYxj3LK1W4lX07elBPWFB1L4HLTkYgkzQoyBkHR1O2lSvAn+7bKagi0Q5PjIcZnWG+JAi0lg==", - "license": "MIT" - }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -2543,6 +2537,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", @@ -6148,6 +6148,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 74773da4c7..ab8ba2a7c4 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "build": "npm run typecheck && vite build", "build:types": "vite build --config vite.types.config.mts && node scripts/prepare-types.js", "zipdist": "node scripts/zipdist.js", - "typecheck": "vue-tsc --noEmit", + "typecheck": "vue-tsc --build", "format": "prettier --write './**/*.{js,ts,tsx,vue,mts}'", "format:check": "prettier --check './**/*.{js,ts,tsx,vue,mts}'", "test:browser": "npx playwright test", @@ -76,7 +76,6 @@ "@alloc/quick-lru": "^5.2.0", "@atlaskit/pragmatic-drag-and-drop": "^1.3.1", "@comfyorg/comfyui-electron-types": "^0.4.43", - "@comfyorg/litegraph": "^0.16.3", "@primevue/forms": "^4.2.5", "@primevue/themes": "^4.2.5", "@sentry/vue": "^8.48.0", @@ -93,6 +92,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", "firebase": "^11.6.0", diff --git a/src/assets/css/style.css b/src/assets/css/style.css index 289392447f..9fe38ec955 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 2e7b036b55..2d31123b98 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 16510d3fd4..c6fa18a8d8 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/dialog/content/MissingCoreNodesMessage.vue b/src/components/dialog/content/MissingCoreNodesMessage.vue index 40347061e4..3c407b666e 100644 --- a/src/components/dialog/content/MissingCoreNodesMessage.vue +++ b/src/components/dialog/content/MissingCoreNodesMessage.vue @@ -42,7 +42,6 @@ diff --git a/src/components/graph/GraphCanvasMenu.vue b/src/components/graph/GraphCanvasMenu.vue index 8f5d036cbc..c055ee50c2 100644 --- a/src/components/graph/GraphCanvasMenu.vue +++ b/src/components/graph/GraphCanvasMenu.vue @@ -60,7 +60,6 @@ + + diff --git a/src/components/graph/debug/QuadTreeDebugSection.vue b/src/components/graph/debug/QuadTreeDebugSection.vue new file mode 100644 index 0000000000..872a5fcaac --- /dev/null +++ b/src/components/graph/debug/QuadTreeDebugSection.vue @@ -0,0 +1,111 @@ + + + diff --git a/src/components/graph/debug/QuadTreeVisualization.vue b/src/components/graph/debug/QuadTreeVisualization.vue new file mode 100644 index 0000000000..28ade900d2 --- /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 0000000000..903071dba7 --- /dev/null +++ b/src/components/graph/debug/VueNodeDebugPanel.vue @@ -0,0 +1,164 @@ + + + diff --git a/src/components/graph/selectionToolbox/ColorPickerButton.vue b/src/components/graph/selectionToolbox/ColorPickerButton.vue index 3f7daacf51..f1b51cd186 100644 --- a/src/components/graph/selectionToolbox/ColorPickerButton.vue +++ b/src/components/graph/selectionToolbox/ColorPickerButton.vue @@ -40,8 +40,6 @@ diff --git a/src/components/graph/vueNodes/LGraphNode.vue b/src/components/graph/vueNodes/LGraphNode.vue new file mode 100644 index 0000000000..a3b36b2c8d --- /dev/null +++ b/src/components/graph/vueNodes/LGraphNode.vue @@ -0,0 +1,203 @@ + + + diff --git a/src/components/graph/vueNodes/NodeContent.vue b/src/components/graph/vueNodes/NodeContent.vue new file mode 100644 index 0000000000..d51a48e35a --- /dev/null +++ b/src/components/graph/vueNodes/NodeContent.vue @@ -0,0 +1,41 @@ + + + diff --git a/src/components/graph/vueNodes/NodeHeader.vue b/src/components/graph/vueNodes/NodeHeader.vue new file mode 100644 index 0000000000..5abf19b3aa --- /dev/null +++ b/src/components/graph/vueNodes/NodeHeader.vue @@ -0,0 +1,148 @@ + + + diff --git a/src/components/graph/vueNodes/NodeSlots.vue b/src/components/graph/vueNodes/NodeSlots.vue new file mode 100644 index 0000000000..6d1d6ec885 --- /dev/null +++ b/src/components/graph/vueNodes/NodeSlots.vue @@ -0,0 +1,139 @@ + + + diff --git a/src/components/graph/vueNodes/NodeWidgets.vue b/src/components/graph/vueNodes/NodeWidgets.vue new file mode 100644 index 0000000000..487c1dc7a0 --- /dev/null +++ b/src/components/graph/vueNodes/NodeWidgets.vue @@ -0,0 +1,166 @@ + + + diff --git a/src/components/graph/vueNodes/OutputSlot.vue b/src/components/graph/vueNodes/OutputSlot.vue new file mode 100644 index 0000000000..2edd3f096f --- /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 0000000000..95c8b40a37 --- /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 34f7eb51f9..ae8fb75674 100644 --- a/src/components/graph/vueWidgets/WidgetButton.vue +++ b/src/components/graph/vueWidgets/WidgetButton.vue @@ -3,7 +3,12 @@ -