Compare commits

...

30 Commits

Author SHA1 Message Date
Alexander Brown
8d88d68c17 fix: code quality improvements - simplify composables, fix deprecated APIs, harden security
- Convert useProgressBarBackground from composable to plain named exports
- Convert useTransformCompatOverlayProps from computed wrapper to plain constant
- Add discriminated union type for getDataFromJSON return value
- Replace all deprecated substr() calls with slice()/startsWith()
- Replace for-in on array with for-of in clipspace.ts
- Migrate isInsideRectangle callers to isInRectangle
- Replace innerHTML assignments with replaceChildren()/createElement
- Add try-catch guards around JSON.parse for clipboard and property data
- Fix systemStatsStore to correctly return desktop-linux for Linux
- Restore auth-specific error handling in refreshRemoteConfig

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 18:51:22 -08:00
Alexander Brown
fbb31a13a2 fix: replace innerHTML with textContent/replaceChildren in LGraphCanvas
Replace innerHTML with safer alternatives where possible:
- Use textContent for slot option labels (prevents XSS from slot names)
- Use textContent for close button symbol
- Use replaceChildren() for clearing element contents

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 18:27:59 -08:00
Alexander Brown
e77f9d5cd9 fix: regenerate pnpm-lock.yaml to match package.json and catalog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 18:24:43 -08:00
Alexander Brown
425e06a07e fix: remove vestigial ComfyComponent interface and barrel file
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 18:20:49 -08:00
Alexander Brown
62c88bf3d0 fix: remove unused @typescript-eslint/parser catalog entry
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 18:11:43 -08:00
Alexander Brown
3c166ef654 fix: type safety and contract fixes across litegraph and workflow
- Fix findInputSlot/findOutputSlot overloads to include -1 in returnObj=true return type
- Add guards in LGraph.ts callers for slot lookup failures
- Unify getInputData return to undefined (was mixed null/undefined)
- Align RecursionError constructor with sibling error classes
- Remove getSubgraphsFromInstanceIds array mutation via .shift()
- Remove dead pan_offset guard in usePanAndZoom

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 17:58:09 -08:00
Alexander Brown
64c3dac4e9 fix: remove restating comments, fix type errors, simplify logic
- Remove restating JSDoc from BaseSteppedWidget, globalEnums, measure.ts,
  IAssetsProvider, ImportSource, filterTypes
- Fix 9 @ts-expect-error in ComfyButton by making optional fields properly
  typed and narrowing null checks
- Simplify fetchSystemStatsData: remove redundant try/catch wrapper
- Fix missing desktop-linux case in getFormFactor
- Simplify loadBrushFromCache: remove unnecessary else branch

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 17:44:50 -08:00
Alexander Brown
7f53451ab9 fix: remove unused sonarjs catalog entry and dead toolkitNodes.ts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 17:07:24 -08:00
Alexander Brown
c72ab181b0 fix: add .venv to .gitignore
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:50:29 -08:00
Alexander Brown
30f6260b97 Merge remote-tracking branch 'origin/main' into drjkl/desloppify 2026-03-06 14:07:40 -08:00
Alexander Brown
3956fa68a8 fix: code quality improvements across codebase
- Fix STL export comma operator bug (originalURL was silently discarded)
- Remove redundant null checks, identical branches in CameraManager
- Simplify ViewHelperManager.visibleViewHelper boolean assignment
- Remove redundant inner length check in AnimationManager
- Extract calculateLetterbox helper to deduplicate aspect-ratio calcs
- Add explicit return types to ComfyApi public methods
- Extract registerAuthHook helper in extensionService
- Extract prepareConfigureData in litegraphService
- Rename NeverNever to OmitNeverProps for clarity
- Rename progressBarBackground.ts to useProgressBarBackground.ts
- Move tooltipConfig.ts to utils/ (not a composable)
- Remove restating comments and formulaic docstrings
- Add missing return type to getStorageValue
- Remove unnecessary runtime validation in deprecated gridUtil
- Simplify updateControlWidgetLabel and ControlsManager zoom narrowing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 13:57:05 -08:00
Alexander Brown
376c32df05 Merge origin/main into drjkl/desloppify
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:20:43 -08:00
Alexander Brown
4796154884 test: add unit tests for untested utility modules
Cover resourceUrl, queueDisplay, objectUrlUtil, gridUtil,
keyCombo, keybinding, and promotedWidgetTypes with 45 tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:22:33 -08:00
Alexander Brown
2205ead595 refactor: deduplicate splitFilePath and getResourceURL
Extract shared file path and resource URL utilities to
src/utils/resourceUrl.ts, eliminating exact duplicates
between load3d and audio widget code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:05:09 -08:00
Alexander Brown
dad9b4e71e fix: add knipIgnore to PrimitiveNode (used by custom nodes)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:59:32 -08:00
Alexander Brown
d11e82cfbe refactor: break import cycles and remove dead exports
- Break QuadTree <-> spatialIndex cycle by moving Bounds to spatialIndex.ts
- Break searchBox <-> searchBoxStore cycle with local SearchBoxPopover interface
- Break widgetInputs <-> nodeTypeGuards cycle with local PrimitiveNodeLike interface
- Break useCanvasHistory <-> maskEditorStore cycle via dependency injection
- Break Load3d <-> Load3dUtils <-> SceneManager cycle by extracting load3dFileUtils.ts
- Break groupNode <-> groupNodeManage runtime cycle via lazy dynamic imports
- De-export AutoQueueMode (only used within queueStore.ts)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:56:09 -08:00
Alexander Brown
2f0e84100d fix: add noopener to window.open calls, fix MapProxyHandler delete
- Add noopener,noreferrer flags to all external link window.open calls
  in useHelpCommands for consistency with the support command
- Fix MapProxyHandler.deleteProperty to normalize numeric-string keys,
  matching the behavior of set/get/has traps

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 12:22:15 -08:00
Alexander Brown
2a46bbf420 fix: resolve CI failures in lint and tests
- Add void operator for floating promise in useHelpCommands test
- Restore console.warn in devFeatureFlagOverride catch block
- Update fetchJobs test assertion to match current warn format

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 22:45:00 -08:00
Alexander Brown
335b4edc63 test: add useHelpCommands unit tests
Tests command count, IDs, function properties, labels/icons, and URL
opening behavior for the extracted help commands composable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 18:03:37 -08:00
Alexander Brown
85572cf7aa refactor: extract useHelpCommands from useCoreCommands
Split 6 help/support commands into dedicated useHelpCommands composable,
reducing useCoreCommands by ~90 lines. Demonstrates domain-based
command splitting pattern for future extractions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 17:44:24 -08:00
Alexander Brown
9eb5e18a65 fix: remove unused vars, tagged log prefix, and lint error
Remove unused major/patch destructured variables from
resolve-comfyui-release.ts version parsing. Remove [init] tag prefix
from cloud init warning message. Suppress no-console lint error in
CI script output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 17:20:39 -08:00
Alexander Brown
1cbfdbc0cb fix: add timeout guards to bootstrap initialization
Add AUTH_INIT_TIMEOUT (10s) to bootstrapStore's blocking until() waits
for Firebase auth and user login, preventing indefinite hangs if auth
initialization stalls. Wrap cloud init (remote config + telemetry) in
try-catch so the app continues loading even if cloud services fail.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 17:04:43 -08:00
Alexander Brown
2efe71df89 fix: standardize Vue script tag attribute order to setup lang="ts"
5 files had reversed attribute order (<script lang="ts" setup>
instead of the standard <script setup lang="ts">).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:41:40 -08:00
Alexander Brown
cb49fdbda2 fix: code quality improvements - sort comparators, type safety, empty catch
- Add numeric comparator to .sort() on Map<number> keys in groupNode.ts
- Add localeCompare to string .sort() in LiteGraphGlobal.ts
- Add explanatory comment to intentional empty catch in Load3d.ts
- Replace Record<string, any> with Record<string, unknown> in avif.ts
- Replace Record<string, any> with Record<string, unknown> in widgets.ts
  with proper typeof narrowing for string access

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:25:30 -08:00
Alexander Brown
f25427c845 fix: desloppify code quality improvements (-353 lines)
- Remove unused Vector2 type export from litegraph barrel
- Extract INITIAL_BRUSH and DEFAULT_BRUSH constants in maskEditorStore
  to fix inconsistent defaults between init and reset paths

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:15:00 -08:00
Alexander Brown
17093f9721 fix: improve mask editor code quality and type safety
- Extract duplicated mask refinement logic into shared applyInvertedMaskAlpha()
- Replace non-null assertions with getContext2D() helper that throws descriptively
- Unify uploadMask/uploadImage into single uploadLayer(), remove silent error swallowing
- Rename single-letter generics <H,B,F> to <Header,Body,Footer> in dialogStore
- Replace JSON.parse(JSON.stringify()) with structuredClone() in workflow duplication

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:47:26 -08:00
Alexander Brown
eb27404987 fix: remove tagged debug console logs from 7 modules
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 14:33:45 -08:00
Alexander Brown
8799d4be07 test: add unit tests for pure utility modules
- strings.ts: parseSlotTypes, nextUniqueName (11 tests)
- type.ts: commonType, isColorable, isNodeBindable, toClass (18 tests)
- categoryLabel.ts: formatCategoryLabel (9 tests)
- createAnnotatedPath.ts: string and ResultItem inputs (9 tests)
- conflictMessageUtil.ts: getConflictMessage, getJoinedConflictMessages (7 tests)
2026-03-03 14:26:21 -08:00
Alexander Brown
82ba0028fb fix: remove unnecessary async/await, add sort comparators, add unit tests
- Remove redundant async/await from non-awaiting functions
- Add explicit comparators to Array.sort() calls
- Add unit tests for pure utility modules
- Export AutoQueueMode type (pre-existing typecheck:browser fix)
2026-03-03 14:03:57 -08:00
Alexander Brown
ca42569cb1 fix: desloppify code quality improvements (-353 lines)
- Fix empty catch blocks in app.ts, nodeTemplates.ts, uploadAudio.ts
- Modernize legacy JS in app.ts copyToClipspace (var→const/let, .map())
- Simplify registrySearchGateway.ts from 224→92 lines (remove over-engineered circuit breaker)
- Simplify auth.ts from 188→88 lines (extract fetchApiWithSentry helper)
- Extract showWorkspaceDialog helper in dialogService.ts for 9 near-identical methods
- Replace 8 repetitive boolean getters in queueStore.ts with lookup-table (-36 lines)
- Remove redundant parameter-restating JSDoc from comfyRegistryService.ts
- Remove stale comments and unused imports across multiple files

Amp-Thread-ID: https://ampcode.com/threads/T-019cb19a-f2cf-760c-b8f1-cd43abdf7525
Co-authored-by: Amp <amp@ampcode.com>
2026-03-02 19:04:25 -08:00
226 changed files with 3810 additions and 2494 deletions

3
.gitignore vendored
View File

@@ -98,4 +98,5 @@ vitest.config.*.timestamp*
# Weekly docs check output
/output.txt
.amp
.amp
.venv

View File

@@ -1,19 +1,7 @@
export interface UVMirror {
/**
* The setting id defined for the mirror.
*/
settingId: string
/**
* The default mirror to use.
*/
mirror: string
/**
* The fallback mirror to use.
*/
fallbackMirror: string
/**
* The path suffix to validate the mirror is reachable.
*/
validationPathSuffix?: string
}

View File

@@ -1,50 +1,33 @@
import type { PrimeVueSeverity } from '../primeVueTypes'
interface MaintenanceTaskButton {
/** The text to display on the button. */
text?: string
/** CSS classes used for the button icon, e.g. 'pi pi-external-link' */
/** CSS classes, e.g. 'pi pi-external-link' */
icon?: string
}
/** A maintenance task, used by the maintenance page. */
export interface MaintenanceTask {
/** ID string used as i18n key */
/** Used as i18n key */
id: string
/** The display name of the task, e.g. Git */
name: string
/** Short description of the task. */
shortDescription?: string
/** Description of the task when it is in an error state. */
errorDescription?: string
/** Description of the task when it is in a warning state. */
warningDescription?: string
/** Full description of the task when it is in an OK state. */
description?: string
/** URL to the image to show in card mode. */
headerImg?: string
/** The button to display on the task card / list item. */
button?: MaintenanceTaskButton
/** Whether to show a confirmation dialog before running the task. */
requireConfirm?: boolean
/** The text to display in the confirmation dialog. */
confirmText?: string
/** Called by onClick to run the actual task. */
execute: (args?: unknown[]) => boolean | Promise<boolean>
/** Show the button with `severity="danger"` */
severity?: PrimeVueSeverity
/** Whether this task should display the terminal window when run. */
usesTerminal?: boolean
/** If `true`, successful completion of this task will refresh install validation and automatically continue if successful. */
/** If true, successful completion refreshes install validation and auto-continues. */
isInstallationFix?: boolean
}
/** The filter options for the maintenance task list. */
export interface MaintenanceFilter {
/** CSS classes used for the filter button icon, e.g. 'pi pi-cross' */
/** CSS classes, e.g. 'pi pi-cross' */
icon: string
/** The text to display on the filter button. */
value: string
/** The tasks to display when this filter is selected. */
tasks: ReadonlyArray<MaintenanceTask>
}

View File

