Compare commits

..

1 Commits

Author SHA1 Message Date
dante01yoon
7faf9eafde fix: resolve empty combo options for promoted subgraph widgets
PromotedWidgetView never registers in widgetValueStore, so reading
options from the store always returned {}. Read options directly from
the widget for promoted views instead.

Fixes #9012

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 10:01:26 +09:00
302 changed files with 3057 additions and 6217 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

@@ -1,13 +1,13 @@
<template>
<router-view />
<GlobalDialog />
<BlockUI full-screen :blocked="isLoading" />
<div
v-if="isLoading"
class="pointer-events-none fixed inset-0 z-1200 flex items-center justify-center"
class="absolute inset-0 flex items-center justify-center"
>
<LogoComfyWaveLoader size="xl" color="yellow" />
<Loader size="lg" class="text-white" />
</div>
<GlobalDialog />
<BlockUI full-screen :blocked="isLoading" />
</template>
<script setup lang="ts">
@@ -15,7 +15,7 @@ import { captureException } from '@sentry/vue'
import BlockUI from 'primevue/blockui'
import { computed, onMounted } from 'vue'
import LogoComfyWaveLoader from '@/components/loader/LogoComfyWaveLoader.vue'
import Loader from '@/components/common/Loader.vue'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { useWorkspaceStore } from '@/stores/workspaceStore'

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

