Compare commits

..

1 Commits

Author SHA1 Message Date
CodeRabbit Fixer
461bea6d99 fix: refactor: create rename-aware detach/attach helper in workflowStore to avoid duplicate mutation paths (#9407)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 15:34:12 +01:00
287 changed files with 3037 additions and 5791 deletions

3
.gitignore vendored
View File

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

View File

@@ -1,7 +1,19 @@
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,33 +1,50 @@
import type { PrimeVueSeverity } from '../primeVueTypes'
interface MaintenanceTaskButton {
/** The text to display on the button. */
text?: string
/** CSS classes, e.g. 'pi pi-external-link' */
/** CSS classes used for the button icon, e.g. 'pi pi-external-link' */
icon?: string
}
/** A maintenance task, used by the maintenance page. */
export interface MaintenanceTask {
/** Used as i18n key */
/** ID string 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 refreshes install validation and auto-continues. */
/** If `true`, successful completion of this task will refresh install validation and automatically continue if successful. */
isInstallationFix?: boolean
}
/** The filter options for the maintenance task list. */
export interface MaintenanceFilter {
/** CSS classes, e.g. 'pi pi-cross' */
/** CSS classes used for the filter button icon, 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,6 +2,11 @@ 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,15 +1,11 @@
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(): ElectronAPI {
return (window as ElectronWindow).electronAPI as ElectronAPI
export function electronAPI() {
return (window as any).electronAPI as ElectronAPI
}
export function isNativeWindow() {

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -819,13 +819,16 @@ 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([workflowA, workflowB])
expect.arrayContaining([workflowPathA, workflowPathB])
)
expect(openWorkflows.indexOf(workflowA)).toBeLessThan(
openWorkflows.indexOf(workflowB)
expect(openWorkflows.indexOf(workflowPathA)).toBeLessThan(
openWorkflows.indexOf(workflowPathB)
)
expect(activeWorkflowName).toEqual(workflowB)
expect(activeWorkflowName).toEqual(workflowPathB)
})
})

View File

@@ -35,21 +35,18 @@ test.describe(
test(`Load workflow in ${fileName} (drop from filesystem)`, async ({
comfyPage
}) => {
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`)
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 }
)
}
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'
const workflowName = 'single_connected_reroute_node.json'
await comfyPage.workflow.setupWorkflowsDirectory({
[`${workflowName}.json`]: `links/${workflowName}.json`
[workflowName]: 'links/single_connected_reroute_node.json'
})
await comfyPage.setup()
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 90 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 106 KiB

View File

@@ -89,11 +89,13 @@
"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",
@@ -143,7 +145,6 @@
"@vue/test-utils": "catalog:",
"@webgpu/types": "catalog:",
"cross-env": "catalog:",
"dotenv": "catalog:",
"eslint": "catalog:",
"eslint-config-prettier": "catalog:",
"eslint-import-resolver-typescript": "catalog:",
@@ -154,7 +155,6 @@
"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,6 +482,9 @@ 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
@@ -497,6 +500,9 @@ 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
@@ -639,9 +645,6 @@ 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)
@@ -672,9 +675,6 @@ 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,25 +2451,21 @@ 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==}
@@ -2635,49 +2631,41 @@ 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==}
@@ -2751,56 +2739,48 @@ 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==}
@@ -2903,56 +2883,48 @@ 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==}
@@ -3121,28 +3093,24 @@ 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==}
@@ -3220,67 +3188,56 @@ 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==}
@@ -3556,28 +3513,24 @@ 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==}
@@ -4053,49 +4006,41 @@ 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==}
@@ -6471,28 +6416,24 @@ 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
function checkNewUnusedKeys() {
async function checkNewUnusedKeys() {
const stagedLocaleFiles = getStagedLocaleFiles()
if (stagedLocaleFiles.length === 0) {
@@ -165,7 +165,7 @@ function checkNewUnusedKeys() {
if (unusedNewKeys.length > 0) {
console.warn('\n⚠ Warning: Found unused NEW i18n keys:\n')
for (const key of unusedNewKeys.sort((a, b) => a.localeCompare(b))) {
for (const key of unusedNewKeys.sort()) {
console.warn(` - ${key}`)
}
@@ -183,9 +183,7 @@ function checkNewUnusedKeys() {
}
// Run the check
try {
checkNewUnusedKeys()
} catch (err) {
checkNewUnusedKeys().catch((err) => {
console.error('Error checking unused keys:', err)
process.exit(1)
}
})

View File

@@ -132,7 +132,7 @@ function resolveRelease(
return null
}
const [, currentMinor] = currentVersion.split('.').map(Number)
const [major, currentMinor, patch] = 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 blob - The Blob to download
* @param filename - The filename to suggest to the browser
* @param blob - The Blob to download
*/
export function downloadBlob(blob: Blob, filename: string): void {
export function downloadBlob(filename: string, blob: Blob): void {
const url = URL.createObjectURL(blob)
triggerLinkDownload(url, filename)
@@ -138,7 +138,7 @@ async function downloadViaBlobFetch(
extractFilenameFromContentDisposition(contentDisposition)
const blob = await response.blob()
downloadBlob(blob, headerFilename ?? fallbackFilename)
downloadBlob(headerFilename ?? fallbackFilename, blob)
}
/**

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
type FormatOptions = {
export type FormatOptions = {
value: number
locale?: string
numberOptions?: Intl.NumberFormatOptions
}
type FormatFromCentsOptions = {
export type FormatFromCentsOptions = {
cents: number
locale?: string
numberOptions?: Intl.NumberFormatOptions
}
type FormatFromUsdOptions = {
export type FormatFromUsdOptions = {
usd: number
locale?: string
numberOptions?: Intl.NumberFormatOptions
@@ -113,3 +113,13 @@ 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,3 +1,12 @@
/**
* 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 '@/utils/tooltipConfig'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
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 setup lang="ts">
<script lang="ts" setup>
import { storeToRefs } from 'pinia'
import InputNumber from 'primevue/inputnumber'
import { computed } from 'vue'

View File

@@ -87,7 +87,7 @@
</div>
</template>
<script setup lang="ts">
<script lang="ts" setup>
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 '@/utils/tooltipConfig'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'

View File

@@ -3,16 +3,9 @@ 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'
@@ -25,8 +18,6 @@ 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 }
@@ -52,7 +43,28 @@ function openTemplates() {
<template>
<div class="pointer-events-auto flex flex-col gap-2">
<WorkflowActionsDropdown source="app_mode_toolbar" />
<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>
<Button
v-if="enableAppBuilder"
@@ -69,21 +81,6 @@ 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: undefined
keybinding: null
} as ComfyCommandImpl
]

View File

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

View File

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

View File

@@ -12,10 +12,7 @@ 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 {
LGraphEventMode,
TitleMode
} from '@/lib/litegraph/src/types/globalEnums'
import { 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'
@@ -162,8 +159,7 @@ function handleDown(e: MouseEvent) {
}
function handleClick(e: MouseEvent) {
const [node, widget] = getHovered(e) ?? []
if (node?.mode !== LGraphEventMode.ALWAYS)
return canvasInteractions.forwardEventToCanvas(e)
if (!node) return canvasInteractions.forwardEventToCanvas(e)
if (!widget) {
if (!isSelectOutputsMode.value) return
@@ -196,10 +192,7 @@ function nodeToDisplayTuple(
const renderedOutputs = computed(() => {
void appModeStore.selectedOutputs.length
return canvas
.graph!.nodes.filter(
(n) =>
n.constructor.nodeData?.output_node && n.mode === LGraphEventMode.ALWAYS
)
.graph!.nodes.filter((n) => n.constructor.nodeData?.output_node)
.map(nodeToDisplayTuple)
})
const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
@@ -211,146 +204,131 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
)
</script>
<template>
<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>
<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 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>
</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>
<Teleport
v-if="isSelectMode && !settingStore.get('Comfy.VueNodes.Enabled')"

View File

@@ -19,31 +19,38 @@
</button>
</template>
<template #default="{ close }">
<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>
<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>
</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'
@@ -53,30 +60,10 @@ 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
@@ -88,11 +75,6 @@ async function onSave(close: () => void) {
}
}
function onEnterAppMode(close: () => void) {
setMode('app')
close()
}
function onExitBuilder(close: () => void) {
void appModeStore.exitBuilder()
close()

View File

@@ -5,7 +5,6 @@ import {
DropdownMenuRoot,
DropdownMenuTrigger
} from 'reka-ui'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
@@ -23,7 +22,6 @@ const { source, align = 'start' } = defineProps<{
const { t } = useI18n()
const canvasStore = useCanvasStore()
const dropdownOpen = ref(false)
const { menuItems } = useWorkflowActionsMenu(
() => useCommandStore().execute('Comfy.RenameWorkflow'),
@@ -42,38 +40,22 @@ function handleOpen(open: boolean) {
})
}
}
function toggleLinearMode() {
dropdownOpen.value = false
void useCommandStore().execute('Comfy.ToggleLinear', {
metadata: { source }
})
}
</script>
<template>
<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"
>
<DropdownMenuRoot @update:open="handleOpen">
<DropdownMenuTrigger as-child>
<slot name="button" :has-unseen-items="hasUnseenItems">
<Button
v-tooltip="{
value: canvasStore.linearMode
? t('breadcrumbsMenu.enterNodeGraph')
: t('breadcrumbsMenu.enterAppMode'),
value: t('breadcrumbsMenu.workflowActions'),
showDelay: 300,
hideDelay: 300
}"
:aria-label="
canvasStore.linearMode
? t('breadcrumbsMenu.enterNodeGraph')
: t('breadcrumbsMenu.enterAppMode')
"
variant="base"
class="m-1"
@pointerdown.stop
@click="toggleLinearMode"
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"
>
<i
class="size-4"
@@ -83,36 +65,15 @@ function toggleLinearMode() {
: '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>
<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>
</slot>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
:align

View File

@@ -1,12 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import {
fetchModelMetadata,
getBadgeLabel,
hasValidDirectory,
isModelDownloadable
} from '@/components/dialog/content/missingModelsUtils'
import type { ModelWithUrl } from '@/components/dialog/content/missingModelsUtils'
import { fetchModelMetadata } from './missingModelsUtils'
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
@@ -14,84 +8,6 @@ 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 { useGraphCopyHandler } from '@/composables/useGraphCopyHandler'
import { useCopy } from '@/composables/useCopy'
import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
import { usePaste } from '@/composables/usePaste'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
@@ -446,7 +446,7 @@ useNodeBadge()
useGlobalLitegraph()
useContextMenuTranslation()
useGraphCopyHandler()
useCopy()
usePaste()
useWorkflowAutoSave()

View File

@@ -1,12 +1,12 @@
<template>
<div>
<ZoomControlsModal :visible="isPopoverOpen" @close="hidePopover" />
<ZoomControlsModal :visible="isModalVisible" @close="hideModal" />
<!-- Backdrop -->
<div
v-if="hasActivePopup"
class="fixed inset-0 z-1200"
@click="hidePopover"
@click="hideModal"
></div>
<ButtonGroup
@@ -40,7 +40,7 @@
:aria-label="t('zoomControls.label')"
data-testid="zoom-controls-button"
:style="stringifiedMinimapStyles.buttonStyles"
@click="togglePopover"
@click="toggleModal"
>
<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 { isPopoverOpen, togglePopover, hidePopover, hasActivePopup } =
const { isModalVisible, toggleModal, hideModal, 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',
isPopoverOpen.value ? 'not-active:bg-interface-panel-selected-surface!' : '',
isModalVisible.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 '@/extensions/core/load3d/composables/useLoad3d'
import { useLoad3d } from '@/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 '@/extensions/core/load3d/composables/useLoad3dDrag'
import { useLoad3dDrag } from '@/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 '@/extensions/core/load3d/composables/useLoad3dDrag'
import { useLoad3dViewer } from '@/extensions/core/load3d/composables/useLoad3dViewer'
import { useLoad3dDrag } from '@/composables/useLoad3dDrag'
import { useLoad3dViewer } from '@/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 = (event: Event) => {
const onBlendModeChange = async (event: Event) => {
const value = (event.target as HTMLSelectElement).value
let blendMode: MaskBlendMode
@@ -217,7 +217,7 @@ const onBlendModeChange = (event: Event) => {
store.maskBlendMode = blendMode
canvasManager.updateMaskColor()
await canvasManager.updateMaskColor()
}
const setActiveLayer = (layer: ImageLayer) => {

View File

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

View File

@@ -79,16 +79,16 @@ const handleTouchStart = (event: TouchEvent) => {
panZoom.handleTouchStart(event)
}
const handleTouchMove = (event: TouchEvent) => {
panZoom.handleTouchMove(event)
const handleTouchMove = async (event: TouchEvent) => {
await panZoom.handleTouchMove(event)
}
const handleTouchEnd = (event: TouchEvent) => {
panZoom.handleTouchEnd(event)
}
const handleWheel = (event: WheelEvent) => {
panZoom.zoom(event)
const handleWheel = async (event: WheelEvent) => {
await 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 = () => {
const onRotateLeft = async () => {
try {
canvasTransform.rotateCounterclockwise()
await canvasTransform.rotateCounterclockwise()
} catch (error) {
console.error('[TopBarHeader] Rotate left failed:', error)
}
}
const onRotateRight = () => {
const onRotateRight = async () => {
try {
canvasTransform.rotateClockwise()
await canvasTransform.rotateClockwise()
} catch (error) {
console.error('[TopBarHeader] Rotate right failed:', error)
}
}
const onMirrorHorizontal = () => {
const onMirrorHorizontal = async () => {
try {
canvasTransform.mirrorHorizontal()
await canvasTransform.mirrorHorizontal()
} catch (error) {
console.error('[TopBarHeader] Mirror horizontal failed:', error)
}
}
const onMirrorVertical = () => {
const onMirrorVertical = async () => {
try {
canvasTransform.mirrorVertical()
await 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 '@/utils/tooltipConfig'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
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 '@/utils/tooltipConfig'
import * as tooltipConfig from '@/composables/useTooltipConfig'
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 '@/utils/tooltipConfig'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
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 '@/utils/tooltipConfig'
import * as tooltipConfig from '@/composables/useTooltipConfig'
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 '@/utils/tooltipConfig'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
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 '@/utils/tooltipConfig'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
const {
hideShowAssetsAction = false,

View File

@@ -193,15 +193,8 @@ 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 {
progressBarContainerClass,
progressBarPrimaryClass,
progressBarSecondaryClass,
hasProgressPercent,
hasAnyProgressPercent,
progressPercentStyle
} from '@/composables/useProgressBarBackground'
import { buildTooltipConfig } from '@/utils/tooltipConfig'
import { useProgressBarBackground } from '@/composables/useProgressBarBackground'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import type { JobState } from '@/types/queue'
import { iconForJobState } from '@/utils/queueDisplay'
import { cn } from '@/utils/tailwindUtil'
@@ -244,6 +237,14 @@ 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 setup lang="ts">
<script lang="ts" setup>
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'

View File

@@ -2,6 +2,7 @@
<BaseWorkflowsSidebarTab
:title="$t('linearMode.appModeToolbar.apps')"
:filter="isAppWorkflow"
:label-transform="stripAppJsonSuffix"
hide-leaf-icon
:search-subject="$t('linearMode.appModeToolbar.apps')"
data-testid="apps-sidebar"
@@ -17,14 +18,8 @@
<NoResultsPlaceholder
button-variant="secondary"
text-class="text-muted-foreground text-sm"
:message="
isAppMode
? $t('linearMode.appModeToolbar.appsEmptyMessage')
: `${$t('linearMode.appModeToolbar.appsEmptyMessage')}\n${$t('linearMode.appModeToolbar.appsEmptyMessageAction')}`
"
:button-label="
isAppMode ? undefined : $t('linearMode.appModeToolbar.enterAppMode')
"
:message="$t('linearMode.appModeToolbar.appsEmptyMessage')"
:button-label="$t('linearMode.appModeToolbar.enterAppMode')"
@action="enterAppMode"
/>
</template>
@@ -37,12 +32,16 @@ import BaseWorkflowsSidebarTab from '@/components/sidebar/tabs/BaseWorkflowsSide
import { useAppMode } from '@/composables/useAppMode'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
const { isAppMode, setMode } = useAppMode()
const { 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 = (assets: AssetItem[]) => {
addMultipleToWorkflow(assets)
const handleBulkAddToWorkflow = async (assets: AssetItem[]) => {
await addMultipleToWorkflow(assets)
clearSelection()
}

View File

@@ -154,7 +154,6 @@ 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 {
@@ -164,23 +163,26 @@ import {
} from '@/platform/workflow/management/stores/workflowStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { TreeExplorerNode, TreeNode } from '@/types/treeExplorerTypes'
import {
ensureWorkflowSuffix,
getFilenameDetails,
getWorkflowSuffix
} from '@/utils/formatUtil'
import { ensureWorkflowSuffix, getWorkflowSuffix } from '@/utils/formatUtil'
import { buildTree, sortedTree } from '@/utils/treeUtil'
const { title, filter, searchSubject, dataTestid, hideLeafIcon } = defineProps<{
const {
title,
filter,
searchSubject,
dataTestid,
labelTransform,
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
@@ -302,18 +304,14 @@ const renderTreeNode = (
},
contextMenuItems() {
return [
...(isAppMode.value
? []
: [
{
label: t('g.insert'),
icon: 'pi pi-file-export',
command: async () => {
const workflow = node.data
await workflowService.insertWorkflow(workflow)
}
}
]),
{
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',
@@ -328,7 +326,8 @@ const renderTreeNode = (
}
: { handleClick }
const label = node.leaf ? getFilenameDetails(node.label).filename : node.label
const label =
node.leaf && labelTransform ? labelTransform(node.label) : node.label
return {
key: node.key,

View File

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

View File

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

View File

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

View File

@@ -20,7 +20,6 @@ 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'
},
@@ -50,7 +49,6 @@ const variants = [
'textonly',
'muted-textonly',
'destructive-textonly',
'base',
'overlay-white',
'gradient'
] as const satisfies Array<ButtonVariants['variant']>

View File

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

View File

@@ -137,11 +137,11 @@ export function useLegacyBilling(): BillingState & BillingActions {
await legacySubscribe()
}
function previewSubscribe(
async function previewSubscribe(
_planSlug: string
): Promise<PreviewSubscribeResponse | null> {
// Legacy billing doesn't support preview - returns null
return Promise.resolve(null)
return null
}
async function manageSubscription(): Promise<void> {
@@ -152,10 +152,9 @@ export function useLegacyBilling(): BillingState & BillingActions {
await legacyManageSubscription()
}
function fetchPlans(): Promise<void> {
async 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,13 +4,28 @@ 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> => {
@@ -23,20 +38,37 @@ 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
}
/** Includes descendant nodes from any selected subgraphs. */
/**
* 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.
*/
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 { Point, Size } from '@/lib/litegraph/src/interfaces'
import type { Size, Vector2 } from '@/lib/litegraph/src/litegraph'
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: Point
pos: Vector2
/* The size of the element on litegraph canvas */
size: Size
/* The scale factor of the canvas */

View File

@@ -530,6 +530,7 @@ 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 unpackNodes = (
const doUnpack = (
subgraphNodes: SubgraphNode[],
skipMissingNodes: boolean
) => {
@@ -65,7 +65,7 @@ export function useSubgraphOperations() {
if (subgraphNodes.length === 0) {
return
}
unpackNodes(subgraphNodes, true)
doUnpack(subgraphNodes, true)
}
const addSubgraphToLibrary = async () => {

View File

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

View File

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

View File

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

View File

@@ -1,21 +1,16 @@
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 interface CanvasLayers {
maskCanvas: HTMLCanvasElement | null
maskCtx: CanvasRenderingContext2D | null
rgbCanvas: HTMLCanvasElement | null
rgbCtx: CanvasRenderingContext2D | null
imgCanvas: HTMLCanvasElement | null
imgCtx: CanvasRenderingContext2D | null
}
export function useCanvasHistory(maxStates = 20) {
const store = useMaskEditorStore()
export function useCanvasHistory(layers: CanvasLayers, maxStates = 20) {
const states = ref<CanvasState[]>([])
const currentStateIndex = ref(-1)
const initialized = ref(false)
@@ -32,7 +27,7 @@ export function useCanvasHistory(layers: CanvasLayers, maxStates = 20) {
})
const saveInitialState = () => {
const { maskCtx, rgbCtx, imgCtx, maskCanvas, rgbCanvas, imgCanvas } = layers
const { maskCtx, rgbCtx, imgCtx, maskCanvas, rgbCanvas, imgCanvas } = store
// Ensure all 3 contexts and canvases are ready
if (
@@ -84,7 +79,7 @@ export function useCanvasHistory(layers: CanvasLayers, maxStates = 20) {
providedRgbData?: ImageData | ImageBitmap,
providedImgData?: ImageData | ImageBitmap
) => {
const { maskCtx, rgbCtx, imgCtx, maskCanvas, rgbCanvas, imgCanvas } = layers
const { maskCtx, rgbCtx, imgCtx, maskCanvas, rgbCanvas, imgCanvas } = store
if (
!maskCtx ||
@@ -149,7 +144,7 @@ export function useCanvasHistory(layers: CanvasLayers, maxStates = 20) {
}
const restoreState = (state: CanvasState) => {
const { maskCtx, rgbCtx, imgCtx, maskCanvas, rgbCanvas, imgCanvas } = layers
const { maskCtx, rgbCtx, imgCtx, maskCanvas, rgbCanvas, imgCanvas } = store
if (
!maskCtx ||
!rgbCtx ||
@@ -174,13 +169,13 @@ export function useCanvasHistory(layers: CanvasLayers, maxStates = 20) {
imgCanvas.height = newHeight
}
const canvasLayers = [
const layers = [
{ ctx: maskCtx, data: state.mask },
{ ctx: rgbCtx, data: state.rgb },
{ ctx: imgCtx, data: state.img }
]
canvasLayers.forEach(({ ctx, data }) => {
layers.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', () => {
it('should set canvas dimensions', async () => {
const manager = useCanvasManager()
const origImage = createMockImage(512, 512)
const maskImage = createMockImage(512, 512)
manager.invalidateCanvas(origImage, maskImage, null)
await 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', () => {
it('should draw original image', async () => {
const manager = useCanvasManager()
const origImage = createMockImage(512, 512)
const maskImage = createMockImage(512, 512)
manager.invalidateCanvas(origImage, maskImage, null)
await manager.invalidateCanvas(origImage, maskImage, null)
expect(mockStore.imgCtx.drawImage).toHaveBeenCalledWith(
origImage,
@@ -122,14 +122,14 @@ describe('useCanvasManager', () => {
)
})
it('should draw paint image when provided', () => {
it('should draw paint image when provided', async () => {
const manager = useCanvasManager()
const origImage = createMockImage(512, 512)
const maskImage = createMockImage(512, 512)
const paintImage = createMockImage(512, 512)
manager.invalidateCanvas(origImage, maskImage, paintImage)
await 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', () => {
it('should not draw paint image when null', async () => {
const manager = useCanvasManager()
const origImage = createMockImage(512, 512)
const maskImage = createMockImage(512, 512)
manager.invalidateCanvas(origImage, maskImage, null)
await manager.invalidateCanvas(origImage, maskImage, null)
expect(mockStore.rgbCtx.drawImage).not.toHaveBeenCalled()
})
it('should prepare mask', () => {
it('should prepare mask', async () => {
const manager = useCanvasManager()
const origImage = createMockImage(512, 512)
const maskImage = createMockImage(512, 512)
manager.invalidateCanvas(origImage, maskImage, null)
await 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', () => {
it('should throw error when canvas missing', async () => {
const manager = useCanvasManager()
mockStore.imgCanvas = null! as HTMLCanvasElement
@@ -172,12 +172,12 @@ describe('useCanvasManager', () => {
const origImage = createMockImage(512, 512)
const maskImage = createMockImage(512, 512)
expect(() =>
await expect(
manager.invalidateCanvas(origImage, maskImage, null)
).toThrow('Canvas elements or contexts not available')
).rejects.toThrow('Canvas elements or contexts not available')
})
it('should throw error when context missing', () => {
it('should throw error when context missing', async () => {
const manager = useCanvasManager()
mockStore.imgCtx = null! as CanvasRenderingContext2D
@@ -185,20 +185,20 @@ describe('useCanvasManager', () => {
const origImage = createMockImage(512, 512)
const maskImage = createMockImage(512, 512)
expect(() =>
await expect(
manager.invalidateCanvas(origImage, maskImage, null)
).toThrow('Canvas elements or contexts not available')
).rejects.toThrow('Canvas elements or contexts not available')
})
})
describe('updateMaskColor', () => {
it('should update mask color for black blend mode', () => {
it('should update mask color for black blend mode', async () => {
const manager = useCanvasManager()
mockStore.maskBlendMode = MaskBlendMode.Black
mockStore.maskColor = { r: 0, g: 0, b: 0 }
manager.updateMaskColor()
await 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', () => {
it('should update mask color for white blend mode', async () => {
const manager = useCanvasManager()
mockStore.maskBlendMode = MaskBlendMode.White
mockStore.maskColor = { r: 255, g: 255, b: 255 }
manager.updateMaskColor()
await 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', () => {
it('should update mask color for negative blend mode', async () => {
const manager = useCanvasManager()
mockStore.maskBlendMode = MaskBlendMode.Negative
mockStore.maskColor = { r: 255, g: 255, b: 255 }
manager.updateMaskColor()
await 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', () => {
it('should update all pixels with mask color', async () => {
const manager = useCanvasManager()
mockStore.maskColor = { r: 128, g: 64, b: 32 }
mockStore.maskCanvas.width = 100
mockStore.maskCanvas.height = 100
manager.updateMaskColor()
await 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', () => {
it('should return early when canvas missing', async () => {
const manager = useCanvasManager()
mockStore.maskCanvas = null! as HTMLCanvasElement
manager.updateMaskColor()
await manager.updateMaskColor()
expect(mockStore.maskCtx.getImageData).not.toHaveBeenCalled()
})
it('should return early when context missing', () => {
it('should return early when context missing', async () => {
const manager = useCanvasManager()
mockStore.maskCtx = null! as CanvasRenderingContext2D
manager.updateMaskColor()
await manager.updateMaskColor()
expect(mockStore.canvasBackground.style.backgroundColor).toBe('')
})
it('should handle different opacity values', () => {
it('should handle different opacity values', async () => {
const manager = useCanvasManager()
mockStore.maskOpacity = 0.5
manager.updateMaskColor()
await manager.updateMaskColor()
expect(mockStore.maskCanvas.style.opacity).toBe('0.5')
})
})
describe('prepareMask', () => {
it('should invert mask alpha', () => {
it('should invert mask alpha', async () => {
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)
manager.invalidateCanvas(origImage, maskImage, null)
await 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', () => {
it('should apply mask color to all pixels', async () => {
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)
manager.invalidateCanvas(origImage, maskImage, null)
await 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', () => {
it('should set composite operation', async () => {
const manager = useCanvasManager()
const origImage = createMockImage(100, 100)
const maskImage = createMockImage(100, 100)
manager.invalidateCanvas(origImage, maskImage, null)
await 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 = (
const prepareMask = async (
image: HTMLImageElement,
maskCanvasEl: HTMLCanvasElement,
maskContext: CanvasRenderingContext2D
): void => {
): Promise<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 = (
const invalidateCanvas = async (
origImage: HTMLImageElement,
maskImage: HTMLImageElement,
paintImage: HTMLImageElement | null
): void => {
): Promise<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)
}
prepareMask(maskImage, maskCanvas, maskCtx)
await prepareMask(maskImage, maskCanvas, maskCtx)
}
const setCanvasBackground = (): void => {
@@ -81,7 +81,7 @@ export function useCanvasManager() {
}
}
const updateMaskColor = (): void => {
const updateMaskColor = async (): Promise<void> => {
const { maskCanvas, maskCtx, maskColor, maskBlendMode, maskOpacity } = store
if (!maskCanvas || !maskCtx) return

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ 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
@@ -29,32 +30,6 @@ 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()
@@ -124,10 +99,31 @@ export function useMaskEditorSaver() {
const canvas = document.createElement('canvas')
canvas.width = imgCanvas.width
canvas.height = imgCanvas.height
const ctx = getContext2D(canvas)
const ctx = canvas.getContext('2d')!
ctx.drawImage(imgCanvas, 0, 0)
applyInvertedMaskAlpha(ctx, maskCanvas)
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)
const blob = await canvasToBlob(canvas)
const ref = createFileRef(filename)
@@ -154,9 +150,11 @@ export function useMaskEditorSaver() {
const canvas = document.createElement('canvas')
canvas.width = imgCanvas.width
canvas.height = imgCanvas.height
const ctx = getContext2D(canvas)
const ctx = canvas.getContext('2d')!
ctx.drawImage(imgCanvas, 0, 0)
ctx.globalCompositeOperation = 'source-over'
ctx.drawImage(paintCanvas, 0, 0)
const blob = await canvasToBlob(canvas)
@@ -174,11 +172,34 @@ export function useMaskEditorSaver() {
const canvas = document.createElement('canvas')
canvas.width = imgCanvas.width
canvas.height = imgCanvas.height
const ctx = getContext2D(canvas)
const ctx = canvas.getContext('2d')!
ctx.drawImage(imgCanvas, 0, 0)
ctx.globalCompositeOperation = 'source-over'
ctx.drawImage(paintCanvas, 0, 0)
applyInvertedMaskAlpha(ctx, maskCanvas)
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)
const blob = await canvasToBlob(canvas)
const ref = createFileRef(filename)
@@ -189,26 +210,16 @@ export function useMaskEditorSaver() {
async function uploadAllLayers(outputData: EditorOutputData): Promise<void> {
const sourceRef = dataStore.inputData!.sourceRef
const actualMaskedRef = await uploadLayer(
outputData.maskedImage,
sourceRef,
'/upload/mask'
)
const actualPaintRef = await uploadLayer(
outputData.paintLayer,
sourceRef,
'/upload/image'
)
const actualPaintedRef = await uploadLayer(
const actualMaskedRef = await uploadMask(outputData.maskedImage, sourceRef)
const actualPaintRef = await uploadImage(outputData.paintLayer, sourceRef)
const actualPaintedRef = await uploadImage(
outputData.paintedImage,
sourceRef,
'/upload/image'
sourceRef
)
const actualPaintedMaskedRef = await uploadLayer(
const actualPaintedMaskedRef = await uploadMask(
outputData.paintedMaskedImage,
actualPaintedRef,
'/upload/mask'
actualPaintedRef
)
outputData.maskedImage.ref = actualMaskedRef
@@ -217,10 +228,9 @@ export function useMaskEditorSaver() {
outputData.paintedMaskedImage.ref = actualPaintedMaskedRef
}
async function uploadLayer(
async function uploadMask(
layer: EditorOutputLayer,
originalRef: ImageRef,
endpoint: '/upload/mask' | '/upload/image'
originalRef: ImageRef
): Promise<ImageRef> {
const formData = new FormData()
formData.append('image', layer.blob, layer.ref.filename)
@@ -228,22 +238,61 @@ export function useMaskEditorSaver() {
formData.append('type', 'input')
formData.append('subfolder', 'clipspace')
const response = await api.fetchApi(endpoint, {
const response = await api.fetchApi('/upload/mask', {
method: 'POST',
body: formData
})
if (!response.ok) {
throw new Error(`Failed to upload to ${endpoint}: ${layer.ref.filename}`)
throw new Error(`Failed to upload mask: ${layer.ref.filename}`)
}
const data = await response.json()
if (data?.name) {
return {
filename: data.name,
subfolder: data.subfolder || layer.ref.subfolder,
type: data.type || layer.ref.type
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
}
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
@@ -324,7 +373,7 @@ export function useMaskEditorSaver() {
const canvas = document.createElement('canvas')
canvas.width = source.width
canvas.height = source.height
const ctx = getContext2D(canvas)
const ctx = canvas.getContext('2d')!
ctx.drawImage(source, 0, 0)
return canvas
}

View File

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

View File

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

View File

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

View File

@@ -46,13 +46,16 @@ 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
@@ -61,17 +64,20 @@ 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)
@@ -95,6 +101,8 @@ 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>()
function taskIdToKey(id: string | number | undefined) {
if (id === undefined) return null
const taskIdToKey = (id: string | number | undefined) => {
if (id === null || 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: (blob: Blob, filename: string) =>
downloadBlobMock(blob, filename)
downloadBlob: (filename: string, blob: Blob) =>
downloadBlobMock(filename, blob)
}))
const dialogServiceMock = {
@@ -594,7 +594,7 @@ describe('useJobMenu', () => {
expect(dialogServiceMock.prompt).not.toHaveBeenCalled()
expect(downloadBlobMock).toHaveBeenCalledTimes(1)
const [blob, filename] = downloadBlobMock.mock.calls[0]
const [filename, blob] = 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 = () => {
const addOutputLoaderNode = async () => {
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(blob, filename)
downloadBlob(filename, blob)
}
const deleteJobAsset = async () => {

View File

@@ -22,7 +22,7 @@ export const useBrowserTabTitle = () => {
: `[${Math.round(executionStore.executionProgress * 100)}%]`
)
const isMenuBarActive = computed(
const newMenuEnabled = computed(
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
)
@@ -37,12 +37,13 @@ export const useBrowserTabTitle = () => {
() => !!workflowStore.activeWorkflow?.isPersisted
)
const shouldShowUnsavedIndicator = computed(
() =>
!workspaceStore.shiftDown &&
!isAutoSaveEnabled.value &&
(!isActiveWorkflowPersisted.value || isActiveWorkflowModified.value)
)
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 isUnsavedText = computed(() =>
shouldShowUnsavedIndicator.value ? ' *' : ''
@@ -86,7 +87,7 @@ export const useBrowserTabTitle = () => {
const workflowTitle = computed(
() =>
executionText.value +
(isMenuBarActive.value ? workflowNameText.value : DEFAULT_TITLE)
(newMenuEnabled.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) {
console.warn('Cached request failed, caching null result:', err)
// Set cache on error to prevent retrying bad requests
cache.set(cacheKey, null)
return null
} finally {
@@ -86,11 +86,11 @@ export function useCachedRequest<TParams, TResult>(
/**
* Cached version of the request function
*/
const call = (params: TParams): Promise<TResult | null> => {
const call = async (params: TParams): Promise<TResult | null> => {
const cacheKey = cacheKeyFn(params)
const cachedResult = cache.get(cacheKey)
if (cachedResult !== undefined) return Promise.resolve(cachedResult)
if (cachedResult !== undefined) return cachedResult
const pendingRequest = pendingRequests.get(cacheKey)
if (pendingRequest) return handlePendingRequest(pendingRequest)

View File

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

View File

@@ -1,7 +1,8 @@
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 { useHelpCommands } from '@/composables/useHelpCommands'
import { useExternalLink } from '@/composables/useExternalLink'
import { useModelSelectorDialog } from '@/composables/useModelSelectorDialog'
import {
DEFAULT_DARK_COLOR_PALETTE,
@@ -21,6 +22,7 @@ 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'
@@ -81,6 +83,7 @@ export function useCoreCommands(): ComfyCommand[] {
const canvasStore = useCanvasStore()
const executionStore = useExecutionStore()
const telemetry = useTelemetry()
const { staticUrls, buildDocsUrl } = useExternalLink()
const settingStore = useSettingStore()
const bottomPanelStore = useBottomPanelStore()
@@ -772,7 +775,51 @@ export function useCoreCommands(): ComfyCommand[] {
}
}
},
...useHelpCommands(),
{
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')
}
},
{
id: 'Workspace.SearchBox.Toggle',
icon: 'pi pi-search',
@@ -782,6 +829,16 @@ 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',
@@ -801,6 +858,35 @@ 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]',
@@ -819,14 +905,6 @@ 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]',
@@ -841,12 +919,6 @@ 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)
}
@@ -1177,29 +1249,7 @@ export function useCoreCommands(): ComfyCommand[] {
})
return
}
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
})
}
await api.freeMemory({ freeExecutionCache: false })
}
},
{
@@ -1219,29 +1269,7 @@ export function useCoreCommands(): ComfyCommand[] {
})
return
}
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
})
}
await api.freeMemory({ freeExecutionCache: true })
}
},
{

View File

@@ -5,7 +5,23 @@ import { electronAPI } from '@/utils/envUtil'
import { i18n } from '@/i18n'
/**
* Composable for building docs.comfy.org URLs with automatic locale and platform detection.
* 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)
* ```
*/
export function useExternalLink() {
const locale = computed(() => String(i18n.global.locale.value))

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