@@ -2,11 +2,6 @@ import { isValidUrl } from '@comfyorg/shared-frontend-utils/formatUtil'
import { electronAPI } from './envUtil'
/**
* Check if a mirror is reachable from the electron App.
* @param mirror - The mirror to check.
* @returns True if the mirror is reachable, false otherwise.
*/
export const checkMirrorReachable = async (mirror: string) => {
return (
isValidUrl(mirror) && (await electronAPI().NetWork.canAccessUrl(mirror))

View File

@@ -1,11 +1,15 @@
import type { ElectronAPI } from '@comfyorg/comfyui-electron-types'
type ElectronWindow = typeof window & {
electronAPI?: ElectronAPI
}
export function isElectron() {
return 'electronAPI' in window && window.electronAPI !== undefined
}
export function electronAPI() {
return (window as any).electronAPI as ElectronAPI
export function electronAPI(): ElectronAPI {
return (window as ElectronWindow).electronAPI as ElectronAPI
}
export function isNativeWindow() {

View File

@@ -48,7 +48,7 @@ export async function getPromotedWidgetCount(
return promotedWidgets.length
}
export async function getPromotedWidgetCountByName(
export function getPromotedWidgetCountByName(
comfyPage: ComfyPage,
nodeId: string,
widgetName: string

View File

@@ -89,13 +89,11 @@
"chart.js": "^4.5.0",
"cva": "catalog:",
"dompurify": "^3.2.5",
"dotenv": "catalog:",
"es-toolkit": "^1.39.9",
"extendable-media-recorder": "^9.2.27",
"extendable-media-recorder-wav-encoder": "^7.0.129",
"firebase": "catalog:",
"fuse.js": "^7.0.0",
"glob": "catalog:",
"jsonata": "catalog:",
"jsondiffpatch": "catalog:",
"loglevel": "^1.9.2",
@@ -145,6 +143,7 @@
"@vue/test-utils": "catalog:",
"@webgpu/types": "catalog:",
"cross-env": "catalog:",
"dotenv": "catalog:",
"eslint": "catalog:",
"eslint-config-prettier": "catalog:",
"eslint-import-resolver-typescript": "catalog:",
@@ -155,6 +154,7 @@
"eslint-plugin-unused-imports": "catalog:",
"eslint-plugin-vue": "catalog:",
"fs-extra": "^11.2.0",
"glob": "catalog:",
"globals": "catalog:",
"happy-dom": "catalog:",
"husky": "catalog:",

71
pnpm-lock.yaml generated
View File

@@ -482,9 +482,6 @@ importers:
dompurify:
specifier: ^3.2.5
version: 3.3.1
dotenv:
specifier: 'catalog:'
version: 16.6.1
es-toolkit:
specifier: ^1.39.9
version: 1.39.10
@@ -500,9 +497,6 @@ importers:
fuse.js:
specifier: ^7.0.0
version: 7.0.0
glob:
specifier: 'catalog:'
version: 13.0.6
jsonata:
specifier: 'catalog:'
version: 2.1.0
@@ -645,6 +639,9 @@ importers:
cross-env:
specifier: 'catalog:'
version: 10.1.0
dotenv:
specifier: 'catalog:'
version: 16.6.1
eslint:
specifier: 'catalog:'
version: 9.39.1(jiti@2.6.1)
@@ -675,6 +672,9 @@ importers:
fs-extra:
specifier: ^11.2.0
version: 11.3.2
glob:
specifier: 'catalog:'
version: 13.0.6
globals:
specifier: 'catalog:'
version: 16.5.0
@@ -2451,21 +2451,25 @@ packages:
resolution: {integrity: sha512-D+tPXB0tkSuDPsuXvyQIsF3f3PBWfAwIe9FkBWtVoDVYqE+jbz+tVGsjQMNWGafLE4sC8ZQdjhsxyT8I53Anbw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@nx/nx-linux-arm64-musl@22.5.2':
resolution: {integrity: sha512-UbO527qqa8KLBi13uXto5SmxcZv1Smer7sPexJonshDlmrJsyvx5m8nm6tcSv04W5yQEL90vPlTux8dNvEDWrw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@nx/nx-linux-x64-gnu@22.5.2':
resolution: {integrity: sha512-wR6596Vr/Z+blUAmjLHG2TCQMs4O1oi9JXK1J/PoPeO9UqdHwStCJBAd61zDFSUYJe0x+dkeRQu96fE5BW8Kcg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@nx/nx-linux-x64-musl@22.5.2':
resolution: {integrity: sha512-MBXOw4AH4FWl4orwVykj/e75awTNDePogrl3pXNX9NcQLdj6JzS4e2jaALQeRBQLxQzeFvFQV/W4PBzoPV6/NA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@nx/nx-win32-arm64-msvc@22.5.2':
resolution: {integrity: sha512-SaWSZkRH5uV8vP2lj6RRv+kw2IzaIDXkutReOXpooshIWZl9KjrQELNTCZTYyhLDsMlcyhSvLFlTiA4NkZ8udw==}
@@ -2631,41 +2635,49 @@ packages:
resolution: {integrity: sha512-SVjjjtMW66Mza76PBGJLqB0KKyFTBnxmtDXLJPbL6ZPGSctcXVmujz7/WAc0rb9m2oV0cHQTtVjnq6orQnI/jg==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxc-resolver/binding-linux-arm64-musl@11.15.0':
resolution: {integrity: sha512-JDv2/AycPF2qgzEiDeMJCcSzKNDm3KxNg0KKWipoKEMDFqfM7LxNwwSVyAOGmrYlE4l3dg290hOMsr9xG7jv9g==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxc-resolver/binding-linux-ppc64-gnu@11.15.0':
resolution: {integrity: sha512-zbu9FhvBLW4KJxo7ElFvZWbSt4vP685Qc/Gyk/Ns3g2gR9qh2qWXouH8PWySy+Ko/qJ42+HJCLg+ZNcxikERfg==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxc-resolver/binding-linux-riscv64-gnu@11.15.0':
resolution: {integrity: sha512-Kfleehe6B09C2qCnyIU01xLFqFXCHI4ylzkicfX/89j+gNHh9xyNdpEvit88Kq6i5tTGdavVnM6DQfOE2qNtlg==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxc-resolver/binding-linux-riscv64-musl@11.15.0':
resolution: {integrity: sha512-J7LPiEt27Tpm8P+qURDwNc8q45+n+mWgyys4/V6r5A8v5gDentHRGUx3iVk5NxdKhgoGulrzQocPTZVosq25Eg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxc-resolver/binding-linux-s390x-gnu@11.15.0':
resolution: {integrity: sha512-+8/d2tAScPjVJNyqa7GPGnqleTB/XW9dZJQ2D/oIM3wpH3TG+DaFEXBbk4QFJ9K9AUGBhvQvWU2mQyhK/yYn3Q==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxc-resolver/binding-linux-x64-gnu@11.15.0':
resolution: {integrity: sha512-xtvSzH7Nr5MCZI2FKImmOdTl9kzuQ51RPyLh451tvD2qnkg3BaqI9Ox78bTk57YJhlXPuxWSOL5aZhKAc9J6qg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxc-resolver/binding-linux-x64-musl@11.15.0':
resolution: {integrity: sha512-14YL1zuXj06+/tqsuUZuzL0T425WA/I4nSVN1kBXeC5WHxem6lQ+2HGvG+crjeJEqHgZUT62YIgj88W+8E7eyg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxc-resolver/binding-openharmony-arm64@11.15.0':
resolution: {integrity: sha512-/7Qli+1Wk93coxnrQaU8ySlICYN8HsgyIrzqjgIkQEpI//9eUeaeIHZptNl2fMvBGeXa7k2QgLbRNaBRgpnvMw==}
@@ -2739,48 +2751,56 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-arm64-musl@0.34.0':
resolution: {integrity: sha512-H+F8+71gHQoGTFPPJ6z4dD0Fzfzi0UP8Zx94h5kUmIFThLvMq5K1Y/bUUubiXwwHfwb5C3MPjUpYijiy0rj51Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-ppc64-gnu@0.34.0':
resolution: {integrity: sha512-dIGnzTNhCXqQD5pzBwduLg8pClm+t8R53qaE9i5h8iua1iaFAJyLffh4847CNZSlASb7gn1Ofuv7KoG/EpoGZg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.34.0':
resolution: {integrity: sha512-FGQ2GTTooilDte/ogwWwkHuuL3lGtcE3uKM2EcC7kOXNWdUfMY6Jx3JCodNVVbFoybv4A+HuCj8WJji2uu1Ceg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.34.0':
resolution: {integrity: sha512-2dGbGneJ7ptOIVKMwEIHdCkdZEomh74X3ggo4hCzEXL/rl9HwfsZDR15MkqfQqAs6nVXMvtGIOMxjDYa5lwKaA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.34.0':
resolution: {integrity: sha512-cCtGgmrTrxq3OeSG0UAO+w6yLZTMeOF4XM9SAkNrRUxYhRQELSDQ/iNPCLyHhYNi38uHJQbS5RQweLUDpI4ajA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.34.0':
resolution: {integrity: sha512-7AvMzmeX+k7GdgitXp99GQoIV/QZIpAS7rwxQvC/T541yWC45nwvk4mpnU8N+V6dE5SPEObnqfhCjO80s7qIsg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-musl@0.34.0':
resolution: {integrity: sha512-uNiglhcmivJo1oDMh3hoN/Z0WsbEXOpRXZdQ3W/IkOpyV8WF308jFjSC1ZxajdcNRXWej0zgge9QXba58Owt+g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxfmt/binding-openharmony-arm64@0.34.0':
resolution: {integrity: sha512-5eFsTjCyji25j6zznzlMc+wQAZJoL9oWy576xhqd2efv+N4g1swIzuSDcb1dz4gpcVC6veWe9pAwD7HnrGjLwg==}
@@ -2883,48 +2903,56 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.49.0':
resolution: {integrity: sha512-xeqkMOARgGBlEg9BQuPDf6ZW711X6BT5qjDyeM5XNowCJeTSdmMhpePJjTEiVbbr3t21sIlK8RE6X5bc04nWyQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.49.0':
resolution: {integrity: sha512-uvcqRO6PnlJGbL7TeePhTK5+7/JXbxGbN+C6FVmfICDeeRomgQqrfVjf0lUrVpUU8ii8TSkIbNdft3M+oNlOsQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.49.0':
resolution: {integrity: sha512-Dw1HkdXAwHNH+ZDserHP2RzXQmhHtpsYYI0hf8fuGAVCIVwvS6w1+InLxpPMY25P8ASRNiFN3hADtoh6lI+4lg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.49.0':
resolution: {integrity: sha512-EPlMYaA05tJ9km/0dI9K57iuMq3Tw+nHst7TNIegAJZrBPtsOtYaMFZEaWj02HA8FI5QvSnRHMt+CI+RIhXJBQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.49.0':
resolution: {integrity: sha512-yZiQL9qEwse34aMbnMb5VqiAWfDY+fLFuoJbHOuzB1OaJZbN1MRF9Nk+W89PIpGr5DNPDipwjZb8+Q7wOywoUQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.49.0':
resolution: {integrity: sha512-CcCDwMMXSchNkhdgvhVn3DLZ4EnBXAD8o8+gRzahg+IdSt/72y19xBgShJgadIRF0TsRcV/MhDUMwL5N/W54aQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.49.0':
resolution: {integrity: sha512-u3HfKV8BV6t6UCCbN0RRiyqcymhrnpunVmLFI8sEa5S/EBu+p/0bJ3D7LZ2KT6PsBbrB71SWq4DeFrskOVgIZg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxlint/binding-openharmony-arm64@1.49.0':
resolution: {integrity: sha512-dRDpH9fw+oeUMpM4br0taYCFpW6jQtOuEIec89rOgDA1YhqwmeRcx0XYeCv7U48p57qJ1XZHeMGM9LdItIjfzA==}
@@ -3093,24 +3121,28 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.3':
resolution: {integrity: sha512-Z03/wrqau9Bicfgb3Dbs6SYTHliELk2PM2LpG2nFd+cGupTMF5kanLEcj2vuuJLLhptNyS61rtk7SOZ+lPsTUA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.3':
resolution: {integrity: sha512-iSXXZsQp08CSilff/DCTFZHSVEpEwdicV3W8idHyrByrcsRDVh9sGC3sev6d8BygSGj3vt8GvUKBPCoyMA4tgQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0-rc.3':
resolution: {integrity: sha512-qaj+MFudtdCv9xZo9znFvkgoajLdc+vwf0Kz5N44g+LU5XMe+IsACgn3UG7uTRlCCvhMAGXm1XlpEA5bZBrOcw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0-rc.3':
resolution: {integrity: sha512-U662UnMETyjT65gFmG9ma+XziENrs7BBnENi/27swZPYagubfHRirXHG2oMl+pEax2WvO7Kb9gHZmMakpYqBHQ==}
@@ -3188,56 +3220,67 @@ packages:
resolution: {integrity: sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.53.5':
resolution: {integrity: sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.53.5':
resolution: {integrity: sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.53.5':
resolution: {integrity: sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.53.5':
resolution: {integrity: sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-gnu@4.53.5':
resolution: {integrity: sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.53.5':
resolution: {integrity: sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.53.5':
resolution: {integrity: sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.53.5':
resolution: {integrity: sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.53.5':
resolution: {integrity: sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.53.5':
resolution: {integrity: sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openharmony-arm64@4.53.5':
resolution: {integrity: sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==}
@@ -3513,24 +3556,28 @@ packages:
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.2.0':
resolution: {integrity: sha512-XKcSStleEVnbH6W/9DHzZv1YhjE4eSS6zOu2eRtYAIh7aV4o3vIBs+t/B15xlqoxt6ef/0uiqJVB6hkHjWD/0A==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.2.0':
resolution: {integrity: sha512-/hlXCBqn9K6fi7eAM0RsobHwJYa5V/xzWspVTzxnX+Ft9v6n+30Pz8+RxCn7sQL/vRHHLS30iQPrHQunu6/vJA==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.2.0':
resolution: {integrity: sha512-lKUaygq4G7sWkhQbfdRRBkaq4LY39IriqBQ+Gk6l5nKq6Ay2M2ZZb1tlIyRNgZKS8cbErTwuYSor0IIULC0SHw==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.2.0':
resolution: {integrity: sha512-xuDjhAsFdUuFP5W9Ze4k/o4AskUtI8bcAGU4puTYprr89QaYFmhYOPfP+d1pH+k9ets6RoE23BXZM1X1jJqoyw==}
@@ -4006,41 +4053,49 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
@@ -6416,24 +6471,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.31.1:
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.31.1:
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.31.1:
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.31.1:
resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}

View File

@@ -133,7 +133,7 @@ function isKeyUsed(key: string, sourceFiles: string[]): boolean {
}
// Main function
async function checkNewUnusedKeys() {
function checkNewUnusedKeys() {
const stagedLocaleFiles = getStagedLocaleFiles()
if (stagedLocaleFiles.length === 0) {
@@ -165,7 +165,7 @@ async function checkNewUnusedKeys() {
if (unusedNewKeys.length > 0) {
console.warn('\n⚠ Warning: Found unused NEW i18n keys:\n')
for (const key of unusedNewKeys.sort()) {
for (const key of unusedNewKeys.sort((a, b) => a.localeCompare(b))) {
console.warn(` - ${key}`)
}
@@ -183,7 +183,9 @@ async function checkNewUnusedKeys() {
}
// Run the check
checkNewUnusedKeys().catch((err) => {
try {
checkNewUnusedKeys()
} catch (err) {
console.error('Error checking unused keys:', err)
process.exit(1)
})
}

View File

@@ -132,7 +132,7 @@ function resolveRelease(
return null
}
const [major, currentMinor, patch] = currentVersion.split('.').map(Number)
const [, currentMinor] = currentVersion.split('.').map(Number)
// Fetch all branches
exec('git fetch origin', frontendRepoPath)
@@ -264,7 +264,7 @@ if (!releaseInfo) {
}
// Output as JSON for GitHub Actions
// oxlint-disable-next-line no-console -- CI script output
console.log(JSON.stringify(releaseInfo, null, 2))
export { resolveRelease }

View File

@@ -51,10 +51,10 @@ export function downloadFile(url: string, filename?: string): void {
/**
* Download a Blob by creating a temporary object URL and anchor element
* @param filename - The filename to suggest to the browser
* @param blob - The Blob to download
* @param filename - The filename to suggest to the browser
*/
export function downloadBlob(filename: string, blob: Blob): void {
export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
triggerLinkDownload(url, filename)
@@ -138,7 +138,7 @@ async function downloadViaBlobFetch(
extractFilenameFromContentDisposition(contentDisposition)
const blob = await response.blob()
downloadBlob(headerFilename ?? fallbackFilename, blob)
downloadBlob(blob, headerFilename ?? fallbackFilename)
}
/**

View File

@@ -45,19 +45,19 @@ export const usdToCredits = (usd: number): number =>
export const creditsToUsd = (credits: number): number =>
Math.round((credits / CREDITS_PER_USD) * 100) / 100
export type FormatOptions = {
type FormatOptions = {
value: number
locale?: string
numberOptions?: Intl.NumberFormatOptions
}
export type FormatFromCentsOptions = {
type FormatFromCentsOptions = {
cents: number
locale?: string
numberOptions?: Intl.NumberFormatOptions
}
export type FormatFromUsdOptions = {
type FormatFromUsdOptions = {
usd: number
locale?: string
numberOptions?: Intl.NumberFormatOptions
@@ -113,13 +113,3 @@ export const formatUsdFromCents = ({
locale,
numberOptions
})
/**
* Clamps a USD value to the allowed range for credit purchases
* @param value - The USD amount to clamp
* @returns The clamped value between $1 and $1000, or 0 if NaN
*/
export const clampUsd = (value: number): number => {
if (Number.isNaN(value)) return 0
return Math.min(1000, Math.max(1, value))
}

View File

@@ -1,12 +1,3 @@
/**
* Utilities for pointer event handling
*/
/**
* Checks if a pointer or mouse event is a middle button input
* @param event - The pointer or mouse event to check
* @returns true if the event is from the middle button/wheel
*/
export function isMiddlePointerInput(
event: PointerEvent | MouseEvent
): boolean {

View File

@@ -141,7 +141,7 @@ import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { buildTooltipConfig } from '@/utils/tooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'

View File

@@ -32,7 +32,7 @@
</div>
</template>
<script lang="ts" setup>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import InputNumber from 'primevue/inputnumber'
import { computed } from 'vue'

View File

@@ -87,7 +87,7 @@
</div>
</template>
<script lang="ts" setup>
<script setup lang="ts">
import {
useDraggable,
useEventListener,
@@ -108,7 +108,7 @@ import StatusBadge from '@/components/common/StatusBadge.vue'
import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
import Button from '@/components/ui/button/Button.vue'
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { buildTooltipConfig } from '@/utils/tooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'

View File

@@ -41,7 +41,7 @@ const mockCommands: ComfyCommandImpl[] = [
icon: 'pi pi-test',
tooltip: 'Test tooltip',
menubarLabel: 'Other Command',
keybinding: null
keybinding: undefined
} as ComfyCommandImpl
]

View File

@@ -103,7 +103,7 @@ describe('ShortcutsList', () => {
id: 'No.Keybinding',
label: 'No Keybinding',
category: 'essentials',
keybinding: null
keybinding: undefined
} as ComfyCommandImpl
]

View File

@@ -1,6 +1,12 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fetchModelMetadata } from './missingModelsUtils'
import {
fetchModelMetadata,
getBadgeLabel,
hasValidDirectory,
isModelDownloadable
} from '@/components/dialog/content/missingModelsUtils'
import type { ModelWithUrl } from '@/components/dialog/content/missingModelsUtils'
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
@@ -8,6 +14,84 @@ vi.stubGlobal('fetch', fetchMock)
vi.mock('@/platform/distribution/types', () => ({ isDesktop: false }))
vi.mock('@/stores/electronDownloadStore', () => ({}))
function makeModel(overrides: Partial<ModelWithUrl> = {}): ModelWithUrl {
return {
name: 'model.safetensors',
url: 'https://civitai.com/api/download/12345',
directory: 'checkpoints',
...overrides
}
}
describe('isModelDownloadable', () => {
it('allows civitai URLs with valid suffix', () => {
expect(isModelDownloadable(makeModel())).toBe(true)
})
it('allows huggingface URLs with valid suffix', () => {
expect(
isModelDownloadable(
makeModel({
url: 'https://huggingface.co/some/model',
name: 'model.ckpt'
})
)
).toBe(true)
})
it('allows localhost URLs with valid suffix', () => {
expect(
isModelDownloadable(makeModel({ url: 'http://localhost:8080/model' }))
).toBe(true)
})
it('rejects URLs from unknown sources', () => {
expect(
isModelDownloadable(
makeModel({ url: 'https://evil.com/model.safetensors' })
)
).toBe(false)
})
it('rejects files with invalid suffix', () => {
expect(isModelDownloadable(makeModel({ name: 'model.exe' }))).toBe(false)
})
it('allows whitelisted URLs regardless of suffix', () => {
expect(
isModelDownloadable(
makeModel({
url: 'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth',
name: 'RealESRGAN_x4plus.pth'
})
)
).toBe(true)
})
})
describe('hasValidDirectory', () => {
it('returns true when directory exists in paths', () => {
const paths = { checkpoints: ['/models/checkpoints'] }
expect(hasValidDirectory(makeModel(), paths)).toBe(true)
})
it('returns false when directory is missing', () => {
expect(hasValidDirectory(makeModel(), {})).toBe(false)
})
})
describe('getBadgeLabel', () => {
it('maps known directories to badge labels', () => {
expect(getBadgeLabel('vae')).toBe('VAE')
expect(getBadgeLabel('loras')).toBe('LORA')
expect(getBadgeLabel('checkpoints')).toBe('CHECKPOINT')
})
it('uppercases unknown directories', () => {
expect(getBadgeLabel('custom_dir')).toBe('CUSTOM_DIR')
})
})
let testId = 0
describe('fetchModelMetadata', () => {

View File

@@ -149,7 +149,7 @@ import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useNodeBadge } from '@/composables/node/useNodeBadge'
import { useCanvasDrop } from '@/composables/useCanvasDrop'
import { useContextMenuTranslation } from '@/composables/useContextMenuTranslation'
import { useCopy } from '@/composables/useCopy'
import { useGraphCopyHandler } from '@/composables/useGraphCopyHandler'
import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
import { usePaste } from '@/composables/usePaste'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
@@ -446,7 +446,7 @@ useNodeBadge()
useGlobalLitegraph()
useContextMenuTranslation()
useCopy()
useGraphCopyHandler()
usePaste()
useWorkflowAutoSave()

View File

@@ -1,12 +1,12 @@
<template>
<div>
<ZoomControlsModal :visible="isModalVisible" @close="hideModal" />
<ZoomControlsModal :visible="isPopoverOpen" @close="hidePopover" />
<!-- Backdrop -->
<div
v-if="hasActivePopup"
class="fixed inset-0 z-1200"
@click="hideModal"
@click="hidePopover"
></div>
<ButtonGroup
@@ -40,7 +40,7 @@
:aria-label="t('zoomControls.label')"
data-testid="zoom-controls-button"
:style="stringifiedMinimapStyles.buttonStyles"
@click="toggleModal"
@click="togglePopover"
>
<span class="inline-flex items-center gap-1 px-2 text-xs">
<span>{{ canvasStore.appScalePercentage }}%</span>
@@ -110,7 +110,7 @@ const settingStore = useSettingStore()
const canvasInteractions = useCanvasInteractions()
const minimap = useMinimap()
const { isModalVisible, toggleModal, hideModal, hasActivePopup } =
const { isPopoverOpen, togglePopover, hidePopover, hasActivePopup } =
useZoomControls()
const stringifiedMinimapStyles = computed(() => {
@@ -157,7 +157,7 @@ const minimapCommandText = computed(() =>
// Computed properties for button classes and states
const zoomButtonClass = computed(() => [
'bg-comfy-menu-bg',
isModalVisible.value ? 'not-active:bg-interface-panel-selected-surface!' : '',
isPopoverOpen.value ? 'not-active:bg-interface-panel-selected-surface!' : '',
'hover:bg-interface-button-hover-surface!',
'p-0',
'h-8',

View File

@@ -76,7 +76,7 @@ import Load3DScene from '@/components/load3d/Load3DScene.vue'
import AnimationControls from '@/components/load3d/controls/AnimationControls.vue'
import RecordingControls from '@/components/load3d/controls/RecordingControls.vue'
import ViewerControls from '@/components/load3d/controls/ViewerControls.vue'
import { useLoad3d } from '@/composables/useLoad3d'
import { useLoad3d } from '@/extensions/core/load3d/composables/useLoad3d'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'

View File

@@ -33,7 +33,7 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
import { useLoad3dDrag } from '@/composables/useLoad3dDrag'
import { useLoad3dDrag } from '@/extensions/core/load3d/composables/useLoad3dDrag'
const props = defineProps<{
initializeLoad3d: (containerRef: HTMLElement) => Promise<void>

View File

@@ -103,8 +103,8 @@ import LightControls from '@/components/load3d/controls/viewer/ViewerLightContro
import ModelControls from '@/components/load3d/controls/viewer/ViewerModelControls.vue'
import SceneControls from '@/components/load3d/controls/viewer/ViewerSceneControls.vue'
import Button from '@/components/ui/button/Button.vue'
import { useLoad3dDrag } from '@/composables/useLoad3dDrag'
import { useLoad3dViewer } from '@/composables/useLoad3dViewer'
import { useLoad3dDrag } from '@/extensions/core/load3d/composables/useLoad3dDrag'
import { useLoad3dViewer } from '@/extensions/core/load3d/composables/useLoad3dViewer'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useLoad3dService } from '@/services/load3dService'
import { useDialogStore } from '@/stores/dialogStore'

View File

@@ -200,7 +200,7 @@ const onMaskOpacityChange = (value: number) => {
maskLayerVisible.value = value !== 0
}
const onBlendModeChange = async (event: Event) => {
const onBlendModeChange = (event: Event) => {
const value = (event.target as HTMLSelectElement).value
let blendMode: MaskBlendMode
@@ -217,7 +217,7 @@ const onBlendModeChange = async (event: Event) => {
store.maskBlendMode = blendMode
await canvasManager.updateMaskColor()
canvasManager.updateMaskColor()
}
const setActiveLayer = (layer: ImageLayer) => {

View File

@@ -149,7 +149,7 @@ const initUI = async () => {
const imageLoader = useImageLoader()
const image = await imageLoader.loadImages()
await panZoom.initializeCanvasPanZoom(
panZoom.initializeCanvasPanZoom(
image,
containerRef.value,
toolPanelRef.value?.$el as HTMLElement | undefined,
@@ -181,9 +181,9 @@ onMounted(() => {
keyboard.addListeners()
if (containerRef.value) {
resizeObserver = new ResizeObserver(async () => {
resizeObserver = new ResizeObserver(() => {
if (panZoom) {
await panZoom.invalidatePanZoom()
panZoom.invalidatePanZoom()
}
})
resizeObserver.observe(containerRef.value)

View File

@@ -79,16 +79,16 @@ const handleTouchStart = (event: TouchEvent) => {
panZoom.handleTouchStart(event)
}
const handleTouchMove = async (event: TouchEvent) => {
await panZoom.handleTouchMove(event)
const handleTouchMove = (event: TouchEvent) => {
panZoom.handleTouchMove(event)
}
const handleTouchEnd = (event: TouchEvent) => {
panZoom.handleTouchEnd(event)
}
const handleWheel = async (event: WheelEvent) => {
await panZoom.zoom(event)
const handleWheel = (event: WheelEvent) => {
panZoom.zoom(event)
const newCursorPoint = { x: event.clientX, y: event.clientY }
panZoom.updateCursorPosition(newCursorPoint)
}

View File

@@ -161,33 +161,33 @@ const onRedo = () => {
store.canvasHistory.redo()
}
const onRotateLeft = async () => {
const onRotateLeft = () => {
try {
await canvasTransform.rotateCounterclockwise()
canvasTransform.rotateCounterclockwise()
} catch (error) {
console.error('[TopBarHeader] Rotate left failed:', error)
}
}
const onRotateRight = async () => {
const onRotateRight = () => {
try {
await canvasTransform.rotateClockwise()
canvasTransform.rotateClockwise()
} catch (error) {
console.error('[TopBarHeader] Rotate right failed:', error)
}
}
const onMirrorHorizontal = async () => {
const onMirrorHorizontal = () => {
try {
await canvasTransform.mirrorHorizontal()
canvasTransform.mirrorHorizontal()
} catch (error) {
console.error('[TopBarHeader] Mirror horizontal failed:', error)
}
}
const onMirrorVertical = async () => {
const onMirrorVertical = () => {
try {
await canvasTransform.mirrorVertical()
canvasTransform.mirrorVertical()
} catch (error) {
console.error('[TopBarHeader] Mirror vertical failed:', error)
}

View File

@@ -95,7 +95,7 @@ import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { buildTooltipConfig } from '@/utils/tooltipConfig'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'

View File

@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import QueueOverlayActive from './QueueOverlayActive.vue'
import * as tooltipConfig from '@/composables/useTooltipConfig'
import * as tooltipConfig from '@/utils/tooltipConfig'
const i18n = createI18n({
legacy: false,

View File

@@ -95,7 +95,7 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { buildTooltipConfig } from '@/utils/tooltipConfig'
defineProps<{
totalProgressStyle: Record<string, string>

View File

@@ -48,7 +48,7 @@ vi.mock('@/stores/workspace/sidebarTabStore', () => ({
}))
import QueueOverlayHeader from './QueueOverlayHeader.vue'
import * as tooltipConfig from '@/composables/useTooltipConfig'
import * as tooltipConfig from '@/utils/tooltipConfig'
const tooltipDirectiveStub = {
mounted: vi.fn(),

View File

@@ -32,7 +32,7 @@ import { useI18n } from 'vue-i18n'
import JobHistoryActionsMenu from '@/components/queue/JobHistoryActionsMenu.vue'
import Button from '@/components/ui/button/Button.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { buildTooltipConfig } from '@/utils/tooltipConfig'
defineProps<{
headerTitle: string

View File

@@ -121,7 +121,7 @@ import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { jobSortModes } from '@/composables/queue/useJobList'
import type { JobSortMode } from '@/composables/queue/useJobList'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { buildTooltipConfig } from '@/utils/tooltipConfig'
const {
hideShowAssetsAction = false,

View File

@@ -193,8 +193,15 @@ import { useI18n } from 'vue-i18n'
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
import QueueAssetPreview from '@/components/queue/job/QueueAssetPreview.vue'
import Button from '@/components/ui/button/Button.vue'
import { useProgressBarBackground } from '@/composables/useProgressBarBackground'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import {
progressBarContainerClass,
progressBarPrimaryClass,
progressBarSecondaryClass,
hasProgressPercent,
hasAnyProgressPercent,
progressPercentStyle
} from '@/composables/useProgressBarBackground'
import { buildTooltipConfig } from '@/utils/tooltipConfig'
import type { JobState } from '@/types/queue'
import { iconForJobState } from '@/utils/queueDisplay'
import { cn } from '@/utils/tailwindUtil'
@@ -237,14 +244,6 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const {
progressBarContainerClass,
progressBarPrimaryClass,
progressBarSecondaryClass,
hasProgressPercent,
hasAnyProgressPercent,
progressPercentStyle
} = useProgressBarBackground()
const cancelTooltipConfig = computed(() => buildTooltipConfig(t('g.cancel')))
const deleteTooltipConfig = computed(() => buildTooltipConfig(t('g.delete')))

View File

@@ -1,4 +1,4 @@
<script lang="ts" setup>
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'

View File

@@ -533,8 +533,8 @@ const handleBulkDelete = async (assets: AssetItem[]) => {
}
}
const handleBulkAddToWorkflow = async (assets: AssetItem[]) => {
await addMultipleToWorkflow(assets)
const handleBulkAddToWorkflow = (assets: AssetItem[]) => {
addMultipleToWorkflow(assets)
clearSelection()
}

View File

@@ -17,7 +17,7 @@
</div>
</template>
<script lang="ts" setup>
<script setup lang="ts">
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import Button from '@/components/ui/button/Button.vue'

View File

@@ -11,7 +11,7 @@
</div>
</template>
<script lang="ts" setup>
<script setup lang="ts">
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { computed } from 'vue'

View File

@@ -190,39 +190,35 @@ function useBillingContextInternal(): BillingContext {
}
}
async function fetchStatus(): Promise<void> {
function fetchStatus(): Promise<void> {
return activeContext.value.fetchStatus()
}
async function fetchBalance(): Promise<void> {
function fetchBalance(): Promise<void> {
return activeContext.value.fetchBalance()
}
async function subscribe(
planSlug: string,
returnUrl?: string,
cancelUrl?: string
) {
function subscribe(planSlug: string, returnUrl?: string, cancelUrl?: string) {
return activeContext.value.subscribe(planSlug, returnUrl, cancelUrl)
}
async function previewSubscribe(planSlug: string) {
function previewSubscribe(planSlug: string) {
return activeContext.value.previewSubscribe(planSlug)
}
async function manageSubscription() {
function manageSubscription() {
return activeContext.value.manageSubscription()
}
async function cancelSubscription() {
function cancelSubscription() {
return activeContext.value.cancelSubscription()
}
async function fetchPlans() {
function fetchPlans() {
return activeContext.value.fetchPlans()
}
async function requireActiveSubscription() {
function requireActiveSubscription() {
return activeContext.value.requireActiveSubscription()
}

View File

@@ -137,11 +137,11 @@ export function useLegacyBilling(): BillingState & BillingActions {
await legacySubscribe()
}
async function previewSubscribe(
function previewSubscribe(
_planSlug: string
): Promise<PreviewSubscribeResponse | null> {
// Legacy billing doesn't support preview - returns null
return null
return Promise.resolve(null)
}
async function manageSubscription(): Promise<void> {
@@ -152,9 +152,10 @@ export function useLegacyBilling(): BillingState & BillingActions {
await legacyManageSubscription()
}
async function fetchPlans(): Promise<void> {
function fetchPlans(): Promise<void> {
// Legacy billing doesn't have workspace-style plans
// Plans are hardcoded in the UI for legacy subscriptions
return Promise.resolve()
}
async function requireActiveSubscription(): Promise<void> {

View File

@@ -4,28 +4,13 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { collectFromNodes } from '@/utils/graphTraversalUtil'
/**
* Composable for handling selected LiteGraph items filtering and operations.
* This provides utilities for working with selected items on the canvas,
* including filtering out items that should not be included in selection operations.
*/
export function useSelectedLiteGraphItems() {
const canvasStore = useCanvasStore()
/**
* Items that should not show in the selection overlay are ignored.
* @param item - The item to check.
* @returns True if the item should be ignored, false otherwise.
*/
const isIgnoredItem = (item: Positionable): boolean => {
return item instanceof Reroute
}
/**
* Filter out items that should not show in the selection overlay.
* @param items - The Set of items to filter.
* @returns The filtered Set of items.
*/
const filterSelectableItems = (
items: Set<Positionable>
): Set<Positionable> => {
@@ -38,37 +23,20 @@ export function useSelectedLiteGraphItems() {
return result
}
/**
* Get the filtered selected items from the canvas.
* @returns The filtered Set of selected items.
*/
const getSelectableItems = (): Set<Positionable> => {
const { selectedItems } = canvasStore.getCanvas()
return filterSelectableItems(selectedItems)
}
/**
* Check if there are any selectable items.
* @returns True if there are selectable items, false otherwise.
*/
const hasSelectableItems = (): boolean => {
return getSelectableItems().size > 0
}
/**
* Check if there are multiple selectable items.
* @returns True if there are multiple selectable items, false otherwise.
*/
const hasMultipleSelectableItems = (): boolean => {
return getSelectableItems().size > 1
}
/**
* Get only the selected nodes (LGraphNode instances) from the canvas.
* This filters out other types of selected items like groups or reroutes.
* If a selected node is a subgraph, this also includes all nodes within it.
* @returns Array of selected LGraphNode instances and their descendants.
*/
/** Includes descendant nodes from any selected subgraphs. */
const getSelectedNodes = (): LGraphNode[] => {
const selectedNodes = app.canvas.selected_nodes
if (!selectedNodes) return []

View File

@@ -2,13 +2,13 @@ import type { CSSProperties } from 'vue'
import { ref, watch } from 'vue'
import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import type { Size, Vector2 } from '@/lib/litegraph/src/litegraph'
import type { Point, Size } from '@/lib/litegraph/src/interfaces'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
export interface PositionConfig {
/* The position of the element on litegraph canvas */
pos: Vector2
pos: Point
/* The size of the element on litegraph canvas */
size: Size
/* The scale factor of the canvas */

View File

@@ -530,7 +530,6 @@ function captureDynamicSubmenu(
return converted
}
console.warn('[ContextMenuConverter] No items captured for:', item.content)
return undefined
}

View File

@@ -34,7 +34,7 @@ export function useSubgraphOperations() {
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
const doUnpack = (
const unpackNodes = (
subgraphNodes: SubgraphNode[],
skipMissingNodes: boolean
) => {
@@ -65,7 +65,7 @@ export function useSubgraphOperations() {
if (subgraphNodes.length === 0) {
return
}
doUnpack(subgraphNodes, true)
unpackNodes(subgraphNodes, true)
}
const addSubgraphToLibrary = async () => {

View File

@@ -64,8 +64,8 @@ function useVueNodeLifecycleIndividual() {
try {
nodeManager.value.cleanup()
} catch {
/* empty */
} catch (error) {
console.warn('Node manager cleanup failed:', error)
}
nodeManager.value = null
}

View File

@@ -32,21 +32,12 @@ const saveBrushToCache = debounce(function (key: string, brush: Brush): void {
}
}, 300)
/**
* Loads brush settings from local storage.
* @param key - The storage key.
* @returns The brush settings object or null if not found.
*/
function loadBrushFromCache(key: string): Brush | null {
try {
const brushString = getStorageValue(key)
if (brushString) {
return JSON.parse(brushString) as Brush
} else {
return null
}
} catch (error) {
console.error('Failed to load brush from cache:', error)
if (brushString) return JSON.parse(brushString) as Brush
return null
} catch {
return null
}
}
@@ -220,12 +211,12 @@ export function useBrushDrawing(initialSettings?: {
// Sync GPU on Undo/Redo
watch(
() => store.canvasHistory.currentStateIndex,
async () => {
() => {
// Skip update if state was just saved
if (isSavingHistory.value) return
// Update GPU textures to match restored canvas state
await updateGPUFromCanvas()
updateGPUFromCanvas()
// Clear preview to remove artifacts
if (renderer && previewContext) {
@@ -238,7 +229,7 @@ export function useBrushDrawing(initialSettings?: {
watch(
() => store.gpuTexturesNeedRecreation,
async (needsRecreation) => {
(needsRecreation) => {
if (
!needsRecreation ||
!device ||
@@ -303,7 +294,7 @@ export function useBrushDrawing(initialSettings?: {
)
} else {
// Fallback: read from canvas
await updateGPUFromCanvas()
updateGPUFromCanvas()
}
// Update preview canvas if it exists
@@ -426,7 +417,7 @@ export function useBrushDrawing(initialSettings?: {
/**
* Updates the GPU textures from the current canvas state.
*/
async function updateGPUFromCanvas(): Promise<void> {
function updateGPUFromCanvas(): void {
if (
!device ||
!maskTexture ||
@@ -523,7 +514,7 @@ export function useBrushDrawing(initialSettings?: {
})
// Upload initial data
await updateGPUFromCanvas()
updateGPUFromCanvas()
console.warn('✅ GPU resources initialized successfully')
@@ -810,7 +801,7 @@ export function useBrushDrawing(initialSettings?: {
* Draws a point using the stroke processor for smoothing.
* @param point - The point to draw.
*/
async function drawWithBetterSmoothing(point: Point): Promise<void> {
function drawWithBetterSmoothing(point: Point): void {
if (!strokeProcessor) return
// Process point to generate equidistant points
@@ -964,7 +955,7 @@ export function useBrushDrawing(initialSettings?: {
strokeProcessor = new StrokeProcessor(targetSpacing)
// Process first point
await drawWithBetterSmoothing(coords_canvas)
drawWithBetterSmoothing(coords_canvas)
smoothingLastDrawTime.value = new Date()
} catch (error) {
@@ -986,18 +977,17 @@ export function useBrushDrawing(initialSettings?: {
const currentTool = store.currentTool
if (diff > 20 && !isDrawing.value) {
requestAnimationFrame(async () => {
requestAnimationFrame(() => {
if (!isDrawing.value) return // Fix: Prevent race condition
try {
initShape(CompositionOperation.SourceOver)
await gpuDrawPoint(coords_canvas)
// smoothingCordsArray.value.push(coords_canvas) // Removed in favor of StrokeProcessor
gpuDrawPoint(coords_canvas)
} catch (error) {
console.error('[useBrushDrawing] Drawing error:', error)
}
})
} else {
requestAnimationFrame(async () => {
requestAnimationFrame(() => {
if (!isDrawing.value) return // Fix: Prevent race condition
try {
if (currentTool === 'eraser' || event.buttons === 2) {
@@ -1005,7 +995,7 @@ export function useBrushDrawing(initialSettings?: {
} else {
initShape(CompositionOperation.SourceOver)
}
await drawWithBetterSmoothing(coords_canvas)
drawWithBetterSmoothing(coords_canvas)
} catch (error) {
console.error('[useBrushDrawing] Drawing error:', error)
}
@@ -1118,7 +1108,7 @@ export function useBrushDrawing(initialSettings?: {
* Starts the brush adjustment interaction.
* @param event - The pointer event.
*/
async function startBrushAdjustment(event: PointerEvent): Promise<void> {
function startBrushAdjustment(event: PointerEvent): void {
event.preventDefault()
const coords = { x: event.offsetX, y: event.offsetY }
@@ -1132,7 +1122,7 @@ export function useBrushDrawing(initialSettings?: {
* Handles the brush adjustment movement.
* @param event - The pointer event.
*/
async function handleBrushAdjustment(event: PointerEvent): Promise<void> {
function handleBrushAdjustment(event: PointerEvent): void {
if (!initialPoint.value) {
return
}
@@ -1366,7 +1356,7 @@ export function useBrushDrawing(initialSettings?: {
* @param point - The point to draw.
* @param opacity - The opacity of the point.
*/
async function gpuDrawPoint(point: Point, opacity: number = 1) {
function gpuDrawPoint(point: Point, opacity: number = 1) {
if (renderer) {
const width = store.maskCanvas!.width
const height = store.maskCanvas!.height

View File

@@ -1,16 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { useCanvasHistory } from '@/composables/maskeditor/useCanvasHistory'
// Define the store shape to avoid 'any' and cast to the expected type
interface MaskEditorStoreState {
maskCanvas: HTMLCanvasElement | null
rgbCanvas: HTMLCanvasElement | null
imgCanvas: HTMLCanvasElement | null
maskCtx: CanvasRenderingContext2D | null
rgbCtx: CanvasRenderingContext2D | null
imgCtx: CanvasRenderingContext2D | null
}
import type { CanvasLayers } from '@/composables/maskeditor/useCanvasHistory'
import { useCanvasHistory } from '@/composables/maskeditor/useCanvasHistory'
// Use vi.hoisted to create isolated mock state container
const mockRefs = vi.hoisted(() => ({
@@ -22,49 +14,27 @@ const mockRefs = vi.hoisted(() => ({
imgCtx: null as CanvasRenderingContext2D | null
}))
const mockStore: MaskEditorStoreState = {
const mockLayers: CanvasLayers = {
get maskCanvas() {
return mockRefs.maskCanvas
},
set maskCanvas(val) {
mockRefs.maskCanvas = val
},
get rgbCanvas() {
return mockRefs.rgbCanvas
},
set rgbCanvas(val) {
mockRefs.rgbCanvas = val
},
get imgCanvas() {
return mockRefs.imgCanvas
},
set imgCanvas(val) {
mockRefs.imgCanvas = val
},
get maskCtx() {
return mockRefs.maskCtx
},
set maskCtx(val) {
mockRefs.maskCtx = val
},
get rgbCtx() {
return mockRefs.rgbCtx
},
set rgbCtx(val) {
mockRefs.rgbCtx = val
},
get imgCtx() {
return mockRefs.imgCtx
},
set imgCtx(val) {
mockRefs.imgCtx = val
}
}
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: vi.fn(() => mockStore)
}))
// Mock ImageBitmap using safe global augmentation pattern
if (typeof globalThis.ImageBitmap === 'undefined') {
globalThis.ImageBitmap = class ImageBitmap {
@@ -140,14 +110,14 @@ describe('useCanvasHistory', () => {
describe('initialization', () => {
it('should initialize with default values', () => {
const history = useCanvasHistory()
const history = useCanvasHistory(mockLayers)
expect(history.canUndo.value).toBe(false)
expect(history.canRedo.value).toBe(false)
})
it('should save initial state', () => {
const history = useCanvasHistory()
const history = useCanvasHistory(mockLayers)
history.saveInitialState()
@@ -173,7 +143,7 @@ describe('useCanvasHistory', () => {
height: 0
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
const history = useCanvasHistory()
const history = useCanvasHistory(mockLayers)
history.saveInitialState()
expect(rafSpy).toHaveBeenCalled()
@@ -189,7 +159,7 @@ describe('useCanvasHistory', () => {
mockRefs.maskCtx = null
const history = useCanvasHistory()
const history = useCanvasHistory(mockLayers)
history.saveInitialState()
expect(rafSpy).toHaveBeenCalled()
@@ -213,7 +183,7 @@ describe('useCanvasHistory', () => {
describe('saveState', () => {
it('should save a new state', () => {
const history = useCanvasHistory()
const history = useCanvasHistory(mockLayers)
history.saveInitialState()
vi.mocked(mockRefs.maskCtx!.getImageData).mockClear()
@@ -234,7 +204,7 @@ describe('useCanvasHistory', () => {
})
it('should clear redo states when saving new state', () => {
const history = useCanvasHistory()
const history = useCanvasHistory(mockLayers)
history.saveInitialState()
history.saveState()
@@ -249,7 +219,7 @@ describe('useCanvasHistory', () => {
})
it('should respect maxStates limit', () => {
const history = useCanvasHistory(3)
const history = useCanvasHistory(mockLayers, 3)
history.saveInitialState()
history.saveState()
@@ -269,7 +239,7 @@ describe('useCanvasHistory', () => {
})
it('should call saveInitialState if not initialized', () => {
const history = useCanvasHistory()
const history = useCanvasHistory(mockLayers)
history.saveState()
@@ -279,7 +249,7 @@ describe('useCanvasHistory', () => {
})
it('should not save state if context is missing', () => {
const history = useCanvasHistory()
const history = useCanvasHistory(mockLayers)
history.saveInitialState()
@@ -299,7 +269,7 @@ describe('useCanvasHistory', () => {
describe('undo', () => {
it('should undo to previous state', () => {
const history = useCanvasHistory()
const history = useCanvasHistory(mockLayers)
history.saveInitialState()
history.saveState()
@@ -314,7 +284,7 @@ describe('useCanvasHistory', () => {
})
it('should not undo when no undo states available', () => {
const history = useCanvasHistory()
const history = useCanvasHistory(mockLayers)
history.saveInitialState()
@@ -330,7 +300,7 @@ describe('useCanvasHistory', () => {
})
it('should undo multiple times', () => {
const history = useCanvasHistory()
const history = useCanvasHistory(mockLayers)
history.saveInitialState()
history.saveState()
@@ -348,7 +318,7 @@ describe('useCanvasHistory', () => {
})
it('should not undo beyond first state', () => {
const history = useCanvasHistory()
const history = useCanvasHistory(mockLayers)
history.saveInitialState()
history.saveState()
@@ -369,7 +339,7 @@ describe('useCanvasHistory', () => {
describe('redo', () => {
it('should redo to next state', () => {
const history = useCanvasHistory()
const history = useCanvasHistory(mockLayers)
history.saveInitialState()
history.saveState()
@@ -389,7 +359,7 @@ describe('useCanvasHistory', () => {
})
it('should not redo when no redo states available', () => {
const history = useCanvasHistory()
const history = useCanvasHistory(mockLayers)
history.saveInitialState()
@@ -405,7 +375,7 @@ describe('useCanvasHistory', () => {
})
it('should redo multiple times', () => {
const history = useCanvasHistory()
const history = useCanvasHistory(mockLayers)
history.saveInitialState()
history.saveState()
@@ -427,7 +397,7 @@ describe('useCanvasHistory', () => {
})
it('should not redo beyond last state', () => {
const history = useCanvasHistory()
const history = useCanvasHistory(mockLayers)
history.saveInitialState()
history.saveState()
@@ -449,7 +419,7 @@ describe('useCanvasHistory', () => {
describe('clearStates', () => {
it('should clear all states', () => {
const history = useCanvasHistory()
const history = useCanvasHistory(mockLayers)
history.saveInitialState()
history.saveState()
@@ -462,7 +432,7 @@ describe('useCanvasHistory', () => {
})
it('should allow saving initial state after clear', () => {
const history = useCanvasHistory()
const history = useCanvasHistory(mockLayers)
history.saveInitialState()
history.clearStates()
@@ -481,13 +451,13 @@ describe('useCanvasHistory', () => {
describe('canUndo computed', () => {
it('should be false with no states', () => {
const history = useCanvasHistory()
const history = useCanvasHistory(mockLayers)
expect(history.canUndo.value).toBe(false)
})
it('should be false with only initial state', () => {
const history = useCanvasHistory()
const history = useCanvasHistory(mockLayers)
history.saveInitialState()
@@ -495,7 +465,7 @@ describe('useCanvasHistory', () => {
})
it('should be true after saving a state', () => {
const history = useCanvasHistory()
const history = useCanvasHistory(mockLayers)
history.saveInitialState()
history.saveState()
@@ -504,7 +474,7 @@ describe('useCanvasHistory', () => {
})
it('should be false after undoing to first state', () => {
const history = useCanvasHistory()
const history = useCanvasHistory(mockLayers)
history.saveInitialState()
history.saveState()
@@ -516,7 +486,7 @@ describe('useCanvasHistory', () => {
describe('canRedo computed', () => {
it('should be false with no undo', () => {
const history = useCanvasHistory()
const history = useCanvasHistory(mockLayers)
history.saveInitialState()
history.saveState()
@@ -525,7 +495,7 @@ describe('useCanvasHistory', () => {
})
it('should be true after undo', () => {
const history = useCanvasHistory()
const history = useCanvasHistory(mockLayers)
history.saveInitialState()
history.saveState()
@@ -535,7 +505,7 @@ describe('useCanvasHistory', () => {
})
it('should be false after redo to last state', () => {
const history = useCanvasHistory()
const history = useCanvasHistory(mockLayers)
history.saveInitialState()
history.saveState()
@@ -546,7 +516,7 @@ describe('useCanvasHistory', () => {
})
it('should be false after saving new state', () => {
const history = useCanvasHistory()
const history = useCanvasHistory(mockLayers)
history.saveInitialState()
history.saveState()
@@ -562,7 +532,7 @@ describe('useCanvasHistory', () => {
describe('restoreState', () => {
it('should not restore if context is missing', () => {
const history = useCanvasHistory()
const history = useCanvasHistory(mockLayers)
history.saveInitialState()
history.saveState()
@@ -583,7 +553,7 @@ describe('useCanvasHistory', () => {
describe('edge cases', () => {
it('should handle rapid state saves', async () => {
const history = useCanvasHistory()
const history = useCanvasHistory(mockLayers)
history.saveInitialState()
@@ -596,7 +566,7 @@ describe('useCanvasHistory', () => {
})
it('should handle maxStates of 1', () => {
const history = useCanvasHistory(1)
const history = useCanvasHistory(mockLayers, 1)
history.saveInitialState()
history.saveState()
@@ -605,7 +575,7 @@ describe('useCanvasHistory', () => {
})
it('should handle undo/redo cycling', () => {
const history = useCanvasHistory()
const history = useCanvasHistory(mockLayers)
history.saveInitialState()
history.saveState()
@@ -629,7 +599,7 @@ describe('useCanvasHistory', () => {
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
}
const history = useCanvasHistory()
const history = useCanvasHistory(mockLayers)
history.saveInitialState()

View File

@@ -1,16 +1,21 @@
import { ref, computed } from 'vue'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
// Define the state interface for better readability
interface CanvasState {
mask: ImageData | ImageBitmap
rgb: ImageData | ImageBitmap
img: ImageData | ImageBitmap
}
export function useCanvasHistory(maxStates = 20) {
const store = useMaskEditorStore()
export interface CanvasLayers {
maskCanvas: HTMLCanvasElement | null
maskCtx: CanvasRenderingContext2D | null
rgbCanvas: HTMLCanvasElement | null
rgbCtx: CanvasRenderingContext2D | null
imgCanvas: HTMLCanvasElement | null
imgCtx: CanvasRenderingContext2D | null
}
export function useCanvasHistory(layers: CanvasLayers, maxStates = 20) {
const states = ref<CanvasState[]>([])
const currentStateIndex = ref(-1)
const initialized = ref(false)
@@ -27,7 +32,7 @@ export function useCanvasHistory(maxStates = 20) {
})
const saveInitialState = () => {
const { maskCtx, rgbCtx, imgCtx, maskCanvas, rgbCanvas, imgCanvas } = store
const { maskCtx, rgbCtx, imgCtx, maskCanvas, rgbCanvas, imgCanvas } = layers
// Ensure all 3 contexts and canvases are ready
if (
@@ -79,7 +84,7 @@ export function useCanvasHistory(maxStates = 20) {
providedRgbData?: ImageData | ImageBitmap,
providedImgData?: ImageData | ImageBitmap
) => {
const { maskCtx, rgbCtx, imgCtx, maskCanvas, rgbCanvas, imgCanvas } = store
const { maskCtx, rgbCtx, imgCtx, maskCanvas, rgbCanvas, imgCanvas } = layers
if (
!maskCtx ||
@@ -144,7 +149,7 @@ export function useCanvasHistory(maxStates = 20) {
}
const restoreState = (state: CanvasState) => {
const { maskCtx, rgbCtx, imgCtx, maskCanvas, rgbCanvas, imgCanvas } = store
const { maskCtx, rgbCtx, imgCtx, maskCanvas, rgbCanvas, imgCanvas } = layers
if (
!maskCtx ||
!rgbCtx ||
@@ -169,13 +174,13 @@ export function useCanvasHistory(maxStates = 20) {
imgCanvas.height = newHeight
}
const layers = [
const canvasLayers = [
{ ctx: maskCtx, data: state.mask },
{ ctx: rgbCtx, data: state.rgb },
{ ctx: imgCtx, data: state.img }
]
layers.forEach(({ ctx, data }) => {
canvasLayers.forEach(({ ctx, data }) => {
if (data instanceof ImageBitmap) {
ctx.clearRect(0, 0, data.width, data.height)
ctx.drawImage(data, 0, 0)

View File

@@ -89,13 +89,13 @@ describe('useCanvasManager', () => {
})
describe('invalidateCanvas', () => {
it('should set canvas dimensions', async () => {
it('should set canvas dimensions', () => {
const manager = useCanvasManager()
const origImage = createMockImage(512, 512)
const maskImage = createMockImage(512, 512)
await manager.invalidateCanvas(origImage, maskImage, null)
manager.invalidateCanvas(origImage, maskImage, null)
expect(mockStore.imgCanvas.width).toBe(512)
expect(mockStore.imgCanvas.height).toBe(512)
@@ -105,13 +105,13 @@ describe('useCanvasManager', () => {
expect(mockStore.rgbCanvas.height).toBe(512)
})
it('should draw original image', async () => {
it('should draw original image', () => {
const manager = useCanvasManager()
const origImage = createMockImage(512, 512)
const maskImage = createMockImage(512, 512)
await manager.invalidateCanvas(origImage, maskImage, null)
manager.invalidateCanvas(origImage, maskImage, null)
expect(mockStore.imgCtx.drawImage).toHaveBeenCalledWith(
origImage,
@@ -122,14 +122,14 @@ describe('useCanvasManager', () => {
)
})
it('should draw paint image when provided', async () => {
it('should draw paint image when provided', () => {
const manager = useCanvasManager()
const origImage = createMockImage(512, 512)
const maskImage = createMockImage(512, 512)
const paintImage = createMockImage(512, 512)
await manager.invalidateCanvas(origImage, maskImage, paintImage)
manager.invalidateCanvas(origImage, maskImage, paintImage)
expect(mockStore.rgbCtx.drawImage).toHaveBeenCalledWith(
paintImage,
@@ -140,31 +140,31 @@ describe('useCanvasManager', () => {
)
})
it('should not draw paint image when null', async () => {
it('should not draw paint image when null', () => {
const manager = useCanvasManager()
const origImage = createMockImage(512, 512)
const maskImage = createMockImage(512, 512)
await manager.invalidateCanvas(origImage, maskImage, null)
manager.invalidateCanvas(origImage, maskImage, null)
expect(mockStore.rgbCtx.drawImage).not.toHaveBeenCalled()
})
it('should prepare mask', async () => {
it('should prepare mask', () => {
const manager = useCanvasManager()
const origImage = createMockImage(512, 512)
const maskImage = createMockImage(512, 512)
await manager.invalidateCanvas(origImage, maskImage, null)
manager.invalidateCanvas(origImage, maskImage, null)
expect(mockStore.maskCtx.drawImage).toHaveBeenCalled()
expect(mockStore.maskCtx.getImageData).toHaveBeenCalled()
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
})
it('should throw error when canvas missing', async () => {
it('should throw error when canvas missing', () => {
const manager = useCanvasManager()
mockStore.imgCanvas = null! as HTMLCanvasElement
@@ -172,12 +172,12 @@ describe('useCanvasManager', () => {
const origImage = createMockImage(512, 512)
const maskImage = createMockImage(512, 512)
await expect(
expect(() =>
manager.invalidateCanvas(origImage, maskImage, null)
).rejects.toThrow('Canvas elements or contexts not available')
).toThrow('Canvas elements or contexts not available')
})
it('should throw error when context missing', async () => {
it('should throw error when context missing', () => {
const manager = useCanvasManager()
mockStore.imgCtx = null! as CanvasRenderingContext2D
@@ -185,20 +185,20 @@ describe('useCanvasManager', () => {
const origImage = createMockImage(512, 512)
const maskImage = createMockImage(512, 512)
await expect(
expect(() =>
manager.invalidateCanvas(origImage, maskImage, null)
).rejects.toThrow('Canvas elements or contexts not available')
).toThrow('Canvas elements or contexts not available')
})
})
describe('updateMaskColor', () => {
it('should update mask color for black blend mode', async () => {
it('should update mask color for black blend mode', () => {
const manager = useCanvasManager()
mockStore.maskBlendMode = MaskBlendMode.Black
mockStore.maskColor = { r: 0, g: 0, b: 0 }
await manager.updateMaskColor()
manager.updateMaskColor()
expect(mockStore.maskCtx.fillStyle).toBe('rgb(0, 0, 0)')
expect(mockStore.maskCanvas.style.mixBlendMode).toBe('initial')
@@ -208,13 +208,13 @@ describe('useCanvasManager', () => {
)
})
it('should update mask color for white blend mode', async () => {
it('should update mask color for white blend mode', () => {
const manager = useCanvasManager()
mockStore.maskBlendMode = MaskBlendMode.White
mockStore.maskColor = { r: 255, g: 255, b: 255 }
await manager.updateMaskColor()
manager.updateMaskColor()
expect(mockStore.maskCtx.fillStyle).toBe('rgb(255, 255, 255)')
expect(mockStore.maskCanvas.style.mixBlendMode).toBe('initial')
@@ -223,13 +223,13 @@ describe('useCanvasManager', () => {
)
})
it('should update mask color for negative blend mode', async () => {
it('should update mask color for negative blend mode', () => {
const manager = useCanvasManager()
mockStore.maskBlendMode = MaskBlendMode.Negative
mockStore.maskColor = { r: 255, g: 255, b: 255 }
await manager.updateMaskColor()
manager.updateMaskColor()
expect(mockStore.maskCanvas.style.mixBlendMode).toBe('difference')
expect(mockStore.maskCanvas.style.opacity).toBe('1')
@@ -238,14 +238,14 @@ describe('useCanvasManager', () => {
)
})
it('should update all pixels with mask color', async () => {
it('should update all pixels with mask color', () => {
const manager = useCanvasManager()
mockStore.maskColor = { r: 128, g: 64, b: 32 }
mockStore.maskCanvas.width = 100
mockStore.maskCanvas.height = 100
await manager.updateMaskColor()
manager.updateMaskColor()
for (let i = 0; i < mockImageData.data.length; i += 4) {
expect(mockImageData.data[i]).toBe(128)
@@ -260,39 +260,39 @@ describe('useCanvasManager', () => {
)
})
it('should return early when canvas missing', async () => {
it('should return early when canvas missing', () => {
const manager = useCanvasManager()
mockStore.maskCanvas = null! as HTMLCanvasElement
await manager.updateMaskColor()
manager.updateMaskColor()
expect(mockStore.maskCtx.getImageData).not.toHaveBeenCalled()
})
it('should return early when context missing', async () => {
it('should return early when context missing', () => {
const manager = useCanvasManager()
mockStore.maskCtx = null! as CanvasRenderingContext2D
await manager.updateMaskColor()
manager.updateMaskColor()
expect(mockStore.canvasBackground.style.backgroundColor).toBe('')
})
it('should handle different opacity values', async () => {
it('should handle different opacity values', () => {
const manager = useCanvasManager()
mockStore.maskOpacity = 0.5
await manager.updateMaskColor()
manager.updateMaskColor()
expect(mockStore.maskCanvas.style.opacity).toBe('0.5')
})
})
describe('prepareMask', () => {
it('should invert mask alpha', async () => {
it('should invert mask alpha', () => {
const manager = useCanvasManager()
for (let i = 0; i < mockImageData.data.length; i += 4) {
@@ -302,14 +302,14 @@ describe('useCanvasManager', () => {
const origImage = createMockImage(100, 100)
const maskImage = createMockImage(100, 100)
await manager.invalidateCanvas(origImage, maskImage, null)
manager.invalidateCanvas(origImage, maskImage, null)
for (let i = 0; i < mockImageData.data.length; i += 4) {
expect(mockImageData.data[i + 3]).toBe(127)
}
})
it('should apply mask color to all pixels', async () => {
it('should apply mask color to all pixels', () => {
const manager = useCanvasManager()
mockStore.maskColor = { r: 100, g: 150, b: 200 }
@@ -317,7 +317,7 @@ describe('useCanvasManager', () => {
const origImage = createMockImage(100, 100)
const maskImage = createMockImage(100, 100)
await manager.invalidateCanvas(origImage, maskImage, null)
manager.invalidateCanvas(origImage, maskImage, null)
for (let i = 0; i < mockImageData.data.length; i += 4) {
expect(mockImageData.data[i]).toBe(100)
@@ -326,13 +326,13 @@ describe('useCanvasManager', () => {
}
})
it('should set composite operation', async () => {
it('should set composite operation', () => {
const manager = useCanvasManager()
const origImage = createMockImage(100, 100)
const maskImage = createMockImage(100, 100)
await manager.invalidateCanvas(origImage, maskImage, null)
manager.invalidateCanvas(origImage, maskImage, null)
expect(mockStore.maskCtx.globalCompositeOperation).toBe('source-over')
})

View File

@@ -5,11 +5,11 @@ import { MaskBlendMode } from '@/extensions/core/maskeditor/types'
export function useCanvasManager() {
const store = useMaskEditorStore()
const prepareMask = async (
const prepareMask = (
image: HTMLImageElement,
maskCanvasEl: HTMLCanvasElement,
maskContext: CanvasRenderingContext2D
): Promise<void> => {
): void => {
const maskColor = store.maskColor
maskContext.drawImage(image, 0, 0, maskCanvasEl.width, maskCanvasEl.height)
@@ -33,11 +33,11 @@ export function useCanvasManager() {
maskContext.putImageData(maskData, 0, 0)
}
const invalidateCanvas = async (
const invalidateCanvas = (
origImage: HTMLImageElement,
maskImage: HTMLImageElement,
paintImage: HTMLImageElement | null
): Promise<void> => {
): void => {
const { imgCanvas, maskCanvas, rgbCanvas, imgCtx, maskCtx, rgbCtx } = store
if (
@@ -64,7 +64,7 @@ export function useCanvasManager() {
rgbCtx.drawImage(paintImage, 0, 0, paintImage.width, paintImage.height)
}
await prepareMask(maskImage, maskCanvas, maskCtx)
prepareMask(maskImage, maskCanvas, maskCtx)
}
const setCanvasBackground = (): void => {
@@ -81,7 +81,7 @@ export function useCanvasManager() {
}
}
const updateMaskColor = async (): Promise<void> => {
const updateMaskColor = (): void => {
const { maskCanvas, maskCtx, maskColor, maskBlendMode, maskOpacity } = store
if (!maskCanvas || !maskCtx) return

View File

@@ -118,10 +118,7 @@ export function useCanvasTransform() {
* Recreates and updates GPU textures after transformation
* This is required because GPU textures have immutable dimensions
*/
const recreateGPUTextures = async (
width: number,
height: number
): Promise<void> => {
const recreateGPUTextures = (width: number, height: number): void => {
if (
!store.tgpuRoot ||
!store.maskCanvas ||
@@ -181,7 +178,7 @@ export function useCanvasTransform() {
/**
* Rotates all canvas layers 90 degrees clockwise and updates GPU
*/
const rotateClockwise = async (): Promise<void> => {
const rotateClockwise = (): void => {
const { maskCanvas, maskCtx, rgbCanvas, rgbCtx, imgCanvas, imgCtx } = store
if (
@@ -220,7 +217,7 @@ export function useCanvasTransform() {
// Recreate GPU textures with new dimensions if GPU is active
if (store.tgpuRoot) {
await recreateGPUTextures(origHeight, origWidth)
recreateGPUTextures(origHeight, origWidth)
}
// Save to history
@@ -230,7 +227,7 @@ export function useCanvasTransform() {
/**
* Rotates all canvas layers 90 degrees counter-clockwise and updates GPU
*/
const rotateCounterclockwise = async (): Promise<void> => {
const rotateCounterclockwise = (): void => {
const { maskCanvas, maskCtx, rgbCanvas, rgbCtx, imgCanvas, imgCtx } = store
if (
@@ -269,7 +266,7 @@ export function useCanvasTransform() {
// Recreate GPU textures with new dimensions if GPU is active
if (store.tgpuRoot) {
await recreateGPUTextures(origHeight, origWidth)
recreateGPUTextures(origHeight, origWidth)
}
// Save to history
@@ -279,7 +276,7 @@ export function useCanvasTransform() {
/**
* Mirrors all canvas layers horizontally and updates GPU
*/
const mirrorHorizontal = async (): Promise<void> => {
const mirrorHorizontal = (): void => {
const { maskCanvas, maskCtx, rgbCanvas, rgbCtx, imgCanvas, imgCtx } = store
if (
@@ -306,7 +303,7 @@ export function useCanvasTransform() {
// Update GPU textures if GPU is active (dimensions unchanged, just data)
if (store.tgpuRoot) {
await recreateGPUTextures(maskCanvas.width, maskCanvas.height)
recreateGPUTextures(maskCanvas.width, maskCanvas.height)
}
// Save to history
@@ -316,7 +313,7 @@ export function useCanvasTransform() {
/**
* Mirrors all canvas layers vertically and updates GPU
*/
const mirrorVertical = async (): Promise<void> => {
const mirrorVertical = (): void => {
const { maskCanvas, maskCtx, rgbCanvas, rgbCtx, imgCanvas, imgCtx } = store
if (
@@ -343,7 +340,7 @@ export function useCanvasTransform() {
// Update GPU textures if GPU is active (dimensions unchanged, just data)
if (store.tgpuRoot) {
await recreateGPUTextures(maskCanvas.width, maskCanvas.height)
recreateGPUTextures(maskCanvas.width, maskCanvas.height)
}
// Save to history

View File

@@ -35,13 +35,9 @@ function useImageLoaderInternal() {
store.image = baseImage
await canvasManager.invalidateCanvas(
baseImage,
maskImage,
paintImage || null
)
canvasManager.invalidateCanvas(baseImage, maskImage, paintImage || null)
await canvasManager.updateMaskColor()
canvasManager.updateMaskColor()
return baseImage
}

View File

@@ -11,7 +11,6 @@ import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
// Private layer filename functions
interface ImageLayerFilenames {
maskedImage: string
paint: string
@@ -30,6 +29,32 @@ function imageLayerFilenamesByTimestamp(
}
}
function getContext2D(canvas: HTMLCanvasElement): CanvasRenderingContext2D {
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Failed to get 2D rendering context')
return ctx
}
function applyInvertedMaskAlpha(
targetCtx: CanvasRenderingContext2D,
maskCanvas: HTMLCanvasElement
): void {
const maskCtx = getContext2D(maskCanvas)
const maskData = maskCtx.getImageData(
0,
0,
maskCanvas.width,
maskCanvas.height
)
const { width, height } = targetCtx.canvas
const imageData = targetCtx.getImageData(0, 0, width, height)
for (let i = 0; i < imageData.data.length; i += 4) {
imageData.data[i + 3] = 255 - maskData.data[i + 3]
}
targetCtx.putImageData(imageData, 0, 0)
}
export function useMaskEditorSaver() {
const dataStore = useMaskEditorDataStore()
const editorStore = useMaskEditorStore()
@@ -99,31 +124,10 @@ export function useMaskEditorSaver() {
const canvas = document.createElement('canvas')
canvas.width = imgCanvas.width
canvas.height = imgCanvas.height
const ctx = canvas.getContext('2d')!
const ctx = getContext2D(canvas)
ctx.drawImage(imgCanvas, 0, 0)
const maskCtx = maskCanvas.getContext('2d')!
const maskData = maskCtx.getImageData(
0,
0,
maskCanvas.width,
maskCanvas.height
)
const refinedMaskData = new Uint8ClampedArray(maskData.data.length)
for (let i = 0; i < maskData.data.length; i += 4) {
refinedMaskData[i] = 0
refinedMaskData[i + 1] = 0
refinedMaskData[i + 2] = 0
refinedMaskData[i + 3] = 255 - maskData.data[i + 3]
}
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
for (let i = 0; i < imageData.data.length; i += 4) {
imageData.data[i + 3] = refinedMaskData[i + 3]
}
ctx.putImageData(imageData, 0, 0)
applyInvertedMaskAlpha(ctx, maskCanvas)
const blob = await canvasToBlob(canvas)
const ref = createFileRef(filename)
@@ -150,11 +154,9 @@ export function useMaskEditorSaver() {
const canvas = document.createElement('canvas')
canvas.width = imgCanvas.width
canvas.height = imgCanvas.height
const ctx = canvas.getContext('2d')!
const ctx = getContext2D(canvas)
ctx.drawImage(imgCanvas, 0, 0)
ctx.globalCompositeOperation = 'source-over'
ctx.drawImage(paintCanvas, 0, 0)
const blob = await canvasToBlob(canvas)
@@ -172,34 +174,11 @@ export function useMaskEditorSaver() {
const canvas = document.createElement('canvas')
canvas.width = imgCanvas.width
canvas.height = imgCanvas.height
const ctx = canvas.getContext('2d')!
const ctx = getContext2D(canvas)
ctx.drawImage(imgCanvas, 0, 0)
ctx.globalCompositeOperation = 'source-over'
ctx.drawImage(paintCanvas, 0, 0)
const maskCtx = maskCanvas.getContext('2d')!
const maskData = maskCtx.getImageData(
0,
0,
maskCanvas.width,
maskCanvas.height
)
const refinedMaskData = new Uint8ClampedArray(maskData.data.length)
for (let i = 0; i < maskData.data.length; i += 4) {
refinedMaskData[i] = 0
refinedMaskData[i + 1] = 0
refinedMaskData[i + 2] = 0
refinedMaskData[i + 3] = 255 - maskData.data[i + 3]
}
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
for (let i = 0; i < imageData.data.length; i += 4) {
imageData.data[i + 3] = refinedMaskData[i + 3]
}
ctx.putImageData(imageData, 0, 0)
applyInvertedMaskAlpha(ctx, maskCanvas)
const blob = await canvasToBlob(canvas)
const ref = createFileRef(filename)
@@ -210,16 +189,26 @@ export function useMaskEditorSaver() {
async function uploadAllLayers(outputData: EditorOutputData): Promise<void> {
const sourceRef = dataStore.inputData!.sourceRef
const actualMaskedRef = await uploadMask(outputData.maskedImage, sourceRef)
const actualPaintRef = await uploadImage(outputData.paintLayer, sourceRef)
const actualPaintedRef = await uploadImage(
const actualMaskedRef = await uploadLayer(
outputData.maskedImage,
sourceRef,
'/upload/mask'
)
const actualPaintRef = await uploadLayer(
outputData.paintLayer,
sourceRef,
'/upload/image'
)
const actualPaintedRef = await uploadLayer(
outputData.paintedImage,
sourceRef
sourceRef,
'/upload/image'
)
const actualPaintedMaskedRef = await uploadMask(
const actualPaintedMaskedRef = await uploadLayer(
outputData.paintedMaskedImage,
actualPaintedRef
actualPaintedRef,
'/upload/mask'
)
outputData.maskedImage.ref = actualMaskedRef
@@ -228,9 +217,10 @@ export function useMaskEditorSaver() {
outputData.paintedMaskedImage.ref = actualPaintedMaskedRef
}
async function uploadMask(
async function uploadLayer(
layer: EditorOutputLayer,
originalRef: ImageRef
originalRef: ImageRef,
endpoint: '/upload/mask' | '/upload/image'
): Promise<ImageRef> {
const formData = new FormData()
formData.append('image', layer.blob, layer.ref.filename)
@@ -238,61 +228,22 @@ export function useMaskEditorSaver() {
formData.append('type', 'input')
formData.append('subfolder', 'clipspace')
const response = await api.fetchApi('/upload/mask', {
const response = await api.fetchApi(endpoint, {
method: 'POST',
body: formData
})
if (!response.ok) {
throw new Error(`Failed to upload mask: ${layer.ref.filename}`)
throw new Error(`Failed to upload to ${endpoint}: ${layer.ref.filename}`)
}
try {
const data = await response.json()
if (data?.name) {
return {
filename: data.name,
subfolder: data.subfolder || layer.ref.subfolder,
type: data.type || layer.ref.type
}
const data = await response.json()
if (data?.name) {
return {
filename: data.name,
subfolder: data.subfolder || layer.ref.subfolder,
type: data.type || layer.ref.type
}
} catch (error) {
console.warn('[MaskEditorSaver] Failed to parse upload response:', error)
}
return layer.ref
}
async function uploadImage(
layer: EditorOutputLayer,
originalRef: ImageRef
): Promise<ImageRef> {
const formData = new FormData()
formData.append('image', layer.blob, layer.ref.filename)
formData.append('original_ref', JSON.stringify(originalRef))
formData.append('type', 'input')
formData.append('subfolder', 'clipspace')
const response = await api.fetchApi('/upload/image', {
method: 'POST',
body: formData
})
if (!response.ok) {
throw new Error(`Failed to upload image: ${layer.ref.filename}`)
}
try {
const data = await response.json()
if (data?.name) {
return {
filename: data.name,
subfolder: data.subfolder || layer.ref.subfolder,
type: data.type || layer.ref.type
}
}
} catch (error) {
console.warn('[MaskEditorSaver] Failed to parse upload response:', error)
}
return layer.ref
@@ -373,7 +324,7 @@ export function useMaskEditorSaver() {
const canvas = document.createElement('canvas')
canvas.width = source.width
canvas.height = source.height
const ctx = canvas.getContext('2d')!
const ctx = getContext2D(canvas)
ctx.drawImage(source, 0, 0)
return canvas
}

View File

@@ -59,14 +59,9 @@ export function usePanAndZoom() {
store.canvasHistory.undo()
}
const invalidatePanZoom = async (): Promise<void> => {
const invalidatePanZoom = (): void => {
// Single validation check upfront
if (
!image.value?.width ||
!image.value?.height ||
!pan_offset.value ||
!zoom_ratio.value
) {
if (!image.value?.width || !image.value?.height || !zoom_ratio.value) {
console.warn('Missing required properties for pan/zoom')
return
}
@@ -113,7 +108,7 @@ export function usePanAndZoom() {
initialPan.value = { ...pan_offset.value }
}
const handlePanMove = async (event: PointerEvent): Promise<void> => {
const handlePanMove = (event: PointerEvent): void => {
if (mouseDownPoint.value === null) {
throw new Error('mouseDownPoint is null')
}
@@ -126,10 +121,10 @@ export function usePanAndZoom() {
pan_offset.value = { x: pan_x, y: pan_y }
await invalidatePanZoom()
invalidatePanZoom()
}
const handleSingleTouchPan = async (touch: Touch): Promise<void> => {
const handleSingleTouchPan = (touch: Touch): void => {
if (lastTouchPoint.value === null) {
lastTouchPoint.value = { x: touch.clientX, y: touch.clientY }
return
@@ -141,7 +136,7 @@ export function usePanAndZoom() {
pan_offset.value.x += deltaX
pan_offset.value.y += deltaY
await invalidatePanZoom()
invalidatePanZoom()
lastTouchPoint.value = { x: touch.clientX, y: touch.clientY }
}
@@ -175,7 +170,7 @@ export function usePanAndZoom() {
}
}
const handleTouchMove = async (event: TouchEvent): Promise<void> => {
const handleTouchMove = (event: TouchEvent): void => {
event.preventDefault()
if (penPointerIdList.value.length > 0) return
@@ -215,11 +210,11 @@ export function usePanAndZoom() {
pan_offset.value.x += touchX - touchX * scaleFactor
pan_offset.value.y += touchY - touchY * scaleFactor
await invalidatePanZoom()
invalidatePanZoom()
lastTouchZoomDistance.value = newDistance
lastTouchMidPoint.value = midpoint
} else if (event.touches.length === 1) {
await handleSingleTouchPan(event.touches[0])
handleSingleTouchPan(event.touches[0])
}
}
@@ -239,7 +234,7 @@ export function usePanAndZoom() {
}
}
const zoom = async (event: WheelEvent): Promise<void> => {
const zoom = (event: WheelEvent): void => {
const cursorPosition = { x: event.clientX, y: event.clientY }
const oldZoom = zoom_ratio.value
@@ -263,7 +258,7 @@ export function usePanAndZoom() {
pan_offset.value.x += mouseX - mouseX * scaleFactor
pan_offset.value.y += mouseY - mouseY * scaleFactor
await invalidatePanZoom()
invalidatePanZoom()
const newImageWidth = maskCanvas.value.clientWidth
@@ -287,7 +282,7 @@ export function usePanAndZoom() {
return { sidePanelWidth, toolPanelWidth }
}
const smoothResetView = async (duration: number = 500): Promise<void> => {
const smoothResetView = (duration: number = 500): void => {
if (!image.value || !rootElement.value) return
const startZoom = zoom_ratio.value
@@ -321,7 +316,7 @@ export function usePanAndZoom() {
}
const startTime = performance.now()
const animate = async (currentTime: number) => {
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime
const progress = Math.min(elapsed / duration, 1)
const eased = 1 - Math.pow(1 - progress, 3)
@@ -330,7 +325,7 @@ export function usePanAndZoom() {
pan_offset.value.x = startPan.x + (targetPan.x - startPan.x) * eased
pan_offset.value.y = startPan.y + (targetPan.y - startPan.y) * eased
await invalidatePanZoom()
invalidatePanZoom()
const interpolatedRatio = startZoom + (1.0 - startZoom) * eased
store.displayZoomRatio = interpolatedRatio
@@ -344,12 +339,12 @@ export function usePanAndZoom() {
interpolatedZoomRatio.value = 1.0
}
const initializeCanvasPanZoom = async (
const initializeCanvasPanZoom = (
img: HTMLImageElement,
root: HTMLElement,
toolPanel?: HTMLElement | null,
sidePanel?: HTMLElement | null
): Promise<void> => {
): void => {
rootElement.value = root
toolPanelElement.value = toolPanel || null
sidePanelElement.value = sidePanel || null
@@ -391,14 +386,14 @@ export function usePanAndZoom() {
penPointerIdList.value = []
await invalidatePanZoom()
invalidatePanZoom()
}
watch(
() => store.resetZoomTrigger,
async () => {
() => {
if (interpolatedZoomRatio.value === 1) return
await smoothResetView()
smoothResetView()
}
)

View File

@@ -180,7 +180,7 @@ export function useToolManager(
const isSpacePressed = keyboard.isKeyDown(' ')
if (event.buttons === 4 || (event.buttons === 1 && isSpacePressed)) {
await panZoom.handlePanMove(event)
panZoom.handlePanMove(event)
return
}

View File

@@ -331,9 +331,6 @@ export const formatPricingResult = (
}
if (!isPricingResult(result)) {
if (result !== undefined && result !== null) {
console.warn('[pricing/jsonata] invalid result format:', result)
}
return ''
}

View File

@@ -46,16 +46,13 @@ export const useComputedWithWidgetWatch = (
) => {
const { widgetNames, triggerCanvasRedraw = false } = options
// Create a reactive trigger based on widget values
const widgetValues = ref<Record<string, unknown>>({})
// Initialize widget observers
if (node.widgets) {
const widgetsToObserve = widgetNames
? node.widgets.filter((widget) => widgetNames.includes(widget.name))
: node.widgets
// Initialize current values
const currentValues: Record<string, unknown> = {}
widgetsToObserve.forEach((widget) => {
currentValues[widget.name] = widget.value
@@ -64,20 +61,17 @@ export const useComputedWithWidgetWatch = (
widgetsToObserve.forEach((widget) => {
widget.callback = useChainCallback(widget.callback, () => {
// Update the reactive widget values
widgetValues.value = {
...widgetValues.value,
[widget.name]: widget.value
}
// Optionally trigger a canvas redraw
if (triggerCanvasRedraw) {
node.graph?.setDirtyCanvas(true, true)
}
})
})
if (widgetNames && widgetNames.length > widgetsToObserve.length) {
//Inputs have been included
const indexesToObserve = widgetNames
.map((name) =>
widgetsToObserve.some((w) => w.name == name)
@@ -101,8 +95,6 @@ export const useComputedWithWidgetWatch = (
}
}
// Returns a function that creates a computed that responds to widget changes.
// The computed will be re-evaluated whenever any observed widget changes.
return <T>(computeFn: () => T): ComputedRef<T> => {
return computedWithControl(widgetValues, computeFn)
}

View File

@@ -57,8 +57,8 @@ export type JobGroup = {
const ADDED_HINT_DURATION_MS = 3000
const relativeTimeFormatterCache = new Map<string, Intl.RelativeTimeFormat>()
const taskIdToKey = (id: string | number | undefined) => {
if (id === null || id === undefined) return null
function taskIdToKey(id: string | number | undefined) {
if (id === undefined) return null
const key = String(id)
return key.length ? key : null
}

View File

@@ -79,8 +79,8 @@ vi.mock('@/scripts/api', () => ({
const downloadBlobMock = vi.fn()
vi.mock('@/scripts/utils', () => ({
downloadBlob: (filename: string, blob: Blob) =>
downloadBlobMock(filename, blob)
downloadBlob: (blob: Blob, filename: string) =>
downloadBlobMock(blob, filename)
}))
const dialogServiceMock = {
@@ -594,7 +594,7 @@ describe('useJobMenu', () => {
expect(dialogServiceMock.prompt).not.toHaveBeenCalled()
expect(downloadBlobMock).toHaveBeenCalledTimes(1)
const [filename, blob] = downloadBlobMock.mock.calls[0]
const [blob, filename] = downloadBlobMock.mock.calls[0]
expect(filename).toBe('Job 7.json')
await expect(blob.text()).resolves.toBe(
JSON.stringify({ foo: 'bar' }, null, 2)
@@ -621,7 +621,7 @@ describe('useJobMenu', () => {
message: expect.stringContaining('workflowService.enterFilename'),
defaultValue: 'Job job-1.json'
})
const [filename] = downloadBlobMock.mock.calls[0]
const [, filename] = downloadBlobMock.mock.calls[0]
expect(filename).toBe('custom-name.json')
})
@@ -642,7 +642,7 @@ describe('useJobMenu', () => {
await entry?.onClick?.()
expect(appendJsonExtMock).toHaveBeenCalledWith('existing.json')
const [filename] = downloadBlobMock.mock.calls[0]
const [, filename] = downloadBlobMock.mock.calls[0]
expect(filename).toBe('existing.json')
})

View File

@@ -117,7 +117,7 @@ export function useJobMenu(
// This is very magical only because it matches the respective backend implementation
// There is or will be a better way to do this
const addOutputLoaderNode = async () => {
const addOutputLoaderNode = () => {
const item = currentMenuItem()
if (!item) return
const result: ResultItemImpl | undefined = item.taskRef?.previewOutput
@@ -200,7 +200,7 @@ export function useJobMenu(
const json = JSON.stringify(data, null, 2)
const blob = new Blob([json], { type: 'application/json' })
downloadBlob(filename, blob)
downloadBlob(blob, filename)
}
const deleteJobAsset = async () => {

View File

@@ -22,7 +22,7 @@ export const useBrowserTabTitle = () => {
: `[${Math.round(executionStore.executionProgress * 100)}%]`
)
const newMenuEnabled = computed(
const isMenuBarActive = computed(
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
)
@@ -37,13 +37,12 @@ export const useBrowserTabTitle = () => {
() => !!workflowStore.activeWorkflow?.isPersisted
)
const shouldShowUnsavedIndicator = computed(() => {
if (workspaceStore.shiftDown) return false
if (isAutoSaveEnabled.value) return false
if (!isActiveWorkflowPersisted.value) return true
if (isActiveWorkflowModified.value) return true
return false
})
const shouldShowUnsavedIndicator = computed(
() =>
!workspaceStore.shiftDown &&
!isAutoSaveEnabled.value &&
(!isActiveWorkflowPersisted.value || isActiveWorkflowModified.value)
)
const isUnsavedText = computed(() =>
shouldShowUnsavedIndicator.value ? ' *' : ''
@@ -87,7 +86,7 @@ export const useBrowserTabTitle = () => {
const workflowTitle = computed(
() =>
executionText.value +
(newMenuEnabled.value ? workflowNameText.value : DEFAULT_TITLE)
(isMenuBarActive.value ? workflowNameText.value : DEFAULT_TITLE)
)
const title = computed(() => nodeExecutionTitle.value || workflowTitle.value)

View File

@@ -48,7 +48,7 @@ export function useCachedRequest<TParams, TResult>(
return result
} catch (err) {
// Set cache on error to prevent retrying bad requests
console.warn('Cached request failed, caching null result:', err)
cache.set(cacheKey, null)
return null
} finally {
@@ -86,11 +86,11 @@ export function useCachedRequest<TParams, TResult>(
/**
* Cached version of the request function
*/
const call = async (params: TParams): Promise<TResult | null> => {
const call = (params: TParams): Promise<TResult | null> => {
const cacheKey = cacheKeyFn(params)
const cachedResult = cache.get(cacheKey)
if (cachedResult !== undefined) return cachedResult
if (cachedResult !== undefined) return Promise.resolve(cachedResult)
const pendingRequest = pendingRequests.get(cacheKey)
if (pendingRequest) return handlePendingRequest(pendingRequest)

View File

@@ -1,8 +1,7 @@
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { useSubgraphOperations } from '@/composables/graph/useSubgraphOperations'
import { useExternalLink } from '@/composables/useExternalLink'
import { useHelpCommands } from '@/composables/useHelpCommands'
import { useModelSelectorDialog } from '@/composables/useModelSelectorDialog'
import {
DEFAULT_DARK_COLOR_PALETTE,
@@ -22,7 +21,6 @@ import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import { createModelNodeFromAsset } from '@/platform/assets/utils/createModelNodeFromAsset'
import { useSettingStore } from '@/platform/settings/settingStore'
import { buildSupportUrl } from '@/platform/support/config'
import { useTelemetry } from '@/platform/telemetry'
import type { ExecutionTriggerSource } from '@/platform/telemetry/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
@@ -83,7 +81,6 @@ export function useCoreCommands(): ComfyCommand[] {
const canvasStore = useCanvasStore()
const executionStore = useExecutionStore()
const telemetry = useTelemetry()
const { staticUrls, buildDocsUrl } = useExternalLink()
const settingStore = useSettingStore()
const bottomPanelStore = useBottomPanelStore()
@@ -775,51 +772,7 @@ export function useCoreCommands(): ComfyCommand[] {
}
}
},
{
id: 'Comfy.Help.OpenComfyUIIssues',
icon: 'pi pi-github',
label: 'Open ComfyUI Issues',
menubarLabel: 'ComfyUI Issues',
versionAdded: '1.5.5',
function: () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'github',
is_external: true,
source: 'menu'
})
window.open(staticUrls.githubIssues, '_blank')
}
},
{
id: 'Comfy.Help.OpenComfyUIDocs',
icon: 'pi pi-info-circle',
label: 'Open ComfyUI Docs',
menubarLabel: 'ComfyUI Docs',
versionAdded: '1.5.5',
function: () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'docs',
is_external: true,
source: 'menu'
})
window.open(buildDocsUrl('/', { includeLocale: true }), '_blank')
}
},
{
id: 'Comfy.Help.OpenComfyOrgDiscord',
icon: 'pi pi-discord',
label: 'Open Comfy-Org Discord',
menubarLabel: 'Comfy-Org Discord',
versionAdded: '1.5.5',
function: () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'discord',
is_external: true,
source: 'menu'
})
window.open(staticUrls.discord, '_blank')
}
},
...useHelpCommands(),
{
id: 'Workspace.SearchBox.Toggle',
icon: 'pi pi-search',
@@ -829,16 +782,6 @@ export function useCoreCommands(): ComfyCommand[] {
useSearchBoxStore().toggleVisible()
}
},
{
id: 'Comfy.Help.AboutComfyUI',
icon: 'pi pi-info-circle',
label: 'Open About ComfyUI',
menubarLabel: 'About ComfyUI',
versionAdded: '1.6.4',
function: () => {
settingsDialog.showAbout()
}
},
{
id: 'Comfy.DuplicateWorkflow',
icon: 'pi pi-clone',
@@ -858,35 +801,6 @@ export function useCoreCommands(): ComfyCommand[] {
await workflowService.closeWorkflow(workflowStore.activeWorkflow)
}
},
{
id: 'Comfy.ContactSupport',
icon: 'pi pi-question',
label: 'Contact Support',
versionAdded: '1.17.8',
function: () => {
const { userEmail, resolvedUserInfo } = useCurrentUser()
const supportUrl = buildSupportUrl({
userEmail: userEmail.value,
userId: resolvedUserInfo.value?.id
})
window.open(supportUrl, '_blank', 'noopener,noreferrer')
}
},
{
id: 'Comfy.Help.OpenComfyUIForum',
icon: 'pi pi-comments',
label: 'Open ComfyUI Forum',
menubarLabel: 'ComfyUI Forum',
versionAdded: '1.8.2',
function: () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'menu'
})
window.open(staticUrls.forum, '_blank')
}
},
{
id: 'Comfy.Canvas.CopySelected',
icon: 'icon-[lucide--copy]',
@@ -1263,7 +1177,29 @@ export function useCoreCommands(): ComfyCommand[] {
})
return
}
await api.freeMemory({ freeExecutionCache: false })
try {
const res = await api.freeMemory({ freeExecutionCache: false })
if (res.status === 200) {
useToastStore().add({
severity: 'success',
summary: 'Models have been unloaded.',
life: 3000
})
} else {
useToastStore().add({
severity: 'error',
summary:
'Unloading of models failed. Installed ComfyUI may be an outdated version.',
life: 5000
})
}
} catch {
useToastStore().add({
severity: 'error',
summary: 'An error occurred while trying to unload models.',
life: 5000
})
}
}
},
{
@@ -1283,7 +1219,29 @@ export function useCoreCommands(): ComfyCommand[] {
})
return
}
await api.freeMemory({ freeExecutionCache: true })
try {
const res = await api.freeMemory({ freeExecutionCache: true })
if (res.status === 200) {
useToastStore().add({
severity: 'success',
summary: 'Models and Execution Cache have been cleared.',
life: 3000
})
} else {
useToastStore().add({
severity: 'error',
summary:
'Unloading of models failed. Installed ComfyUI may be an outdated version.',
life: 5000
})
}
} catch {
useToastStore().add({
severity: 'error',
summary: 'An error occurred while trying to unload models.',
life: 5000
})
}
}
},
{

View File

@@ -5,23 +5,7 @@ import { electronAPI } from '@/utils/envUtil'
import { i18n } from '@/i18n'
/**
* Composable for building docs.comfy.org URLs with automatic locale and platform detection
*
* @example
* ```ts
* const { buildDocsUrl } = useExternalLink()
*
* // Simple usage
* const changelogUrl = buildDocsUrl('/changelog', { includeLocale: true })
* // => 'https://docs.comfy.org/zh-CN/changelog' (if Chinese)
*
* // With platform detection
* const desktopUrl = buildDocsUrl('/installation/desktop', {
* includeLocale: true,
* platform: true
* })
* // => 'https://docs.comfy.org/zh-CN/installation/desktop/macos' (if Chinese + macOS)
* ```
* Composable for building docs.comfy.org URLs with automatic locale and platform detection.
*/
export function useExternalLink() {
const locale = computed(() => String(i18n.global.locale.value))

View File

@@ -1,3 +1,13 @@
import type { ContextMenu as ContextMenuType } from '@/lib/litegraph/src/ContextMenu'
import type { DragAndScale as DragAndScaleType } from '@/lib/litegraph/src/DragAndScale'
import type { LGraph as LGraphType } from '@/lib/litegraph/src/LGraph'
import type { LGraphBadge as LGraphBadgeType } from '@/lib/litegraph/src/LGraphBadge'
import type { LGraphCanvas as LGraphCanvasType } from '@/lib/litegraph/src/LGraphCanvas'
import type { LGraphGroup as LGraphGroupType } from '@/lib/litegraph/src/LGraphGroup'
import type { LGraphNode as LGraphNodeType } from '@/lib/litegraph/src/LGraphNode'
import type { LiteGraphGlobal } from '@/lib/litegraph/src/LiteGraphGlobal'
import type { LLink as LLinkType } from '@/lib/litegraph/src/LLink'
import {
ContextMenu,
DragAndScale,
@@ -10,26 +20,31 @@ import {
LiteGraph
} from '@/lib/litegraph/src/litegraph'
declare global {
interface Window {
LiteGraph: LiteGraphGlobal
LGraph: typeof LGraphType
LLink: typeof LLinkType
LGraphNode: typeof LGraphNodeType
LGraphGroup: typeof LGraphGroupType
DragAndScale: typeof DragAndScaleType
LGraphCanvas: typeof LGraphCanvasType
ContextMenu: typeof ContextMenuType
LGraphBadge: typeof LGraphBadgeType
}
}
/**
* Assign all properties of LiteGraph to window to make it backward compatible.
*/
export const useGlobalLitegraph = () => {
// @ts-expect-error fixme ts strict error
window['LiteGraph'] = LiteGraph
// @ts-expect-error fixme ts strict error
window['LGraph'] = LGraph
// @ts-expect-error fixme ts strict error
window['LLink'] = LLink
// @ts-expect-error fixme ts strict error
window['LGraphNode'] = LGraphNode
// @ts-expect-error fixme ts strict error
window['LGraphGroup'] = LGraphGroup
// @ts-expect-error fixme ts strict error
window['DragAndScale'] = DragAndScale
// @ts-expect-error fixme ts strict error
window['LGraphCanvas'] = LGraphCanvas
// @ts-expect-error fixme ts strict error
window['ContextMenu'] = ContextMenu
// @ts-expect-error fixme ts strict error
window['LGraphBadge'] = LGraphBadge
export function useGlobalLitegraph() {
window.LiteGraph = LiteGraph
window.LGraph = LGraph
window.LLink = LLink
window.LGraphNode = LGraphNode
window.LGraphGroup = LGraphGroup
window.DragAndScale = DragAndScale
window.LGraphCanvas = LGraphCanvas
window.ContextMenu = ContextMenu
window.LGraphBadge = LGraphBadge
}

View File

@@ -11,7 +11,7 @@ const clipboardHTMLWrapper = [
/**
* Adds a handler on copy that serializes selected nodes to JSON
*/
export const useCopy = () => {
export const useGraphCopyHandler = () => {
const canvasStore = useCanvasStore()
useEventListener(document, 'copy', (e) => {

View File

@@ -0,0 +1,90 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useHelpCommands } from '@/composables/useHelpCommands'
const mockLocale = ref('en')
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual('vue-i18n')
return {
...actual,
useI18n: vi.fn(() => ({
locale: mockLocale
}))
}
})
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({}))
}))
vi.mock('@/composables/auth/useFirebaseAuth', () => ({
useFirebaseAuth: vi.fn(() => null)
}))
vi.mock('firebase/auth', () => ({
setPersistence: vi.fn(),
browserLocalPersistence: {},
onAuthStateChanged: vi.fn()
}))
vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
useSettingsDialog: vi.fn(() => ({
showAbout: vi.fn()
}))
}))
describe('useHelpCommands', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('returns all help command objects', () => {
const commands = useHelpCommands()
expect(commands.length).toBe(6)
})
it('includes expected command IDs', () => {
const commands = useHelpCommands()
const ids = commands.map((c) => c.id)
expect(ids).toContain('Comfy.Help.OpenComfyUIIssues')
expect(ids).toContain('Comfy.Help.OpenComfyUIDocs')
expect(ids).toContain('Comfy.Help.OpenComfyOrgDiscord')
expect(ids).toContain('Comfy.Help.AboutComfyUI')
expect(ids).toContain('Comfy.Help.OpenComfyUIForum')
expect(ids).toContain('Comfy.ContactSupport')
})
it('all commands have required function property', () => {
const commands = useHelpCommands()
for (const command of commands) {
expect(typeof command.function).toBe('function')
}
})
it('all commands have labels and icons', () => {
const commands = useHelpCommands()
for (const command of commands) {
expect(command.label).toBeTruthy()
expect(command.icon).toBeTruthy()
}
})
it('opens external URLs when help commands are invoked', () => {
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
const commands = useHelpCommands()
const issuesCommand = commands.find(
(c) => c.id === 'Comfy.Help.OpenComfyUIIssues'
)!
void issuesCommand.function()
expect(openSpy).toHaveBeenCalledWith(
expect.any(String),
'_blank',
'noopener,noreferrer'
)
openSpy.mockRestore()
})
})

View File

@@ -0,0 +1,103 @@
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useExternalLink } from '@/composables/useExternalLink'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { buildSupportUrl } from '@/platform/support/config'
import { useTelemetry } from '@/platform/telemetry'
import type { ComfyCommand } from '@/stores/commandStore'
export function useHelpCommands(): ComfyCommand[] {
const telemetry = useTelemetry()
const { staticUrls, buildDocsUrl } = useExternalLink()
const settingsDialog = useSettingsDialog()
return [
{
id: 'Comfy.Help.OpenComfyUIIssues',
icon: 'pi pi-github',
label: 'Open ComfyUI Issues',
menubarLabel: 'ComfyUI Issues',
versionAdded: '1.5.5',
function: () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'github',
is_external: true,
source: 'menu'
})
window.open(staticUrls.githubIssues, '_blank', 'noopener,noreferrer')
}
},
{
id: 'Comfy.Help.OpenComfyUIDocs',
icon: 'pi pi-info-circle',
label: 'Open ComfyUI Docs',
menubarLabel: 'ComfyUI Docs',
versionAdded: '1.5.5',
function: () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'docs',
is_external: true,
source: 'menu'
})
window.open(
buildDocsUrl('/', { includeLocale: true }),
'_blank',
'noopener,noreferrer'
)
}
},
{
id: 'Comfy.Help.OpenComfyOrgDiscord',
icon: 'pi pi-discord',
label: 'Open Comfy-Org Discord',
menubarLabel: 'Comfy-Org Discord',
versionAdded: '1.5.5',
function: () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'discord',
is_external: true,
source: 'menu'
})
window.open(staticUrls.discord, '_blank', 'noopener,noreferrer')
}
},
{
id: 'Comfy.Help.AboutComfyUI',
icon: 'pi pi-info-circle',
label: 'Open About ComfyUI',
menubarLabel: 'About ComfyUI',
versionAdded: '1.6.4',
function: () => {
settingsDialog.showAbout()
}
},
{
id: 'Comfy.Help.OpenComfyUIForum',
icon: 'pi pi-comments',
label: 'Open ComfyUI Forum',
menubarLabel: 'ComfyUI Forum',
versionAdded: '1.8.2',
function: () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'menu'
})
window.open(staticUrls.forum, '_blank', 'noopener,noreferrer')
}
},
{
id: 'Comfy.ContactSupport',
icon: 'pi pi-question',
label: 'Contact Support',
versionAdded: '1.17.8',
function: () => {
const { userEmail, resolvedUserInfo } = useCurrentUser()
const supportUrl = buildSupportUrl({
userEmail: userEmail.value,
userId: resolvedUserInfo.value?.id
})
window.open(supportUrl, '_blank', 'noopener,noreferrer')
}
}
]
}

View File

@@ -56,16 +56,13 @@ export function useLazyPagination<T>(
return Math.ceil(itemData.length / itemsPerPage)
})
const loadNextPage = async () => {
const loadNextPage = () => {
if (isLoading.value || !hasMoreItems.value) return
isLoading.value = true
const loadedPagesArray = Array.from(loadedPages.value)
const nextPage = Math.max(...loadedPagesArray, 0) + 1
// Simulate network delay
// await new Promise((resolve) => setTimeout(resolve, 5000))
const newLoadedPages = new Set(loadedPages.value)
newLoadedPages.add(nextPage)
loadedPages.value = newLoadedPages

View File

@@ -35,13 +35,11 @@ function progressPercentStyle(value: ProgressPercent) {
return { width: `${normalized}%` }
}
export function useProgressBarBackground() {
return {
progressBarContainerClass,
progressBarPrimaryClass,
progressBarSecondaryClass,
hasProgressPercent,
hasAnyProgressPercent,
progressPercentStyle
}
export {
progressBarContainerClass,
progressBarPrimaryClass,
progressBarSecondaryClass,
hasProgressPercent,
hasAnyProgressPercent,
progressPercentStyle
}

View File

@@ -68,7 +68,7 @@ export function useTemplateFiltering(
template.models.forEach((model) => modelSet.add(model))
}
})
return Array.from(modelSet).sort()
return Array.from(modelSet).sort((a, b) => a.localeCompare(b))
})
const availableUseCases = computed(() => {
@@ -78,7 +78,7 @@ export function useTemplateFiltering(
template.tags.forEach((tag) => tagSet.add(tag))
}
})
return Array.from(tagSet).sort()
return Array.from(tagSet).sort((a, b) => a.localeCompare(b))
})
const availableRunsOn = computed(() => {

View File

@@ -1,18 +0,0 @@
/**
* Build a tooltip configuration object compatible with v-tooltip.
* Consumers pass the translated text value.
*/
export const buildTooltipConfig = (value: string) => ({
value,
showDelay: 300,
hideDelay: 0,
pt: {
text: {
class:
'border-node-component-tooltip-border bg-node-component-tooltip-surface text-node-component-tooltip border rounded-md px-2 py-1 text-xs leading-none shadow-none'
},
arrow: {
class: 'border-t-node-component-tooltip-border'
}
}
})

View File

@@ -1,48 +1,4 @@
import type { HintedString } from '@primevue/core'
import { computed } from 'vue'
/**
* Options for configuring transform-compatible overlay props
* Props to keep PrimeVue overlays within CSS-transformed parent elements.
*/
interface TransformCompatOverlayOptions {
/**
* Where to append the overlay. 'self' keeps overlay within component
* for proper transform inheritance, 'body' teleports to document body
*/
appendTo?: HintedString<'body' | 'self'> | undefined | HTMLElement
// Future: other props needed for transform compatibility
// scrollTarget?: string | HTMLElement
// autoZIndex?: boolean
}
/**
* Composable that provides props to make PrimeVue overlay components
* compatible with CSS-transformed parent elements.
*
* Vue nodes use CSS transforms for positioning/scaling. PrimeVue overlay
* components (Select, MultiSelect, TreeSelect, etc.) teleport to document
* body by default, breaking transform inheritance. This composable provides
* the necessary props to keep overlays within their component elements.
*
* @param overrides - Optional overrides for specific use cases
* @returns Computed props object to spread on PrimeVue overlay components
*
* @example
* ```vue
* <template>
* <Select v-bind="overlayProps" />
* </template>
*
* <script setup>
* const overlayProps = useTransformCompatOverlayProps()
* </script>
* ```
*/
export function useTransformCompatOverlayProps(
overrides: TransformCompatOverlayOptions = {}
) {
return computed(() => ({
appendTo: 'self' as const,
...overrides
}))
}
export const TRANSFORM_COMPAT_OVERLAY_PROPS = { appendTo: 'self' as const }

View File

@@ -1,27 +1,27 @@
import { computed, ref } from 'vue'
export function useZoomControls() {
const isModalVisible = ref(false)
const isPopoverOpen = ref(false)
const showModal = () => {
isModalVisible.value = true
const showPopover = () => {
isPopoverOpen.value = true
}
const hideModal = () => {
isModalVisible.value = false
const hidePopover = () => {
isPopoverOpen.value = false
}
const toggleModal = () => {
isModalVisible.value = !isModalVisible.value
const togglePopover = () => {
isPopoverOpen.value = !isPopoverOpen.value
}
const hasActivePopup = computed(() => isModalVisible.value)
const hasActivePopup = computed(() => isPopoverOpen.value)
return {
isModalVisible,
showModal,
hideModal,
toggleModal,
isPopoverOpen,
showPopover,
hidePopover,
togglePopover,
hasActivePopup
}
}

View File

@@ -1,11 +1,4 @@
/** Default panel size (%) for sidebar and builder panels */
export const SIDE_PANEL_SIZE = 20
/** Default panel size (%) for the center/main panel */
export const CENTER_PANEL_SIZE = 80
/** Minimum panel size (%) for the sidebar */
export const SIDEBAR_MIN_SIZE = 10
/** Minimum panel size (%) for the builder panel */
export const BUILDER_MIN_SIZE = 15

View File

@@ -1,10 +0,0 @@
/**
* Toolkit (Essentials) node detection constants.
*
* Re-exported from essentialsNodes.ts — the single source of truth.
* Used by telemetry to track toolkit node adoption and popularity.
*/
export {
TOOLKIT_NOVEL_NODE_NAMES as TOOLKIT_NODE_NAMES,
TOOLKIT_BLUEPRINT_MODULES
} from './essentialsNodes'

View File

@@ -1,19 +1,7 @@
interface UVMirror {
/**
* The setting id defined for the mirror.
*/
settingId: string
/**
* The default mirror to use.
*/
mirror: string
/**
* The fallback mirror to use.
*/
fallbackMirror: string
/**
* The path suffix to validate the mirror is reachable.
*/
validationPathSuffix?: string
}

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { isPromotedWidgetView } from './promotedWidgetTypes'
describe('isPromotedWidgetView', () => {
it('returns true for widgets with sourceNodeId and sourceWidgetName', () => {
const widget = {
name: 'test',
value: 0,
sourceNodeId: 'node-1',
sourceWidgetName: 'steps'
}
expect(isPromotedWidgetView(widget as never)).toBe(true)
})
it('returns false for regular widgets', () => {
const widget = { name: 'test', value: 0 }
expect(isPromotedWidgetView(widget as never)).toBe(false)
})
})

View File

@@ -228,7 +228,7 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
if (hasPreviewWidget()) continue
// Nodes in CANVAS_IMAGE_PREVIEW_NODE_TYPES support a virtual $$
// preview widget. Eagerly promote it so getPseudoWidgetPreviewTargets
// preview widget. Eagerly promote it so resolvePseudoWidgetPreviewTargets
// includes this node and onDrawBackground can call updatePreviews on it
// once execution outputs arrive.
if (supportsVirtualCanvasImagePreview(node)) {

View File

@@ -9,10 +9,17 @@ type ProxyWidgetsProperty = z.infer<typeof proxyWidgetsPropertySchema>
export function parseProxyWidgets(
property: NodeProperty | undefined
): ProxyWidgetsProperty {
if (typeof property === 'string') property = JSON.parse(property)
const result = proxyWidgetsPropertySchema.safeParse(
typeof property === 'string' ? JSON.parse(property) : property
)
let parsed = property
if (typeof parsed === 'string') {
try {
parsed = JSON.parse(parsed)
} catch {
throw new Error(
`Invalid assignment for properties.proxyWidgets:\nMalformed JSON string`
)
}
}
const result = proxyWidgetsPropertySchema.safeParse(parsed)
if (result.success) return result.data
const error = fromZodError(result.error)

View File

@@ -1,8 +1,8 @@
import { app } from '../../scripts/app'
import { ComfyApp } from '../../scripts/app'
import { $el, ComfyDialog } from '../../scripts/ui'
import { app } from '@/scripts/app'
import { ComfyApp } from '@/scripts/app'
import { $el, ComfyDialog } from '@/scripts/ui'
export class ClipspaceDialog extends ComfyDialog {
class ClipspaceDialog extends ComfyDialog {
static items: Array<
HTMLButtonElement & {
contextPredicate?: () => boolean
@@ -85,10 +85,8 @@ export class ClipspaceDialog extends ComfyDialog {
override createButtons() {
const buttons = []
for (let idx in ClipspaceDialog.items) {
const item = ClipspaceDialog.items[idx]
if (!item.contextPredicate || item.contextPredicate())
buttons.push(ClipspaceDialog.items[idx])
for (const item of ClipspaceDialog.items) {
if (!item.contextPredicate || item.contextPredicate()) buttons.push(item)
}
buttons.push(

View File

@@ -4,7 +4,7 @@ import {
isComboWidget
} from '@/lib/litegraph/src/litegraph'
import { app } from '../../scripts/app'
import { app } from '@/scripts/app'
// Adds filtering to combo context menus

View File

@@ -1,4 +1,4 @@
import { app } from '../../scripts/app'
import { app } from '@/scripts/app'
// Allows you to edit the attention weight by holding ctrl (or cmd) and using the up/down arrow keys

View File

@@ -27,8 +27,8 @@ import { ExecutableGroupNodeChildDTO } from '@/utils/executableGroupNodeChildDTO
import { GROUP } from '@/utils/executableGroupNodeDto'
import { deserialiseAndCreate, serialise } from '@/utils/vintageClipboard'
import { api } from '../../scripts/api'
import { app } from '../../scripts/app'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { ManageGroupDialog } from './groupNodeManage'
import { mergeIfValid } from './widgetInputs'
@@ -732,7 +732,7 @@ export class GroupNodeConfig {
) {
// Add converted widgets sorted into their index order (ordered as they were converted) so link ids match up
const convertedSlots = [...converted.keys()]
.sort()
.sort((a, b) => a - b)
.map((k) => converted.get(k))
for (let i = 0; i < convertedSlots.length; i++) {
const inputName = convertedSlots[i]

View File

@@ -8,11 +8,16 @@ import type {
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { type ComfyApp, app } from '../../scripts/app'
import { $el } from '../../scripts/ui'
import { ComfyDialog } from '../../scripts/ui/dialog'
import { DraggableList } from '../../scripts/ui/draggableList'
import { GroupNodeConfig, GroupNodeHandler } from './groupNode'
import type { ComfyApp } from '@/scripts/app'
import { app } from '@/scripts/app'
import { $el } from '@/scripts/ui'
import { ComfyDialog } from '@/scripts/ui/dialog'
import { DraggableList } from '@/scripts/ui/draggableList'
import type { GroupNodeConfig } from './groupNode'
// Lazy import to break circular dependency with groupNode.ts
// These are only called at runtime (in methods), not at module init time.
const lazyGroupNode = () => import('./groupNode')
import './groupNodeManage.css'
const ORDER: symbol = Symbol()
@@ -112,16 +117,17 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
this.changeTab(this.selectedTab)
}
getGroupData() {
async getGroupData() {
this.groupNodeType = LiteGraph.registered_node_types[
`${PREFIX}${SEPARATOR}` + this.selectedGroup
] as unknown as LGraphNodeConstructor<LGraphNode>
const { GroupNodeHandler } = await lazyGroupNode()
this.groupData = GroupNodeHandler.getGroupData(this.groupNodeType)!
}
changeGroup(group: string, reset = true): void {
async changeGroup(group: string, reset = true): Promise<void> {
this.selectedGroup = group
this.getGroupData()
await this.getGroupData()
const nodes = this.groupData.nodeData.nodes
this.nodeItems = nodes.map(
@@ -344,7 +350,7 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
return !!outputs.length
}
override show(groupNodeType?: string | HTMLElement | HTMLElement[]): void {
override show(groupNodeType?: string | HTMLElement | HTMLElement[]) {
// Extract string type - this method repurposes the show signature
const nodeType =
typeof groupNodeType === 'string' ? groupNodeType : undefined
@@ -392,8 +398,8 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
$el(
'select',
{
onchange: (e: Event) => {
this.changeGroup((e.target as HTMLSelectElement).value)
onchange: async (e: Event) => {
await this.changeGroup((e.target as HTMLSelectElement).value)
}
},
groupNodes.map((g) =>
@@ -539,6 +545,7 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
if (groupTypeNodes) recreateNodes.push(...groupTypeNodes)
}
const { GroupNodeConfig } = await lazyGroupNode()
await GroupNodeConfig.registerFromWorkflow(types, [])
for (const node of recreateNodes) {
@@ -547,7 +554,7 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
this.modifications = {}
this.app.canvas.setDirty(true, true)
this.changeGroup(this.selectedGroup!, false)
await this.changeGroup(this.selectedGroup!, false)
}
},
'Save'
@@ -561,13 +568,13 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
])
this.element.replaceChildren(outer)
this.changeGroup(
this.element.showModal()
void this.changeGroup(
nodeType
? (groupNodes.find((g) => `${PREFIX}${SEPARATOR}${g}` === nodeType) ??
groupNodes[0])
: groupNodes[0]
)
this.element.showModal()
this.element.addEventListener('close', () => {
this.draggable?.dispose()

View File

@@ -2,15 +2,12 @@ import type {
IContextMenuValue,
Positionable
} from '@/lib/litegraph/src/interfaces'
import {
LGraphCanvas,
LGraphGroup,
type LGraphNode
} from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphCanvas, LGraphGroup } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { ComfyExtension } from '@/types/comfy'
import { app } from '../../scripts/app'
import { app } from '@/scripts/app'
function setNodeMode(node: LGraphNode, mode: number) {
node.mode = mode

View File

@@ -2,7 +2,10 @@ import { nextTick } from 'vue'
import Load3D from '@/components/load3d/Load3D.vue'
import Load3DViewerContent from '@/components/load3d/Load3dViewerContent.vue'
import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
import {
nodeToLoad3dMap,
useLoad3d
} from '@/extensions/core/load3d/composables/useLoad3d'
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
import type {
CameraConfig,

View File

@@ -64,9 +64,7 @@ export class AnimationManager implements AnimationManagerInterface {
this.currentAnimation = new THREE.AnimationMixer(model)
if (this.animationClips.length > 0) {
this.updateSelectedAnimation(0)
}
this.updateSelectedAnimation(0)
} else {
this.animationClips = []
}

View File

@@ -73,11 +73,9 @@ export class CameraManager implements CameraManagerInterface {
setControls(controls: OrbitControls): void {
this.controls = controls
if (this.controls) {
this.controls.addEventListener('end', () => {
this.eventManager.emitEvent('cameraChanged', this.getCameraState())
})
}
this.controls.addEventListener('end', () => {
this.eventManager.emitEvent('cameraChanged', this.getCameraState())
})
}
getCurrentCameraType(): CameraType {
@@ -117,13 +115,11 @@ export class CameraManager implements CameraManagerInterface {
this.activeCamera.position.copy(position)
this.activeCamera.rotation.copy(rotation)
if (this.activeCamera instanceof THREE.OrthographicCamera) {
this.activeCamera.zoom = oldZoom
this.activeCamera.updateProjectionMatrix()
} else if (this.activeCamera instanceof THREE.PerspectiveCamera) {
this.activeCamera.zoom = oldZoom
this.activeCamera.updateProjectionMatrix()
}
const cam = this.activeCamera as
| THREE.PerspectiveCamera
| THREE.OrthographicCamera
cam.zoom = oldZoom
cam.updateProjectionMatrix()
if (this.controls) {
this.controls.object = this.activeCamera
@@ -160,13 +156,11 @@ export class CameraManager implements CameraManagerInterface {
this.controls?.target.copy(state.target)
if (this.activeCamera instanceof THREE.OrthographicCamera) {
this.activeCamera.zoom = state.zoom
this.activeCamera.updateProjectionMatrix()
} else if (this.activeCamera instanceof THREE.PerspectiveCamera) {
this.activeCamera.zoom = state.zoom
this.activeCamera.updateProjectionMatrix()
}
const cam = this.activeCamera as
| THREE.PerspectiveCamera
| THREE.OrthographicCamera
cam.zoom = state.zoom
cam.updateProjectionMatrix()
this.controls?.update()
}

View File

@@ -29,10 +29,9 @@ export class ControlsManager implements ControlsManagerInterface {
const cameraState = {
position: this.camera.position.clone(),
target: this.controls.target.clone(),
zoom:
this.camera instanceof THREE.OrthographicCamera
? (this.camera as THREE.OrthographicCamera).zoom
: (this.camera as THREE.PerspectiveCamera).zoom,
zoom: (
this.camera as THREE.PerspectiveCamera | THREE.OrthographicCamera
).zoom,
cameraType:
this.camera instanceof THREE.PerspectiveCamera
? 'perspective'

View File

@@ -7,7 +7,6 @@ import type {
ModelConfig,
SceneConfig
} from '@/extensions/core/load3d/interfaces'
import type { Dictionary } from '@/lib/litegraph/src/interfaces'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -25,7 +24,7 @@ type Load3DConfigurationSettings = {
class Load3DConfiguration {
constructor(
private load3d: Load3d,
private properties?: Dictionary<NodeProperty | undefined>
private properties?: Record<string, NodeProperty | undefined>
) {}
configureForSaveMesh(loadFolder: 'input' | 'output', filePath: string) {

View File

@@ -268,6 +268,36 @@ class Load3d {
return this.isViewerMode || (this.targetWidth > 0 && this.targetHeight > 0)
}
private calculateLetterbox(
containerWidth: number,
containerHeight: number
): {
renderWidth: number
renderHeight: number
offsetX: number
offsetY: number
} {
const containerAspectRatio = containerWidth / containerHeight
if (containerAspectRatio > this.targetAspectRatio) {
const renderHeight = containerHeight
const renderWidth = renderHeight * this.targetAspectRatio
return {
renderWidth,
renderHeight,
offsetX: (containerWidth - renderWidth) / 2,
offsetY: 0
}
}
const renderWidth = containerWidth
const renderHeight = renderWidth / this.targetAspectRatio
return {
renderWidth,
renderHeight,
offsetX: 0,
offsetY: (containerHeight - renderHeight) / 2
}
}
forceRender(): void {
const delta = this.clock.getDelta()
this.animationManager.update(delta)
@@ -299,22 +329,8 @@ class Load3d {
}
if (this.shouldMaintainAspectRatio()) {
const containerAspectRatio = containerWidth / containerHeight
let renderWidth: number
let renderHeight: number
let offsetX: number = 0
let offsetY: number = 0
if (containerAspectRatio > this.targetAspectRatio) {
renderHeight = containerHeight
renderWidth = renderHeight * this.targetAspectRatio
offsetX = (containerWidth - renderWidth) / 2
} else {
renderWidth = containerWidth
renderHeight = renderWidth / this.targetAspectRatio
offsetY = (containerHeight - renderHeight) / 2
}
const { renderWidth, renderHeight, offsetX, offsetY } =
this.calculateLetterbox(containerWidth, containerHeight)
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
this.renderer.setScissor(0, 0, containerWidth, containerHeight)
@@ -325,8 +341,7 @@ class Load3d {
this.renderer.setViewport(offsetX, offsetY, renderWidth, renderHeight)
this.renderer.setScissor(offsetX, offsetY, renderWidth, renderHeight)
const renderAspectRatio = renderWidth / renderHeight
this.cameraManager.updateAspectRatio(renderAspectRatio)
this.cameraManager.updateAspectRatio(renderWidth / renderHeight)
} else {
// No aspect ratio constraint: fill the entire container
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
@@ -436,7 +451,7 @@ class Load3d {
await ModelExporter.exportOBJ(model, filename, originalURL)
break
case 'stl':
;(await ModelExporter.exportSTL(model, filename), originalURL)
await ModelExporter.exportSTL(model, filename, originalURL)
break
default:
throw new Error(`Unsupported export format: ${format}`)
@@ -468,18 +483,10 @@ class Load3d {
const containerHeight = this.renderer.domElement.clientHeight
if (this.shouldMaintainAspectRatio()) {
const containerAspectRatio = containerWidth / containerHeight
let renderWidth: number
let renderHeight: number
if (containerAspectRatio > this.targetAspectRatio) {
renderHeight = containerHeight
renderWidth = renderHeight * this.targetAspectRatio
} else {
renderWidth = containerWidth
renderHeight = renderWidth / this.targetAspectRatio
}
const { renderWidth, renderHeight } = this.calculateLetterbox(
containerWidth,
containerHeight
)
this.sceneManager.updateBackgroundSize(
this.sceneManager.backgroundTexture,
@@ -558,7 +565,9 @@ class Load3d {
if (this.loadingPromise) {
try {
await this.loadingPromise
} catch (e) {}
} catch {
// Previous load error is irrelevant — we only need to wait for it to settle
}
}
this.loadingPromise = this._loadModelInternal(url, originalFileName)
@@ -653,17 +662,10 @@ class Load3d {
}
if (this.shouldMaintainAspectRatio()) {
const containerAspectRatio = containerWidth / containerHeight
let renderWidth: number
let renderHeight: number
if (containerAspectRatio > this.targetAspectRatio) {
renderHeight = containerHeight
renderWidth = renderHeight * this.targetAspectRatio
} else {
renderWidth = containerWidth
renderHeight = renderWidth / this.targetAspectRatio
}
const { renderWidth, renderHeight } = this.calculateLetterbox(
containerWidth,
containerHeight
)
this.renderer.setSize(containerWidth, containerHeight)
this.cameraManager.handleResize(renderWidth, renderHeight)

View File

@@ -2,7 +2,8 @@ import type Load3d from '@/extensions/core/load3d/Load3d'
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { getResourceURL, splitFilePath } from '@/utils/resourceUrl'
class Load3dUtils {
static async generateThumbnailIfNeeded(
@@ -114,31 +115,9 @@ class Load3dUtils {
return uploadPath
}
static splitFilePath(path: string): [string, string] {
const folder_separator = path.lastIndexOf('/')
if (folder_separator === -1) {
return ['', path]
}
return [
path.substring(0, folder_separator),
path.substring(folder_separator + 1)
]
}
static splitFilePath = splitFilePath
static getResourceURL(
subfolder: string,
filename: string,
type: string = 'input'
): string {
const params = [
'filename=' + encodeURIComponent(filename),
'type=' + type,
'subfolder=' + subfolder,
app.getRandParam().substring(1)
].join('&')
return `/view?${params}`
}
static getResourceURL = getResourceURL
static async uploadMultipleFiles(files: FileList, subfolder: string = '3d') {
const uploadPromises = Array.from(files).map((file) =>

View File

@@ -39,7 +39,7 @@ export class ModelExporter {
try {
const response = await fetch(url)
const blob = await response.blob()
downloadBlob(desiredFilename, blob)
downloadBlob(blob, desiredFilename)
} catch (error) {
console.error('Error downloading from URL:', error)
useToastStore().addAlert(t('toastMessages.failedToDownloadFile'))
@@ -147,11 +147,11 @@ export class ModelExporter {
private static saveArrayBuffer(buffer: ArrayBuffer, filename: string): void {
const blob = new Blob([buffer], { type: 'application/octet-stream' })
downloadBlob(filename, blob)
downloadBlob(blob, filename)
}
private static saveString(text: string, filename: string): void {
const blob = new Blob([text], { type: 'text/plain' })
downloadBlob(filename, blob)
downloadBlob(blob, filename)
}
}

View File

@@ -225,7 +225,7 @@ export class RecordingManager {
try {
const blob = new Blob(this.recordedChunks, { type: 'video/webm' })
downloadBlob(filename, blob)
downloadBlob(blob, filename)
this.eventManager.emitEvent('recordingExported', null)
} catch (error) {

View File

@@ -1,7 +1,7 @@
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import Load3dUtils from './Load3dUtils'
import { getResourceURL, splitFilePath } from '@/utils/resourceUrl'
import {
type BackgroundRenderModeType,
type EventManagerInterface,
@@ -148,7 +148,7 @@ export class SceneManager implements SceneManagerInterface {
this.eventManager.emitEvent('backgroundImageLoadingStart', null)
let type = 'input'
let pathParts = Load3dUtils.splitFilePath(uploadPath)
let pathParts = splitFilePath(uploadPath)
let subfolder = pathParts[0]
let filename = pathParts[1]
@@ -160,7 +160,7 @@ export class SceneManager implements SceneManagerInterface {
pathParts = ['', filename]
}
let imageUrl = Load3dUtils.getResourceURL(...pathParts, type)
let imageUrl = getResourceURL(...pathParts, type)
if (!imageUrl.startsWith('/api')) {
imageUrl = '/api' + imageUrl

View File

@@ -92,13 +92,8 @@ export class ViewHelperManager implements ViewHelperManagerInterface {
handleResize(): void {}
visibleViewHelper(visible: boolean) {
if (visible) {
this.viewHelper.visible = true
this.viewHelperContainer.style.display = 'block'
} else {
this.viewHelper.visible = false
this.viewHelperContainer.style.display = 'none'
}
this.viewHelper.visible = visible
this.viewHelperContainer.style.display = visible ? 'block' : 'none'
}
recreateViewHelper(): void {

View File

@@ -1,7 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref, shallowRef } from 'vue'
import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
import {
nodeToLoad3dMap,
useLoad3d
} from '@/extensions/core/load3d/composables/useLoad3d'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import type { Size } from '@/lib/litegraph/src/interfaces'

Some files were not shown because too many files have changed in this diff Show More