@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Loader from './Loader.vue'
const meta: Meta<typeof Loader> = {
title: 'Components/Loader/Loader',
title: 'Components/Common/Loader',
component: Loader,
tags: ['autodocs'],
parameters: {

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

@@ -1,96 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import LogoCFillLoader from './LogoCFillLoader.vue'
const meta: Meta<typeof LogoCFillLoader> = {
title: 'Components/Loader/LogoCFillLoader',
component: LogoCFillLoader,
tags: ['autodocs'],
parameters: {
layout: 'centered',
backgrounds: { default: 'dark' }
},
argTypes: {
size: {
control: 'select',
options: ['sm', 'md', 'lg', 'xl']
},
color: {
control: 'select',
options: ['yellow', 'blue', 'white', 'black']
},
bordered: {
control: 'boolean'
},
disableAnimation: {
control: 'boolean'
}
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const Small: Story = {
args: { size: 'sm' }
}
export const Large: Story = {
args: { size: 'lg' }
}
export const ExtraLarge: Story = {
args: { size: 'xl' }
}
export const NoBorder: Story = {
args: { bordered: false }
}
export const Static: Story = {
args: { disableAnimation: true }
}
export const BrandColors: Story = {
render: () => ({
components: { LogoCFillLoader },
template: `
<div class="flex items-end gap-12">
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-neutral-400">Yellow</span>
<LogoCFillLoader size="lg" color="yellow" />
</div>
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-neutral-400">Blue</span>
<LogoCFillLoader size="lg" color="blue" />
</div>
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-neutral-400">White</span>
<LogoCFillLoader size="lg" color="white" />
</div>
<div class="p-4 bg-white rounded" style="background: white">
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-neutral-600">Black</span>
<LogoCFillLoader size="lg" color="black" />
</div>
</div>
</div>
`
})
}
export const AllSizes: Story = {
render: () => ({
components: { LogoCFillLoader },
template: `
<div class="flex items-end gap-8">
<LogoCFillLoader size="sm" color="yellow" />
<LogoCFillLoader size="md" color="yellow" />
<LogoCFillLoader size="lg" color="yellow" />
<LogoCFillLoader size="xl" color="yellow" />
</div>
`
})
}

View File

@@ -1,100 +0,0 @@
<template>
<span role="status" :class="cn('inline-flex', colorClass)">
<svg
:width="Math.round(heightMap[size] * (VB_W / VB_H))"
:height="heightMap[size]"
:viewBox="`0 0 ${VB_W} ${VB_H}`"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<mask :id="maskId">
<path :d="C_PATH" fill="white" />
</mask>
</defs>
<path
v-if="bordered"
:d="C_PATH"
stroke="currentColor"
stroke-width="2"
fill="none"
opacity="0.4"
/>
<g :mask="`url(#${maskId})`">
<rect
:class="disableAnimation ? undefined : 'c-fill-rect'"
:x="-BLEED"
:y="-BLEED"
:width="VB_W + BLEED * 2"
:height="VB_H + BLEED * 2"
fill="currentColor"
/>
</g>
</svg>
<span class="sr-only">{{ t('g.loading') }}</span>
</span>
</template>
<script setup lang="ts">
import { useId, computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { useI18n } from 'vue-i18n'
const {
size = 'md',
color = 'black',
bordered = true,
disableAnimation = false
} = defineProps<{
size?: 'sm' | 'md' | 'lg' | 'xl'
color?: 'yellow' | 'blue' | 'white' | 'black'
bordered?: boolean
disableAnimation?: boolean
}>()
const { t } = useI18n()
const maskId = `c-mask-${useId()}`
const VB_W = 185
const VB_H = 201
const BLEED = 1
// Larger than LogoComfyWaveLoader because the C logo is near-square (185×201)
// while the COMFY wordmark is wide (879×284), so larger heights are needed
// for visually comparable perceived size.
const heightMap = { sm: 48, md: 80, lg: 120, xl: 200 } as const
const colorMap = {
yellow: 'text-brand-yellow',
blue: 'text-brand-blue',
white: 'text-white',
black: 'text-black'
} as const
const colorClass = computed(() => colorMap[color])
const C_PATH =
'M42.1217 200.812C37.367 200.812 33.5304 199.045 31.0285 195.703C28.4569 192.27 27.7864 187.477 29.1882 182.557L34.8172 162.791C35.2661 161.217 34.9537 159.523 33.9747 158.214C32.9958 156.908 31.464 156.139 29.8371 156.139L13.6525 156.139C8.89521 156.139 5.05862 154.374 2.55797 151.032C-0.0136533 147.597-0.684085 142.804 0.71869 137.883L20.0565 70.289L22.1916 62.8625C25.0617 52.7847 35.5288 44.5943 45.528 44.5943L64.8938 44.5943C67.2048 44.5943 69.2376 43.0535 69.8738 40.8175L76.2782 18.3344C79.1454 8.26681 89.6127 0.0763962 99.6117 0.0763945L141.029 0.00258328L171.349-2.99253e-05C176.104-3.0756e-05 179.941 1.765 182.442 5.10626C185.013 8.53932 185.684 13.3324 184.282 18.2528L175.612 48.6947C172.746 58.7597 162.279 66.9475 152.28 66.9475L110.771 67.0265L91.4113 67.0265C89.1029 67.0265 87.0727 68.5647 86.4326 70.7983L70.2909 127.179C69.8394 128.756 70.1518 130.454 71.1334 131.763C72.1123 133.07 73.6441 133.839 75.2697 133.839C75.2736 133.839 102.699 133.785 102.699 133.785L132.929 133.785C137.685 133.785 141.522 135.55 144.023 138.892C146.594 142.327 147.265 147.12 145.862 152.041L137.192 182.478C134.326 192.545 123.859 200.733 113.86 200.733L72.3517 200.812L42.1217 200.812Z'
</script>
<style scoped>
.c-fill-rect {
animation: c-fill-up 2.5s cubic-bezier(0.25, 0, 0.3, 1) forwards;
will-change: transform;
}
@keyframes c-fill-up {
0% {
transform: translateY(calc(v-bind(VB_H) * 1px + v-bind(BLEED) * 1px));
}
100% {
transform: translateY(calc(v-bind(BLEED) * -1px));
}
}
@media (prefers-reduced-motion: reduce) {
.c-fill-rect {
animation: none;
}
}
</style>

View File

@@ -1,96 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import LogoComfyWaveLoader from './LogoComfyWaveLoader.vue'
const meta: Meta<typeof LogoComfyWaveLoader> = {
title: 'Components/Loader/LogoComfyWaveLoader',
component: LogoComfyWaveLoader,
tags: ['autodocs'],
parameters: {
layout: 'centered',
backgrounds: { default: 'dark' }
},
argTypes: {
size: {
control: 'select',
options: ['sm', 'md', 'lg', 'xl']
},
color: {
control: 'select',
options: ['yellow', 'blue', 'white', 'black']
},
bordered: {
control: 'boolean'
},
disableAnimation: {
control: 'boolean'
}
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const Small: Story = {
args: { size: 'sm' }
}
export const Large: Story = {
args: { size: 'lg' }
}
export const ExtraLarge: Story = {
args: { size: 'xl' }
}
export const NoBorder: Story = {
args: { bordered: false }
}
export const Static: Story = {
args: { disableAnimation: true }
}
export const BrandColors: Story = {
render: () => ({
components: { LogoComfyWaveLoader },
template: `
<div class="flex flex-col items-center gap-12">
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-neutral-400">#F0FF41 (Yellow)</span>
<LogoComfyWaveLoader size="lg" color="yellow" />
</div>
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-neutral-400">#172DD7 (Blue)</span>
<LogoComfyWaveLoader size="lg" color="blue" />
</div>
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-neutral-400">White</span>
<LogoComfyWaveLoader size="lg" color="white" />
</div>
<div class="p-4 bg-white rounded" style="background: white">
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-neutral-600">Black</span>
<LogoComfyWaveLoader size="lg" color="black" />
</div>
</div>
</div>
`
})
}
export const AllSizes: Story = {
render: () => ({
components: { LogoComfyWaveLoader },
template: `
<div class="flex flex-col items-center gap-8">
<LogoComfyWaveLoader size="sm" color="yellow" />
<LogoComfyWaveLoader size="md" color="yellow" />
<LogoComfyWaveLoader size="lg" color="yellow" />
<LogoComfyWaveLoader size="xl" color="yellow" />
</div>
`
})
}

File diff suppressed because one or more lines are too long

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

@@ -2,7 +2,7 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Loader from '@/components/loader/Loader.vue'
import Loader from '@/components/common/Loader.vue'
import StatusBadge from '@/components/common/StatusBadge.vue'
import type { AssetDownload } from '@/stores/assetDownloadStore'
import { cn } from '@/utils/tailwindUtil'

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)
}

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