Compare commits

..

37 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
0b73285ca1 fix: extract and harden subgraph node ID deduplication (#9510)
## Summary

Extract and harden subgraph node ID deduplication to prevent widget
store key collisions when multiple subgraph copies share identical node
IDs.

## Changes

- **What**: Extract `deduplicateSubgraphNodeIds` from `LGraph.ts` into
`utils/subgraphDeduplication.ts`, decomposed into focused helpers
(`remapNodeIds`, `findNextAvailableId`, `patchSerialisedLinks`,
`patchPromotedWidgets`, `patchProxyWidgets`). Clone inputs internally so
caller data is never mutated. Add safety limit on ID search to prevent
unbounded loops. Add `console.warn` on remapped IDs matching existing
`ensureGlobalIdUniqueness` behavior. Add test fixture and 5 behavioral
tests covering ID remapping, link patching, promoted widget patching,
proxyWidget patching, and no-op when IDs are unique.

## Review Focus

- The cloning strategy in `deduplicateSubgraphNodeIds` — it
`structuredClone`s subgraphs and rootNodes, returning the clones. The
caller uses `effectiveNodesData` to thread the patched root nodes
through to node creation.
- The `MAX_NODE_ID` safety limit (100M) — is this a reasonable ceiling?

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9510-fix-extract-and-harden-subgraph-node-ID-deduplication-31b6d73d365081f48c7de75e2bfc48b3)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-03-06 21:56:56 +00:00
AustinMroz
7a01be388f More app fixes (#9432)
- Increased the z-index on app mode outputs so that they display above a
zoomed image
- The "view job" button on the job queued toast in mobile app mode will
take you to outputs instead of assets
- Image previews now have a minimum zoom of ~20% and a maximum zoom of
~50x
- The enter panel in linear mode now has a minimum size of ~1/5th screen
size
- In arrange mode, dragging to rearrange inputs will no longer cause a
horizontal scrollbar to appear.
- Videos will now display the first frame instead of a generic video
icon
- Muted/Bypassed nodes can no longer be selected as inputs/outputs, or
be displayed when in app mode.
- Linked input can no longer be selected or displayed
- Adds a share workflow button in app mode and wires up the existing
context menu

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9432-More-app-fixes-31a6d73d365081509cd0ea74bfdc9b95)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-06 13:41:52 -08:00
pythongosssss
3ddff9f7b6 feat: Update workflow menu to allow quick toggling modes (#9436)
## Summary

Adds a quick toggle mode button to the workflow menu for users to easier
discover & change modes

## Changes

- **What**: 
- remove specific app mode rendering
- increase spacing around breadcrumbs menu
- add current mode text to menu
- add base button variant

## Screenshots (if applicable)

<img width="258" height="137" alt="image"
src="https://github.com/user-attachments/assets/2ed7b276-c52c-44cd-b107-399f769574af"
/>
<img width="233" height="172" alt="image"
src="https://github.com/user-attachments/assets/2639d30c-2150-4434-a86b-732649c4b142"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9436-feat-Update-workflow-menu-to-allow-quick-toggling-modes-31a6d73d365081b589eee0e03cd6f1de)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-03-06 20:03:02 +00:00
pythongosssss
4ff14b5eb9 feat/fix: App mode QA updates (#9439)
## Summary

Various fixes from app mode QA

## Changes

- **What**: 
- fix: prevent inserting nodes from workflow/apps sidebar tabs
- fix: hide json extension in workflow tab
- fix: hide apps nav button in apps tab when already in apps mode
- fix: center text on arrange page
- fix: prevent IoItems from "jumping" due to stale transform after drag
and drop op
- fix: refactor side panels and add custom stable pixel based sizing
- fix: make outputs/inputs lists in app builder scrollable
- fix: fix rerun not working correctly

- feat: add text to interrupt button
- feat: add enter app mode button to builder toolbar
- feat: add tooltip to download button on linear view
- feat: show last output of workflow in arrange tab if available
- feat: show download count in download all button, hide if only 1 asset
to download

## Review Focus

- Rerun - I am not sure why it was triggering widget actions, removing
it seemed like the correct fix
- useStablePrimeVueSplitter - this is a workaround for the fact it uses
percent sizing, I also tried switching to reka-ui splitters, but they
also only support % sizing in our version [pixel based looks to have
been added in a newer version, will log an issue to upgrade & replace
splitters with this]


## Screenshots (if applicable)

<img width="1314" height="1129" alt="image"
src="https://github.com/user-attachments/assets/c430f9d6-7c29-4853-803e-5b6fe7086fca"
/>
<img width="511" height="283" alt="image"
src="https://github.com/user-attachments/assets/b7e594d4-70a1-41e3-8ba1-78512f2a5c8b"
/>
<img width="254" height="232" alt="image"
src="https://github.com/user-attachments/assets/1d146399-39ea-4b0e-928c-340b74957535"
/>
<img width="487" height="198" alt="image"
src="https://github.com/user-attachments/assets/e2ba7f5d-8ff5-47f4-9526-61ebb99514b8"
/>
<img width="378" height="647" alt="image"
src="https://github.com/user-attachments/assets/a47a3054-9320-4327-bdc0-b0a16e19f83d"
/>
<img width="1016" height="476" alt="image"
src="https://github.com/user-attachments/assets/479ae50e-d380-4d56-a5c9-5df142b14ed0"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9439-feat-fix-App-mode-QA-updates-31a6d73d365081b38337d63207b88817)
by [Unito](https://www.unito.io)
2026-03-06 20:02:19 +00:00
pythongosssss
bae1081a08 fix: update loadWorkflowInMedia test to only assert upload request URL (#9488)
## Summary

Fixes flakey test to only assert that the upload request is made with
the correct URL

## Changes

- **What**
- Replace waitForResponse with waitForRequest for the no_workflow.webp
upload test to only assert the request is initiated with the correct URL
- Move request listener setup before the drag-drop action to avoid race
conditions
- Remove screenshot assertion for the upload case since the upload may
not complete before the screenshot is taken

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9488-fix-update-loadWorkflowInMedia-test-to-only-assert-upload-request-URL-31b6d73d365081f69a9aeb1095da7d60)
by [Unito](https://www.unito.io)
2026-03-06 11:38:53 -08:00
AustinMroz
55b8236c8d Fix localization on share and hide entry (#9395)
A placeholder share entry was added in #9368, but the localization for
this share label was then removed in #9361.

This localization is re-added in a location that is less likely to be
overwritten and the menu item is set to hidden. I'll manually connect it
to the workflow sharing feature flag in a followup PR after that has
been merged.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9395-Fix-localization-on-share-and-hide-entry-3196d73d36508146a343f625a5327bdd)
by [Unito](https://www.unito.io)
2026-03-06 09:35:18 -08:00
Johnpaul Chiwetelu
5e17bbbf85 feat: expose litegraph internal keybindings (#9459)
## Summary

Migrate hardcoded litegraph canvas keybindings (Ctrl+A/C/V, Delete,
Backspace) into the customizable keybinding system so users can remap
them via Settings > Keybindings.

## Changes

- **What**: Register Ctrl+A (SelectAll), Ctrl+C (CopySelected), Ctrl+V
(PasteFromClipboard), Ctrl+Shift+V (PasteFromClipboardWithConnect),
Delete/Backspace (DeleteSelectedItems) as core keybindings in
`defaults.ts`. Add new `PasteFromClipboardWithConnect` command. Remove
hardcoded handling from litegraph `processKey()`, the `app.ts` Ctrl+C/V
monkey-patch, and the `keybindingService` canvas forwarding logic.

Fixes #1082
Fixes #2015

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9459-feat-expose-litegraph-internal-keybindings-31b6d73d3650819a8499fd96c8a6678f)
by [Unito](https://www.unito.io)
2026-03-06 18:30:35 +01: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
289 changed files with 5882 additions and 3042 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -819,16 +819,13 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
const activeWorkflowName =
await comfyPage.menu.workflowsTab.getActiveWorkflowName()
const workflowPathA = `${workflowA}.json`
const workflowPathB = `${workflowB}.json`
expect(openWorkflows).toEqual(
expect.arrayContaining([workflowPathA, workflowPathB])
expect.arrayContaining([workflowA, workflowB])
)
expect(openWorkflows.indexOf(workflowPathA)).toBeLessThan(
openWorkflows.indexOf(workflowPathB)
expect(openWorkflows.indexOf(workflowA)).toBeLessThan(
openWorkflows.indexOf(workflowB)
)
expect(activeWorkflowName).toEqual(workflowPathB)
expect(activeWorkflowName).toEqual(workflowB)
})
})

View File

@@ -35,18 +35,21 @@ test.describe(
test(`Load workflow in ${fileName} (drop from filesystem)`, async ({
comfyPage
}) => {
const waitForUpload = filesWithUpload.has(fileName)
await comfyPage.dragDrop.dragAndDropFile(
`workflowInMedia/${fileName}`,
{ waitForUpload }
)
if (waitForUpload) {
await comfyPage.page.waitForResponse(
(resp) => resp.url().includes('/view') && resp.status() !== 0,
{ timeout: 10000 }
)
const shouldUpload = filesWithUpload.has(fileName)
const uploadRequestPromise = shouldUpload
? comfyPage.page.waitForRequest((req) =>
req.url().includes('/upload/')
)
: null
await comfyPage.dragDrop.dragAndDropFile(`workflowInMedia/${fileName}`)
if (uploadRequestPromise) {
const request = await uploadRequestPromise
expect(request.url()).toContain('/upload/')
} else {
await expect(comfyPage.canvas).toHaveScreenshot(`${fileName}.png`)
}
await expect(comfyPage.canvas).toHaveScreenshot(`${fileName}.png`)
})
})

View File

@@ -13,9 +13,9 @@ test.describe('Reroute Node', { tag: ['@screenshot', '@node'] }, () => {
})
test('loads from inserted workflow', async ({ comfyPage }) => {
const workflowName = 'single_connected_reroute_node.json'
const workflowName = 'single_connected_reroute_node'
await comfyPage.workflow.setupWorkflowsDirectory({
[workflowName]: 'links/single_connected_reroute_node.json'
[`${workflowName}.json`]: `links/${workflowName}.json`
})
await comfyPage.setup()
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -21,14 +21,12 @@ test.describe('Workflows sidebar', () => {
test('Can create new blank workflow', async ({ comfyPage }) => {
const tab = comfyPage.menu.workflowsTab
expect(await tab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json'
])
expect(await tab.getOpenedWorkflowNames()).toEqual(['*Unsaved Workflow'])
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
expect(await tab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json',
'*Unsaved Workflow (2).json'
'*Unsaved Workflow',
'*Unsaved Workflow (2)'
])
})
@@ -41,37 +39,37 @@ test.describe('Workflows sidebar', () => {
const tab = comfyPage.menu.workflowsTab
await tab.open()
expect(await tab.getTopLevelSavedWorkflowNames()).toEqual(
expect.arrayContaining(['workflow1.json', 'workflow2.json'])
expect.arrayContaining(['workflow1', 'workflow2'])
)
})
test('Can duplicate workflow', async ({ comfyPage }) => {
const tab = comfyPage.menu.workflowsTab
await comfyPage.menu.topbar.saveWorkflow('workflow1.json')
await comfyPage.menu.topbar.saveWorkflow('workflow1')
expect(await tab.getTopLevelSavedWorkflowNames()).toEqual(
expect.arrayContaining(['workflow1.json'])
expect.arrayContaining(['workflow1'])
)
await comfyPage.command.executeCommand('Comfy.DuplicateWorkflow')
expect(await tab.getOpenedWorkflowNames()).toEqual([
'workflow1.json',
'*workflow1 (Copy).json'
'workflow1',
'*workflow1 (Copy)'
])
await comfyPage.command.executeCommand('Comfy.DuplicateWorkflow')
expect(await tab.getOpenedWorkflowNames()).toEqual([
'workflow1.json',
'*workflow1 (Copy).json',
'*workflow1 (Copy) (2).json'
'workflow1',
'*workflow1 (Copy)',
'*workflow1 (Copy) (2)'
])
await comfyPage.command.executeCommand('Comfy.DuplicateWorkflow')
expect(await tab.getOpenedWorkflowNames()).toEqual([
'workflow1.json',
'*workflow1 (Copy).json',
'*workflow1 (Copy) (2).json',
'*workflow1 (Copy) (3).json'
'workflow1',
'*workflow1 (Copy)',
'*workflow1 (Copy) (2)',
'*workflow1 (Copy) (3)'
])
})
@@ -85,12 +83,12 @@ test.describe('Workflows sidebar', () => {
await comfyPage.command.executeCommand('Comfy.LoadDefaultWorkflow')
const originalNodeCount = await comfyPage.nodeOps.getNodeCount()
await tab.insertWorkflow(tab.getPersistedItem('workflow1.json'))
await tab.insertWorkflow(tab.getPersistedItem('workflow1'))
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toEqual(originalNodeCount + 1)
await tab.getPersistedItem('workflow1.json').click()
await tab.getPersistedItem('workflow1').click()
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toEqual(1)
})
@@ -113,22 +111,22 @@ test.describe('Workflows sidebar', () => {
const openedWorkflow = tab.getOpenedItem('foo/bar')
await tab.renameWorkflow(openedWorkflow, 'foo/baz')
expect(await tab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json',
'foo/baz.json'
'*Unsaved Workflow',
'foo/baz'
])
})
test('Can save workflow as', async ({ comfyPage }) => {
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.menu.topbar.saveWorkflowAs('workflow3.json')
await comfyPage.menu.topbar.saveWorkflowAs('workflow3')
await expect
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
.toEqual(['*Unsaved Workflow.json', 'workflow3.json'])
.toEqual(['*Unsaved Workflow', 'workflow3'])
await comfyPage.menu.topbar.saveWorkflowAs('workflow4.json')
await comfyPage.menu.topbar.saveWorkflowAs('workflow4')
await expect
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
.toEqual(['*Unsaved Workflow.json', 'workflow3.json', 'workflow4.json'])
.toEqual(['*Unsaved Workflow', 'workflow3', 'workflow4'])
})
test('Exported workflow does not contain localized slot names', async ({
@@ -184,15 +182,15 @@ test.describe('Workflows sidebar', () => {
})
test('Can save workflow as with same name', async ({ comfyPage }) => {
await comfyPage.menu.topbar.saveWorkflow('workflow5.json')
await comfyPage.menu.topbar.saveWorkflow('workflow5')
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
'workflow5.json'
'workflow5'
])
await comfyPage.menu.topbar.saveWorkflowAs('workflow5.json')
await comfyPage.menu.topbar.saveWorkflowAs('workflow5')
await comfyPage.confirmDialog.click('overwrite')
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
'workflow5.json'
'workflow5'
])
})
@@ -212,25 +210,25 @@ test.describe('Workflows sidebar', () => {
test('Can overwrite other workflows with save as', async ({ comfyPage }) => {
const topbar = comfyPage.menu.topbar
await topbar.saveWorkflow('workflow1.json')
await topbar.saveWorkflowAs('workflow2.json')
await topbar.saveWorkflow('workflow1')
await topbar.saveWorkflowAs('workflow2')
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
.toEqual(['workflow1.json', 'workflow2.json'])
.toEqual(['workflow1', 'workflow2'])
await expect
.poll(() => comfyPage.menu.workflowsTab.getActiveWorkflowName())
.toEqual('workflow2.json')
.toEqual('workflow2')
await topbar.saveWorkflowAs('workflow1.json')
await topbar.saveWorkflowAs('workflow1')
await comfyPage.confirmDialog.click('overwrite')
// The old workflow1.json should be deleted and the new one should be saved.
// The old workflow1 should be deleted and the new one should be saved.
await expect
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
.toEqual(['workflow2.json', 'workflow1.json'])
.toEqual(['workflow2', 'workflow1'])
await expect
.poll(() => comfyPage.menu.workflowsTab.getActiveWorkflowName())
.toEqual('workflow1.json')
.toEqual('workflow1')
})
test('Does not report warning when switching between opened workflows', async ({
@@ -266,17 +264,15 @@ test.describe('Workflows sidebar', () => {
)
await closeButton.click()
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json'
'*Unsaved Workflow'
])
})
test('Can close saved workflow with command', async ({ comfyPage }) => {
const tab = comfyPage.menu.workflowsTab
await comfyPage.menu.topbar.saveWorkflow('workflow1.json')
await comfyPage.menu.topbar.saveWorkflow('workflow1')
await comfyPage.command.executeCommand('Workspace.CloseWorkflow')
expect(await tab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json'
])
expect(await tab.getOpenedWorkflowNames()).toEqual(['*Unsaved Workflow'])
})
test('Can delete workflows (confirm disabled)', async ({ comfyPage }) => {
@@ -284,7 +280,7 @@ test.describe('Workflows sidebar', () => {
const { topbar, workflowsTab } = comfyPage.menu
const filename = 'workflow18.json'
const filename = 'workflow18'
await topbar.saveWorkflow(filename)
expect(await workflowsTab.getOpenedWorkflowNames()).toEqual([filename])
@@ -295,14 +291,14 @@ test.describe('Workflows sidebar', () => {
await expect(workflowsTab.getOpenedItem(filename)).not.toBeVisible()
expect(await workflowsTab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json'
'*Unsaved Workflow'
])
})
test('Can delete workflows', async ({ comfyPage }) => {
const { topbar, workflowsTab } = comfyPage.menu
const filename = 'workflow18.json'
const filename = 'workflow18'
await topbar.saveWorkflow(filename)
expect(await workflowsTab.getOpenedWorkflowNames()).toEqual([filename])
@@ -314,7 +310,7 @@ test.describe('Workflows sidebar', () => {
await expect(workflowsTab.getOpenedItem(filename)).not.toBeVisible()
expect(await workflowsTab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json'
'*Unsaved Workflow'
])
})
@@ -326,13 +322,11 @@ test.describe('Workflows sidebar', () => {
const { workflowsTab } = comfyPage.menu
await workflowsTab.open()
await workflowsTab
.getPersistedItem('workflow1.json')
.click({ button: 'right' })
await workflowsTab.getPersistedItem('workflow1').click({ button: 'right' })
await comfyPage.contextMenu.clickMenuItem('Duplicate')
await expect
.poll(() => workflowsTab.getOpenedWorkflowNames())
.toEqual(['*Unsaved Workflow.json', '*workflow1 (Copy).json'])
.toEqual(['*Unsaved Workflow', '*workflow1 (Copy)'])
})
test('Can drop workflow from workflows sidebar', async ({ comfyPage }) => {
@@ -344,7 +338,7 @@ test.describe('Workflows sidebar', () => {
// Wait for workflow to appear in Browse section after sync
const workflowItem =
comfyPage.menu.workflowsTab.getPersistedItem('workflow1.json')
comfyPage.menu.workflowsTab.getPersistedItem('workflow1')
await expect(workflowItem).toBeVisible({ timeout: 3000 })
const nodeCount = await comfyPage.nodeOps.getGraphNodesCount()
@@ -361,7 +355,7 @@ test.describe('Workflows sidebar', () => {
}
await comfyPage.page.dragAndDrop(
'.comfyui-workflows-browse .node-label:has-text("workflow1.json")',
'.comfyui-workflows-browse .node-label:has-text("workflow1")',
'#graph-canvas',
{ targetPosition }
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 108 KiB

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

@@ -3,9 +3,16 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkflowActionsDropdown from '@/components/common/WorkflowActionsDropdown.vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import Button from '@/components/ui/button/Button.vue'
import { useAppMode } from '@/composables/useAppMode'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import { isCloud } from '@/platform/distribution/types'
import {
openShareDialog,
prefetchShareDialog
} from '@/platform/workflow/sharing/composables/lazyShareDialog'
import { useAppModeStore } from '@/stores/appModeStore'
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
@@ -18,6 +25,8 @@ const workspaceStore = useWorkspaceStore()
const { enableAppBuilder } = useAppMode()
const appModeStore = useAppModeStore()
const { enterBuilder } = appModeStore
const { toastErrorHandler } = useErrorHandling()
const { flags } = useFeatureFlags()
const { hasNodes } = storeToRefs(appModeStore)
const tooltipOptions = { showDelay: 300, hideDelay: 300 }
@@ -43,28 +52,7 @@ function openTemplates() {
<template>
<div class="pointer-events-auto flex flex-col gap-2">
<WorkflowActionsDropdown source="app_mode_toolbar">
<template #button="{ hasUnseenItems }">
<Button
v-tooltip.right="{
value: t('sideToolbar.labels.menu'),
...tooltipOptions
}"
variant="secondary"
size="unset"
:aria-label="t('sideToolbar.labels.menu')"
class="relative h-10 gap-1 rounded-lg pr-2 pl-3 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
>
<i class="icon-[lucide--panels-top-left] size-4" />
<i class="icon-[lucide--chevron-down] size-4 text-muted-foreground" />
<span
v-if="hasUnseenItems"
aria-hidden="true"
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
/>
</Button>
</template>
</WorkflowActionsDropdown>
<WorkflowActionsDropdown source="app_mode_toolbar" />
<Button
v-if="enableAppBuilder"
@@ -81,6 +69,21 @@ function openTemplates() {
>
<i class="icon-[lucide--hammer] size-4" />
</Button>
<Button
v-if="isCloud && flags.workflowSharingEnabled"
v-tooltip.right="{
value: t('actionbar.shareTooltip'),
...tooltipOptions
}"
variant="secondary"
size="unset"
:aria-label="t('actionbar.shareTooltip')"
class="size-10 rounded-lg"
@click="() => openShareDialog().catch(toastErrorHandler)"
@pointerenter="prefetchShareDialog"
>
<i class="icon-[lucide--send] size-4" />
</Button>
<div
class="flex w-10 flex-col overflow-hidden rounded-lg bg-secondary-background"

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,7 +1,7 @@
<template>
<div
data-testid="subgraph-breadcrumb"
class="subgraph-breadcrumb -mt-4 flex w-auto items-center pt-4 drop-shadow-(--interface-panel-drop-shadow)"
class="subgraph-breadcrumb -mt-3 flex w-auto items-center pt-4 pl-1 drop-shadow-(--interface-panel-drop-shadow)"
:class="{
'subgraph-breadcrumb-collapse': collapseTabs,
'subgraph-breadcrumb-overflow': overflowingTabs

View File

@@ -12,7 +12,10 @@ import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import {
LGraphEventMode,
TitleMode
} from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -159,7 +162,8 @@ function handleDown(e: MouseEvent) {
}
function handleClick(e: MouseEvent) {
const [node, widget] = getHovered(e) ?? []
if (!node) return canvasInteractions.forwardEventToCanvas(e)
if (node?.mode !== LGraphEventMode.ALWAYS)
return canvasInteractions.forwardEventToCanvas(e)
if (!widget) {
if (!isSelectOutputsMode.value) return
@@ -192,7 +196,10 @@ function nodeToDisplayTuple(
const renderedOutputs = computed(() => {
void appModeStore.selectedOutputs.length
return canvas
.graph!.nodes.filter((n) => n.constructor.nodeData?.output_node)
.graph!.nodes.filter(
(n) =>
n.constructor.nodeData?.output_node && n.mode === LGraphEventMode.ALWAYS
)
.map(nodeToDisplayTuple)
})
const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
@@ -204,131 +211,146 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
)
</script>
<template>
<div class="flex items-center border-b border-border-subtle p-2 font-bold">
{{
isArrangeMode ? t('nodeHelpPage.inputs') : t('linearMode.builder.title')
}}
</div>
<DraggableList
v-if="isArrangeMode"
v-slot="{ dragClass }"
v-model="appModeStore.selectedInputs"
>
<div
v-for="{ nodeId, widgetName, node, widget } in arrangeInputs"
:key="`${nodeId}: ${widgetName}`"
:class="cn(dragClass, 'pointer-events-auto my-2 p-2')"
:aria-label="`${widget?.label ?? widgetName} ${node.title}`"
>
<div v-if="widget" class="pointer-events-none" inert>
<WidgetItem
:widget="widget"
:node="node"
show-node-name
hidden-widget-actions
/>
</div>
<div v-else class="pointer-events-none p-1 text-sm text-muted-foreground">
{{ widgetName }}
<p class="text-xs italic">
({{ t('linearMode.builder.unknownWidget') }})
</p>
</div>
<div class="flex h-full flex-col">
<div class="flex items-center border-b border-border-subtle p-2 font-bold">
{{
isArrangeMode ? t('nodeHelpPage.inputs') : t('linearMode.builder.title')
}}
</div>
</DraggableList>
<PropertiesAccordionItem
v-if="isSelectInputsMode"
:label="t('nodeHelpPage.inputs')"
enable-empty-state
:disabled="!appModeStore.selectedInputs.length"
class="border-b border-border-subtle"
:tooltip="`${t('linearMode.builder.inputsDesc')}\n${t('linearMode.builder.inputsExample')}`"
:tooltip-delay="100"
>
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.inputs') }}
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
</div>
</template>
<template #empty>
<div
class="w-full p-4 pt-2 text-muted-foreground"
v-text="t('linearMode.builder.promptAddInputs')"
/>
</template>
<div
class="w-full p-4 pt-2 text-muted-foreground"
v-text="t('linearMode.builder.promptAddInputs')"
/>
<DraggableList v-slot="{ dragClass }" v-model="appModeStore.selectedInputs">
<IoItem
v-for="{
nodeId,
widgetName,
label,
subLabel,
rename
} in inputsWithState"
:key="`${nodeId}: ${widgetName}`"
:class="cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')"
:title="label ?? widgetName"
:sub-title="subLabel"
:rename
:remove="
() =>
remove(
appModeStore.selectedInputs,
([id, name]) => nodeId == id && widgetName === name
)
"
/>
</DraggableList>
</PropertiesAccordionItem>
<PropertiesAccordionItem
v-if="isSelectOutputsMode"
:label="t('nodeHelpPage.outputs')"
enable-empty-state
:disabled="!appModeStore.selectedOutputs.length"
:tooltip="`${t('linearMode.builder.outputsDesc')}\n${t('linearMode.builder.outputsExample')}`"
:tooltip-delay="100"
>
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.outputs') }}
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
</div>
</template>
<template #empty>
<div
class="w-full p-4 pt-2 text-muted-foreground"
v-text="t('linearMode.builder.promptAddOutputs')"
/>
</template>
<div
class="w-full p-4 pt-2 text-muted-foreground"
v-text="t('linearMode.builder.promptAddOutputs')"
/>
<DraggableList
v-slot="{ dragClass }"
v-model="appModeStore.selectedOutputs"
>
<IoItem
v-for="([key, title], index) in outputsWithState"
:key
:class="
cn(
dragClass,
'my-2 rounded-lg bg-warning-background/40 p-2',
index === 0 && 'ring-2 ring-warning-background'
)
"
:title
:sub-title="String(key)"
:remove="() => remove(appModeStore.selectedOutputs, (k) => k == key)"
/>
</DraggableList>
</PropertiesAccordionItem>
<div class="min-h-0 flex-1 overflow-y-auto">
<DraggableList
v-if="isArrangeMode"
v-slot="{ dragClass }"
v-model="appModeStore.selectedInputs"
class="overflow-x-clip"
>
<div
v-for="{ nodeId, widgetName, node, widget } in arrangeInputs"
:key="`${nodeId}: ${widgetName}`"
:class="cn(dragClass, 'pointer-events-auto my-2 p-2')"
:aria-label="`${widget?.label ?? widgetName} ${node.title}`"
>
<div v-if="widget" class="pointer-events-none" inert>
<WidgetItem
:widget="widget"
:node="node"
show-node-name
hidden-widget-actions
/>
</div>
<div
v-else
class="pointer-events-none p-1 text-sm text-muted-foreground"
>
{{ widgetName }}
<p class="text-xs italic">
({{ t('linearMode.builder.unknownWidget') }})
</p>
</div>
</div>
</DraggableList>
<PropertiesAccordionItem
v-if="isSelectInputsMode"
:label="t('nodeHelpPage.inputs')"
enable-empty-state
:disabled="!appModeStore.selectedInputs.length"
class="border-b border-border-subtle"
:tooltip="`${t('linearMode.builder.inputsDesc')}\n${t('linearMode.builder.inputsExample')}`"
:tooltip-delay="100"
>
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.inputs') }}
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
</div>
</template>
<template #empty>
<div
class="w-full p-4 pt-2 text-muted-foreground"
v-text="t('linearMode.builder.promptAddInputs')"
/>
</template>
<div
class="w-full p-4 pt-2 text-muted-foreground"
v-text="t('linearMode.builder.promptAddInputs')"
/>
<DraggableList
v-slot="{ dragClass }"
v-model="appModeStore.selectedInputs"
>
<IoItem
v-for="{
nodeId,
widgetName,
label,
subLabel,
rename
} in inputsWithState"
:key="`${nodeId}: ${widgetName}`"
:class="
cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')
"
:title="label ?? widgetName"
:sub-title="subLabel"
:rename
:remove="
() =>
remove(
appModeStore.selectedInputs,
([id, name]) => nodeId == id && widgetName === name
)
"
/>
</DraggableList>
</PropertiesAccordionItem>
<PropertiesAccordionItem
v-if="isSelectOutputsMode"
:label="t('nodeHelpPage.outputs')"
enable-empty-state
:disabled="!appModeStore.selectedOutputs.length"
:tooltip="`${t('linearMode.builder.outputsDesc')}\n${t('linearMode.builder.outputsExample')}`"
:tooltip-delay="100"
>
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.outputs') }}
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
</div>
</template>
<template #empty>
<div
class="w-full p-4 pt-2 text-muted-foreground"
v-text="t('linearMode.builder.promptAddOutputs')"
/>
</template>
<div
class="w-full p-4 pt-2 text-muted-foreground"
v-text="t('linearMode.builder.promptAddOutputs')"
/>
<DraggableList
v-slot="{ dragClass }"
v-model="appModeStore.selectedOutputs"
>
<IoItem
v-for="([key, title], index) in outputsWithState"
:key
:class="
cn(
dragClass,
'my-2 rounded-lg bg-warning-background/40 p-2',
index === 0 && 'ring-2 ring-warning-background'
)
"
:title
:sub-title="String(key)"
:remove="
() => remove(appModeStore.selectedOutputs, (k) => k == key)
"
/>
</DraggableList>
</PropertiesAccordionItem>
</div>
</div>
<Teleport
v-if="isSelectMode && !settingStore.get('Comfy.VueNodes.Enabled')"

View File

@@ -19,38 +19,31 @@
</button>
</template>
<template #default="{ close }">
<button
:class="
cn(
'flex w-full items-center gap-3 rounded-md border-none bg-transparent px-3 py-2 text-sm',
hasOutputs
? 'cursor-pointer hover:bg-secondary-background-hover'
: 'pointer-events-none opacity-50'
)
"
:disabled="!hasOutputs"
@click="onSave(close)"
>
<i class="icon-[lucide--save] size-4" />
{{ t('g.save') }}
</button>
<div class="my-1 border-t border-border-default" />
<button
class="flex w-full cursor-pointer items-center gap-3 rounded-md border-none bg-transparent px-3 py-2 text-sm hover:bg-secondary-background-hover"
@click="onExitBuilder(close)"
>
<i class="icon-[lucide--square-pen] size-4" />
{{ t('builderMenu.exitAppBuilder') }}
</button>
<template v-for="(item, index) in menuItems" :key="item.label">
<div v-if="index > 0" class="my-1 border-t border-border-default" />
<Button
variant="textonly"
size="unset"
class="flex w-full items-center justify-start gap-3 rounded-md px-3 py-2 text-sm"
:disabled="item.disabled"
@click="item.action(close)"
>
<i :class="cn(item.icon, 'size-4')" />
{{ item.label }}
</Button>
</template>
</template>
</Popover>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import Popover from '@/components/ui/Popover.vue'
import { useAppMode } from '@/composables/useAppMode'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
@@ -60,10 +53,30 @@ import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const appModeStore = useAppModeStore()
const { hasOutputs } = storeToRefs(appModeStore)
const { setMode } = useAppMode()
const workflowService = useWorkflowService()
const workflowStore = useWorkflowStore()
const { toastErrorHandler } = useErrorHandling()
const menuItems = computed(() => [
{
label: t('g.save'),
icon: 'icon-[lucide--save]',
disabled: !hasOutputs.value,
action: onSave
},
{
label: t('builderMenu.enterAppMode'),
icon: 'icon-[lucide--panels-top-left]',
action: onEnterAppMode
},
{
label: t('builderMenu.exitAppBuilder'),
icon: 'icon-[lucide--square-pen]',
action: onExitBuilder
}
])
async function onSave(close: () => void) {
const workflow = workflowStore.activeWorkflow
if (!workflow) return
@@ -75,6 +88,11 @@ async function onSave(close: () => void) {
}
}
function onEnterAppMode(close: () => void) {
setMode('app')
close()
}
function onExitBuilder(close: () => void) {
void appModeStore.exitBuilder()
close()

View File

@@ -5,6 +5,7 @@ import {
DropdownMenuRoot,
DropdownMenuTrigger
} from 'reka-ui'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
@@ -22,6 +23,7 @@ const { source, align = 'start' } = defineProps<{
const { t } = useI18n()
const canvasStore = useCanvasStore()
const dropdownOpen = ref(false)
const { menuItems } = useWorkflowActionsMenu(
() => useCommandStore().execute('Comfy.RenameWorkflow'),
@@ -40,22 +42,38 @@ function handleOpen(open: boolean) {
})
}
}
function toggleLinearMode() {
dropdownOpen.value = false
void useCommandStore().execute('Comfy.ToggleLinear', {
metadata: { source }
})
}
</script>
<template>
<DropdownMenuRoot @update:open="handleOpen">
<DropdownMenuTrigger as-child>
<slot name="button" :has-unseen-items="hasUnseenItems">
<DropdownMenuRoot v-model:open="dropdownOpen" @update:open="handleOpen">
<slot name="button" :has-unseen-items="hasUnseenItems">
<div
class="pointer-events-auto inline-flex items-center rounded-lg bg-secondary-background"
>
<Button
v-tooltip="{
value: t('breadcrumbsMenu.workflowActions'),
value: canvasStore.linearMode
? t('breadcrumbsMenu.enterNodeGraph')
: t('breadcrumbsMenu.enterAppMode'),
showDelay: 300,
hideDelay: 300
}"
variant="secondary"
size="unset"
:aria-label="t('breadcrumbsMenu.workflowActions')"
class="pointer-events-auto relative h-10 gap-1 rounded-lg pr-2 pl-3 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
:aria-label="
canvasStore.linearMode
? t('breadcrumbsMenu.enterNodeGraph')
: t('breadcrumbsMenu.enterAppMode')
"
variant="base"
class="m-1"
@pointerdown.stop
@click="toggleLinearMode"
>
<i
class="size-4"
@@ -65,15 +83,36 @@ function handleOpen(open: boolean) {
: 'icon-[comfy--workflow]'
"
/>
<i class="icon-[lucide--chevron-down] size-4 text-muted-foreground" />
<span
v-if="hasUnseenItems"
aria-hidden="true"
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
/>
</Button>
</slot>
</DropdownMenuTrigger>
<DropdownMenuTrigger as-child>
<Button
v-tooltip="{
value: t('breadcrumbsMenu.workflowActions'),
showDelay: 300,
hideDelay: 300
}"
variant="secondary"
size="unset"
:aria-label="t('breadcrumbsMenu.workflowActions')"
class="relative h-10 gap-1 rounded-lg pr-2 pl-2.5 text-center data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
>
<span>{{
canvasStore.linearMode
? t('breadcrumbsMenu.app')
: t('breadcrumbsMenu.graph')
}}</span>
<i
class="icon-[lucide--chevron-down] size-4 text-muted-foreground"
/>
<span
v-if="hasUnseenItems"
aria-hidden="true"
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
/>
</Button>
</DropdownMenuTrigger>
</div>
</slot>
<DropdownMenuPortal>
<DropdownMenuContent
:align

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

@@ -2,7 +2,6 @@
<BaseWorkflowsSidebarTab
:title="$t('linearMode.appModeToolbar.apps')"
:filter="isAppWorkflow"
:label-transform="stripAppJsonSuffix"
hide-leaf-icon
:search-subject="$t('linearMode.appModeToolbar.apps')"
data-testid="apps-sidebar"
@@ -18,8 +17,14 @@
<NoResultsPlaceholder
button-variant="secondary"
text-class="text-muted-foreground text-sm"
:message="$t('linearMode.appModeToolbar.appsEmptyMessage')"
:button-label="$t('linearMode.appModeToolbar.enterAppMode')"
:message="
isAppMode
? $t('linearMode.appModeToolbar.appsEmptyMessage')
: `${$t('linearMode.appModeToolbar.appsEmptyMessage')}\n${$t('linearMode.appModeToolbar.appsEmptyMessageAction')}`
"
:button-label="
isAppMode ? undefined : $t('linearMode.appModeToolbar.enterAppMode')
"
@action="enterAppMode"
/>
</template>
@@ -32,16 +37,12 @@ import BaseWorkflowsSidebarTab from '@/components/sidebar/tabs/BaseWorkflowsSide
import { useAppMode } from '@/composables/useAppMode'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
const { setMode } = useAppMode()
const { isAppMode, setMode } = useAppMode()
function isAppWorkflow(workflow: ComfyWorkflow): boolean {
return workflow.suffix === 'app.json'
}
function stripAppJsonSuffix(label: string): string {
return label.replace(/\.app\.json$/i, '')
}
function enterAppMode() {
setMode('app')
}

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

@@ -154,6 +154,7 @@ import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue
import WorkflowTreeLeaf from '@/components/sidebar/tabs/workflows/WorkflowTreeLeaf.vue'
import Button from '@/components/ui/button/Button.vue'
import { useTreeExpansion } from '@/composables/useTreeExpansion'
import { useAppMode } from '@/composables/useAppMode'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import {
@@ -163,26 +164,23 @@ import {
} from '@/platform/workflow/management/stores/workflowStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { TreeExplorerNode, TreeNode } from '@/types/treeExplorerTypes'
import { ensureWorkflowSuffix, getWorkflowSuffix } from '@/utils/formatUtil'
import {
ensureWorkflowSuffix,
getFilenameDetails,
getWorkflowSuffix
} from '@/utils/formatUtil'
import { buildTree, sortedTree } from '@/utils/treeUtil'
const {
title,
filter,
searchSubject,
dataTestid,
labelTransform,
hideLeafIcon
} = defineProps<{
const { title, filter, searchSubject, dataTestid, hideLeafIcon } = defineProps<{
title: string
filter?: (workflow: ComfyWorkflow) => boolean
searchSubject: string
dataTestid: string
labelTransform?: (label: string) => string
hideLeafIcon?: boolean
}>()
const { t } = useI18n()
const { isAppMode } = useAppMode()
const applyFilter = (workflows: ComfyWorkflow[]) =>
filter ? workflows.filter(filter) : workflows
@@ -304,14 +302,18 @@ const renderTreeNode = (
},
contextMenuItems() {
return [
{
label: t('g.insert'),
icon: 'pi pi-file-export',
command: async () => {
const workflow = node.data
await workflowService.insertWorkflow(workflow)
}
},
...(isAppMode.value
? []
: [
{
label: t('g.insert'),
icon: 'pi pi-file-export',
command: async () => {
const workflow = node.data
await workflowService.insertWorkflow(workflow)
}
}
]),
{
label: t('g.duplicate'),
icon: 'pi pi-file-export',
@@ -326,8 +328,7 @@ const renderTreeNode = (
}
: { handleClick }
const label =
node.leaf && labelTransform ? labelTransform(node.label) : node.label
const label = node.leaf ? getFilenameDetails(node.label).filename : node.label
return {
key: node.key,

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

@@ -9,7 +9,8 @@ const panY = ref(0.0)
function handleWheel(e: WheelEvent) {
const zoomPaneEl = zoomPane.value
if (!zoomPaneEl) return
if (!zoomPaneEl || (e.deltaY < 0 ? zoom.value > 1200 : zoom.value < -500))
return
zoom.value -= e.deltaY
const { x, y, width, height } = zoomPaneEl.getBoundingClientRect()

View File

@@ -20,6 +20,7 @@ export const buttonVariants = cva({
'destructive-textonly':
'bg-transparent text-destructive-background hover:bg-destructive-background/10',
'overlay-white': 'bg-white text-gray-600 hover:bg-white/90',
base: 'bg-base-background text-base-foreground hover:bg-secondary-background-hover',
gradient:
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90'
},
@@ -49,6 +50,7 @@ const variants = [
'textonly',
'muted-textonly',
'destructive-textonly',
'base',
'overlay-white',
'gradient'
] as const satisfies Array<ButtonVariants['variant']>

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]',
@@ -905,6 +819,14 @@ export function useCoreCommands(): ComfyCommand[] {
app.canvas.pasteFromClipboard()
}
},
{
id: 'Comfy.Canvas.PasteFromClipboardWithConnect',
icon: 'icon-[lucide--clipboard-paste]',
label: () => t('Paste with Connect'),
function: () => {
app.canvas.pasteFromClipboard({ connectInputs: true })
}
},
{
id: 'Comfy.Canvas.SelectAll',
icon: 'icon-[lucide--lasso-select]',
@@ -919,6 +841,12 @@ export function useCoreCommands(): ComfyCommand[] {
label: 'Delete Selected Items',
versionAdded: '1.10.5',
function: () => {
if (app.canvas.selectedItems.size === 0) {
app.canvas.canvas.dispatchEvent(
new CustomEvent('litegraph:no-items-selected', { bubbles: true })
)
return
}
app.canvas.deleteSelected()
app.canvas.setDirty(true, true)
}
@@ -1249,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
})
}
}
},
{
@@ -1269,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
}

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