Compare commits

..

14 Commits

Author SHA1 Message Date
bymyself
dc424f3d29 test: add failing test for batch image node stacking bug 2026-03-16 00:55:02 +00:00
bymyself
75022f302b fix: use node.pos/size instead of getBounding for positioning
getBounding returns stale data for freshly created nodes since
updateArea only runs at the start of each render frame. Use
node.pos and node.size directly which are set immediately by
createNode.
2026-03-15 10:37:43 +00:00
bymyself
959f1365dd fix: restore position-before-connect order to match main
Connections can modify node bounding boxes (adding input slots), so
positioning must happen before connect() to get correct getBounding()
values. This matches the operation order on main.
2026-03-15 10:03:04 +00:00
bymyself
216c707c87 fix: make PasteNodesResult non-exported to pass knip 2026-03-15 07:27:20 +00:00
bymyself
47a2f312c8 fix: await upload completion before closing undo batch
Make pasteFiles async so upload completion is awaitable. Keep the
emitBeforeChange/emitAfterChange transaction open until all uploads
finish, ensuring widget value changes from onUploadComplete are
captured in the same undo entry as the node creation.
2026-03-15 07:21:49 +00:00
bymyself
80a4b31da4 fix: update test to match restored selectItems call in multi-image branch 2026-03-15 05:01:05 +00:00
bymyself
93bb2d0d6f fix: restore positionBatchNodes/selectItems for multi-image and deduplicate getFileType
- Add back positionBatchNodes and selectItems calls in multi-image branch
- Extract duplicate getFileType into a module-level function
2026-03-12 08:06:30 -07:00
bymyself
f04809d124 test: fix undo test to call changeTracker.undo() directly
Amp-Thread-ID: https://ampcode.com/threads/T-019cbb7e-7e09-7668-bac9-ff06ee44a8e4
2026-03-04 21:55:12 -08:00
bymyself
6171bd9ac4 test: fix batch image import E2E tests for CI reliability
Amp-Thread-ID: https://ampcode.com/threads/T-019cbb7e-7e09-7668-bac9-ff06ee44a8e4
2026-03-04 21:45:36 -08:00
GitHub Action
218cf60f5f [automated] Apply ESLint and Oxfmt fixes 2026-03-05 05:26:04 +00:00
bymyself
fc56f7ee85 test: add E2E tests for batch image import and undo
Amp-Thread-ID: https://ampcode.com/threads/T-019cbb7e-7e09-7668-bac9-ff06ee44a8e4
2026-03-04 21:23:34 -08:00
bymyself
c664b5bc38 fix: update test mock canvas with emitBeforeChange/emitAfterChange and correct selectItems assertion
Amp-Thread-ID: https://ampcode.com/threads/T-019cbb71-dfcd-772a-8995-5fc69ae59f4b
2026-03-04 16:45:13 -08:00
GitHub Action
724d60822a [automated] Apply ESLint and Oxfmt fixes 2026-03-04 21:22:34 +00:00
bymyself
a15d3ce49b fix: batch image import into single undo entry
Wrap handleFileList in emitBeforeChange()/emitAfterChange() so
dropping multiple images creates one undo entry instead of one per
node. Add JSDoc to the undocumented batching APIs and unit tests
for ChangeTracker batching.
2026-03-04 13:20:17 -08:00
112 changed files with 919 additions and 2482 deletions

View File

@@ -45,7 +45,7 @@ jobs:
- name: Run performance tests
id: perf
continue-on-error: true
run: pnpm exec playwright test --project=performance --workers=1 --repeat-each=3
run: pnpm exec playwright test --project=performance --workers=1
- name: Upload perf metrics
if: always()
@@ -61,7 +61,6 @@ jobs:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
pull-requests: write
@@ -91,31 +90,6 @@ jobs:
path: temp/perf-baseline/
if_no_artifact_found: warn
- name: Download historical perf baselines
continue-on-error: true
run: |
RUNS=$(gh api \
"/repos/${{ github.repository }}/actions/workflows/ci-perf-report.yaml/runs?branch=${{ github.event.pull_request.base.ref }}&event=push&status=success&per_page=5" \
--jq '.workflow_runs[].id' || true)
if [ -z "$RUNS" ]; then
echo "No historical runs available"
exit 0
fi
mkdir -p temp/perf-history
INDEX=0
for RUN_ID in $RUNS; do
DIR="temp/perf-history/$INDEX"
mkdir -p "$DIR"
gh run download "$RUN_ID" -n perf-metrics -D "$DIR/" 2>/dev/null || true
INDEX=$((INDEX + 1))
done
echo "Downloaded $(ls temp/perf-history/*/perf-metrics.json 2>/dev/null | wc -l) historical baselines"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Generate perf report
run: npx --yes tsx scripts/perf-report.ts > perf-report.md

View File

@@ -35,7 +35,7 @@
}
],
"no-control-regex": "off",
"no-eval": "error",
"no-eval": "off",
"no-redeclare": "error",
"no-restricted-imports": [
"error",

View File

@@ -4,6 +4,17 @@ import type { Page } from '@playwright/test'
import type { Position } from '../types'
function getFileType(fileName: string): string {
if (fileName.endsWith('.png')) return 'image/png'
if (fileName.endsWith('.svg')) return 'image/svg+xml'
if (fileName.endsWith('.webp')) return 'image/webp'
if (fileName.endsWith('.webm')) return 'video/webm'
if (fileName.endsWith('.json')) return 'application/json'
if (fileName.endsWith('.glb')) return 'model/gltf-binary'
if (fileName.endsWith('.avif')) return 'image/avif'
return 'application/octet-stream'
}
export class DragDropHelper {
constructor(
private readonly page: Page,
@@ -48,17 +59,6 @@ export class DragDropHelper {
const filePath = this.assetPath(fileName)
const buffer = readFileSync(filePath)
const getFileType = (fileName: string) => {
if (fileName.endsWith('.png')) return 'image/png'
if (fileName.endsWith('.svg')) return 'image/svg+xml'
if (fileName.endsWith('.webp')) return 'image/webp'
if (fileName.endsWith('.webm')) return 'video/webm'
if (fileName.endsWith('.json')) return 'application/json'
if (fileName.endsWith('.glb')) return 'model/gltf-binary'
if (fileName.endsWith('.avif')) return 'image/avif'
return 'application/octet-stream'
}
evaluateParams.fileName = fileName
evaluateParams.fileType = getFileType(fileName)
evaluateParams.buffer = [...new Uint8Array(buffer)]
@@ -155,6 +155,104 @@ export class DragDropHelper {
await this.nextFrame()
}
async dragAndDropFiles(
fileNames: string[],
options: {
dropPosition?: Position
waitForUploadCount?: number
} = {}
): Promise<void> {
const { dropPosition = { x: 100, y: 100 }, waitForUploadCount = 0 } =
options
const files = fileNames.map((fileName) => {
const filePath = this.assetPath(fileName)
const buffer = readFileSync(filePath)
return {
fileName,
fileType: getFileType(fileName),
buffer: [...new Uint8Array(buffer)]
}
})
let uploadResponsePromise: Promise<unknown> | null = null
if (waitForUploadCount > 0) {
let uploadCount = 0
uploadResponsePromise = new Promise<void>((resolve) => {
const handler = (resp: { url(): string; status(): number }) => {
if (resp.url().includes('/upload/') && resp.status() === 200) {
uploadCount++
if (uploadCount >= waitForUploadCount) {
this.page.off('response', handler)
resolve()
}
}
}
this.page.on('response', handler)
})
}
await this.page.evaluate(
async (params) => {
const dataTransfer = new DataTransfer()
for (const f of params.files) {
const file = new File([new Uint8Array(f.buffer)], f.fileName, {
type: f.fileType
})
dataTransfer.items.add(file)
}
const targetElement = document.elementFromPoint(
params.dropPosition.x,
params.dropPosition.y
)
if (!targetElement) {
throw new Error(
`No element found at drop position: (${params.dropPosition.x}, ${params.dropPosition.y}).`
)
}
const eventOptions = {
bubbles: true,
cancelable: true,
dataTransfer,
clientX: params.dropPosition.x,
clientY: params.dropPosition.y
}
const graphCanvasElement = document.querySelector('#graph-canvas')
if (graphCanvasElement && !graphCanvasElement.contains(targetElement)) {
graphCanvasElement.dispatchEvent(
new DragEvent('dragover', eventOptions)
)
}
const dropEvent = new DragEvent('drop', eventOptions)
Object.defineProperty(dropEvent, 'preventDefault', {
value: () => {},
writable: false
})
Object.defineProperty(dropEvent, 'stopPropagation', {
value: () => {},
writable: false
})
targetElement.dispatchEvent(new DragEvent('dragover', eventOptions))
targetElement.dispatchEvent(dropEvent)
},
{ files, dropPosition }
)
if (uploadResponsePromise) {
await uploadResponsePromise
}
await this.nextFrame()
}
async dragAndDropFile(
fileName: string,
options: { dropPosition?: Position; waitForUpload?: boolean } = {}

View File

@@ -0,0 +1,90 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import type { WorkspaceStore } from '../types/globals'
test.describe('Batch Image Import', () => {
test('Dropping multiple images creates LoadImage nodes and a BatchImagesNode', async ({
comfyPage
}) => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.dragDrop.dragAndDropFiles(
['image32x32.webp', 'image64x64.webp'],
{ waitForUploadCount: 2 }
)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 10000 })
.toBe(initialCount + 3)
const batchNodes =
await comfyPage.nodeOps.getNodeRefsByType('BatchImagesNode')
expect(batchNodes).toHaveLength(1)
})
test('Dropping a single image does not create a BatchImagesNode', async ({
comfyPage
}) => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.dragDrop.dragAndDropFile('image32x32.webp', {
waitForUpload: true
})
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 10000 })
.toBe(initialCount + 1)
const batchNodes =
await comfyPage.nodeOps.getNodeRefsByType('BatchImagesNode')
expect(batchNodes).toHaveLength(0)
})
test('Batch image import produces a single undo entry', async ({
comfyPage
}) => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
const initialUndoSize = await comfyPage.workflow.getUndoQueueSize()
await comfyPage.dragDrop.dragAndDropFiles(
['image32x32.webp', 'image64x64.webp'],
{ waitForUploadCount: 2 }
)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 10000 })
.toBe(initialCount + 3)
await expect
.poll(() => comfyPage.workflow.getUndoQueueSize(), { timeout: 5000 })
.toBe((initialUndoSize ?? 0) + 1)
})
test('Batch image import can be undone as a single action', async ({
comfyPage
}) => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.dragDrop.dragAndDropFiles(
['image32x32.webp', 'image64x64.webp'],
{ waitForUploadCount: 2 }
)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 10000 })
.toBe(initialCount + 3)
// Call undo directly on the change tracker to avoid keyboard focus issues
await comfyPage.page.evaluate(async () => {
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow
await workflow?.changeTracker.undo()
})
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 10000 })
.toBe(initialCount)
})
})

View File

@@ -0,0 +1,63 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Batch Image Node Positioning', () => {
test('Dropped image nodes should not overlap', async ({ comfyPage }) => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.dragDrop.dragAndDropFiles(
['image32x32.webp', 'image64x64.webp'],
{ waitForUploadCount: 2 }
)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 10000 })
.toBe(initialCount + 3)
const loadImageNodes =
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
expect(loadImageNodes).toHaveLength(2)
// Get bounding boxes via page.evaluate to read all node bounds atomically
const boundingBoxes = await comfyPage.page.evaluate(() => {
const graph = window.app!.graph
const loadImageNodes = graph.nodes.filter((n) => n.type === 'LoadImage')
return loadImageNodes.map((node) => {
const [x, y, w, h] = node.getBounding()
return { x, y, width: w, height: h }
})
})
expect(boundingBoxes).toHaveLength(2)
// Assert: no two bounding boxes overlap
for (let i = 0; i < boundingBoxes.length; i++) {
for (let j = i + 1; j < boundingBoxes.length; j++) {
const a = boundingBoxes[i]
const b = boundingBoxes[j]
const overlapsX = a.x < b.x + b.width && a.x + a.width > b.x
const overlapsY = a.y < b.y + b.height && a.y + a.height > b.y
const overlaps = overlapsX && overlapsY
expect(
overlaps,
`Nodes ${i} and ${j} overlap! ` +
`Node ${i}: (${a.x}, ${a.y}, ${a.width}x${a.height}), ` +
`Node ${j}: (${b.x}, ${b.y}, ${b.width}x${b.height})`
).toBe(false)
}
}
// Verify minimum spacing between consecutive nodes (sorted by Y position)
const sorted = [...boundingBoxes].sort((a, b) => a.y - b.y)
for (let i = 0; i < sorted.length - 1; i++) {
const gap = sorted[i + 1].y - (sorted[i].y + sorted[i].height)
expect(
gap,
`Gap between nodes ${i} and ${i + 1} is ${gap}px, expected >= 10px`
).toBeGreaterThanOrEqual(10)
}
})
})

View File

@@ -67,66 +67,4 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
recordMeasurement(m)
console.log(`Clipping: ${m.layouts} forced layouts`)
})
test('subgraph idle style recalculations', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/nested-subgraph')
await comfyPage.perf.startMeasuring()
for (let i = 0; i < 120; i++) {
await comfyPage.nextFrame()
}
const m = await comfyPage.perf.stopMeasuring('subgraph-idle')
recordMeasurement(m)
console.log(
`Subgraph idle: ${m.styleRecalcs} style recalcs, ${m.layouts} layouts`
)
})
test('subgraph mouse interaction style recalculations', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/nested-subgraph')
await comfyPage.perf.startMeasuring()
const canvas = comfyPage.canvas
const box = await canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not available')
for (let i = 0; i < 100; i++) {
await comfyPage.page.mouse.move(
box.x + (box.width * i) / 100,
box.y + (box.height * (i % 3)) / 3
)
}
const m = await comfyPage.perf.stopMeasuring('subgraph-mouse-sweep')
recordMeasurement(m)
console.log(
`Subgraph mouse sweep: ${m.styleRecalcs} style recalcs, ${m.layouts} layouts`
)
})
test('subgraph DOM widget clipping during node selection', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/nested-subgraph')
await comfyPage.perf.startMeasuring()
const canvas = comfyPage.canvas
const box = await canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not available')
for (let i = 0; i < 20; i++) {
await comfyPage.page.mouse.click(
box.x + box.width / 3 + (i % 5) * 30,
box.y + box.height / 3 + (i % 4) * 30
)
await comfyPage.nextFrame()
}
const m = await comfyPage.perf.stopMeasuring('subgraph-dom-widget-clipping')
recordMeasurement(m)
console.log(`Subgraph clipping: ${m.layouts} forced layouts`)
})
})

View File

@@ -1,7 +1,6 @@
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
import pluginJs from '@eslint/js'
import pluginI18n from '@intlify/eslint-plugin-vue-i18n'
import betterTailwindcss from 'eslint-plugin-better-tailwindcss'
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'
import { importX } from 'eslint-plugin-import-x'
import oxlint from 'eslint-plugin-oxlint'
@@ -112,28 +111,6 @@ export default defineConfig([
tseslintConfigs.recommended,
// Difference in typecheck on CI vs Local
pluginVue.configs['flat/recommended'],
// Tailwind CSS v4 linting (class ordering, duplicates, conflicts, etc.)
betterTailwindcss.configs.recommended,
{
settings: {
'better-tailwindcss': {
entryPoint: 'packages/design-system/src/css/style.css'
}
},
rules: {
// Off: requires whitelisting non-Tailwind classes (PrimeIcons, custom CSS)
'better-tailwindcss/no-unknown-classes': 'off',
// Off: may conflict with oxfmt formatting
'better-tailwindcss/enforce-consistent-line-wrapping': 'off',
// Off: large batch change, enable and apply with `eslint --fix`
'better-tailwindcss/enforce-consistent-class-order': 'off',
// Off: large batch change (v3→v4 renames like rounded→rounded-sm),
// enable and apply with `eslint --fix` in a follow-up PR
'better-tailwindcss/enforce-canonical-classes': 'off',
// Off: large batch change, enable and apply with `eslint --fix`
'better-tailwindcss/no-deprecated-classes': 'off'
}
},
// Disables ESLint rules that conflict with formatters
eslintConfigPrettier,
// @ts-expect-error Type incompatibility between storybook plugin and ESLint config types

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.41.11",
"version": "1.41.10",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -146,7 +146,6 @@
"eslint": "catalog:",
"eslint-config-prettier": "catalog:",
"eslint-import-resolver-typescript": "catalog:",
"eslint-plugin-better-tailwindcss": "catalog:",
"eslint-plugin-import-x": "catalog:",
"eslint-plugin-oxlint": "catalog:",
"eslint-plugin-storybook": "catalog:",

75
pnpm-lock.yaml generated
View File

@@ -180,9 +180,6 @@ catalogs:
eslint-import-resolver-typescript:
specifier: ^4.4.4
version: 4.4.4
eslint-plugin-better-tailwindcss:
specifier: ^4.3.1
version: 4.3.1
eslint-plugin-import-x:
specifier: ^4.16.1
version: 4.16.1
@@ -639,9 +636,6 @@ importers:
eslint-import-resolver-typescript:
specifier: 'catalog:'
version: 4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.56.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1))
eslint-plugin-better-tailwindcss:
specifier: 'catalog:'
version: 4.3.1(eslint@9.39.1(jiti@2.6.1))(oxlint@1.49.0(oxlint-tsgolint@0.14.2))(tailwindcss@4.2.0)(typescript@5.9.3)
eslint-plugin-import-x:
specifier: 'catalog:'
version: 4.16.1(@typescript-eslint/utils@8.56.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1))
@@ -1922,10 +1916,6 @@ packages:
resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/css-tree@3.6.9':
resolution: {integrity: sha512-3D5/OHibNEGk+wKwNwMbz63NMf367EoR4mVNNpxddCHKEb2Nez7z62J2U6YjtErSsZDoY0CsccmoUpdEbkogNA==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
'@eslint/eslintrc@3.3.3':
resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -5324,19 +5314,6 @@ packages:
eslint-import-resolver-webpack:
optional: true
eslint-plugin-better-tailwindcss@4.3.1:
resolution: {integrity: sha512-b6xM31GukKz0WlgMD0tQdY/rLjf/9mWIk8EcA45ngOKJPPQf1C482xZtBlT357jyunQE2mOk4NlPcL4i9Pr85A==}
engines: {node: ^20.19.0 || ^22.12.0 || >=23.0.0}
peerDependencies:
eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0
oxlint: ^1.35.0
tailwindcss: ^3.3.0 || ^4.1.17
peerDependenciesMeta:
eslint:
optional: true
oxlint:
optional: true
eslint-plugin-import-x@4.16.1:
resolution: {integrity: sha512-vPZZsiOKaBAIATpFE2uMI4w5IRwdv/FpQ+qZZMR4E+PeOcM4OeoEbqxRMnywdxP19TyB/3h6QBB0EWon7letSQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -6581,9 +6558,6 @@ packages:
mdn-data@2.12.2:
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
mdn-data@2.23.0:
resolution: {integrity: sha512-786vq1+4079JSeu2XdcDjrhi/Ry7BWtjDl9WtGPWLiIHb2T66GvIVflZTBoSNZ5JqTtJGYEVMuFA/lbQlMOyDQ==}
mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
@@ -7806,10 +7780,6 @@ packages:
resolution: {integrity: sha512-2SG1TnJGjMkD4+gblONMGYSrwAzYi+ymOitD+Jb/iMYm57nH20PlkVeMQRah3yDMKEa0QQYUF/QPWpdW7C6zNg==}
engines: {node: ^14.18.0 || >=16.0.0}
synckit@0.11.12:
resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==}
engines: {node: ^14.18.0 || >=16.0.0}
table@6.9.0:
resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==}
engines: {node: '>=10.0.0'}
@@ -7818,10 +7788,6 @@ packages:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'}
tailwind-csstree@0.1.4:
resolution: {integrity: sha512-FzD187HuFIZEyeR7Xy6sJbJll2d4SybS90satC8SKIuaNRC05CxMvdzN7BUsfDQffcnabckRM5OIcfArjsZ0mg==}
engines: {node: '>=18.18'}
tailwind-merge@2.6.0:
resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==}
@@ -7952,10 +7918,6 @@ packages:
ts-map@1.0.3:
resolution: {integrity: sha512-vDWbsl26LIcPGmDpoVzjEP6+hvHZkBkLW7JpvwbCv/5IYPJlsbzCVXY3wsCeAxAUeTclNOUZxnLdGh3VBD/J6w==}
tsconfig-paths-webpack-plugin@4.2.0:
resolution: {integrity: sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==}
engines: {node: '>=10.13.0'}
tsconfig-paths@3.15.0:
resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
@@ -9746,11 +9708,6 @@ snapshots:
dependencies:
'@types/json-schema': 7.0.15
'@eslint/css-tree@3.6.9':
dependencies:
mdn-data: 2.23.0
source-map-js: 1.2.1
'@eslint/eslintrc@3.3.3':
dependencies:
ajv: 6.14.0
@@ -13500,23 +13457,6 @@ snapshots:
- supports-color
optional: true
eslint-plugin-better-tailwindcss@4.3.1(eslint@9.39.1(jiti@2.6.1))(oxlint@1.49.0(oxlint-tsgolint@0.14.2))(tailwindcss@4.2.0)(typescript@5.9.3):
dependencies:
'@eslint/css-tree': 3.6.9
'@valibot/to-json-schema': 1.5.0(valibot@1.2.0(typescript@5.9.3))
enhanced-resolve: 5.19.0
jiti: 2.6.1
synckit: 0.11.12
tailwind-csstree: 0.1.4
tailwindcss: 4.2.0
tsconfig-paths-webpack-plugin: 4.2.0
valibot: 1.2.0(typescript@5.9.3)
optionalDependencies:
eslint: 9.39.1(jiti@2.6.1)
oxlint: 1.49.0(oxlint-tsgolint@0.14.2)
transitivePeerDependencies:
- typescript
eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.56.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1)):
dependencies:
'@typescript-eslint/types': 8.56.0
@@ -14953,8 +14893,6 @@ snapshots:
mdn-data@2.12.2: {}
mdn-data@2.23.0: {}
mdurl@2.0.0: {}
media-encoder-host-broker@8.0.19:
@@ -16633,10 +16571,6 @@ snapshots:
'@pkgr/core': 0.2.9
tslib: 2.8.1
synckit@0.11.12:
dependencies:
'@pkgr/core': 0.2.9
table@6.9.0:
dependencies:
ajv: 8.18.0
@@ -16647,8 +16581,6 @@ snapshots:
tagged-tag@1.0.0: {}
tailwind-csstree@0.1.4: {}
tailwind-merge@2.6.0: {}
tailwindcss-primeui@0.6.1(tailwindcss@4.2.0):
@@ -16759,13 +16691,6 @@ snapshots:
ts-map@1.0.3: {}
tsconfig-paths-webpack-plugin@4.2.0:
dependencies:
chalk: 4.1.2
enhanced-resolve: 5.19.0
tapable: 2.3.0
tsconfig-paths: 4.2.0
tsconfig-paths@3.15.0:
dependencies:
'@types/json5': 0.0.29

View File

@@ -61,7 +61,6 @@ catalog:
eslint: ^9.39.1
eslint-config-prettier: ^10.1.8
eslint-import-resolver-typescript: ^4.4.4
eslint-plugin-better-tailwindcss: ^4.3.1
eslint-plugin-import-x: ^4.16.1
eslint-plugin-oxlint: 1.25.0
eslint-plugin-storybook: ^10.2.10

View File

@@ -1,14 +1,4 @@
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import type { MetricStats } from './perf-stats'
import {
classifyChange,
computeStats,
formatSignificance,
isNoteworthy,
zScore
} from './perf-stats'
import { existsSync, readFileSync } from 'node:fs'
interface PerfMeasurement {
name: string
@@ -30,76 +20,12 @@ interface PerfReport {
const CURRENT_PATH = 'test-results/perf-metrics.json'
const BASELINE_PATH = 'temp/perf-baseline/perf-metrics.json'
const HISTORY_DIR = 'temp/perf-history'
type MetricKey = 'styleRecalcs' | 'layouts' | 'taskDurationMs'
const REPORTED_METRICS: { key: MetricKey; label: string; unit: string }[] = [
{ key: 'styleRecalcs', label: 'style recalcs', unit: '' },
{ key: 'layouts', label: 'layouts', unit: '' },
{ key: 'taskDurationMs', label: 'task duration', unit: 'ms' }
]
function groupByName(
measurements: PerfMeasurement[]
): Map<string, PerfMeasurement[]> {
const map = new Map<string, PerfMeasurement[]>()
for (const m of measurements) {
const list = map.get(m.name) ?? []
list.push(m)
map.set(m.name, list)
}
return map
}
function loadHistoricalReports(): PerfReport[] {
if (!existsSync(HISTORY_DIR)) return []
const reports: PerfReport[] = []
for (const dir of readdirSync(HISTORY_DIR)) {
const filePath = join(HISTORY_DIR, dir, 'perf-metrics.json')
if (!existsSync(filePath)) continue
try {
reports.push(JSON.parse(readFileSync(filePath, 'utf-8')) as PerfReport)
} catch {
console.warn(`Skipping malformed perf history: ${filePath}`)
}
}
return reports
}
function getHistoricalStats(
reports: PerfReport[],
testName: string,
metric: MetricKey
): MetricStats {
const values: number[] = []
for (const r of reports) {
const group = groupByName(r.measurements)
const samples = group.get(testName)
if (samples) {
const mean =
samples.reduce((sum, s) => sum + s[metric], 0) / samples.length
values.push(mean)
}
}
return computeStats(values)
}
function computeCV(stats: MetricStats): number {
return stats.mean > 0 ? (stats.stddev / stats.mean) * 100 : 0
}
function formatValue(value: number, unit: string): string {
return unit === 'ms' ? `${value.toFixed(0)}ms` : `${value.toFixed(0)}`
}
function formatDelta(pct: number | null): string {
if (pct === null) return '—'
const sign = pct >= 0 ? '+' : ''
return `${sign}${pct.toFixed(0)}%`
}
function meanMetric(samples: PerfMeasurement[], key: MetricKey): number {
return samples.reduce((sum, s) => sum + s[key], 0) / samples.length
function formatDelta(pct: number): string {
if (pct >= 20) return `+${pct.toFixed(0)}% 🔴`
if (pct >= 10) return `+${pct.toFixed(0)}% 🟠`
if (pct > -10) return `${pct >= 0 ? '+' : ''}${pct.toFixed(0)}% ⚪`
return `${pct.toFixed(0)}% 🟢`
}
function formatBytes(bytes: number): string {
@@ -108,167 +34,18 @@ function formatBytes(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function renderFullReport(
prGroups: Map<string, PerfMeasurement[]>,
baseline: PerfReport,
historical: PerfReport[]
): string[] {
const lines: string[] = []
const baselineGroups = groupByName(baseline.measurements)
const tableHeader = [
'| Metric | Baseline | PR (n=3) | Δ | Sig |',
'|--------|----------|----------|---|-----|'
]
const flaggedRows: string[] = []
const allRows: string[] = []
for (const [testName, prSamples] of prGroups) {
const baseSamples = baselineGroups.get(testName)
for (const { key, label, unit } of REPORTED_METRICS) {
const prValues = prSamples.map((s) => s[key])
const prMean = prValues.reduce((a, b) => a + b, 0) / prValues.length
const histStats = getHistoricalStats(historical, testName, key)
const cv = computeCV(histStats)
if (!baseSamples?.length) {
allRows.push(
`| ${testName}: ${label} | — | ${formatValue(prMean, unit)} | new | — |`
)
continue
}
const baseVal = meanMetric(baseSamples, key)
const deltaPct =
baseVal === 0
? prMean === 0
? 0
: null
: ((prMean - baseVal) / baseVal) * 100
const z = zScore(prMean, histStats)
const sig = classifyChange(z, cv)
const row = `| ${testName}: ${label} | ${formatValue(baseVal, unit)} | ${formatValue(prMean, unit)} | ${formatDelta(deltaPct)} | ${formatSignificance(sig, z)} |`
allRows.push(row)
if (isNoteworthy(sig)) {
flaggedRows.push(row)
}
}
function calcDelta(
baseline: number,
current: number
): { pct: number; isNew: boolean } {
if (baseline > 0) {
return { pct: ((current - baseline) / baseline) * 100, isNew: false }
}
if (flaggedRows.length > 0) {
lines.push(
`⚠️ **${flaggedRows.length} regression${flaggedRows.length > 1 ? 's' : ''} detected**`,
'',
...tableHeader,
...flaggedRows,
''
)
} else {
lines.push('No regressions detected.', '')
}
lines.push(
`<details><summary>All metrics</summary>`,
'',
...tableHeader,
...allRows,
'',
'</details>',
''
)
lines.push(
`<details><summary>Historical variance (last ${historical.length} runs)</summary>`,
'',
'| Metric | μ | σ | CV |',
'|--------|---|---|-----|'
)
for (const [testName] of prGroups) {
for (const { key, label, unit } of REPORTED_METRICS) {
const stats = getHistoricalStats(historical, testName, key)
if (stats.n < 2) continue
const cv = computeCV(stats)
lines.push(
`| ${testName}: ${label} | ${formatValue(stats.mean, unit)} | ${formatValue(stats.stddev, unit)} | ${cv.toFixed(1)}% |`
)
}
}
lines.push('', '</details>')
return lines
return current > 0 ? { pct: Infinity, isNew: true } : { pct: 0, isNew: false }
}
function renderColdStartReport(
prGroups: Map<string, PerfMeasurement[]>,
baseline: PerfReport,
historicalCount: number
): string[] {
const lines: string[] = []
const baselineGroups = groupByName(baseline.measurements)
lines.push(
`> Collecting baseline variance data (${historicalCount}/5 runs). Significance will appear after 2 main branch runs.`,
'',
'| Metric | Baseline | PR | Δ |',
'|--------|----------|-----|---|'
)
for (const [testName, prSamples] of prGroups) {
const baseSamples = baselineGroups.get(testName)
for (const { key, label, unit } of REPORTED_METRICS) {
const prValues = prSamples.map((s) => s[key])
const prMean = prValues.reduce((a, b) => a + b, 0) / prValues.length
if (!baseSamples?.length) {
lines.push(
`| ${testName}: ${label} | — | ${formatValue(prMean, unit)} | new |`
)
continue
}
const baseVal = meanMetric(baseSamples, key)
const deltaPct =
baseVal === 0
? prMean === 0
? 0
: null
: ((prMean - baseVal) / baseVal) * 100
lines.push(
`| ${testName}: ${label} | ${formatValue(baseVal, unit)} | ${formatValue(prMean, unit)} | ${formatDelta(deltaPct)} |`
)
}
}
return lines
}
function renderNoBaselineReport(
prGroups: Map<string, PerfMeasurement[]>
): string[] {
const lines: string[] = []
lines.push(
'No baseline found — showing absolute values.\n',
'| Metric | Value |',
'|--------|-------|'
)
for (const [testName, prSamples] of prGroups) {
const prMean = (key: MetricKey) =>
prSamples.reduce((sum, s) => sum + s[key], 0) / prSamples.length
lines.push(
`| ${testName}: style recalcs | ${prMean('styleRecalcs').toFixed(0)} |`
)
lines.push(`| ${testName}: layouts | ${prMean('layouts').toFixed(0)} |`)
lines.push(
`| ${testName}: task duration | ${prMean('taskDurationMs').toFixed(0)}ms |`
)
const heapMean =
prSamples.reduce((sum, s) => sum + s.heapDeltaBytes, 0) / prSamples.length
lines.push(`| ${testName}: heap delta | ${formatBytes(heapMean)} |`)
}
return lines
function formatDeltaCell(delta: { pct: number; isNew: boolean }): string {
return delta.isNew ? 'new 🔴' : formatDelta(delta.pct)
}
function main() {
@@ -285,18 +62,55 @@ function main() {
? JSON.parse(readFileSync(BASELINE_PATH, 'utf-8'))
: null
const historical = loadHistoricalReports()
const prGroups = groupByName(current.measurements)
const lines: string[] = []
lines.push('## ⚡ Performance Report\n')
if (baseline && historical.length >= 2) {
lines.push(...renderFullReport(prGroups, baseline, historical))
} else if (baseline) {
lines.push(...renderColdStartReport(prGroups, baseline, historical.length))
if (baseline) {
lines.push(
'| Metric | Baseline | PR | Δ |',
'|--------|----------|-----|---|'
)
for (const m of current.measurements) {
const base = baseline.measurements.find((b) => b.name === m.name)
if (!base) {
lines.push(`| ${m.name}: style recalcs | — | ${m.styleRecalcs} | new |`)
lines.push(`| ${m.name}: layouts | — | ${m.layouts} | new |`)
lines.push(
`| ${m.name}: task duration | — | ${m.taskDurationMs.toFixed(0)}ms | new |`
)
continue
}
const recalcDelta = calcDelta(base.styleRecalcs, m.styleRecalcs)
lines.push(
`| ${m.name}: style recalcs | ${base.styleRecalcs} | ${m.styleRecalcs} | ${formatDeltaCell(recalcDelta)} |`
)
const layoutDelta = calcDelta(base.layouts, m.layouts)
lines.push(
`| ${m.name}: layouts | ${base.layouts} | ${m.layouts} | ${formatDeltaCell(layoutDelta)} |`
)
const taskDelta = calcDelta(base.taskDurationMs, m.taskDurationMs)
lines.push(
`| ${m.name}: task duration | ${base.taskDurationMs.toFixed(0)}ms | ${m.taskDurationMs.toFixed(0)}ms | ${formatDeltaCell(taskDelta)} |`
)
}
} else {
lines.push(...renderNoBaselineReport(prGroups))
lines.push(
'No baseline found — showing absolute values.\n',
'| Metric | Value |',
'|--------|-------|'
)
for (const m of current.measurements) {
lines.push(`| ${m.name}: style recalcs | ${m.styleRecalcs} |`)
lines.push(`| ${m.name}: layouts | ${m.layouts} |`)
lines.push(
`| ${m.name}: task duration | ${m.taskDurationMs.toFixed(0)}ms |`
)
lines.push(`| ${m.name}: heap delta | ${formatBytes(m.heapDeltaBytes)} |`)
}
}
lines.push('\n<details><summary>Raw data</summary>\n')

View File

@@ -1,133 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
classifyChange,
computeStats,
formatSignificance,
isNoteworthy,
zScore
} from './perf-stats'
describe('computeStats', () => {
it('returns zeros for empty array', () => {
const stats = computeStats([])
expect(stats).toEqual({ mean: 0, stddev: 0, min: 0, max: 0, n: 0 })
})
it('returns value with zero stddev for single element', () => {
const stats = computeStats([42])
expect(stats).toEqual({ mean: 42, stddev: 0, min: 42, max: 42, n: 1 })
})
it('computes correct stats for known values', () => {
// Values: [2, 4, 4, 4, 5, 5, 7, 9]
// Mean = 5, sample variance ≈ 4.57, sample stddev ≈ 2.14
const stats = computeStats([2, 4, 4, 4, 5, 5, 7, 9])
expect(stats.mean).toBe(5)
expect(stats.stddev).toBeCloseTo(2.138, 2)
expect(stats.min).toBe(2)
expect(stats.max).toBe(9)
expect(stats.n).toBe(8)
})
it('uses sample stddev (n-1 denominator)', () => {
// [10, 20] → mean=15, variance=(25+25)/1=50, stddev≈7.07
const stats = computeStats([10, 20])
expect(stats.mean).toBe(15)
expect(stats.stddev).toBeCloseTo(7.071, 2)
expect(stats.n).toBe(2)
})
it('handles identical values', () => {
const stats = computeStats([5, 5, 5, 5])
expect(stats.mean).toBe(5)
expect(stats.stddev).toBe(0)
})
})
describe('zScore', () => {
it('returns null when stddev is 0', () => {
const stats = computeStats([5, 5, 5])
expect(zScore(10, stats)).toBeNull()
})
it('returns null when n < 2', () => {
const stats = computeStats([5])
expect(zScore(10, stats)).toBeNull()
})
it('computes correct z-score', () => {
const stats = { mean: 100, stddev: 10, min: 80, max: 120, n: 5 }
expect(zScore(120, stats)).toBe(2)
expect(zScore(80, stats)).toBe(-2)
expect(zScore(100, stats)).toBe(0)
})
})
describe('classifyChange', () => {
it('returns noisy when CV > 50%', () => {
expect(classifyChange(3, 60)).toBe('noisy')
expect(classifyChange(-3, 51)).toBe('noisy')
})
it('does not classify as noisy when CV is exactly 50%', () => {
expect(classifyChange(3, 50)).toBe('regression')
expect(classifyChange(-3, 50)).toBe('improvement')
})
it('returns neutral when z is null', () => {
expect(classifyChange(null, 10)).toBe('neutral')
})
it('returns regression when z > 2', () => {
expect(classifyChange(2.1, 10)).toBe('regression')
expect(classifyChange(5, 10)).toBe('regression')
})
it('returns improvement when z < -2', () => {
expect(classifyChange(-2.1, 10)).toBe('improvement')
expect(classifyChange(-5, 10)).toBe('improvement')
})
it('returns neutral when z is within [-2, 2]', () => {
expect(classifyChange(0, 10)).toBe('neutral')
expect(classifyChange(1.9, 10)).toBe('neutral')
expect(classifyChange(-1.9, 10)).toBe('neutral')
expect(classifyChange(2, 10)).toBe('neutral')
expect(classifyChange(-2, 10)).toBe('neutral')
})
})
describe('formatSignificance', () => {
it('formats regression with z-score and emoji', () => {
expect(formatSignificance('regression', 3.2)).toBe('⚠️ z=3.2')
})
it('formats improvement with z-score without emoji', () => {
expect(formatSignificance('improvement', -2.5)).toBe('z=-2.5')
})
it('formats noisy as descriptive text', () => {
expect(formatSignificance('noisy', null)).toBe('variance too high')
})
it('formats neutral with z-score without emoji', () => {
expect(formatSignificance('neutral', 0.5)).toBe('z=0.5')
})
it('formats neutral without z-score as dash', () => {
expect(formatSignificance('neutral', null)).toBe('—')
})
})
describe('isNoteworthy', () => {
it('returns true for regressions', () => {
expect(isNoteworthy('regression')).toBe(true)
})
it('returns false for non-regressions', () => {
expect(isNoteworthy('improvement')).toBe(false)
expect(isNoteworthy('neutral')).toBe(false)
expect(isNoteworthy('noisy')).toBe(false)
})
})

View File

@@ -1,63 +0,0 @@
export interface MetricStats {
mean: number
stddev: number
min: number
max: number
n: number
}
export function computeStats(values: number[]): MetricStats {
const n = values.length
if (n === 0) return { mean: 0, stddev: 0, min: 0, max: 0, n: 0 }
if (n === 1)
return { mean: values[0], stddev: 0, min: values[0], max: values[0], n: 1 }
const mean = values.reduce((a, b) => a + b, 0) / n
const variance = values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / (n - 1)
return {
mean,
stddev: Math.sqrt(variance),
min: Math.min(...values),
max: Math.max(...values),
n
}
}
export function zScore(value: number, stats: MetricStats): number | null {
if (stats.stddev === 0 || stats.n < 2) return null
return (value - stats.mean) / stats.stddev
}
export type Significance = 'regression' | 'improvement' | 'neutral' | 'noisy'
export function classifyChange(
z: number | null,
historicalCV: number
): Significance {
if (historicalCV > 50) return 'noisy'
if (z === null) return 'neutral'
if (z > 2) return 'regression'
if (z < -2) return 'improvement'
return 'neutral'
}
export function formatSignificance(
sig: Significance,
z: number | null
): string {
switch (sig) {
case 'regression':
return `⚠️ z=${z!.toFixed(1)}`
case 'improvement':
return `z=${z!.toFixed(1)}`
case 'noisy':
return 'variance too high'
case 'neutral':
return z !== null ? `z=${z.toFixed(1)}` : '—'
}
}
export function isNoteworthy(sig: Significance): boolean {
return sig === 'regression'
}

View File

@@ -64,7 +64,7 @@
layout="vertical"
:pt:gutter="
cn(
'rounded-tl-lg rounded-tr-lg',
'rounded-tl-lg rounded-tr-lg ',
!(bottomPanelVisible && !focusMode) && 'hidden'
)
"

View File

@@ -296,13 +296,11 @@ describe('TopMenuSection', () => {
describe('inline progress summary', () => {
const configureSettings = (
pinia: ReturnType<typeof createTestingPinia>,
qpoV2Enabled: boolean,
showRunProgressBar = true
qpoV2Enabled: boolean
) => {
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) => {
if (key === 'Comfy.Queue.QPOV2') return qpoV2Enabled
if (key === 'Comfy.Queue.ShowRunProgressBar') return showRunProgressBar
if (key === 'Comfy.UseNewMenu') return 'Top'
return undefined
})
@@ -334,19 +332,6 @@ describe('TopMenuSection', () => {
).toBe(false)
})
it('does not render inline progress summary when run progress bar is disabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true, false)
const wrapper = createWrapper({ pinia })
await nextTick()
expect(
wrapper.findComponent({ name: 'QueueInlineProgressSummary' }).exists()
).toBe(false)
})
it('teleports inline progress summary when actionbar is floating', async () => {
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
const actionbarTarget = document.createElement('div')

View File

@@ -125,7 +125,6 @@ import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -165,16 +164,14 @@ const isActionbarFloating = computed(
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
)
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
useQueueFeatureFlags()
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
const isQueueProgressOverlayEnabled = computed(
() => !isQueuePanelV2Enabled.value
)
const shouldShowInlineProgressSummary = computed(
() =>
isQueuePanelV2Enabled.value &&
isActionbarEnabled.value &&
isRunProgressBarEnabled.value
() => isQueuePanelV2Enabled.value && isActionbarEnabled.value
)
const shouldShowQueueNotificationBanners = computed(
() => isActionbarEnabled.value

View File

@@ -1,101 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
import { i18n } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
const configureSettings = (
pinia: ReturnType<typeof createTestingPinia>,
showRunProgressBar: boolean
) => {
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) => {
if (key === 'Comfy.UseNewMenu') return 'Top'
if (key === 'Comfy.Queue.QPOV2') return true
if (key === 'Comfy.Queue.ShowRunProgressBar') return showRunProgressBar
return undefined
})
}
const mountActionbar = (showRunProgressBar: boolean) => {
const topMenuContainer = document.createElement('div')
document.body.appendChild(topMenuContainer)
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, showRunProgressBar)
const wrapper = mount(ComfyActionbar, {
attachTo: document.body,
props: {
topMenuContainer,
queueOverlayExpanded: false
},
global: {
plugins: [pinia, i18n],
stubs: {
ContextMenu: {
name: 'ContextMenu',
template: '<div />'
},
Panel: {
name: 'Panel',
template: '<div><slot /></div>'
},
StatusBadge: true,
ComfyRunButton: {
name: 'ComfyRunButton',
template: '<button type="button">Run</button>'
},
QueueInlineProgress: true
},
directives: {
tooltip: () => {}
}
}
})
return {
wrapper,
topMenuContainer
}
}
describe('ComfyActionbar', () => {
beforeEach(() => {
i18n.global.locale.value = 'en'
localStorage.clear()
})
it('teleports inline progress when run progress bar is enabled', async () => {
const { wrapper, topMenuContainer } = mountActionbar(true)
try {
await nextTick()
expect(
topMenuContainer.querySelector('[data-testid="queue-inline-progress"]')
).not.toBeNull()
} finally {
wrapper.unmount()
topMenuContainer.remove()
}
})
it('does not teleport inline progress when run progress bar is disabled', async () => {
const { wrapper, topMenuContainer } = mountActionbar(false)
try {
await nextTick()
expect(
topMenuContainer.querySelector('[data-testid="queue-inline-progress"]')
).toBeNull()
} finally {
wrapper.unmount()
topMenuContainer.remove()
}
})
})

View File

@@ -107,7 +107,6 @@ import { useI18n } from 'vue-i18n'
import StatusBadge from '@/components/common/StatusBadge.vue'
import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
import Button from '@/components/ui/button/Button.vue'
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
@@ -128,7 +127,7 @@ const emit = defineEmits<{
(event: 'update:progressTarget', target: HTMLElement | null): void
}>()
const settingStore = useSettingStore()
const settingsStore = useSettingStore()
const commandStore = useCommandStore()
const executionStore = useExecutionStore()
const queueStore = useQueueStore()
@@ -138,10 +137,11 @@ const { isIdle: isExecutionIdle } = storeToRefs(executionStore)
const { activeJobsCount } = storeToRefs(queueStore)
const { activeSidebarTabId } = storeToRefs(sidebarTabStore)
const position = computed(() => settingStore.get('Comfy.UseNewMenu'))
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
const visible = computed(() => position.value !== 'Disabled')
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
useQueueFeatureFlags()
const isQueuePanelV2Enabled = computed(() =>
settingsStore.get('Comfy.Queue.QPOV2')
)
const panelRef = ref<ComponentPublicInstance | null>(null)
const panelElement = computed<HTMLElement | null>(() => {
@@ -325,13 +325,7 @@ const onMouseLeaveDropZone = () => {
}
const inlineProgressTarget = computed(() => {
if (
!visible.value ||
!isQueuePanelV2Enabled.value ||
!isRunProgressBarEnabled.value
) {
return null
}
if (!visible.value || !isQueuePanelV2Enabled.value) return null
if (isDocked.value) return topMenuContainer ?? null
return panelElement.value
})

View File

@@ -39,8 +39,7 @@ const workflowStore = useWorkflowStore()
const { t } = useI18n()
const canvas: LGraphCanvas = canvasStore.getCanvas()
const { isSelectMode, isSelectInputsMode, isSelectOutputsMode, isArrangeMode } =
useAppMode()
const { isSelectMode, isArrangeMode } = useAppMode()
const hoveringSelectable = ref(false)
provide(HideLayoutFieldKey, true)
@@ -162,7 +161,6 @@ function handleClick(e: MouseEvent) {
if (!node) return canvasInteractions.forwardEventToCanvas(e)
if (!widget) {
if (!isSelectOutputsMode.value) return
if (!node.constructor.nodeData?.output_node)
return canvasInteractions.forwardEventToCanvas(e)
const index = appModeStore.selectedOutputs.findIndex((id) => id == node.id)
@@ -170,7 +168,6 @@ function handleClick(e: MouseEvent) {
else appModeStore.selectedOutputs.splice(index, 1)
return
}
if (!isSelectInputsMode.value) return
const index = appModeStore.selectedInputs.findIndex(
([nodeId, widgetName]) => node.id == nodeId && widget.name === widgetName
@@ -237,7 +234,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
</div>
</DraggableList>
<PropertiesAccordionItem
v-if="isSelectInputsMode"
v-else
:label="t('nodeHelpPage.inputs')"
enable-empty-state
:disabled="!appModeStore.selectedInputs.length"
@@ -286,7 +283,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
</DraggableList>
</PropertiesAccordionItem>
<PropertiesAccordionItem
v-if="isSelectOutputsMode"
v-if="!isArrangeMode"
:label="t('nodeHelpPage.outputs')"
enable-empty-state
:disabled="!appModeStore.selectedOutputs.length"
@@ -347,46 +344,42 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
@wheel="canvasInteractions.forwardEventToCanvas"
>
<TransformPane :canvas="canvasStore.getCanvas()">
<template v-if="isSelectInputsMode">
<div
v-for="[key, style] in renderedInputs"
:key
:style="toValue(style)"
class="fixed bg-primary-background/30 rounded-lg"
/>
</template>
<template v-else>
<div
v-for="[key, style, isSelected] in renderedOutputs"
:key
:style="toValue(style)"
:class="
cn(
'fixed ring-warning-background ring-5 rounded-2xl',
!isSelected && 'ring-warning-background/50'
)
"
>
<div class="absolute top-0 right-0 size-8">
<div
v-if="isSelected"
class="absolute -top-1/2 -right-1/2 size-full p-2 bg-warning-background rounded-lg cursor-pointer pointer-events-auto"
@click.stop="
remove(appModeStore.selectedOutputs, (k) => k == key)
"
@pointerdown.stop
>
<i class="icon-[lucide--check] bg-text-foreground size-full" />
</div>
<div
v-else
class="absolute -top-1/2 -right-1/2 size-full ring-warning-background/50 ring-4 ring-inset bg-component-node-background rounded-lg cursor-pointer pointer-events-auto"
@click.stop="appModeStore.selectedOutputs.push(key)"
@pointerdown.stop
/>
<div
v-for="[key, style] in renderedInputs"
:key
:style="toValue(style)"
class="fixed bg-primary-background/30 rounded-lg"
/>
<div
v-for="[key, style, isSelected] in renderedOutputs"
:key
:style="toValue(style)"
:class="
cn(
'fixed ring-warning-background ring-5 rounded-2xl',
!isSelected && 'ring-warning-background/50'
)
"
>
<div class="absolute top-0 right-0 size-8">
<div
v-if="isSelected"
class="absolute -top-1/2 -right-1/2 size-full p-2 bg-warning-background rounded-lg cursor-pointer pointer-events-auto"
@click.stop="
remove(appModeStore.selectedOutputs, (k) => k == key)
"
@pointerdown.stop
>
<i class="icon-[lucide--check] bg-text-foreground size-full" />
</div>
<div
v-else
class="absolute -top-1/2 -right-1/2 size-full ring-warning-background/50 ring-4 ring-inset bg-component-node-background rounded-lg cursor-pointer pointer-events-auto"
@click.stop="appModeStore.selectedOutputs.push(key)"
@pointerdown.stop
/>
</div>
</template>
</div>
</TransformPane>
</div>
</Teleport>

View File

@@ -63,7 +63,7 @@ describe('BuilderFooterToolbar', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockState.mode = 'builder:inputs'
mockState.mode = 'builder:select'
mockHasOutputs.value = true
mockState.settingView = false
})
@@ -87,7 +87,7 @@ describe('BuilderFooterToolbar', () => {
}
it('disables back on the first step', () => {
mockState.mode = 'builder:inputs'
mockState.mode = 'builder:select'
const { back } = getButtons(mountComponent())
expect(back.attributes('disabled')).toBeDefined()
})
@@ -111,8 +111,8 @@ describe('BuilderFooterToolbar', () => {
expect(next.attributes('disabled')).toBeDefined()
})
it('enables next on inputs step', () => {
mockState.mode = 'builder:inputs'
it('enables next on select step', () => {
mockState.mode = 'builder:select'
const { next } = getButtons(mountComponent())
expect(next.attributes('disabled')).toBeUndefined()
})
@@ -121,14 +121,14 @@ describe('BuilderFooterToolbar', () => {
mockState.mode = 'builder:arrange'
const { back } = getButtons(mountComponent())
await back.trigger('click')
expect(mockSetMode).toHaveBeenCalledWith('builder:outputs')
expect(mockSetMode).toHaveBeenCalledWith('builder:select')
})
it('calls setMode on next click from inputs step', async () => {
mockState.mode = 'builder:inputs'
it('calls setMode on next click from select step', async () => {
mockState.mode = 'builder:select'
const { next } = getButtons(mountComponent())
await next.trigger('click')
expect(mockSetMode).toHaveBeenCalledWith('builder:outputs')
expect(mockSetMode).toHaveBeenCalledWith('builder:arrange')
})
it('opens default view dialog on next click from arrange step', async () => {

View File

@@ -6,14 +6,17 @@
<div
class="inline-flex items-center gap-1 rounded-2xl border border-border-default bg-base-background p-2 shadow-interface"
>
<template v-for="(step, index) in steps" :key="step.id">
<template
v-for="(step, index) in [selectStep, arrangeStep]"
:key="step.id"
>
<button
:class="
cn(
stepClasses,
activeStep === step.id
? 'bg-interface-builder-mode-background'
: 'hover:bg-secondary-background bg-transparent'
activeStep === step.id && 'bg-interface-builder-mode-background',
activeStep !== step.id &&
'hover:bg-secondary-background bg-transparent'
)
"
:aria-current="activeStep === step.id ? 'step' : undefined"
@@ -29,13 +32,13 @@
<!-- Default view -->
<ConnectOutputPopover
v-if="!hasOutputs"
:is-select-active="isSelectStep"
@switch="navigateToStep('builder:outputs')"
:is-select-active="activeStep === 'builder:select'"
@switch="navigateToStep('builder:select')"
>
<button :class="cn(stepClasses, 'opacity-30 bg-transparent')">
<StepBadge
:step="defaultViewStep"
:index="steps.length"
:index="2"
:model-value="activeStep"
/>
<StepLabel :step="defaultViewStep" />
@@ -55,7 +58,7 @@
>
<StepBadge
:step="defaultViewStep"
:index="steps.length"
:index="2"
:model-value="activeStep"
/>
<StepLabel :step="defaultViewStep" />
@@ -81,22 +84,15 @@ import { useBuilderSteps } from './useBuilderSteps'
const { t } = useI18n()
const appModeStore = useAppModeStore()
const { hasOutputs } = storeToRefs(appModeStore)
const { activeStep, isSelectStep, navigateToStep } = useBuilderSteps()
const { activeStep, navigateToStep } = useBuilderSteps()
const stepClasses =
'inline-flex h-14 min-h-8 cursor-pointer items-center gap-3 rounded-lg py-2 pr-4 pl-2 transition-colors border-none'
const selectInputsStep: BuilderToolbarStep<BuilderStepId> = {
id: 'builder:inputs',
title: t('builderToolbar.inputs'),
subtitle: t('builderToolbar.inputsDescription'),
icon: 'icon-[lucide--mouse-pointer-click]'
}
const selectOutputsStep: BuilderToolbarStep<BuilderStepId> = {
id: 'builder:outputs',
title: t('builderToolbar.outputs'),
subtitle: t('builderToolbar.outputsDescription'),
const selectStep: BuilderToolbarStep<BuilderStepId> = {
id: 'builder:select',
title: t('builderToolbar.select'),
subtitle: t('builderToolbar.selectDescription'),
icon: 'icon-[lucide--mouse-pointer-click]'
}
@@ -113,5 +109,4 @@ const defaultViewStep: BuilderToolbarStep<BuilderStepId> = {
subtitle: t('builderToolbar.defaultViewDescription'),
icon: 'icon-[lucide--eye]'
}
const steps = [selectInputsStep, selectOutputsStep, arrangeStep]
</script>

View File

@@ -46,7 +46,7 @@
</PopoverClose>
<PopoverClose as-child>
<Button variant="secondary" size="md" @click="emit('switch')">
{{ t('builderToolbar.switchToOutputs') }}
{{ t('builderToolbar.switchToSelect') }}
</Button>
</PopoverClose>
</template>

View File

@@ -7,8 +7,7 @@ import { useAppMode } from '@/composables/useAppMode'
import { useAppSetDefaultView } from './useAppSetDefaultView'
const BUILDER_STEPS = [
'builder:inputs',
'builder:outputs',
'builder:select',
'builder:arrange',
'setDefaultView'
] as const
@@ -26,7 +25,7 @@ export function useBuilderSteps(options?: { hasOutputs?: Ref<boolean> }) {
if (isBuilderMode.value) {
return mode.value as BuilderStepId
}
return 'builder:inputs'
return 'builder:select'
})
const activeStepIndex = computed(() =>
@@ -41,12 +40,6 @@ export function useBuilderSteps(options?: { hasOutputs?: Ref<boolean> }) {
return activeStepIndex.value >= BUILDER_STEPS.length - 1
})
const isSelectStep = computed(
() =>
activeStep.value === 'builder:inputs' ||
activeStep.value === 'builder:outputs'
)
function navigateToStep(stepId: BuilderStepId) {
if (stepId === 'setDefaultView') {
setMode('builder:arrange')
@@ -71,7 +64,6 @@ export function useBuilderSteps(options?: { hasOutputs?: Ref<boolean> }) {
activeStepIndex,
isFirstStep,
isLastStep,
isSelectStep,
navigateToStep,
goBack,
goNext

View File

@@ -1,6 +1,4 @@
<script setup lang="ts">
import { computed } from 'vue'
import { statusBadgeVariants } from './statusBadge.variants'
import type { StatusBadgeVariants } from './statusBadge.variants'
@@ -13,17 +11,17 @@ const {
severity?: StatusBadgeVariants['severity']
variant?: StatusBadgeVariants['variant']
}>()
const badgeClass = computed(() =>
statusBadgeVariants({
severity,
variant: variant ?? (label == null ? 'dot' : 'label')
})
)
</script>
<template>
<span :class="badgeClass">
<span
:class="
statusBadgeVariants({
severity,
variant: variant ?? (label == null ? 'dot' : 'label')
})
"
>
{{ label }}
</span>
</template>

View File

@@ -39,8 +39,8 @@
<BottomPanel />
</template>
<template v-if="showUI" #right-side-panel>
<AppBuilder v-if="isBuilderMode" />
<NodePropertiesPanel v-else />
<AppBuilder v-if="mode === 'builder:select'" />
<NodePropertiesPanel v-else-if="!isBuilderMode" />
</template>
<template #graph-canvas-panel>
<GraphCanvasMenu
@@ -204,7 +204,7 @@ const nodeSearchboxPopoverRef = shallowRef<InstanceType<
const settingStore = useSettingStore()
const nodeDefStore = useNodeDefStore()
const workspaceStore = useWorkspaceStore()
const { isBuilderMode } = useAppMode()
const { mode, isBuilderMode } = useAppMode()
const canvasStore = useCanvasStore()
const workflowStore = useWorkflowStore()
const executionStore = useExecutionStore()

View File

@@ -1,123 +0,0 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h } from 'vue'
import { i18n } from '@/i18n'
import JobHistoryActionsMenu from '@/components/queue/JobHistoryActionsMenu.vue'
const popoverCloseSpy = vi.fn()
vi.mock('@/components/ui/Popover.vue', () => {
const PopoverStub = defineComponent({
name: 'Popover',
setup(_, { slots }) {
return () =>
h('div', [
slots.button?.(),
slots.default?.({
close: () => {
popoverCloseSpy()
}
})
])
}
})
return { default: PopoverStub }
})
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
const mockGetSetting = vi.fn<(key: string) => boolean | undefined>((key) =>
key === 'Comfy.Queue.QPOV2' || key === 'Comfy.Queue.ShowRunProgressBar'
? true
: undefined
)
const mockSetSetting = vi.fn()
const mockSetMany = vi.fn()
const mockSidebarTabStore = {
activeSidebarTabId: null as string | null
}
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: mockGetSetting,
set: mockSetSetting,
setMany: mockSetMany
})
}))
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
useSidebarTabStore: () => mockSidebarTabStore
}))
const mountMenu = () =>
mount(JobHistoryActionsMenu, {
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
}
})
describe('JobHistoryActionsMenu', () => {
beforeEach(() => {
i18n.global.locale.value = 'en'
popoverCloseSpy.mockClear()
mockSetSetting.mockClear()
mockSetMany.mockClear()
mockSidebarTabStore.activeSidebarTabId = null
mockGetSetting.mockImplementation((key: string) =>
key === 'Comfy.Queue.QPOV2' || key === 'Comfy.Queue.ShowRunProgressBar'
? true
: undefined
)
})
it('toggles show run progress bar setting from the menu', async () => {
const wrapper = mountMenu()
const showRunProgressBarButton = wrapper.get(
'[data-testid="show-run-progress-bar-action"]'
)
await showRunProgressBarButton.trigger('click')
expect(mockSetSetting).toHaveBeenCalledTimes(1)
expect(mockSetSetting).toHaveBeenCalledWith(
'Comfy.Queue.ShowRunProgressBar',
false
)
})
it('opens docked job history sidebar when enabling from the menu', async () => {
mockGetSetting.mockImplementation((key: string) => {
if (key === 'Comfy.Queue.QPOV2') return false
if (key === 'Comfy.Queue.ShowRunProgressBar') return true
return undefined
})
const wrapper = mountMenu()
const dockedJobHistoryButton = wrapper.get(
'[data-testid="docked-job-history-action"]'
)
await dockedJobHistoryButton.trigger('click')
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
expect(mockSetSetting).toHaveBeenCalledTimes(1)
expect(mockSetSetting).toHaveBeenCalledWith('Comfy.Queue.QPOV2', true)
expect(mockSetMany).not.toHaveBeenCalled()
expect(mockSidebarTabStore.activeSidebarTabId).toBe('job-history')
})
it('emits clear history from the menu', async () => {
const wrapper = mountMenu()
const clearHistoryButton = wrapper.get(
'[data-testid="clear-history-action"]'
)
await clearHistoryButton.trigger('click')
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
expect(wrapper.emitted('clearHistory')).toHaveLength(1)
})
})

View File

@@ -19,7 +19,7 @@
data-testid="docked-job-history-action"
class="w-full justify-between text-sm font-light"
variant="textonly"
size="md"
size="sm"
@click="onToggleDockedJobHistory(close)"
>
<span class="flex items-center gap-2">
@@ -35,32 +35,14 @@
class="icon-[lucide--check] size-4"
/>
</Button>
<Button
data-testid="show-run-progress-bar-action"
class="w-full justify-between text-sm font-light"
variant="textonly"
size="md"
@click="onToggleRunProgressBar"
>
<span class="flex items-center gap-2">
<i class="icon-[lucide--hourglass] size-4 text-text-secondary" />
<span>{{
t('sideToolbar.queueProgressOverlay.showRunProgressBar')
}}</span>
</span>
<i
v-if="isRunProgressBarEnabled"
class="icon-[lucide--check] size-4"
/>
</Button>
<!-- TODO: Bug in assets sidebar panel derives assets from history, so despite this not deleting the assets, it still effectively shows to the user as deleted -->
<template v-if="showClearHistoryAction">
<div class="my-1 border-t border-interface-stroke" />
<Button
data-testid="clear-history-action"
class="h-auto min-h-8 w-full items-start justify-start whitespace-normal"
class="h-auto min-h-0 w-full items-start justify-start whitespace-normal"
variant="textonly"
size="md"
size="sm"
@click="onClearHistoryFromMenu(close)"
>
<i
@@ -94,7 +76,6 @@ import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -109,8 +90,9 @@ const settingStore = useSettingStore()
const sidebarTabStore = useSidebarTabStore()
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
useQueueFeatureFlags()
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
const showClearHistoryAction = computed(() => !isCloud)
const onClearHistoryFromMenu = (close: () => void) => {
@@ -136,11 +118,4 @@ const onToggleDockedJobHistory = async (close: () => void) => {
return
}
}
const onToggleRunProgressBar = async () => {
await settingStore.set(
'Comfy.Queue.ShowRunProgressBar',
!isRunProgressBarEnabled.value
)
}
</script>

View File

@@ -1,9 +1,8 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { defineComponent, h } from 'vue'
import { i18n } from '@/i18n'
const popoverCloseSpy = vi.fn()
vi.mock('@/components/ui/Popover.vue', () => {
@@ -25,9 +24,7 @@ vi.mock('@/components/ui/Popover.vue', () => {
})
const mockGetSetting = vi.fn<(key: string) => boolean | undefined>((key) =>
key === 'Comfy.Queue.QPOV2' || key === 'Comfy.Queue.ShowRunProgressBar'
? true
: undefined
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const mockSetSetting = vi.fn()
const mockSetMany = vi.fn()
@@ -55,6 +52,27 @@ const tooltipDirectiveStub = {
updated: vi.fn()
}
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { more: 'More' },
sideToolbar: {
queueProgressOverlay: {
queuedSuffix: 'queued',
clearQueued: 'Clear queued',
clearQueueTooltip: 'Clear queue',
clearAllJobsTooltip: 'Cancel all running jobs',
moreOptions: 'More options',
clearHistory: 'Clear history',
dockedJobHistory: 'Docked Job History'
}
}
}
}
})
const mountHeader = (props = {}) =>
mount(QueueOverlayHeader, {
props: {
@@ -70,7 +88,6 @@ const mountHeader = (props = {}) =>
describe('QueueOverlayHeader', () => {
beforeEach(() => {
i18n.global.locale.value = 'en'
popoverCloseSpy.mockClear()
mockSetSetting.mockClear()
mockSetMany.mockClear()
@@ -190,19 +207,4 @@ describe('QueueOverlayHeader', () => {
'Comfy.Queue.History.Expanded': true
})
})
it('toggles show run progress bar setting from the menu', async () => {
const wrapper = mountHeader()
const showRunProgressBarButton = wrapper.get(
'[data-testid="show-run-progress-bar-action"]'
)
await showRunProgressBarButton.trigger('click')
expect(mockSetSetting).toHaveBeenCalledTimes(1)
expect(mockSetSetting).toHaveBeenCalledWith(
'Comfy.Queue.ShowRunProgressBar',
false
)
})
})

View File

@@ -323,90 +323,6 @@ describe('useErrorGroups', () => {
)
expect(promptGroup).toBeDefined()
})
it('sorts cards within an execution group by nodeId numerically', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'10': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
},
'2': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
},
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
}
}
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'execution'
)
const nodeIds = execGroup?.cards.map((c) => c.nodeId)
expect(nodeIds).toEqual(['1', '2', '10'])
})
it('sorts cards with subpath nodeIds before higher root IDs', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'2': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
},
'1:20': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
},
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
}
}
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'execution'
)
const nodeIds = execGroup?.cards.map((c) => c.nodeId)
expect(nodeIds).toEqual(['1', '1:20', '2'])
})
it('sorts deeply nested nodeIds by each segment numerically', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'10:11:99': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
},
'10:11:12': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
},
'10:2': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
}
}
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'execution'
)
const nodeIds = execGroup?.cards.map((c) => c.nodeId)
expect(nodeIds).toEqual(['10:2', '10:11:12', '10:11:99'])
})
})
describe('filteredGroups', () => {

View File

@@ -23,10 +23,7 @@ import { st } from '@/i18n'
import type { MissingNodeType } from '@/types/comfy'
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import {
isNodeExecutionId,
compareExecutionId
} from '@/types/nodeIdentification'
import { isNodeExecutionId } from '@/types/nodeIdentification'
const PROMPT_CARD_ID = '__prompt__'
const SINGLE_GROUP_KEY = '__single__'
@@ -154,16 +151,12 @@ function addCardErrorToGroup(
group.get(card.id)?.errors.push(error)
}
function compareNodeId(a: ErrorCardData, b: ErrorCardData): number {
return compareExecutionId(a.nodeId, b.nodeId)
}
function toSortedGroups(groupsMap: Map<string, GroupEntry>): ErrorGroup[] {
return Array.from(groupsMap.entries())
.map(([title, groupData]) => ({
type: 'execution' as const,
title,
cards: Array.from(groupData.cards.values()).sort(compareNodeId),
cards: Array.from(groupData.cards.values()),
priority: groupData.priority
}))
.sort((a, b) => {

View File

@@ -12,7 +12,7 @@
:class="
cn(
'flex items-center gap-1 rounded-full hover:bg-interface-button-hover-surface justify-center',
compact && 'size-full'
compact && 'size-full '
)
"
>

View File

@@ -15,7 +15,7 @@
v-if="collapsible"
:class="
cn(
'pi transition-transform duration-200 text-xs text-text-secondary',
'pi transition-transform duration-200 text-xs text-text-secondary ',
isCollapsed ? 'pi-chevron-right' : 'pi-chevron-down'
)
"

View File

@@ -17,13 +17,13 @@ export const useNodePaste = <T>(
) => {
const { onPaste, fileFilter = () => true, allow_batch = false } = options
node.pasteFiles = function (files: File[]) {
node.pasteFiles = async function (files: File[]) {
const filteredFiles = Array.from(files).filter(fileFilter)
if (!filteredFiles.length) return false
const paste = allow_batch ? filteredFiles : filteredFiles.slice(0, 1)
void onPaste(paste)
await onPaste(paste)
return true
}
}

View File

@@ -1,19 +0,0 @@
import { computed } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
export function useQueueFeatureFlags() {
const settingStore = useSettingStore()
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
const isRunProgressBarEnabled = computed(
() => settingStore.get('Comfy.Queue.ShowRunProgressBar') !== false
)
return {
isQueuePanelV2Enabled,
isRunProgressBarEnabled
}
}

View File

@@ -2,12 +2,7 @@ import { computed, ref } from 'vue'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
export type AppMode =
| 'graph'
| 'app'
| 'builder:inputs'
| 'builder:outputs'
| 'builder:arrange'
export type AppMode = 'graph' | 'app' | 'builder:select' | 'builder:arrange'
const enableAppBuilder = ref(true)
@@ -23,17 +18,13 @@ export function useAppMode() {
const isBuilderMode = computed(
() => isSelectMode.value || isArrangeMode.value
)
const isSelectInputsMode = computed(() => mode.value === 'builder:inputs')
const isSelectOutputsMode = computed(() => mode.value === 'builder:outputs')
const isSelectMode = computed(
() => isSelectInputsMode.value || isSelectOutputsMode.value
)
const isSelectMode = computed(() => mode.value === 'builder:select')
const isArrangeMode = computed(() => mode.value === 'builder:arrange')
const isAppMode = computed(
() => mode.value === 'app' || mode.value === 'builder:arrange'
)
const isGraphMode = computed(
() => mode.value === 'graph' || isSelectMode.value
() => mode.value === 'graph' || mode.value === 'builder:select'
)
function setMode(newMode: AppMode) {
@@ -48,8 +39,6 @@ export function useAppMode() {
enableAppBuilder,
isBuilderMode,
isSelectMode,
isSelectInputsMode,
isSelectOutputsMode,
isArrangeMode,
isAppMode,
isGraphMode,

View File

@@ -28,7 +28,7 @@ function createMockNode(): LGraphNode {
return createMockLGraphNode({
pos: [0, 0],
pasteFile: vi.fn(),
pasteFiles: vi.fn()
pasteFiles: vi.fn().mockResolvedValue(true)
})
}
@@ -201,20 +201,21 @@ describe('pasteImageNodes', () => {
const file2 = createImageFile('test2.jpg', 'image/jpeg')
const result = await pasteImageNodes(mockCanvas, [file1, file2])
await result.completion
expect(createNode).toHaveBeenCalledTimes(2)
expect(createNode).toHaveBeenNthCalledWith(1, mockCanvas, 'LoadImage')
expect(createNode).toHaveBeenNthCalledWith(2, mockCanvas, 'LoadImage')
expect(mockNode1.pasteFile).toHaveBeenCalledWith(file1)
expect(mockNode2.pasteFile).toHaveBeenCalledWith(file2)
expect(result).toEqual([mockNode1, mockNode2])
expect(result.nodes).toEqual([mockNode1, mockNode2])
})
it('should handle empty file list', async () => {
const result = await pasteImageNodes(mockCanvas, [])
expect(createNode).not.toHaveBeenCalled()
expect(result).toEqual([])
expect(result.nodes).toEqual([])
})
})

View File

@@ -57,11 +57,11 @@ function pasteClipboardItems(data: DataTransfer): boolean {
return false
}
function pasteItemsOnNode(
async function pasteItemsOnNode(
items: DataTransferItemList,
node: LGraphNode | null,
contentType: string
): void {
): Promise<void> {
if (!node) return
const filteredItems = Array.from(items).filter((item) =>
@@ -72,10 +72,12 @@ function pasteItemsOnNode(
if (!blob) return
node.pasteFile?.(blob)
node.pasteFiles?.(
Array.from(filteredItems)
.map((i) => i.getAsFile())
.filter((f) => f !== null)
await Promise.resolve(
node.pasteFiles?.(
Array.from(filteredItems)
.map((i) => i.getAsFile())
.filter((f) => f !== null)
)
)
}
@@ -89,27 +91,37 @@ export async function pasteImageNode(
imageNode = await createNode(canvas, 'LoadImage')
}
pasteItemsOnNode(items, imageNode, 'image')
await pasteItemsOnNode(items, imageNode, 'image')
return imageNode
}
interface PasteNodesResult {
nodes: LGraphNode[]
completion: Promise<void>
}
export async function pasteImageNodes(
canvas: LGraphCanvas,
fileList: File[]
): Promise<LGraphNode[]> {
): Promise<PasteNodesResult> {
const nodes: LGraphNode[] = []
const uploads: Promise<void>[] = []
for (const file of fileList) {
const node = await createNode(canvas, 'LoadImage')
if (!node) continue
nodes.push(node)
const transfer = new DataTransfer()
transfer.items.add(file)
const imageNode = await pasteImageNode(canvas, transfer.items)
if (imageNode) {
nodes.push(imageNode)
}
uploads.push(pasteItemsOnNode(transfer.items, node, 'image'))
}
return nodes
return {
nodes,
completion: Promise.all(uploads).then(() => {})
}
}
export async function pasteAudioNode(
@@ -120,7 +132,7 @@ export async function pasteAudioNode(
if (!audioNode) {
audioNode = await createNode(canvas, 'LoadAudio')
}
pasteItemsOnNode(items, audioNode, 'audio')
await pasteItemsOnNode(items, audioNode, 'audio')
return audioNode
}
@@ -151,7 +163,7 @@ export async function pasteVideoNode(
if (!videoNode) {
videoNode = await createNode(canvas, 'LoadVideo')
}
pasteItemsOnNode(items, videoNode, 'video')
await pasteItemsOnNode(items, videoNode, 'video')
return videoNode
}

View File

@@ -12,7 +12,6 @@ import { forEachNode } from '@/utils/graphTraversalUtil'
import { CanvasPointer } from './CanvasPointer'
import type { ContextMenu } from './ContextMenu'
import { createCursorCache } from './cursorCache'
import { DragAndScale } from './DragAndScale'
import type { AnimationOptions } from './DragAndScale'
import type { LGraph } from './LGraph'
@@ -365,8 +364,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.canvas.dispatchEvent(new CustomEvent(type, { detail }))
}
private _setCursor!: ReturnType<typeof createCursorCache>
private _updateCursorStyle() {
if (!this.state.shouldSetCursor) return
@@ -389,7 +386,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
cursor = 'grab'
}
this._setCursor(cursor)
this.canvas.style.cursor = cursor
}
// Whether the canvas was previously being dragged prior to pressing space key.
@@ -1914,7 +1911,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.pointer.element = element
if (!element) return
this._setCursor = createCursorCache(element)
// TODO: classList.add
element.className += ' lgraphcanvas'
@@ -2974,7 +2970,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
// Set appropriate cursor for resize direction
this._setCursor(cursors[resizeDirection])
this.canvas.style.cursor = cursors[resizeDirection]
return
}
}
@@ -3976,14 +3972,41 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
)
}
/** @todo Refactor to where it belongs - e.g. Deleting / creating nodes is not actually canvas event. */
/**
* Signals the start of a compound graph operation. All graph mutations
* between this call and the matching {@link emitAfterChange} are treated
* as a single undoable action by the change tracking system.
*
* Emits a `litegraph:canvas` DOM event with `subType: 'before-change'`,
* which `ChangeTracker` listens for to suppress intermediate state
* snapshots. Calls are nestable — only the outermost pair triggers a
* state check.
*
* Always pair with {@link emitAfterChange} in a `try/finally` block.
*
* @example
* ```ts
* canvas.emitBeforeChange()
* try {
* // multiple graph mutations...
* } finally {
* canvas.emitAfterChange()
* }
* ```
*/
emitBeforeChange(): void {
this.emitEvent({
subType: 'before-change'
})
}
/** @todo See {@link emitBeforeChange} */
/**
* Signals the end of a compound graph operation started by
* {@link emitBeforeChange}. When the outermost pair completes, the
* change tracking system takes a single state snapshot and records
* one undo entry for all mutations since the matching
* `emitBeforeChange`.
*/
emitAfterChange(): void {
this.emitEvent({
subType: 'after-change'
@@ -6577,7 +6600,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
const ySizeFix = opts.posSizeFix[1] * LiteGraph.NODE_SLOT_HEIGHT
const nodeX = opts.position[0] + opts.posAdd[0] + xSizeFix
const nodeY = opts.position[1] + opts.posAdd[1] + ySizeFix
const pos: [number, number] = [nodeX, nodeY]
const pos = [nodeX, nodeY]
const newNode = LiteGraph.createNode(nodeTypeStr, nodeNewOpts?.title, {
pos
})

View File

@@ -10,13 +10,7 @@ import { Reroute } from './Reroute'
import { InputIndicators } from './canvas/InputIndicators'
import { LabelPosition, SlotDirection, SlotShape, SlotType } from './draw'
import { Rectangle } from './infrastructure/Rectangle'
import type {
CreateNodeOptions,
Dictionary,
ISlotType,
Rect,
WhenNullish
} from './interfaces'
import type { Dictionary, ISlotType, Rect, WhenNullish } from './interfaces'
import { distance, isInsideRectangle, overlapBounding } from './measure'
import { SubgraphIONodeBase } from './subgraph/SubgraphIONodeBase'
import { SubgraphSlot } from './subgraph/SubgraphSlotBase'
@@ -531,7 +525,7 @@ export class LiteGraphGlobal {
createNode(
type: string,
title?: string,
options?: CreateNodeOptions
options?: Dictionary<unknown>
): LGraphNode | null {
const base_class = this.registered_node_types[type]
if (!base_class) {
@@ -567,7 +561,10 @@ export class LiteGraphGlobal {
// extra options
if (options) {
Object.assign(node, options)
for (const i in options) {
// @ts-expect-error #577 Requires interface
node[i] = options[i]
}
}
// callback

View File

@@ -1,59 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { createCursorCache } from './cursorCache'
function createMockElement() {
let cursorValue = ''
const setter = vi.fn((value: string) => {
cursorValue = value
})
const element = document.createElement('div')
Object.defineProperty(element.style, 'cursor', {
get: () => cursorValue,
set: setter
})
return { element, setter }
}
describe('createCursorCache', () => {
it('should only write to DOM when cursor value changes', () => {
const { element, setter } = createMockElement()
const setCursor = createCursorCache(element)
setCursor('crosshair')
setCursor('crosshair')
setCursor('crosshair')
expect(setter).toHaveBeenCalledTimes(1)
expect(setter).toHaveBeenCalledWith('crosshair')
})
it('should write to DOM when cursor value differs', () => {
const { element, setter } = createMockElement()
const setCursor = createCursorCache(element)
setCursor('default')
setCursor('crosshair')
setCursor('grabbing')
expect(setter).toHaveBeenCalledTimes(3)
expect(setter).toHaveBeenNthCalledWith(1, 'default')
expect(setter).toHaveBeenNthCalledWith(2, 'crosshair')
expect(setter).toHaveBeenNthCalledWith(3, 'grabbing')
})
it('should skip repeated values interspersed with changes', () => {
const { element, setter } = createMockElement()
const setCursor = createCursorCache(element)
setCursor('default')
setCursor('default')
setCursor('grab')
setCursor('grab')
setCursor('default')
expect(setter).toHaveBeenCalledTimes(3)
})
})

View File

@@ -1,8 +0,0 @@
export function createCursorCache(element: HTMLElement) {
let lastCursor = ''
return function setCursor(cursor: string) {
if (cursor === lastCursor) return
lastCursor = cursor
element.style.cursor = cursor
}
}

View File

@@ -3,17 +3,13 @@ import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
import type { ContextMenu } from './ContextMenu'
import type { LGraphNode, NodeId, NodeProperty } from './LGraphNode'
import type { LGraphNode, NodeId } from './LGraphNode'
import type { LLink, LinkId } from './LLink'
import type { Reroute, RerouteId } from './Reroute'
import type { SubgraphInput } from './subgraph/SubgraphInput'
import type { SubgraphInputNode } from './subgraph/SubgraphInputNode'
import type { SubgraphOutputNode } from './subgraph/SubgraphOutputNode'
import type {
LGraphEventMode,
LinkDirection,
RenderShape
} from './types/globalEnums'
import type { LinkDirection, RenderShape } from './types/globalEnums'
import type { IBaseWidget } from './types/widgets'
export type Dictionary<T> = { [key: string]: T }
@@ -377,22 +373,6 @@ export interface INodeOutputSlot extends INodeSlot {
slot_index?: number
}
/** Options for {@link LiteGraphGlobal.createNode}. Shallow-copied onto the new node. */
export interface CreateNodeOptions {
pos?: Point
size?: Size
properties?: Dictionary<NodeProperty | undefined>
flags?: Partial<INodeFlags>
mode?: LGraphEventMode
color?: string
bgcolor?: string
boxcolor?: string
title?: string
shape?: RenderShape
inputs?: Partial<INodeInputSlot>[]
outputs?: Partial<INodeOutputSlot>[]
}
/** Links */
export interface ConnectingLink extends IInputOrOutput {
node: LGraphNode

View File

@@ -91,7 +91,6 @@ export { RecursionError } from './infrastructure/RecursionError'
export type {
CanvasColour,
ColorOption,
CreateNodeOptions,
IContextMenuOptions,
IContextMenuValue,
INodeInputSlot,
@@ -145,11 +144,7 @@ export { isColorable } from './utils/type'
export { createUuidv4 } from './utils/uuid'
export type { UUID } from './utils/uuid'
export { truncateText } from './utils/textUtils'
export {
evaluateInput,
getWidgetStep,
resolveNodeRootGraphId
} from './utils/widget'
export { getWidgetStep, resolveNodeRootGraphId } from './utils/widget'
export { distributeSpace, type SpaceRequest } from './utils/spaceDistribution'
export { BaseWidget } from './widgets/BaseWidget'

View File

@@ -1,121 +0,0 @@
import { describe, expect, test } from 'vitest'
import { evaluateMathExpression } from '@/lib/litegraph/src/utils/mathParser'
describe('evaluateMathExpression', () => {
test.each([
['2+3', 5],
['10-4', 6],
['3*7', 21],
['15/3', 5]
])('basic arithmetic: %s = %d', (input, expected) => {
expect(evaluateMathExpression(input)).toBe(expected)
})
test.each([
['2+3*4', 14],
['(2+3)*4', 20],
['10-2*3', 4],
['10/2+3', 8]
])('operator precedence: %s = %d', (input, expected) => {
expect(evaluateMathExpression(input)).toBe(expected)
})
test.each([
['3.14*2', 6.28],
['.5+.5', 1],
['1.5+2.5', 4],
['0.1+0.2', 0.1 + 0.2],
['123.', 123],
['123.+3', 126]
])('decimals: %s', (input, expected) => {
expect(evaluateMathExpression(input)).toBe(expected)
})
test.each([
[' 2 + 3 ', 5],
[' 10 - 4 ', 6],
[' ( 2 + 3 ) * 4 ', 20]
])('whitespace handling: "%s" = %d', (input, expected) => {
expect(evaluateMathExpression(input)).toBe(expected)
})
test.each([
['((2+3))', 5],
['(1+(2*(3+4)))', 15],
['((1+2)*(3+4))', 21]
])('nested parentheses: %s = %d', (input, expected) => {
expect(evaluateMathExpression(input)).toBe(expected)
})
test.each([
['-5', -5],
['-(3+2)', -5],
['--5', 5],
['+5', 5],
['-3*2', -6],
['2*-3', -6],
['1+-2', -1],
['2--3', 5],
['-2*-3', 6],
['-(2+3)*-(4+5)', 45]
])('unary operators: %s = %d', (input, expected) => {
expect(evaluateMathExpression(input)).toBe(expected)
})
test.each([
['2 /2+3 * 4.75- -6', 21.25],
['2 / (2 + 3) * 4.33 - -6', 7.732],
['12* 123/-(-5 + 2)', 492],
['((80 - (19)))', 61],
['(1 - 2) + -(-(-(-4)))', 3],
['1 - -(-(-(-4)))', -3],
['12* 123/(-5 + 2)', -492],
['12 * -123', -1476],
['((2.33 / (2.9+3.5)*4) - -6)', 7.45625],
['123.45*(678.90 / (-2.5+ 11.5)-(80 -19) *33.25) / 20 + 11', -12042.760875],
[
'(123.45*(678.90 / (-2.5+ 11.5)-(((80 -(19))) *33.25)) / 20) - (123.45*(678.90 / (-2.5+ 11.5)-(((80 -(19))) *33.25)) / 20) + (13 - 2)/ -(-11) ',
1
]
])('complex expression: %s', (input, expected) => {
expect(evaluateMathExpression(input)).toBeCloseTo(expected as number)
})
test.each(['', 'abc', '2+', '(2+3', '2+3)', '()', '*3', '2 3', '.', '123..'])(
'invalid input returns undefined: "%s"',
(input) => {
expect(evaluateMathExpression(input)).toBeUndefined()
}
)
test('division by zero returns Infinity', () => {
expect(evaluateMathExpression('1/0')).toBe(Infinity)
})
test('0/0 returns NaN', () => {
expect(evaluateMathExpression('0/0')).toBeNaN()
})
test.each([
['10%3', 1],
['10%3+1', 2],
['7%2', 1]
])('modulo: %s = %d', (input, expected) => {
expect(evaluateMathExpression(input)).toBe(expected)
})
test('negative zero is normalized to positive zero', () => {
expect(Object.is(evaluateMathExpression('-0'), 0)).toBe(true)
})
test('deeply nested parentheses exceeding depth limit returns undefined', () => {
const input = '('.repeat(201) + '1' + ')'.repeat(201)
expect(evaluateMathExpression(input)).toBeUndefined()
})
test('parentheses within depth limit evaluate correctly', () => {
const input = '('.repeat(200) + '1' + ')'.repeat(200)
expect(evaluateMathExpression(input)).toBe(1)
})
})

View File

@@ -1,116 +0,0 @@
type Token = { type: 'number'; value: number } | { type: 'op'; value: string }
function tokenize(input: string): Token[] | undefined {
const tokens: Token[] = []
const re = /(\d+(?:\.\d*)?|\.\d+)|([+\-*/%()])/g
let lastIndex = 0
for (const match of input.matchAll(re)) {
const gap = input.slice(lastIndex, match.index)
if (gap.trim()) return undefined
lastIndex = match.index + match[0].length
if (match[1]) tokens.push({ type: 'number', value: parseFloat(match[1]) })
else tokens.push({ type: 'op', value: match[2] })
}
if (input.slice(lastIndex).trim()) return undefined
return tokens
}
/**
* Evaluates a basic arithmetic expression string containing
* `+`, `-`, `*`, `/`, `%`, parentheses, and decimal numbers.
* Returns `undefined` for empty or malformed input.
*/
export function evaluateMathExpression(input: string): number | undefined {
const tokenized = tokenize(input)
if (!tokenized || tokenized.length === 0) return undefined
const tokens: Token[] = tokenized
let pos = 0
let depth = 0
const MAX_DEPTH = 200
function peek(): Token | undefined {
return tokens[pos]
}
function consume(): Token {
return tokens[pos++]
}
function primary(): number | undefined {
const t = peek()
if (!t) return undefined
if (t.type === 'number') {
consume()
return t.value
}
if (t.type === 'op' && t.value === '(') {
if (++depth > MAX_DEPTH) return undefined
consume()
const result = expr()
if (result === undefined) return undefined
const closing = peek()
if (!closing || closing.type !== 'op' || closing.value !== ')') {
return undefined
}
consume()
depth--
return result
}
return undefined
}
function unary(): number | undefined {
const t = peek()
if (t?.type === 'op' && (t.value === '+' || t.value === '-')) {
consume()
const operand = unary()
if (operand === undefined) return undefined
return t.value === '-' ? -operand : operand
}
return primary()
}
function factor(): number | undefined {
let left = unary()
if (left === undefined) return undefined
while (
peek()?.type === 'op' &&
(peek()!.value === '*' || peek()!.value === '/' || peek()!.value === '%')
) {
const op = consume().value
const right = unary()
if (right === undefined) return undefined
left =
op === '*' ? left * right : op === '/' ? left / right : left % right
}
return left
}
function expr(): number | undefined {
let left = factor()
if (left === undefined) return undefined
while (
peek()?.type === 'op' &&
(peek()!.value === '+' || peek()!.value === '-')
) {
const op = consume().value
const right = factor()
if (right === undefined) return undefined
left = op === '+' ? left + right : left - right
}
return left
}
const result = expr()
if (result === undefined || pos !== tokens.length) return undefined
return result === 0 ? 0 : result
}

View File

@@ -3,7 +3,6 @@ import { describe, expect, test } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IWidgetOptions } from '@/lib/litegraph/src/litegraph'
import {
evaluateInput,
getWidgetStep,
resolveNodeRootGraphId
} from '@/lib/litegraph/src/litegraph'
@@ -71,57 +70,3 @@ describe('resolveNodeRootGraphId', () => {
expect(resolveNodeRootGraphId(node, 'app-root-id')).toBe('app-root-id')
})
})
describe('evaluateInput', () => {
test.each([
['42', 42],
['3.14', 3.14],
['-7', -7],
['0', 0]
])('plain number: "%s" = %d', (input, expected) => {
expect(evaluateInput(input)).toBe(expected)
})
test.each([
['2+3', 5],
['(4+2)*3', 18],
['3.14*2', 6.28],
['10/2+3', 8]
])('expression: "%s" = %d', (input, expected) => {
expect(evaluateInput(input)).toBe(expected)
})
test('empty string returns 0 (Number("") === 0)', () => {
expect(evaluateInput('')).toBe(0)
})
test.each(['abc', 'hello world'])(
'invalid input returns undefined: "%s"',
(input) => {
expect(evaluateInput(input)).toBeUndefined()
}
)
test('division by zero returns undefined', () => {
expect(evaluateInput('1/0')).toBeUndefined()
})
test('0/0 returns undefined (NaN is filtered)', () => {
expect(evaluateInput('0/0')).toBeUndefined()
})
test('scientific notation via Number() fallback', () => {
expect(evaluateInput('1e5')).toBe(100000)
})
test('hex notation via Number() fallback', () => {
expect(evaluateInput('0xff')).toBe(255)
})
test.each(['Infinity', '-Infinity'])(
'"%s" returns undefined (non-finite rejected)',
(input) => {
expect(evaluateInput(input)).toBeUndefined()
}
)
})

View File

@@ -2,8 +2,6 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { evaluateMathExpression } from '@/lib/litegraph/src/utils/mathParser'
/**
* The step value for numeric widgets.
* Use {@link IWidgetOptions.step2} if available, otherwise fallback to
@@ -14,13 +12,17 @@ export function getWidgetStep(options: IWidgetOptions<unknown>): number {
}
export function evaluateInput(input: string): number | undefined {
const result = evaluateMathExpression(input)
if (result !== undefined) {
if (!isFinite(result)) return undefined
return result
// Check if v is a valid equation or a number
if (/^[\d\s.()*+/-]+$/.test(input)) {
// Solve the equation if possible
try {
input = eval(input)
} catch {
// Ignore eval errors
}
}
const newValue = Number(input)
if (!isFinite(newValue)) return undefined
if (isNaN(newValue)) return undefined
return newValue
}

View File

@@ -344,10 +344,6 @@
"Workspace_ToggleFocusMode": {
"label": "تبديل وضع التركيز"
},
"Workspace_ToggleSidebarTab_apps": {
"label": "تبديل الشريط الجانبي للتطبيقات",
"tooltip": "التطبيقات"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "تبديل الشريط الجانبي للأصول",
"tooltip": "الأصول"

View File

@@ -326,40 +326,35 @@
"deleteWorkflow": "حذف سير العمل",
"duplicate": "تكرار",
"enterAppMode": "الدخول إلى وضع التطبيق",
"enterBuilderMode": "دخول وضع بناء التطبيق",
"enterNewName": "أدخل اسمًا جديدًا",
"exitAppMode": "الخروج من وضع التطبيق",
"missingNodesWarning": "يحتوي سير العمل على عقد غير مدعومة (مظللة باللون الأحمر).",
"workflowActions": "إجراءات سير العمل"
},
"builderMenu": {
"exitAppBuilder": "الخروج من مُنشئ التطبيق"
"exitAppBuilder": "الخروج من مُنشئ التطبيق",
"saveApp": "حفظ التطبيق"
},
"builderToolbar": {
"app": "تطبيق",
"appDescription": "يفتح كتطبيق بشكل افتراضي",
"arrange": "معاينة",
"arrangeDescription": "مراجعة تخطيط التطبيق",
"backToWorkflow": "العودة إلى سير العمل",
"connectOutput": "توصيل مخرج",
"connectOutputBody1": "يجب توصيل مخرج واحد على الأقل قبل حفظ التطبيق.",
"connectOutputBody2": "انتقل إلى خطوة 'تحديد' وانقر على عقد المخرجات لإضافتها هنا.",
"defaultModeAppliedAppBody": "سيفتح سير العمل هذا في وضع التطبيق بشكل افتراضي من الآن فصاعدًا.",
"defaultModeAppliedAppPrompt": "هل ترغب في عرضه الآن؟",
"defaultModeAppliedGraphBody": "سيفتح سير العمل هذا كـمخطط عقد بشكل افتراضي من الآن فصاعدًا.",
"defaultModeAppliedGraphPrompt": "هل ترغب في عرض التطبيق مع ذلك؟",
"defaultModeAppliedTitle": "تم التعيين بنجاح",
"defaultView": "تعيين العرض الافتراضي",
"defaultViewDescription": "اختر كيفية الفتح",
"defaultViewLabel": "افتراضيًا، سيتم فتح سير العمل هذا كـ:",
"defaultViewTitle": "تعيين العرض الافتراضي لهذا سير العمل",
"emptyWorkflowExplanation": "سير العمل الخاص بك فارغ. تحتاج إلى بعض العقد أولاً للبدء في بناء تطبيق.",
"emptyWorkflowPrompt": "هل ترغب في البدء بقالب؟",
"emptyWorkflowTitle": "لا يحتوي سير العمل هذا على أي عقد",
"filename": "اسم الملف",
"label": "منشئ التطبيقات",
"loadTemplate": "تحميل قالب",
"nodeGraph": "رسم العقد",
"nodeGraphDescription": "يفتح كرسم عقد بشكل افتراضي",
"save": "حفظ",
"saveAs": "حفظ باسم",
"saveAsLabel": "احفظ سير العمل هذا كـ ...",
"saveDescription": "حفظ وإنهاء",
"saveSuccess": "تم الحفظ بنجاح",
"saveSuccessAppMessage": "تم حفظ '{name}'. سيفتح في وضع التطبيق بشكل افتراضي من الآن فصاعدًا.",
"saveSuccessAppPrompt": "هل ترغب في عرضه الآن؟",
"saveSuccessGraphMessage": "تم حفظ '{name}'. سيفتح كرسم عقد بشكل افتراضي.",
"select": "تحديد",
"selectDescription": "اختيار المدخلات/المخرجات",
"switchToSelect": "الانتقال إلى التحديد",
@@ -1339,9 +1334,7 @@
"linearMode": {
"appModeToolbar": {
"appBuilder": "منشئ التطبيقات",
"apps": "التطبيقات",
"appsEmptyMessage": "سيتم عرض التطبيقات المحفوظة هنا.\nانقر أدناه لبناء تطبيقك الأول.",
"enterAppMode": "الدخول إلى وضع التطبيق"
"apps": "التطبيقات"
},
"arrange": {
"atLeastOne": "عقدة واحدة على الأقل",
@@ -1366,16 +1359,13 @@
"outputsExample": "أمثلة: \"حفظ صورة\" أو \"حفظ فيديو\"",
"promptAddInputs": "انقر على معلمات العقدة لإضافتها هنا كمدخلات",
"promptAddOutputs": "انقر على عقد الإخراج لإضافتها هنا. هذه ستكون النتائج المُولدة.",
"title": "وضع بناء التطبيق",
"unknownWidget": "عنصر الواجهة غير مرئي"
"title": "وضع بناء التطبيق"
},
"downloadAll": "تنزيل الكل",
"dragAndDropImage": "اسحب وأسقط صورة",
"enterNodeGraph": "دخول مخطط العقد",
"giveFeedback": "إعطاء ملاحظات",
"graphMode": "وضع الرسم البياني",
"linearMode": "وضع التطبيق",
"mobileControls": "تعديل وتشغيل",
"queue": {
"clear": "مسح قائمة الانتظار",
"clickToClear": "انقر لمسح قائمة الانتظار"
@@ -1383,7 +1373,6 @@
"rerun": "تشغيل مجدد",
"reuseParameters": "إعادة استخدام المعلمات",
"runCount": "عدد مرات التشغيل:",
"viewJob": "عرض المهمة",
"welcome": {
"backToWorkflow": "العودة إلى سير العمل",
"buildApp": "إنشاء تطبيق",
@@ -1760,7 +1749,6 @@
"disabled": "معطل",
"disabledTooltip": "لن يتم وضع سير العمل في قائمة الانتظار تلقائيًا",
"execute": "تنفيذ",
"fullscreen": "ملء الشاشة",
"help": "مساعدة",
"helpAndFeedback": "المساعدة والتعليقات",
"hideMenu": "إخفاء القائمة",
@@ -1908,8 +1896,7 @@
"Workflows": "سير العمل",
"Zoom In": "تكبير",
"Zoom Out": "تصغير",
"Zoom to fit": "تكبير لتناسب",
"linearMode_appModeToolbar_apps": "linearMode.appModeToolbar.apps"
"Zoom to fit": "تكبير لتناسب"
},
"minimap": {
"nodeColors": "ألوان العقد",
@@ -3215,9 +3202,7 @@
"enterFilename": "أدخل اسم الملف",
"enterFilenamePrompt": "أدخل اسم الملف:",
"exportWorkflow": "تصدير سير العمل",
"saveWorkflow": "حفظ سير العمل",
"savedAsApp": "تم التحويل إلى سير عمل تطبيق",
"savedAsWorkflow": "تم التحويل إلى سير عمل مخطط العقد فقط"
"saveWorkflow": "حفظ سير العمل"
},
"workspace": {
"addedToWorkspace": "تمت إضافتك إلى {workspaceName}",

View File

@@ -344,10 +344,6 @@
"Workspace_ToggleFocusMode": {
"label": "Toggle Focus Mode"
},
"Workspace_ToggleSidebarTab_apps": {
"label": "Toggle Apps Sidebar",
"tooltip": "Apps"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "Toggle Assets Sidebar",
"tooltip": "Assets"

View File

@@ -854,7 +854,6 @@
"clearQueued": "Clear queued",
"clearHistory": "Clear job history",
"dockedJobHistory": "Docked Job History",
"showRunProgressBar": "Show run progress bar",
"clearHistoryMenuAssetsNote": "Media assets won't be deleted.",
"filterJobs": "Filter jobs",
"filterBy": "Filter by",
@@ -1281,6 +1280,7 @@
"Duplicate Current Workflow": "Duplicate Current Workflow",
"Export": "Export",
"Export (API)": "Export (API)",
"Share": "Share",
"Convert Selection to Subgraph": "Convert Selection to Subgraph",
"Edit Subgraph Widgets": "Edit Subgraph Widgets",
"Exit Subgraph": "Exit Subgraph",
@@ -1351,7 +1351,6 @@
"Toggle Essential Bottom Panel": "Toggle Essential Bottom Panel",
"Toggle View Controls Bottom Panel": "Toggle View Controls Bottom Panel",
"Focus Mode": "Focus Mode",
"linearMode_appModeToolbar_apps": "linearMode.appModeToolbar.apps",
"Assets": "Assets",
"Model Library": "Model Library",
"Node Library": "Node Library",
@@ -3051,11 +3050,11 @@
},
"arrange": {
"noOutputs": "No outputs added yet",
"switchToOutputs": "Switch to the 'Output' step and click on output nodes to add them here.",
"switchToSelect": "Switch to the 'Select' step and click on output nodes to add them here.",
"connectAtLeastOne": "Connect {atLeastOne} output node so users can see results after running.",
"atLeastOne": "at least one",
"outputExamples": "Examples: 'Save Image' or 'Save Video'",
"switchToOutputsButton": "Switch to Outputs",
"switchToSelectButton": "Switch to Select",
"outputs": "Outputs",
"resultsLabel": "Results generated from the selected output node(s) will be shown here after running this app"
},
@@ -3359,18 +3358,16 @@
},
"builderToolbar": {
"label": "App Builder",
"inputs": "Inputs",
"outputs": "Outputs",
"inputsDescription": "Choose inputs",
"outputsDescription": "Choose outputs",
"select": "Select",
"selectDescription": "Choose inputs/outputs",
"arrange": "Preview",
"arrangeDescription": "Review app layout",
"defaultView": "Set a default view",
"defaultViewDescription": "Choose how this opens",
"connectOutput": "Connect an output",
"connectOutputBody1": "Your app needs at least one output to be connected before it can be saved.",
"connectOutputBody2": "Switch to the 'Output' step and click on output nodes to add them here.",
"switchToOutputs": "Switch to Outputs",
"connectOutputBody2": "Switch to the 'Select' step and click on output nodes to add them here.",
"switchToSelect": "Switch to Select",
"defaultViewTitle": "Set the default view for this workflow",
"defaultViewLabel": "By default, this workflow will open as:",
"app": "App",

View File

@@ -344,10 +344,6 @@
"Workspace_ToggleFocusMode": {
"label": "Alternar Modo de Enfoque"
},
"Workspace_ToggleSidebarTab_apps": {
"label": "Alternar barra lateral de aplicaciones",
"tooltip": "Aplicaciones"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "Alternar barra lateral de recursos",
"tooltip": "Recursos"

View File

@@ -326,40 +326,35 @@
"deleteWorkflow": "Eliminar flujo de trabajo",
"duplicate": "Duplicar",
"enterAppMode": "Entrar en modo aplicación",
"enterBuilderMode": "Entrar al constructor de aplicaciones",
"enterNewName": "Ingrese un nuevo nombre",
"exitAppMode": "Salir del modo aplicación",
"missingNodesWarning": "El flujo de trabajo contiene nodos no compatibles (resaltados en rojo).",
"workflowActions": "Acciones del flujo de trabajo"
},
"builderMenu": {
"exitAppBuilder": "Salir del constructor de aplicaciones"
"exitAppBuilder": "Salir del constructor de aplicaciones",
"saveApp": "Guardar aplicación"
},
"builderToolbar": {
"app": "Aplicación",
"appDescription": "Se abre como una aplicación por defecto",
"arrange": "Vista previa",
"arrangeDescription": "Revisar el diseño de la aplicación",
"backToWorkflow": "Volver al flujo de trabajo",
"connectOutput": "Conectar una salida",
"connectOutputBody1": "Tu aplicación necesita al menos una salida conectada antes de poder guardarse.",
"connectOutputBody2": "Cambia al paso 'Seleccionar' y haz clic en los nodos de salida para agregarlos aquí.",
"defaultModeAppliedAppBody": "Este flujo de trabajo se abrirá en Modo de Aplicación por defecto a partir de ahora.",
"defaultModeAppliedAppPrompt": "¿Te gustaría verlo ahora?",
"defaultModeAppliedGraphBody": "Este flujo de trabajo se abrirá como un grafo de nodos por defecto a partir de ahora.",
"defaultModeAppliedGraphPrompt": "¿Aún quieres ver la aplicación?",
"defaultModeAppliedTitle": "Configurado correctamente",
"defaultView": "Establecer vista predeterminada",
"defaultViewDescription": "Elige cómo se abre esto",
"defaultViewLabel": "Por defecto, este flujo de trabajo se abrirá como:",
"defaultViewTitle": "Establecer la vista predeterminada para este flujo de trabajo",
"emptyWorkflowExplanation": "Tu flujo de trabajo está vacío. Necesitas algunos nodos para empezar a crear una aplicación.",
"emptyWorkflowPrompt": "¿Quieres empezar con una plantilla?",
"emptyWorkflowTitle": "Este flujo de trabajo no tiene nodos",
"filename": "Nombre de archivo",
"label": "Constructor de aplicaciones",
"loadTemplate": "Cargar una plantilla",
"nodeGraph": "Grafo de nodos",
"nodeGraphDescription": "Se abre como grafo de nodos por defecto",
"save": "Guardar",
"saveAs": "Guardar como",
"saveAsLabel": "Guardar este flujo de trabajo como...",
"saveDescription": "Guardar y finalizar",
"saveSuccess": "Guardado exitosamente",
"saveSuccessAppMessage": "'{name}' ha sido guardado. Se abrirá en modo aplicación por defecto de ahora en adelante.",
"saveSuccessAppPrompt": "¿Te gustaría verlo ahora?",
"saveSuccessGraphMessage": "'{name}' ha sido guardado. Se abrirá como grafo de nodos por defecto.",
"select": "Seleccionar",
"selectDescription": "Elegir entradas/salidas",
"switchToSelect": "Cambiar a Seleccionar",
@@ -1339,9 +1334,7 @@
"linearMode": {
"appModeToolbar": {
"appBuilder": "Constructor de aplicaciones",
"apps": "Aplicaciones",
"appsEmptyMessage": "Las aplicaciones guardadas aparecerán aquí.\nHaz clic abajo para crear tu primera aplicación.",
"enterAppMode": "Entrar en modo de aplicación"
"apps": "Aplicaciones"
},
"arrange": {
"atLeastOne": "al menos uno",
@@ -1366,16 +1359,13 @@
"outputsExample": "Ejemplos: “Guardar imagen” o “Guardar video”",
"promptAddInputs": "Haz clic en los parámetros del nodo para agregarlos aquí como entradas",
"promptAddOutputs": "Haz clic en los nodos de salida para agregarlos aquí. Estos serán los resultados generados.",
"title": "Modo constructor de aplicaciones",
"unknownWidget": "Widget no visible"
"title": "Modo constructor de aplicaciones"
},
"downloadAll": "Descargar todo",
"dragAndDropImage": "Arrastra y suelta una imagen",
"enterNodeGraph": "Entrar al grafo de nodos",
"giveFeedback": "Enviar comentarios",
"graphMode": "Modo gráfico",
"linearMode": "Modo App",
"mobileControls": "Editar y ejecutar",
"queue": {
"clear": "Limpiar cola",
"clickToClear": "Haz clic para limpiar la cola"
@@ -1383,7 +1373,6 @@
"rerun": "Volver a ejecutar",
"reuseParameters": "Reutilizar parámetros",
"runCount": "Número de ejecuciones:",
"viewJob": "Ver tarea",
"welcome": {
"backToWorkflow": "Volver al flujo de trabajo",
"buildApp": "Crear aplicación",
@@ -1760,7 +1749,6 @@
"disabled": "Deshabilitado",
"disabledTooltip": "El flujo de trabajo no se encolará automáticamente",
"execute": "Ejecutar",
"fullscreen": "Pantalla completa",
"help": "Ayuda",
"helpAndFeedback": "Ayuda y comentarios",
"hideMenu": "Ocultar menú",
@@ -1908,8 +1896,7 @@
"Workflows": "Flujos de trabajo",
"Zoom In": "Acercar",
"Zoom Out": "Alejar",
"Zoom to fit": "Ajustar al tamaño",
"linearMode_appModeToolbar_apps": "linearMode.appModeToolbar.apps"
"Zoom to fit": "Ajustar al tamaño"
},
"minimap": {
"nodeColors": "Colores de nodos",
@@ -3215,9 +3202,7 @@
"enterFilename": "Introduzca el nombre del archivo",
"enterFilenamePrompt": "Introduce el nombre del archivo:",
"exportWorkflow": "Exportar flujo de trabajo",
"saveWorkflow": "Guardar flujo de trabajo",
"savedAsApp": "Convertido a flujo de trabajo de aplicación",
"savedAsWorkflow": "Convertido a flujo de trabajo solo de grafo de nodos"
"saveWorkflow": "Guardar flujo de trabajo"
},
"workspace": {
"addedToWorkspace": "Has sido añadido a {workspaceName}",

View File

@@ -344,10 +344,6 @@
"Workspace_ToggleFocusMode": {
"label": "تغییر حالت تمرکز"
},
"Workspace_ToggleSidebarTab_apps": {
"label": "نمایش/پنهان کردن نوار کناری اپلیکیشن‌ها",
"tooltip": "اپلیکیشن‌ها"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "تغییر نوار کناری دارایی‌ها",
"tooltip": "دارایی‌ها"

View File

@@ -326,40 +326,35 @@
"deleteWorkflow": "حذف workflow",
"duplicate": "تکرار",
"enterAppMode": "ورود به حالت اپلیکیشن",
"enterBuilderMode": "ورود به حالت سازنده اپلیکیشن",
"enterNewName": "نام جدید را وارد کنید",
"exitAppMode": "خروج از حالت اپلیکیشن",
"missingNodesWarning": "workflow شامل نودهای پشتیبانی‌نشده است (با رنگ قرمز مشخص شده‌اند).",
"workflowActions": "عملیات گردش‌کار"
},
"builderMenu": {
"exitAppBuilder": "خروج از سازنده برنامه"
"exitAppBuilder": "خروج از سازنده برنامه",
"saveApp": "ذخیره برنامه"
},
"builderToolbar": {
"app": "اپلیکیشن",
"appDescription": "به طور پیش‌فرض به صورت اپلیکیشن باز می‌شود",
"arrange": "پیش‌نمایش",
"arrangeDescription": "بررسی چیدمان اپلیکیشن",
"backToWorkflow": "بازگشت به ورک‌فلو",
"connectOutput": "اتصال خروجی",
"connectOutputBody1": "اپلیکیشن شما باید حداقل یک خروجی متصل داشته باشد تا بتوان آن را ذخیره کرد.",
"connectOutputBody2": "به مرحله «انتخاب» بروید و روی nodeهای خروجی کلیک کنید تا اینجا اضافه شوند.",
"defaultModeAppliedAppBody": "این ورک‌فلو از این پس به طور پیش‌فرض در حالت اپلیکیشن باز خواهد شد.",
"defaultModeAppliedAppPrompt": "آیا مایل هستید اکنون آن را مشاهده کنید؟",
"defaultModeAppliedGraphBody": "این ورک‌فلو از این پس به طور پیش‌فرض به صورت گراف node باز خواهد شد.",
"defaultModeAppliedGraphPrompt": "آیا همچنان مایل به مشاهده اپلیکیشن هستید؟",
"defaultModeAppliedTitle": "با موفقیت تنظیم شد",
"defaultView": "تنظیم نمای پیش‌فرض",
"defaultViewDescription": "انتخاب نحوه باز شدن",
"defaultViewLabel": "به طور پیش‌فرض، این گردش‌کار به صورت زیر باز می‌شود:",
"defaultViewTitle": "تنظیم نمای پیش‌فرض برای این گردش‌کار",
"emptyWorkflowExplanation": "ورک‌فلو شما خالی است. ابتدا باید چند node اضافه کنید تا بتوانید اپلیکیشن بسازید.",
"emptyWorkflowPrompt": "آیا می‌خواهید با یک قالب شروع کنید؟",
"emptyWorkflowTitle": "این ورک‌فلو هیچ node ندارد",
"filename": "نام فایل",
"label": "سازنده اپلیکیشن",
"loadTemplate": "بارگذاری قالب",
"nodeGraph": "گراف node",
"nodeGraphDescription": "به طور پیش‌فرض به صورت گراف node باز می‌شود",
"save": "ذخیره",
"saveAs": "ذخیره به عنوان",
"saveAsLabel": "این گردش‌کار را ذخیره کنید به عنوان ...",
"saveDescription": "ذخیره و پایان",
"saveSuccess": "با موفقیت ذخیره شد",
"saveSuccessAppMessage": "'{name}' ذخیره شد. از این پس به طور پیش‌فرض در حالت اپلیکیشن باز خواهد شد.",
"saveSuccessAppPrompt": "آیا مایلید اکنون آن را مشاهده کنید؟",
"saveSuccessGraphMessage": "'{name}' ذخیره شد. به طور پیش‌فرض به صورت گراف node باز خواهد شد.",
"select": "انتخاب",
"selectDescription": "انتخاب ورودی/خروجی‌ها",
"switchToSelect": "رفتن به انتخاب",
@@ -1339,9 +1334,7 @@
"linearMode": {
"appModeToolbar": {
"appBuilder": "سازنده اپلیکیشن",
"apps": "اپلیکیشن‌ها",
"appsEmptyMessage": "اپلیکیشن‌های ذخیره‌شده اینجا نمایش داده می‌شوند.\nبرای ساخت اولین اپلیکیشن خود، روی دکمه زیر کلیک کنید.",
"enterAppMode": "ورود به حالت اپلیکیشن"
"apps": "اپلیکیشن‌ها"
},
"arrange": {
"atLeastOne": "یک",
@@ -1366,16 +1359,13 @@
"outputsExample": "مثال‌ها: «ذخیره تصویر» یا «ذخیره ویدیو»",
"promptAddInputs": "برای افزودن پارامترها به عنوان ورودی، روی پارامترهای گره کلیک کنید",
"promptAddOutputs": "برای افزودن خروجی، روی گره‌های خروجی کلیک کنید. این‌ها نتایج تولیدشده خواهند بود.",
"title": "حالت ساخت اپلیکیشن",
"unknownWidget": "ویجت قابل مشاهده نیست"
"title": "حالت ساخت اپلیکیشن"
},
"downloadAll": "دانلود همه",
"dragAndDropImage": "تصویر را بکشید و رها کنید",
"enterNodeGraph": "ورود به گراف node",
"giveFeedback": "ارسال بازخورد",
"graphMode": "حالت گراف",
"linearMode": "حالت برنامه",
"mobileControls": "ویرایش و اجرا",
"queue": {
"clear": "پاک‌سازی صف",
"clickToClear": "برای پاک‌سازی صف کلیک کنید"
@@ -1383,7 +1373,6 @@
"rerun": "اجرای مجدد",
"reuseParameters": "استفاده مجدد از پارامترها",
"runCount": "تعداد اجرا: ",
"viewJob": "مشاهده وظیفه",
"welcome": {
"backToWorkflow": "بازگشت به گردش‌کار",
"buildApp": "ساخت اپلیکیشن",
@@ -1760,7 +1749,6 @@
"disabled": "غیرفعال",
"disabledTooltip": "workflow به صورت خودکار در صف قرار نمی‌گیرد",
"execute": "اجرا",
"fullscreen": "تمام‌صفحه",
"help": "راهنما",
"helpAndFeedback": "راهنما و بازخورد",
"hideMenu": "مخفی کردن منو",
@@ -1908,8 +1896,7 @@
"Workflows": "Workflowها",
"Zoom In": "بزرگ‌نمایی",
"Zoom Out": "کوچک‌نمایی",
"Zoom to fit": "بزرگ‌نمایی برای تطبیق",
"linearMode_appModeToolbar_apps": "linearMode.appModeToolbar.apps"
"Zoom to fit": "بزرگ‌نمایی برای تطبیق"
},
"minimap": {
"nodeColors": "رنگ‌های نود",
@@ -3227,9 +3214,7 @@
"enterFilename": "نام فایل را وارد کنید",
"enterFilenamePrompt": "نام فایل را وارد کنید:",
"exportWorkflow": "خروجی گرفتن از workflow",
"saveWorkflow": "ذخیره workflow",
"savedAsApp": "به گردش‌کار اپلیکیشن تبدیل شد",
"savedAsWorkflow": "به گردش‌کار فقط گراف node تبدیل شد"
"saveWorkflow": "ذخیره workflow"
},
"workspace": {
"addedToWorkspace": "شما به {workspaceName} اضافه شدید",

View File

@@ -344,10 +344,6 @@
"Workspace_ToggleFocusMode": {
"label": "Basculer le mode focus"
},
"Workspace_ToggleSidebarTab_apps": {
"label": "Basculer la barre latérale des applications",
"tooltip": "Applications"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "Afficher/Masquer la barre latérale des ressources",
"tooltip": "Ressources"

View File

@@ -326,44 +326,39 @@
"deleteWorkflow": "Supprimer le workflow",
"duplicate": "Dupliquer",
"enterAppMode": "Entrer en mode application",
"enterBuilderMode": "Entrer dans le mode constructeur d'application",
"enterNewName": "Entrez un nouveau nom",
"exitAppMode": "Quitter le mode application",
"missingNodesWarning": "Le workflow contient des nœuds non pris en charge (surlignés en rouge).",
"workflowActions": "Actions du workflow"
},
"builderMenu": {
"exitAppBuilder": "Quitter le constructeur d'application"
"exitAppBuilder": "Quitter le constructeur d'application",
"saveApp": "Enregistrer l'application"
},
"builderToolbar": {
"app": "Application",
"appDescription": "S'ouvre par défaut en tant qu'application",
"arrange": "Aperçu",
"arrangeDescription": "Vérifier la disposition de l'application",
"backToWorkflow": "Retour au workflow",
"connectOutput": "Connecter une sortie",
"connectOutputBody1": "Votre application doit avoir au moins une sortie connectée avant de pouvoir être enregistrée.",
"connectOutputBody2": "Passez à l'étape « Sélectionner » et cliquez sur les nœuds de sortie pour les ajouter ici.",
"defaultModeAppliedAppBody": "Ce workflow souvrira désormais par défaut en mode Application.",
"defaultModeAppliedAppPrompt": "Voulez-vous le voir maintenant ?",
"defaultModeAppliedGraphBody": "Ce workflow souvrira désormais par défaut sous forme de graphe de nœuds.",
"defaultModeAppliedGraphPrompt": "Voulez-vous tout de même voir lapplication ?",
"defaultModeAppliedTitle": "Défini avec succès",
"defaultView": "Définir une vue par défaut",
"defaultViewDescription": "Choisissez comment cela s'ouvre",
"defaultViewLabel": "Par défaut, ce workflow s'ouvrira comme :",
"defaultViewTitle": "Définir la vue par défaut pour ce workflow",
"emptyWorkflowExplanation": "Votre workflow est vide. Vous devez dabord ajouter des nœuds pour commencer à créer une application.",
"emptyWorkflowPrompt": "Voulez-vous commencer avec un modèle ?",
"emptyWorkflowTitle": "Ce workflow ne contient aucun nœud",
"filename": "Nom du fichier",
"label": "Créateur d'applications",
"loadTemplate": "Charger un modèle",
"nodeGraph": "Graphe de nœuds",
"nodeGraphDescription": "S'ouvre par défaut en tant que graphe de nœuds",
"save": "Enregistrer",
"saveAs": "Enregistrer sous",
"saveAsLabel": "Enregistrer ce workflow sous ...",
"saveDescription": "Enregistrer et terminer",
"saveSuccess": "Enregistré avec succès",
"saveSuccessAppMessage": "« {name} » a été enregistré. Il s'ouvrira désormais par défaut en mode application.",
"saveSuccessAppPrompt": "Voulez-vous le voir maintenant ?",
"saveSuccessGraphMessage": "« {name} » a été enregistré. Il s'ouvrira par défaut en tant que graphe de nœuds.",
"select": "Sélectionner",
"selectDescription": "Choisir les entrées/sorties",
"switchToSelect": "Passer à Sélectionner",
"viewApp": "Voir lapplication"
"viewApp": "Voir l'application"
},
"clipboard": {
"errorMessage": "Échec de la copie dans le presse-papiers",
@@ -1339,9 +1334,7 @@
"linearMode": {
"appModeToolbar": {
"appBuilder": "Créateur d'applications",
"apps": "Applications",
"appsEmptyMessage": "Les applications enregistrées apparaîtront ici.\nCliquez ci-dessous pour créer votre première application.",
"enterAppMode": "Entrer en mode application"
"apps": "Applications"
},
"arrange": {
"atLeastOne": "au moins un",
@@ -1366,16 +1359,13 @@
"outputsExample": "Exemples : « Enregistrer limage » ou « Enregistrer la vidéo »",
"promptAddInputs": "Cliquez sur les paramètres du nœud pour les ajouter ici comme entrées",
"promptAddOutputs": "Cliquez sur les nœuds de sortie pour les ajouter ici. Ce seront les résultats générés.",
"title": "Mode créateur dapplication",
"unknownWidget": "Widget non visible"
"title": "Mode créateur dapplication"
},
"downloadAll": "Tout télécharger",
"dragAndDropImage": "Glissez-déposez une image",
"enterNodeGraph": "Entrer dans le graphique de nœuds",
"giveFeedback": "Donner un avis",
"graphMode": "Mode graphique",
"linearMode": "Mode App",
"mobileControls": "Éditer & Exécuter",
"queue": {
"clear": "Vider la file d'attente",
"clickToClear": "Cliquez pour vider la file d'attente"
@@ -1383,7 +1373,6 @@
"rerun": "Relancer",
"reuseParameters": "Réutiliser les paramètres",
"runCount": "Nombre dexécutions :",
"viewJob": "Voir la tâche",
"welcome": {
"backToWorkflow": "Retour au workflow",
"buildApp": "Créer une application",
@@ -1760,7 +1749,6 @@
"disabled": "Désactivé",
"disabledTooltip": "Le flux de travail ne sera pas mis en file d'attente automatiquement",
"execute": "Exécuter",
"fullscreen": "Plein écran",
"help": "Aide",
"helpAndFeedback": "Aide et commentaires",
"hideMenu": "Masquer le menu",
@@ -1908,8 +1896,7 @@
"Workflows": "Flux de travail",
"Zoom In": "Zoom avant",
"Zoom Out": "Zoom arrière",
"Zoom to fit": "Ajuster à l'écran",
"linearMode_appModeToolbar_apps": "linearMode.appModeToolbar.apps"
"Zoom to fit": "Ajuster à l'écran"
},
"minimap": {
"nodeColors": "Couleurs des nœuds",
@@ -3215,9 +3202,7 @@
"enterFilename": "Entrez le nom du fichier",
"enterFilenamePrompt": "Entrez le nom du fichier :",
"exportWorkflow": "Exporter le flux de travail",
"saveWorkflow": "Enregistrer le flux de travail",
"savedAsApp": "Converti en workflow d'application",
"savedAsWorkflow": "Converti en workflow graphique de nœuds uniquement"
"saveWorkflow": "Enregistrer le flux de travail"
},
"workspace": {
"addedToWorkspace": "Vous avez été ajouté à {workspaceName}",

View File

@@ -344,10 +344,6 @@
"Workspace_ToggleFocusMode": {
"label": "フォーカスモードの切り替え"
},
"Workspace_ToggleSidebarTab_apps": {
"label": "アプリサイドバーを切り替え",
"tooltip": "アプリ"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "アセットサイドバーの表示切り替え",
"tooltip": "アセット"

View File

@@ -326,40 +326,35 @@
"deleteWorkflow": "ワークフローを削除",
"duplicate": "複製",
"enterAppMode": "アプリモードに入る",
"enterBuilderMode": "アプリビルダーに入る",
"enterNewName": "新しい名前を入力",
"exitAppMode": "アプリモードを終了",
"missingNodesWarning": "ワークフローに未対応のノードが含まれています(赤でハイライト)。",
"workflowActions": "ワークフロー操作"
},
"builderMenu": {
"exitAppBuilder": "アプリビルダーを終了"
"exitAppBuilder": "アプリビルダーを終了",
"saveApp": "アプリを保存"
},
"builderToolbar": {
"app": "アプリ",
"appDescription": "デフォルトでアプリとして開きます",
"arrange": "プレビュー",
"arrangeDescription": "アプリのレイアウトを確認",
"backToWorkflow": "ワークフローに戻る",
"connectOutput": "出力を接続",
"connectOutputBody1": "アプリを保存するには、少なくとも1つの出力を接続する必要があります。",
"connectOutputBody2": "「選択」ステップに切り替えて、出力ノードをクリックしてここに追加してください。",
"defaultModeAppliedAppBody": "このワークフローは今後デフォルトでアプリモードで開きます。",
"defaultModeAppliedAppPrompt": "今すぐ表示しますか?",
"defaultModeAppliedGraphBody": "このワークフローは今後デフォルトでノードグラフとして開きます。",
"defaultModeAppliedGraphPrompt": "アプリを引き続き表示しますか?",
"defaultModeAppliedTitle": "正常に設定されました",
"defaultView": "デフォルトビューを設定",
"defaultViewDescription": "開き方を選択してください",
"defaultViewLabel": "デフォルトでは、このワークフローは次のように開きます:",
"defaultViewTitle": "このワークフローのデフォルトビューを設定",
"emptyWorkflowExplanation": "ワークフローが空です。アプリを作成するにはまずノードが必要です。",
"emptyWorkflowPrompt": "テンプレートから始めますか?",
"emptyWorkflowTitle": "このワークフローにはノードがありません",
"filename": "ファイル名",
"label": "アプリビルダー",
"loadTemplate": "テンプレートを読み込む",
"nodeGraph": "ノードグラフ",
"nodeGraphDescription": "デフォルトでノードグラフとして開きます",
"save": "保存",
"saveAs": "名前を付けて保存",
"saveAsLabel": "このワークフローを次の形式で保存...",
"saveDescription": "保存して終了",
"saveSuccess": "保存に成功しました",
"saveSuccessAppMessage": "「{name}」が保存されました。今後はデフォルトでアプリモードで開きます。",
"saveSuccessAppPrompt": "今すぐ表示しますか?",
"saveSuccessGraphMessage": "「{name}」が保存されました。今後はデフォルトでノードグラフとして開きます。",
"select": "選択",
"selectDescription": "入力/出力を選択",
"switchToSelect": "選択に切り替え",
@@ -1339,9 +1334,7 @@
"linearMode": {
"appModeToolbar": {
"appBuilder": "アプリビルダー",
"apps": "アプリ",
"appsEmptyMessage": "保存されたアプリはここに表示されます。\n下をクリックして最初のアプリを作成しましょう。",
"enterAppMode": "アプリモードに入る"
"apps": "アプリ"
},
"arrange": {
"atLeastOne": "少なくとも1つ",
@@ -1366,16 +1359,13 @@
"outputsExample": "例:「画像を保存」「動画を保存」",
"promptAddInputs": "ノードのパラメータをクリックして、ここに入力として追加してください",
"promptAddOutputs": "出力ノードをクリックしてここに追加してください。これが生成される結果となります。",
"title": "アプリビルダーモード",
"unknownWidget": "ウィジェットが表示されていません"
"title": "アプリビルダーモード"
},
"downloadAll": "すべてダウンロード",
"dragAndDropImage": "画像をドラッグ&ドロップ",
"enterNodeGraph": "ノードグラフに入る",
"giveFeedback": "フィードバックを送る",
"graphMode": "グラフモード",
"linearMode": "アプリモード",
"mobileControls": "編集と実行",
"queue": {
"clear": "キューをクリア",
"clickToClear": "クリックしてキューをクリア"
@@ -1383,7 +1373,6 @@
"rerun": "再実行",
"reuseParameters": "パラメータを再利用",
"runCount": "実行回数:",
"viewJob": "ジョブを表示",
"welcome": {
"backToWorkflow": "ワークフローに戻る",
"buildApp": "アプリを作成",
@@ -1760,7 +1749,6 @@
"disabled": "無効",
"disabledTooltip": "ワークフローは自動的にキューに追加されません",
"execute": "実行",
"fullscreen": "全画面表示",
"help": "ヘルプ",
"helpAndFeedback": "ヘルプとフィードバック",
"hideMenu": "メニューを隠す",
@@ -1908,8 +1896,7 @@
"Workflows": "ワークフロー",
"Zoom In": "ズームイン",
"Zoom Out": "ズームアウト",
"Zoom to fit": "全体表示にズーム",
"linearMode_appModeToolbar_apps": "linearMode.appModeToolbar.apps"
"Zoom to fit": "全体表示にズーム"
},
"minimap": {
"nodeColors": "ノードの色",
@@ -3215,9 +3202,7 @@
"enterFilename": "ファイル名を入力",
"enterFilenamePrompt": "ファイル名を入力してください:",
"exportWorkflow": "ワークフローをエクスポート",
"saveWorkflow": "ワークフローを保存",
"savedAsApp": "アプリワークフローに変換されました",
"savedAsWorkflow": "ノードグラフのみのワークフローに変換されました"
"saveWorkflow": "ワークフローを保存"
},
"workspace": {
"addedToWorkspace": "{workspaceName}に追加されました",

View File

@@ -344,10 +344,6 @@
"Workspace_ToggleFocusMode": {
"label": "포커스 모드 토글"
},
"Workspace_ToggleSidebarTab_apps": {
"label": "앱 사이드바 전환",
"tooltip": "앱"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "에셋 사이드바 전환",
"tooltip": "에셋"

View File

@@ -326,40 +326,35 @@
"deleteWorkflow": "워크플로 삭제",
"duplicate": "복제",
"enterAppMode": "앱 모드로 진입",
"enterBuilderMode": "앱 빌더로 진입",
"enterNewName": "새 이름 입력",
"exitAppMode": "앱 모드 종료",
"missingNodesWarning": "워크플로우에 지원되지 않는 노드가 포함되어 있습니다(빨간색으로 표시됨).",
"workflowActions": "워크플로우 작업"
},
"builderMenu": {
"exitAppBuilder": "앱 빌더 종료"
"exitAppBuilder": "앱 빌더 종료",
"saveApp": "앱 저장"
},
"builderToolbar": {
"app": "앱",
"appDescription": "기본적으로 앱으로 열립니다",
"arrange": "미리보기",
"arrangeDescription": "앱 레이아웃 검토",
"backToWorkflow": "워크플로우로 돌아가기",
"connectOutput": "출력 연결",
"connectOutputBody1": "앱을 저장하려면 최소 한 개의 출력이 연결되어야 합니다.",
"connectOutputBody2": "'선택' 단계로 전환한 후 출력 노드를 클릭하여 여기에 추가하세요.",
"defaultModeAppliedAppBody": "이 워크플로우는 앞으로 기본적으로 앱 모드에서 열립니다.",
"defaultModeAppliedAppPrompt": "지금 확인하시겠습니까?",
"defaultModeAppliedGraphBody": "이 워크플로우는 앞으로 기본적으로 노드 그래프로 열립니다.",
"defaultModeAppliedGraphPrompt": "앱을 계속 보시겠습니까?",
"defaultModeAppliedTitle": "성공적으로 설정됨",
"defaultView": "기본 보기 설정",
"defaultViewDescription": "이 워크플로우가 어떻게 열릴지 선택하세요",
"defaultViewLabel": "기본적으로 이 워크플로우는 다음과 같이 열립니다:",
"defaultViewTitle": "이 워크플로우의 기본 보기 설정",
"emptyWorkflowExplanation": "워크플로우가 비어 있습니다. 앱을 만들려면 먼저 노드가 필요합니다.",
"emptyWorkflowPrompt": "템플릿으로 시작하시겠습니까?",
"emptyWorkflowTitle": "이 워크플로우에는 노드가 없습니다",
"filename": "파일명",
"label": "앱 빌더",
"loadTemplate": "템플릿 불러오기",
"nodeGraph": "노드 그래프",
"nodeGraphDescription": "기본적으로 노드 그래프로 열립니다",
"save": "저장",
"saveAs": "다른 이름으로 저장",
"saveAsLabel": "이 워크플로우를 다음으로 저장 ...",
"saveDescription": "저장 및 완료",
"saveSuccess": "성공적으로 저장되었습니다",
"saveSuccessAppMessage": "'{name}'이(가) 저장되었습니다. 앞으로 기본적으로 앱 모드로 열립니다.",
"saveSuccessAppPrompt": "지금 확인하시겠습니까?",
"saveSuccessGraphMessage": "'{name}'이(가) 저장되었습니다. 앞으로 기본적으로 노드 그래프로 열립니다.",
"select": "선택",
"selectDescription": "입력/출력 선택",
"switchToSelect": "선택으로 전환",
@@ -1339,9 +1334,7 @@
"linearMode": {
"appModeToolbar": {
"appBuilder": "앱 빌더",
"apps": "앱",
"appsEmptyMessage": "저장된 앱이 여기에 표시됩니다.\n아래를 클릭하여 첫 번째 앱을 만들어보세요.",
"enterAppMode": "앱 모드로 들어가기"
"apps": "앱"
},
"arrange": {
"atLeastOne": "최소 한 개",
@@ -1366,16 +1359,13 @@
"outputsExample": "예시: “이미지 저장” 또는 “비디오 저장”",
"promptAddInputs": "노드 파라미터를 클릭하여 입력값으로 추가하세요",
"promptAddOutputs": "출력 노드를 클릭하여 여기에 추가하세요. 이들이 생성된 결과가 됩니다.",
"title": "앱 빌더 모드",
"unknownWidget": "위젯이 표시되지 않습니다"
"title": "앱 빌더 모드"
},
"downloadAll": "모두 다운로드",
"dragAndDropImage": "이미지를 드래그 앤 드롭하세요",
"enterNodeGraph": "노드 그래프로 진입",
"giveFeedback": "피드백 보내기",
"graphMode": "그래프 모드",
"linearMode": "앱 모드",
"mobileControls": "편집 및 실행",
"queue": {
"clear": "대기열 비우기",
"clickToClear": "클릭하여 대기열 비우기"
@@ -1383,7 +1373,6 @@
"rerun": "다시 실행",
"reuseParameters": "파라미터 재사용",
"runCount": "실행 횟수:",
"viewJob": "작업 보기",
"welcome": {
"backToWorkflow": "워크플로우로 돌아가기",
"buildApp": "앱 만들기",
@@ -1760,7 +1749,6 @@
"disabled": "비활성화됨",
"disabledTooltip": "워크플로 작업을 자동으로 실행 대기열에 추가하지 않습니다.",
"execute": "실행",
"fullscreen": "전체 화면",
"help": "도움말",
"helpAndFeedback": "도움말 및 피드백",
"hideMenu": "메뉴 숨기기",
@@ -1908,8 +1896,7 @@
"Workflows": "워크플로",
"Zoom In": "확대",
"Zoom Out": "축소",
"Zoom to fit": "화면에 맞추기",
"linearMode_appModeToolbar_apps": "linearMode.appModeToolbar.apps"
"Zoom to fit": "화면에 맞추기"
},
"minimap": {
"nodeColors": "노드 색상",
@@ -3215,9 +3202,7 @@
"enterFilename": "파일 이름 입력",
"enterFilenamePrompt": "파일 이름을 입력하세요:",
"exportWorkflow": "워크플로 내보내기",
"saveWorkflow": "워크플로 저장",
"savedAsApp": "앱 워크플로우로 변환됨",
"savedAsWorkflow": "노드 그래프 전용 워크플로우로 변환됨"
"saveWorkflow": "워크플로 저장"
},
"workspace": {
"addedToWorkspace": "{workspaceName} 워크스페이스에 추가되었습니다",

View File

@@ -344,10 +344,6 @@
"Workspace_ToggleFocusMode": {
"label": "Alternar modo de foco"
},
"Workspace_ToggleSidebarTab_apps": {
"label": "Alternar Barra Lateral de Apps",
"tooltip": "Apps"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "Alternar barra lateral de assets",
"tooltip": "Assets"

View File

@@ -326,40 +326,35 @@
"deleteWorkflow": "Excluir Fluxo de Trabalho",
"duplicate": "Duplicar",
"enterAppMode": "Entrar no modo de aplicativo",
"enterBuilderMode": "Entrar no construtor de aplicativos",
"enterNewName": "Digite um novo nome",
"exitAppMode": "Sair do modo de aplicativo",
"missingNodesWarning": "O fluxo de trabalho contém nós não suportados (destacados em vermelho).",
"workflowActions": "Ações do fluxo de trabalho"
},
"builderMenu": {
"exitAppBuilder": "Sair do construtor de aplicativos"
"exitAppBuilder": "Sair do construtor de aplicativos",
"saveApp": "Salvar aplicativo"
},
"builderToolbar": {
"app": "Aplicativo",
"appDescription": "Abre como aplicativo por padrão",
"arrange": "Pré-visualizar",
"arrangeDescription": "Revisar layout do aplicativo",
"backToWorkflow": "Voltar ao fluxo de trabalho",
"connectOutput": "Conectar uma saída",
"connectOutputBody1": "Seu aplicativo precisa de pelo menos uma saída conectada antes de ser salvo.",
"connectOutputBody2": "Altere para a etapa 'Selecionar' e clique nos nós de saída para adicioná-los aqui.",
"defaultModeAppliedAppBody": "Este fluxo de trabalho será aberto no Modo de Aplicativo por padrão a partir de agora.",
"defaultModeAppliedAppPrompt": "Gostaria de visualizá-lo agora?",
"defaultModeAppliedGraphBody": "Este fluxo de trabalho será aberto como um grafo de nós por padrão a partir de agora.",
"defaultModeAppliedGraphPrompt": "Ainda deseja visualizar o aplicativo?",
"defaultModeAppliedTitle": "Definido com sucesso",
"defaultView": "Definir visualização padrão",
"defaultViewDescription": "Escolha como isso será aberto",
"defaultViewLabel": "Por padrão, este fluxo será aberto como:",
"defaultViewTitle": "Definir a visualização padrão para este fluxo",
"emptyWorkflowExplanation": "Seu fluxo de trabalho está vazio. Você precisa de alguns nós para começar a criar um aplicativo.",
"emptyWorkflowPrompt": "Deseja começar com um modelo?",
"emptyWorkflowTitle": "Este fluxo de trabalho não possui nós",
"filename": "Nome do arquivo",
"label": "Construtor de aplicativos",
"loadTemplate": "Carregar um modelo",
"nodeGraph": "Grafo de nós",
"nodeGraphDescription": "Abre como grafo de nós por padrão",
"save": "Salvar",
"saveAs": "Salvar como",
"saveAsLabel": "Salvar este fluxo de trabalho como ...",
"saveDescription": "Salvar e finalizar",
"saveSuccess": "Salvo com sucesso",
"saveSuccessAppMessage": "'{name}' foi salvo. Ele abrirá no Modo de Aplicativo por padrão a partir de agora.",
"saveSuccessAppPrompt": "Gostaria de visualizá-lo agora?",
"saveSuccessGraphMessage": "'{name}' foi salvo. Ele abrirá como grafo de nós por padrão.",
"select": "Selecionar",
"selectDescription": "Escolha entradas/saídas",
"switchToSelect": "Ir para Selecionar",
@@ -1339,9 +1334,7 @@
"linearMode": {
"appModeToolbar": {
"appBuilder": "Construtor de aplicativos",
"apps": "Aplicativos",
"appsEmptyMessage": "Os aplicativos salvos aparecerão aqui.\nClique abaixo para criar seu primeiro aplicativo.",
"enterAppMode": "Entrar no modo de aplicativo"
"apps": "Aplicativos"
},
"arrange": {
"atLeastOne": "pelo menos um",
@@ -1366,16 +1359,13 @@
"outputsExample": "Exemplos: “Salvar imagem” ou “Salvar vídeo”",
"promptAddInputs": "Clique nos parâmetros do nó para adicioná-los aqui como entradas",
"promptAddOutputs": "Clique nos nós de saída para adicioná-los aqui. Estes serão os resultados gerados.",
"title": "Modo construtor de app",
"unknownWidget": "Widget não visível"
"title": "Modo construtor de app"
},
"downloadAll": "Baixar tudo",
"dragAndDropImage": "Arraste e solte uma imagem",
"enterNodeGraph": "Entrar no grafo de nós",
"giveFeedback": "Enviar feedback",
"graphMode": "Modo Gráfico",
"linearMode": "Modo App",
"mobileControls": "Editar e Executar",
"queue": {
"clear": "Limpar fila",
"clickToClear": "Clique para limpar a fila"
@@ -1383,7 +1373,6 @@
"rerun": "Executar novamente",
"reuseParameters": "Reutilizar parâmetros",
"runCount": "Número de execuções:",
"viewJob": "Ver tarefa",
"welcome": {
"backToWorkflow": "Voltar ao fluxo de trabalho",
"buildApp": "Criar aplicativo",
@@ -1760,7 +1749,6 @@
"disabled": "Desativado",
"disabledTooltip": "O fluxo de trabalho não será enfileirado automaticamente",
"execute": "Executar",
"fullscreen": "Tela cheia",
"help": "Ajuda",
"helpAndFeedback": "Ajuda e feedback",
"hideMenu": "Ocultar menu",
@@ -1908,8 +1896,7 @@
"Workflows": "Fluxos de trabalho",
"Zoom In": "Aumentar zoom",
"Zoom Out": "Diminuir zoom",
"Zoom to fit": "Ajustar zoom",
"linearMode_appModeToolbar_apps": "linearMode.appModeToolbar.apps"
"Zoom to fit": "Ajustar zoom"
},
"minimap": {
"nodeColors": "Cores dos Nós",
@@ -3227,9 +3214,7 @@
"enterFilename": "Digite o nome do arquivo",
"enterFilenamePrompt": "Digite o nome do arquivo:",
"exportWorkflow": "Exportar Fluxo de Trabalho",
"saveWorkflow": "Salvar fluxo de trabalho",
"savedAsApp": "Convertido para fluxo de aplicativo",
"savedAsWorkflow": "Convertido para fluxo apenas com grafo de nós"
"saveWorkflow": "Salvar fluxo de trabalho"
},
"workspace": {
"addedToWorkspace": "Você foi adicionado ao {workspaceName}",

View File

@@ -344,10 +344,6 @@
"Workspace_ToggleFocusMode": {
"label": "Переключить режим фокуса"
},
"Workspace_ToggleSidebarTab_apps": {
"label": "Переключить боковую панель приложений",
"tooltip": "Приложения"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "Переключить боковую панель ресурсов",
"tooltip": "Ресурсы"

View File

@@ -326,40 +326,35 @@
"deleteWorkflow": "Удалить рабочий процесс",
"duplicate": "Дублировать",
"enterAppMode": "Войти в режим приложения",
"enterBuilderMode": "Войти в режим конструктора приложения",
"enterNewName": "Введите новое имя",
"exitAppMode": "Выйти из режима приложения",
"missingNodesWarning": "В рабочем процессе есть неподдерживаемые узлы (выделены красным).",
"workflowActions": "Действия с рабочим процессом"
},
"builderMenu": {
"exitAppBuilder": "Выйти из конструктора приложений"
"exitAppBuilder": "Выйти из конструктора приложений",
"saveApp": "Сохранить приложение"
},
"builderToolbar": {
"app": "Приложение",
"appDescription": "Открывается как приложение по умолчанию",
"arrange": "Предпросмотр",
"arrangeDescription": "Проверьте макет приложения",
"backToWorkflow": "Назад к рабочему процессу",
"connectOutput": "Подключить выход",
"connectOutputBody1": "Вашему приложению необходимо подключить хотя бы один выход перед сохранением.",
"connectOutputBody2": "Переключитесь на шаг «Выбрать» и кликните по выходным узлам, чтобы добавить их сюда.",
"defaultModeAppliedAppBody": "Этот рабочий процесс теперь будет открываться в режиме приложения по умолчанию.",
"defaultModeAppliedAppPrompt": "Хотите просмотреть его сейчас?",
"defaultModeAppliedGraphBody": "Этот рабочий процесс теперь будет открываться как граф узлов по умолчанию.",
"defaultModeAppliedGraphPrompt": "Хотите всё равно просмотреть приложение?",
"defaultModeAppliedTitle": "Успешно установлено",
"defaultView": "Установить вид по умолчанию",
"defaultViewDescription": "Выберите, как это будет открываться",
"defaultViewLabel": "По умолчанию этот рабочий процесс будет открываться как:",
"defaultViewTitle": "Установить вид по умолчанию для этого рабочего процесса",
"emptyWorkflowExplanation": "Ваш рабочий процесс пуст. Для начала создания приложения добавьте несколько узлов.",
"emptyWorkflowPrompt": "Хотите начать с шаблона?",
"emptyWorkflowTitle": "В этом рабочем процессе нет узлов",
"filename": "Имя файла",
"label": "Конструктор приложений",
"loadTemplate": "Загрузить шаблон",
"nodeGraph": "Граф узлов",
"nodeGraphDescription": "Открывается как граф узлов по умолчанию",
"save": "Сохранить",
"saveAs": "Сохранить как",
"saveAsLabel": "Сохранить этот рабочий процесс как ...",
"saveDescription": "Сохранить и завершить",
"saveSuccess": "Успешно сохранено",
"saveSuccessAppMessage": "«{name}» сохранено. Теперь будет открываться в режиме приложения по умолчанию.",
"saveSuccessAppPrompt": "Хотите просмотреть сейчас?",
"saveSuccessGraphMessage": "«{name}» сохранено. Теперь будет открываться как граф узлов по умолчанию.",
"select": "Выбрать",
"selectDescription": "Выберите входы/выходы",
"switchToSelect": "Переключиться на выбор",
@@ -1339,9 +1334,7 @@
"linearMode": {
"appModeToolbar": {
"appBuilder": "Конструктор приложений",
"apps": "Приложения",
"appsEmptyMessage": "Сохранённые приложения появятся здесь.\nНажмите ниже, чтобы создать своё первое приложение.",
"enterAppMode": "Войти в режим приложения"
"apps": "Приложения"
},
"arrange": {
"atLeastOne": "хотя бы один",
@@ -1366,16 +1359,13 @@
"outputsExample": "Примеры: «Сохранить изображение» или «Сохранить видео»",
"promptAddInputs": "Нажмите на параметры узла, чтобы добавить их сюда как входные данные",
"promptAddOutputs": "Нажмите на выходные узлы, чтобы добавить их сюда. Это будут сгенерированные результаты.",
"title": "Режим конструктора приложений",
"unknownWidget": "Виджет не отображается"
"title": "Режим конструктора приложений"
},
"downloadAll": "Скачать всё",
"dragAndDropImage": "Перетащите изображение",
"enterNodeGraph": "Войти в граф узлов",
"giveFeedback": "Оставить отзыв",
"graphMode": "Графовый режим",
"linearMode": "Режим приложения",
"mobileControls": "Редактировать и запустить",
"queue": {
"clear": "Очистить очередь",
"clickToClear": "Нажмите, чтобы очистить очередь"
@@ -1383,7 +1373,6 @@
"rerun": "Перезапустить",
"reuseParameters": "Повторно использовать параметры",
"runCount": "Количество запусков:",
"viewJob": "Просмотреть задачу",
"welcome": {
"backToWorkflow": "Назад к рабочему процессу",
"buildApp": "Создать приложение",
@@ -1760,7 +1749,6 @@
"disabled": "Отключено",
"disabledTooltip": "Рабочий процесс не будет автоматически помещён в очередь",
"execute": "Выполнить",
"fullscreen": "Полноэкранный режим",
"help": "Справка",
"helpAndFeedback": "Помощь и обратная связь",
"hideMenu": "Скрыть меню",
@@ -1908,8 +1896,7 @@
"Workflows": "Рабочие процессы",
"Zoom In": "Увеличить",
"Zoom Out": "Уменьшить",
"Zoom to fit": "Масштабировать по размеру",
"linearMode_appModeToolbar_apps": "linearMode.appModeToolbar.apps"
"Zoom to fit": "Масштабировать по размеру"
},
"minimap": {
"nodeColors": "Цвета узлов",
@@ -3215,9 +3202,7 @@
"enterFilename": "Введите название файла",
"enterFilenamePrompt": "Введите имя файла:",
"exportWorkflow": "Экспорт рабочего процесса",
"saveWorkflow": "Сохранить рабочий процесс",
"savedAsApp": "Преобразовано в рабочий процесс приложения",
"savedAsWorkflow": "Преобразовано только в рабочий процесс графа узлов"
"saveWorkflow": "Сохранить рабочий процесс"
},
"workspace": {
"addedToWorkspace": "Вы были добавлены в {workspaceName}",

View File

@@ -344,10 +344,6 @@
"Workspace_ToggleFocusMode": {
"label": "Odak Modunu Aç/Kapat"
},
"Workspace_ToggleSidebarTab_apps": {
"label": "Uygulamalar Kenar Çubuğunu Aç/Kapat",
"tooltip": "Uygulamalar"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "Varlıklar Kenar Çubuğunu Aç/Kapat",
"tooltip": "Varlıklar"

View File

@@ -326,40 +326,35 @@
"deleteWorkflow": "İş Akışını Sil",
"duplicate": "Çoğalt",
"enterAppMode": "Uygulama moduna gir",
"enterBuilderMode": "Uygulama oluşturucuya gir",
"enterNewName": "Yeni isim girin",
"exitAppMode": "Uygulama modundan çık",
"missingNodesWarning": "İş akışında desteklenmeyen düğümler var (kırmızı ile vurgulanmış).",
"workflowActions": "İş akışı işlemleri"
},
"builderMenu": {
"exitAppBuilder": "Uygulama oluşturucudan çık"
"exitAppBuilder": "Uygulama oluşturucudan çık",
"saveApp": "Uygulamayı kaydet"
},
"builderToolbar": {
"app": "Uygulama",
"appDescription": "Varsayılan olarak uygulama olarak açılır",
"arrange": "Önizleme",
"arrangeDescription": "Uygulama düzenini gözden geçir",
"backToWorkflow": "İş akışına geri dön",
"connectOutput": "Bir çıktı bağla",
"connectOutputBody1": "Uygulamanızın kaydedilebilmesi için en az bir çıktı bağlanmalıdır.",
"connectOutputBody2": "'Seç' adımına geçin ve çıktı düğümlerine tıklayarak buraya ekleyin.",
"defaultModeAppliedAppBody": "Bu iş akışı artık varsayılan olarak Uygulama Modunda açılacak.",
"defaultModeAppliedAppPrompt": "Şimdi görüntülemek ister misiniz?",
"defaultModeAppliedGraphBody": "Bu iş akışı artık varsayılan olarak bir düğüm grafiği olarak açılacak.",
"defaultModeAppliedGraphPrompt": "Uygulamayı yine de görüntülemek ister misiniz?",
"defaultModeAppliedTitle": "Başarıyla ayarlandı",
"defaultView": "Varsayılan görünümü ayarla",
"defaultViewDescription": "Nasıl açılacağını seçin",
"defaultViewLabel": "Varsayılan olarak, bu iş akışı şu şekilde açılacak:",
"defaultViewTitle": "Bu iş akışı için varsayılan görünümü ayarla",
"emptyWorkflowExplanation": "İş akışınız boş. Bir uygulama oluşturmaya başlamak için önce bazı düğümlere ihtiyacınız var.",
"emptyWorkflowPrompt": "Bir şablonla başlamak ister misiniz?",
"emptyWorkflowTitle": "Bu iş akışında düğüm yok",
"filename": "Dosya adı",
"label": "Uygulama Oluşturucu",
"loadTemplate": "Şablon yükle",
"nodeGraph": "Düğüm grafiği",
"nodeGraphDescription": "Varsayılan olarak düğüm grafiği olarak açılır",
"save": "Kaydet",
"saveAs": "Farklı kaydet",
"saveAsLabel": "Bu iş akışını farklı kaydet ...",
"saveDescription": "Kaydet ve bitir",
"saveSuccess": "Başarıyla kaydedildi",
"saveSuccessAppMessage": "'{name}' kaydedildi. Artık varsayılan olarak Uygulama Modunda açılacak.",
"saveSuccessAppPrompt": "Şimdi görüntülemek ister misiniz?",
"saveSuccessGraphMessage": "'{name}' kaydedildi. Varsayılan olarak düğüm grafiği olarak açılacak.",
"select": "Seç",
"selectDescription": "Girdi/çıktı seçin",
"switchToSelect": "Seç'e Geç",
@@ -1339,9 +1334,7 @@
"linearMode": {
"appModeToolbar": {
"appBuilder": "Uygulama oluşturucu",
"apps": "Uygulamalar",
"appsEmptyMessage": "Kaydedilen uygulamalar burada görünecek.\nİlk uygulamanızı oluşturmak için aşağıya tıklayın.",
"enterAppMode": "Uygulama moduna gir"
"apps": "Uygulamalar"
},
"arrange": {
"atLeastOne": "en az bir",
@@ -1366,16 +1359,13 @@
"outputsExample": "Örnekler: “Resmi Kaydet” veya “Videoyu Kaydet”",
"promptAddInputs": "Girdi olarak eklemek için düğüm parametrelerine tıklayın",
"promptAddOutputs": ıktı olarak eklemek için çıktı düğümlerine tıklayın. Bunlar oluşturulan sonuçlar olacak.",
"title": "Uygulama oluşturucu modu",
"unknownWidget": "Widget görünür değil"
"title": "Uygulama oluşturucu modu"
},
"downloadAll": "Tümünü İndir",
"dragAndDropImage": "Bir görseli sürükleyip bırakın",
"enterNodeGraph": "Düğüm grafiğine gir",
"giveFeedback": "Geri bildirim ver",
"graphMode": "Grafik Modu",
"linearMode": "Uygulama Modu",
"mobileControls": "Düzenle ve Çalıştır",
"queue": {
"clear": "Kuyruğu temizle",
"clickToClear": "Kuyruğu temizlemek için tıklayın"
@@ -1383,7 +1373,6 @@
"rerun": "Tekrar Çalıştır",
"reuseParameters": "Parametreleri Yeniden Kullan",
"runCount": "Çalıştırma sayısı:",
"viewJob": "İşi Görüntüle",
"welcome": {
"backToWorkflow": "İş akışına geri dön",
"buildApp": "Uygulama oluştur",
@@ -1760,7 +1749,6 @@
"disabled": "Devre Dışı",
"disabledTooltip": "İş akışı otomatik olarak kuyruğa alınmayacak",
"execute": "Yürüt",
"fullscreen": "Tam ekran",
"help": "Yardım",
"helpAndFeedback": "Yardım ve Geri Bildirim",
"hideMenu": "Menüyü Gizle",
@@ -1908,8 +1896,7 @@
"Workflows": "İş Akışları",
"Zoom In": "Yakınlaştır",
"Zoom Out": "Uzaklaştır",
"Zoom to fit": "Sığdırmak için yakınlaştır",
"linearMode_appModeToolbar_apps": "linearMode.appModeToolbar.apps"
"Zoom to fit": "Sığdırmak için yakınlaştır"
},
"minimap": {
"nodeColors": "Düğüm Renkleri",
@@ -3215,9 +3202,7 @@
"enterFilename": "Dosya adını girin",
"enterFilenamePrompt": "Dosya adını girin:",
"exportWorkflow": "İş Akışını Dışa Aktar",
"saveWorkflow": "İş akışını kaydet",
"savedAsApp": "Uygulama iş akışına dönüştürüldü",
"savedAsWorkflow": "Yalnızca düğüm grafiği iş akışına dönüştürüldü"
"saveWorkflow": "İş akışını kaydet"
},
"workspace": {
"addedToWorkspace": "{workspaceName} çalışma alanına eklendiniz",

View File

@@ -344,10 +344,6 @@
"Workspace_ToggleFocusMode": {
"label": "切換專注模式"
},
"Workspace_ToggleSidebarTab_apps": {
"label": "切換應用程式側邊欄",
"tooltip": "應用程式"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "切換資源側邊欄",
"tooltip": "資源"

View File

@@ -326,44 +326,39 @@
"deleteWorkflow": "刪除工作流程",
"duplicate": "複製",
"enterAppMode": "進入應用模式",
"enterBuilderMode": "進入應用程式建構器",
"enterNewName": "輸入新名稱",
"exitAppMode": "離開應用模式",
"missingNodesWarning": "工作流程包含不支援的節點(以紅色標示)。",
"workflowActions": "工作流程操作"
},
"builderMenu": {
"exitAppBuilder": "離開應用程式建構器"
"exitAppBuilder": "離開應用程式建構器",
"saveApp": "儲存應用程式"
},
"builderToolbar": {
"app": "應用",
"appDescription": "預設以應用模式開啟",
"arrange": "預覽",
"arrangeDescription": "檢視應用版面",
"backToWorkflow": "返回工作流程",
"connectOutput": "連接輸出",
"connectOutputBody1": "您的應用必須至少連接一個輸出才能儲存。",
"connectOutputBody2": "切換到「選擇」步驟,點擊輸出節點以新增到這裡。",
"defaultModeAppliedAppBody": "此工作流程將從現在起預設以應用程式模式開啟。",
"defaultModeAppliedAppPrompt": "您想現在查看嗎?",
"defaultModeAppliedGraphBody": "此工作流程將從現在起預設以節點圖形模式開啟。",
"defaultModeAppliedGraphPrompt": "您還想查看應用程式嗎?",
"defaultModeAppliedTitle": "設定成功",
"defaultView": "設定預設檢視",
"defaultViewDescription": "選擇開啟方式",
"defaultViewLabel": "預設情況下,此工作流程將以以下方式開啟:",
"defaultViewTitle": "設定此工作流程的預設檢視",
"emptyWorkflowExplanation": "您的工作流程是空的。您需要先新增一些節點才能開始建立應用程式。",
"emptyWorkflowPrompt": "您想從範本開始嗎?",
"emptyWorkflowTitle": "此工作流程沒有節點",
"filename": "檔案名稱",
"label": "應用建立器",
"loadTemplate": "載入範本",
"nodeGraph": "節點圖",
"nodeGraphDescription": "預設以節點圖開啟",
"save": "儲存",
"saveAs": "另存新檔",
"saveAsLabel": "將此工作流程另存為...",
"saveDescription": "儲存並完成",
"saveSuccess": "儲存成功",
"saveSuccessAppMessage": "「{name}」已儲存。從現在起將預設以應用模式開啟。",
"saveSuccessAppPrompt": "您想現在檢視嗎?",
"saveSuccessGraphMessage": "「{name}」已儲存。將預設以節點圖開啟。",
"select": "選擇",
"selectDescription": "選擇輸入/輸出",
"switchToSelect": "切換到選擇",
"viewApp": "查看應用程式"
"viewApp": "檢視應用"
},
"clipboard": {
"errorMessage": "複製到剪貼簿失敗",
@@ -1339,9 +1334,7 @@
"linearMode": {
"appModeToolbar": {
"appBuilder": "應用建立器",
"apps": "應用",
"appsEmptyMessage": "已儲存的應用程式將顯示在這裡。\n點擊下方開始建立您的第一個應用程式。",
"enterAppMode": "進入應用程式模式"
"apps": "應用"
},
"arrange": {
"atLeastOne": "至少一個",
@@ -1366,16 +1359,13 @@
"outputsExample": "例如:「儲存圖像」或「儲存影片」",
"promptAddInputs": "點擊節點參數,將其新增為輸入",
"promptAddOutputs": "點擊輸出節點,將其新增於此。這些將是產生的結果。",
"title": "應用程式建構模式",
"unknownWidget": "元件不可見"
"title": "應用程式建構模式"
},
"downloadAll": "全部下載",
"dragAndDropImage": "拖曳圖片到此",
"enterNodeGraph": "進入節點圖",
"giveFeedback": "提供回饋",
"graphMode": "圖形模式",
"linearMode": "App 模式",
"mobileControls": "編輯與執行",
"queue": {
"clear": "清除佇列",
"clickToClear": "點擊以清除佇列"
@@ -1383,7 +1373,6 @@
"rerun": "重新執行",
"reuseParameters": "重用參數",
"runCount": "執行次數:",
"viewJob": "檢視任務",
"welcome": {
"backToWorkflow": "返回工作流程",
"buildApp": "建立應用",
@@ -1760,7 +1749,6 @@
"disabled": "已停用",
"disabledTooltip": "工作流程將不會自動排入佇列",
"execute": "執行",
"fullscreen": "全螢幕",
"help": "說明",
"helpAndFeedback": "說明與回饋",
"hideMenu": "隱藏選單",
@@ -1908,8 +1896,7 @@
"Workflows": "工作流程",
"Zoom In": "放大",
"Zoom Out": "縮小",
"Zoom to fit": "縮放至適合大小",
"linearMode_appModeToolbar_apps": "linearMode.appModeToolbar.apps"
"Zoom to fit": "縮放至適合大小"
},
"minimap": {
"nodeColors": "節點顏色",
@@ -3215,9 +3202,7 @@
"enterFilename": "輸入檔案名稱",
"enterFilenamePrompt": "請輸入檔案名稱:",
"exportWorkflow": "匯出工作流程",
"saveWorkflow": "儲存工作流程",
"savedAsApp": "已轉換為應用程式工作流程",
"savedAsWorkflow": "已轉換為僅節點圖工作流程"
"saveWorkflow": "儲存工作流程"
},
"workspace": {
"addedToWorkspace": "你已被加入 {workspaceName}",

View File

@@ -344,10 +344,6 @@
"Workspace_ToggleFocusMode": {
"label": "切换焦点模式"
},
"Workspace_ToggleSidebarTab_apps": {
"label": "切换应用侧边栏",
"tooltip": "应用"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "切换资产侧边栏",
"tooltip": "资产"

View File

@@ -326,40 +326,35 @@
"deleteWorkflow": "删除工作流",
"duplicate": "复制",
"enterAppMode": "进入应用模式",
"enterBuilderMode": "进入应用构建器",
"enterNewName": "输入新名称",
"exitAppMode": "退出应用模式",
"missingNodesWarning": "工作流包含不支持的节点(红色突出显示)。",
"workflowActions": "工作流操作"
},
"builderMenu": {
"exitAppBuilder": "退出应用构建器"
"exitAppBuilder": "退出应用构建器",
"saveApp": "保存应用"
},
"builderToolbar": {
"app": "应用",
"appDescription": "默认以应用方式打开",
"arrange": "预览",
"arrangeDescription": "查看应用布局",
"backToWorkflow": "返回工作流",
"connectOutput": "连接输出",
"connectOutputBody1": "您的应用需要至少连接一个输出,才能保存。",
"connectOutputBody2": "切换到“选择”步骤,点击输出节点将其添加到此处。",
"defaultModeAppliedAppBody": "此工作流将从现在起默认以应用模式打开。",
"defaultModeAppliedAppPrompt": "你想现在查看吗?",
"defaultModeAppliedGraphBody": "此工作流将从现在起默认以节点图模式打开。",
"defaultModeAppliedGraphPrompt": "你还想查看应用吗?",
"defaultModeAppliedTitle": "设置成功",
"defaultView": "设置默认视图",
"defaultViewDescription": "选择打开方式",
"defaultViewLabel": "默认情况下,此工作流将以以下方式打开:",
"defaultViewTitle": "为此工作流设置默认视图",
"emptyWorkflowExplanation": "你的工作流为空。你需要先添加一些节点来开始构建应用。",
"emptyWorkflowPrompt": "要使用模板开始吗?",
"emptyWorkflowTitle": "此工作流没有节点",
"filename": "文件名",
"label": "应用构建器",
"loadTemplate": "加载模板",
"nodeGraph": "节点图",
"nodeGraphDescription": "默认以节点图方式打开",
"save": "保存",
"saveAs": "另存为",
"saveAsLabel": "将此工作流另存为...",
"saveDescription": "保存并完成",
"saveSuccess": "保存成功",
"saveSuccessAppMessage": "“{name}”已保存。今后将默认以应用模式打开。",
"saveSuccessAppPrompt": "现在要查看吗?",
"saveSuccessGraphMessage": "“{name}”已保存。今后将默认以节点图方式打开。",
"select": "选择",
"selectDescription": "选择输入/输出",
"switchToSelect": "切换到选择",
@@ -1339,9 +1334,7 @@
"linearMode": {
"appModeToolbar": {
"appBuilder": "应用构建器",
"apps": "应用",
"appsEmptyMessage": "已保存的应用会显示在这里。\n点击下方开始构建你的第一个应用。",
"enterAppMode": "进入应用模式"
"apps": "应用"
},
"arrange": {
"atLeastOne": "至少一个",
@@ -1366,16 +1359,13 @@
"outputsExample": "示例:“保存图像”或“保存视频”",
"promptAddInputs": "点击节点参数,将其添加为输入项",
"promptAddOutputs": "点击输出节点,将其添加到此处。这些将作为生成结果。",
"title": "应用构建模式",
"unknownWidget": "组件不可见"
"title": "应用构建模式"
},
"downloadAll": "全部下载",
"dragAndDropImage": "拖拽图片到此处",
"enterNodeGraph": "进入节点图",
"giveFeedback": "提供反馈",
"graphMode": "图形模式",
"linearMode": "App 模式",
"mobileControls": "编辑与运行",
"queue": {
"clear": "清空队列",
"clickToClear": "点击清空队列"
@@ -1383,7 +1373,6 @@
"rerun": "重新运行",
"reuseParameters": "复用参数",
"runCount": "运行次数:",
"viewJob": "查看任务",
"welcome": {
"backToWorkflow": "返回工作流",
"buildApp": "构建应用",
@@ -1760,7 +1749,6 @@
"disabled": "禁用",
"disabledTooltip": "工作流将不会自动执行",
"execute": "执行",
"fullscreen": "全屏",
"help": "说明",
"helpAndFeedback": "帮助与反馈",
"hideMenu": "隐藏菜单",
@@ -1908,8 +1896,7 @@
"Workflows": "工作流",
"Zoom In": "放大画面",
"Zoom Out": "缩小画面",
"Zoom to fit": "缩放以适应",
"linearMode_appModeToolbar_apps": "linearMode.appModeToolbar.apps"
"Zoom to fit": "缩放以适应"
},
"minimap": {
"nodeColors": "节点颜色",
@@ -3227,9 +3214,7 @@
"enterFilename": "输入文件名",
"enterFilenamePrompt": "输入文件名:",
"exportWorkflow": "导出工作流",
"saveWorkflow": "保存工作流",
"savedAsApp": "已转换为应用工作流",
"savedAsWorkflow": "已转换为节点图工作流"
"saveWorkflow": "保存工作流"
},
"workspace": {
"addedToWorkspace": "您已被加入 {workspaceName}",

View File

@@ -228,9 +228,9 @@ describe('assetMetadataUtils', () => {
expected: 'checkpoints'
},
{
name: 'returns full path for path-style tags',
tags: ['models', 'diffusers/Kolors/text_encoder'],
expected: 'diffusers/Kolors/text_encoder'
name: 'extracts last segment from path-style tags',
tags: ['models', 'models/loras'],
expected: 'loras'
},
{
name: 'returns null when only models tag',

View File

@@ -138,7 +138,8 @@ export function getSourceName(url: string): string {
*/
export function getAssetModelType(asset: AssetItem): string | null {
const typeTag = asset.tags?.find((tag) => tag && tag !== 'models')
return typeTag ?? null
if (!typeTag) return null
return typeTag.includes('/') ? (typeTag.split('/').pop() ?? null) : typeTag
}
/**

View File

@@ -12,7 +12,7 @@ import { app } from '@/scripts/app'
import { useLitegraphService } from '@/services/litegraphService'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
interface ModelNodeCreateOptions {
interface CreateNodeOptions {
position?: Point
}
@@ -48,7 +48,7 @@ type Result<T, E> = { success: true; value: T } | { success: false; error: E }
*/
export function createModelNodeFromAsset(
asset: AssetItem,
options?: ModelNodeCreateOptions
options?: CreateNodeOptions
): Result<LGraphNode, NodeCreationError> {
const validatedAsset = assetItemSchema.safeParse(asset)

View File

@@ -1207,13 +1207,6 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: false,
experimental: true
},
{
id: 'Comfy.Queue.ShowRunProgressBar',
name: 'Show run progress bar',
type: 'hidden',
defaultValue: true,
versionAdded: '1.41.3'
},
{
id: 'Comfy.Node.AlwaysShowAdvancedWidgets',
category: ['LiteGraph', 'Node Widget', 'AlwaysShowAdvancedWidgets'],

View File

@@ -365,14 +365,14 @@ describe('useWorkflowService', () => {
})
const workflow2 = createModeTestWorkflow({
path: 'workflows/two.json',
activeMode: 'builder:inputs'
activeMode: 'builder:select'
})
workflowStore.activeWorkflow = workflow1
expect(appMode.mode.value).toBe('app')
workflowStore.activeWorkflow = workflow2
expect(appMode.mode.value).toBe('builder:inputs')
expect(appMode.mode.value).toBe('builder:select')
})
})
@@ -507,7 +507,7 @@ describe('useWorkflowService', () => {
it('each workflow retains its own mode across tab switches', () => {
const workflow1 = createModeTestWorkflow({
path: 'workflows/one.json',
activeMode: 'builder:inputs'
activeMode: 'builder:select'
})
const workflow2 = createModeTestWorkflow({
path: 'workflows/two.json',
@@ -515,13 +515,13 @@ describe('useWorkflowService', () => {
})
workflowStore.activeWorkflow = workflow1
expect(appMode.mode.value).toBe('builder:inputs')
expect(appMode.mode.value).toBe('builder:select')
workflowStore.activeWorkflow = workflow2
expect(appMode.mode.value).toBe('app')
workflowStore.activeWorkflow = workflow1
expect(appMode.mode.value).toBe('builder:inputs')
expect(appMode.mode.value).toBe('builder:select')
})
})
})

View File

@@ -8,7 +8,7 @@ import { cn } from '@/utils/tailwindUtil'
const { id, name } = defineProps<{
id: string
isSelectInputsMode: boolean
isSelectMode: boolean
name: string
}>()
@@ -25,7 +25,7 @@ function togglePromotion() {
</script>
<template>
<div
v-if="isSelectInputsMode"
v-if="isSelectMode"
class="col-span-2 flex flex-row pointer-events-auto cursor-pointer gap-1 relative"
@pointerdown.capture.stop.prevent="togglePromotion"
@click.capture.stop.prevent

View File

@@ -37,7 +37,7 @@ const { hasOutputs } = storeToRefs(useAppModeStore())
</p>
<div class="flex flex-col gap-1 text-muted-foreground w-lg text-[14px]">
<p class="mt-0 p-0">{{ t('linearMode.arrange.switchToOutputs') }}</p>
<p class="mt-0 p-0">{{ t('linearMode.arrange.switchToSelect') }}</p>
<i18n-t keypath="linearMode.arrange.connectAtLeastOne" tag="div">
<template #atLeastOne>
@@ -50,8 +50,8 @@ const { hasOutputs } = storeToRefs(useAppModeStore())
<p class="mt-0 p-0">{{ t('linearMode.arrange.outputExamples') }}</p>
</div>
<div class="flex flex-row gap-2">
<Button variant="primary" size="lg" @click="setMode('builder:outputs')">
{{ t('linearMode.arrange.switchToOutputsButton') }}
<Button variant="primary" size="lg" @click="setMode('builder:select')">
{{ t('linearMode.arrange.switchToSelectButton') }}
</Button>
</div>
</div>

View File

@@ -200,12 +200,6 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
}))
}))
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: vi.fn().mockReturnValue({
nodeProgressStates: {}
})
}))
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
import { api } from '@/scripts/api'

View File

@@ -5,7 +5,6 @@ import type { ShallowRef } from 'vue'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { MinimapCanvas, MinimapSettingsKey } from '../types'
@@ -202,18 +201,6 @@ export function useMinimap({
}
})
const executionStore = useExecutionStore()
watch(
() => executionStore.nodeProgressStates,
() => {
if (visible.value) {
renderer.forceFullRedraw()
renderer.updateMinimap(viewport.updateBounds, viewport.updateViewport)
}
},
{ deep: true }
)
const toggle = async () => {
visible.value = !visible.value
await settingStore.set('Comfy.Minimap.Visible', visible.value)

View File

@@ -17,12 +17,6 @@ vi.mock('@vueuse/core', () => ({
useThrottleFn: vi.fn((fn) => fn)
}))
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: vi.fn().mockReturnValue({
nodeProgressStates: {}
})
}))
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: vi.fn(),

View File

@@ -20,9 +20,7 @@ describe('useMinimapRenderer', () => {
vi.clearAllMocks()
mockContext = {
clearRect: vi.fn(),
save: vi.fn(),
restore: vi.fn()
clearRect: vi.fn()
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D
mockCanvas = {

View File

@@ -13,15 +13,6 @@ import { useMinimapViewport } from '@/renderer/extensions/minimap/composables/us
import type { MinimapCanvas } from '@/renderer/extensions/minimap/types'
vi.mock('@vueuse/core')
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: vi.fn()
}))
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: vi.fn().mockReturnValue({
nodeProgressStates: {}
})
}))
vi.mock('@/renderer/core/spatial/boundsCalculator', () => ({
calculateNodeBounds: vi.fn(),
calculateMinimapScale: vi.fn(),

View File

@@ -1,11 +1,8 @@
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useExecutionStore } from '@/stores/executionStore'
import type { MinimapNodeData } from '../types'
import { AbstractMinimapDataSource } from './AbstractMinimapDataSource'
let executionStore: ReturnType<typeof useExecutionStore> | null = null
/**
* Layout Store data source implementation
*/
@@ -14,19 +11,12 @@ export class LayoutStoreDataSource extends AbstractMinimapDataSource {
const allNodes = layoutStore.getAllNodes().value
if (allNodes.size === 0) return []
if (!executionStore) {
executionStore = useExecutionStore()
}
const nodeProgressStates = executionStore.nodeLocationProgressStates
const nodes: MinimapNodeData[] = []
for (const [nodeId, layout] of allNodes) {
// Find corresponding LiteGraph node for additional properties
const graphNode = this.graph?._nodes?.find((n) => String(n.id) === nodeId)
const executionState = nodeProgressStates[nodeId]?.state ?? null
nodes.push({
id: nodeId,
x: layout.position.x,
@@ -35,8 +25,7 @@ export class LayoutStoreDataSource extends AbstractMinimapDataSource {
height: layout.size.height,
bgcolor: graphNode?.bgcolor,
mode: graphNode?.mode,
hasErrors: graphNode?.has_errors,
executionState
hasErrors: graphNode?.has_errors
})
}

View File

@@ -1,5 +1,3 @@
import { useExecutionStore } from '@/stores/executionStore'
import type { MinimapNodeData } from '../types'
import { AbstractMinimapDataSource } from './AbstractMinimapDataSource'
@@ -10,25 +8,16 @@ export class LiteGraphDataSource extends AbstractMinimapDataSource {
getNodes(): MinimapNodeData[] {
if (!this.graph?._nodes) return []
const executionStore = useExecutionStore()
const nodeProgressStates = executionStore.nodeProgressStates
return this.graph._nodes.map((node) => {
const nodeId = String(node.id)
const executionState = nodeProgressStates[nodeId]?.state ?? null
return {
id: nodeId,
x: node.pos[0],
y: node.pos[1],
width: node.size[0],
height: node.size[1],
bgcolor: node.bgcolor,
mode: node.mode,
hasErrors: node.has_errors,
executionState
}
})
return this.graph._nodes.map((node) => ({
id: String(node.id),
x: node.pos[0],
y: node.pos[1],
width: node.size[0],
height: node.size[1],
bgcolor: node.bgcolor,
mode: node.mode,
hasErrors: node.has_errors
}))
}
getNodeCount(): number {

View File

@@ -15,13 +15,6 @@ vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
}
}))
// Mock useExecutionStore
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: vi.fn().mockReturnValue({
nodeProgressStates: {}
})
}))
// Helper to create mock links that satisfy LGraph['links'] type
function createMockLinks(): LGraph['links'] {
const map = new Map<number, LLink>()

View File

@@ -22,12 +22,6 @@ vi.mock('@/utils/colorUtil', () => ({
adjustColor: vi.fn((color: string) => color + '_adjusted')
}))
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: vi.fn().mockReturnValue({
nodeProgressStates: {}
})
}))
describe('minimapCanvasRenderer', () => {
let mockCanvas: HTMLCanvasElement
let mockContext: CanvasRenderingContext2D
@@ -48,9 +42,7 @@ describe('minimapCanvasRenderer', () => {
fill: vi.fn(),
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
save: vi.fn(),
restore: vi.fn()
lineWidth: 1
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D
mockCanvas = {

View File

@@ -26,8 +26,6 @@ function getMinimapColors() {
groupColorDefault: isLightTheme ? '#283640' : '#B3C1CB',
bypassColor: isLightTheme ? '#DBDBDB' : '#4B184B',
errorColor: '#FF0000',
runningColor: '#00FF00',
successColor: '#239B23',
isLightTheme
}
}
@@ -105,19 +103,10 @@ function renderNodes(
const nodes = dataSource.getNodes()
if (nodes.length === 0) return
ctx.save()
// Group nodes by color for batch rendering (performance optimization)
const nodesByColor = new Map<
string,
Array<{
x: number
y: number
w: number
h: number
hasErrors?: boolean
executionState?: 'pending' | 'running' | 'finished' | 'error' | null
}>
Array<{ x: number; y: number; w: number; h: number; hasErrors?: boolean }>
>()
for (const node of nodes) {
@@ -132,14 +121,7 @@ function renderNodes(
nodesByColor.set(color, [])
}
nodesByColor.get(color)!.push({
x,
y,
w,
h,
hasErrors: node.hasErrors,
executionState: node.executionState
})
nodesByColor.get(color)!.push({ x, y, w, h, hasErrors: node.hasErrors })
}
// Batch render nodes by color
@@ -150,29 +132,18 @@ function renderNodes(
}
}
ctx.lineWidth = 0.3
for (const nodes of nodesByColor.values()) {
for (const node of nodes) {
if (node.hasErrors && context.settings.renderError) {
ctx.strokeStyle = colors.errorColor
ctx.strokeRect(node.x, node.y, node.w, node.h)
} else if (node.executionState === 'running') {
ctx.strokeStyle = colors.runningColor
ctx.strokeRect(node.x, node.y, node.w, node.h)
} else if (node.executionState === 'finished') {
ctx.strokeStyle = colors.successColor
ctx.strokeRect(node.x, node.y, node.w, node.h)
} else if (
node.executionState === 'error' &&
context.settings.renderError
) {
ctx.strokeStyle = colors.errorColor
ctx.strokeRect(node.x, node.y, node.w, node.h)
// Render error outlines if needed
if (context.settings.renderError) {
ctx.strokeStyle = colors.errorColor
ctx.lineWidth = 0.3
for (const nodes of nodesByColor.values()) {
for (const node of nodes) {
if (node.hasErrors) {
ctx.strokeRect(node.x, node.y, node.w, node.h)
}
}
}
}
ctx.restore()
}
/**

View File

@@ -80,7 +80,6 @@ export interface MinimapNodeData {
bgcolor?: string
mode?: number
hasErrors?: boolean
executionState?: 'pending' | 'running' | 'finished' | 'error' | null
}
/**

View File

@@ -51,9 +51,7 @@
@drop.stop.prevent="handleDrop"
>
<AppOutput
v-if="
lgraphNode?.constructor?.nodeData?.output_node && isSelectOutputsMode
"
v-if="lgraphNode?.constructor?.nodeData?.output_node && isSelectMode"
:id="nodeData.id"
/>
<div
@@ -339,7 +337,7 @@ const { nodeData, error = null } = defineProps<LGraphNodeProps>()
const { t } = useI18n()
const { isSelectMode, isSelectOutputsMode } = useAppMode()
const { isSelectMode } = useAppMode()
const settingStore = useSettingStore()
const { handleNodeCollapse, handleNodeTitleUpdate, handleNodeRightClick } =

View File

@@ -53,7 +53,7 @@
/>
</div>
<!-- Widget Component -->
<AppInput :id="widget.id" :name="widget.name" :is-select-inputs-mode>
<AppInput :id="widget.id" :name="widget.name" :is-select-mode>
<component
:is="widget.vueComponent"
v-model="widget.value"
@@ -123,7 +123,7 @@ const { nodeData } = defineProps<NodeWidgetsProps>()
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
useCanvasInteractions()
const { isSelectInputsMode } = useAppMode()
const { isSelectMode } = useAppMode()
const canvasStore = useCanvasStore()
const { bringNodeToFront } = useNodeZIndex()
const promotionStore = usePromotionStore()

View File

@@ -175,7 +175,7 @@ export function useNodeTooltips(nodeType: MaybeRef<string>) {
class: cn(
context.top && 'border-t-node-component-tooltip-border',
context.bottom && 'border-b-node-component-tooltip-border',
context.left && 'border-l-node-component-tooltip-border',
context.left && 'border-l-node-component-tooltip-border ',
context.right && 'border-r-node-component-tooltip-border'
)
})

View File

@@ -1,18 +1,10 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetTextarea from './WidgetTextarea.vue'
const mockCopyToClipboard = vi.hoisted(() => vi.fn())
vi.mock('@/composables/useCopyToClipboard', () => ({
useCopyToClipboard: vi.fn().mockReturnValue({
copyToClipboard: mockCopyToClipboard
})
}))
function createMockWidget(
value: string = 'default text',
options: SimplifiedWidget['options'] = {},
@@ -39,11 +31,6 @@ function mountComponent(
modelValue,
readonly,
placeholder
},
global: {
mocks: {
$t: (msg: string) => msg
}
}
})
}
@@ -203,41 +190,6 @@ describe('WidgetTextarea Value Binding', () => {
expect(textarea.attributes('placeholder')).toBe('Custom placeholder')
})
})
describe('Copy Button Behavior', () => {
beforeEach(() => {
mockCopyToClipboard.mockClear()
})
it('hides copy button when not read-only', async () => {
const widget = createMockWidget('test')
const wrapper = mountComponent(widget, 'test', false)
const button = wrapper.find('button')
expect(button.exists()).toBe(false)
})
it('copy button has invisible class by default when read-only', () => {
const widget = createMockWidget('test', { read_only: true })
const wrapper = mountComponent(widget, 'test', true)
const button = wrapper.find('button')
expect(button.exists()).toBe(true)
expect(button.classes()).toContain('invisible')
})
it('copy button has group-hover:visible class when read-only, and copies on click', async () => {
const widget = createMockWidget('test value', { read_only: true })
const wrapper = mountComponent(widget, 'test value', true)
const button = wrapper.find('button')
expect(button.exists()).toBe(true)
expect(button.classes()).toContain('group-hover:visible')
await button.trigger('click')
expect(mockCopyToClipboard).toHaveBeenCalledWith('test value')
})
})
describe('Edge Cases', () => {
it('handles very long text', async () => {

View File

@@ -2,7 +2,7 @@
<div
:class="
cn(
'group relative rounded-lg focus-within:ring focus-within:ring-component-node-widget-background-highlighted transition-all',
'relative rounded-lg focus-within:ring focus-within:ring-component-node-widget-background-highlighted transition-all',
widget.borderStyle
)
"
@@ -33,27 +33,13 @@
@pointerup.capture.stop
@contextmenu.capture.stop
/>
<Button
v-if="isReadOnly"
variant="textonly"
size="icon"
class="invisible absolute top-1.5 right-1.5 z-10 hover:bg-base-foreground/10 group-hover:visible"
:title="$t('g.copyToClipboard')"
:aria-label="$t('g.copyToClipboard')"
@click="handleCopy"
@pointerdown.capture.stop
>
<i class="icon-[lucide--copy] size-4" />
</Button>
</div>
</template>
<script setup lang="ts">
import { computed, useId } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Textarea from '@/components/ui/textarea/Textarea.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { useHideLayoutField } from '@/types/widgetTypes'
import { cn } from '@/utils/tailwindUtil'
@@ -72,7 +58,6 @@ const { widget, placeholder = '' } = defineProps<{
const modelValue = defineModel<string>({ default: '' })
const hideLayoutField = useHideLayoutField()
const { copyToClipboard } = useCopyToClipboard()
const filteredProps = computed(() =>
filterWidgetProps(widget.options, INPUT_EXCLUDED_PROPS)
@@ -84,8 +69,4 @@ const id = useId()
const isReadOnly = computed(
() => widget.options?.read_only ?? widget.options?.disabled ?? false
)
function handleCopy() {
copyToClipboard(modelValue.value)
}
</script>

View File

@@ -56,7 +56,7 @@ const theButtonStyle = computed(() =>
<div
:class="
cn(WidgetInputBaseClass, 'flex text-base leading-none', {
'opacity-50 cursor-not-allowed outline-node-component-border': disabled
'opacity-50 cursor-not-allowed outline-zinc-300/10': disabled
})
"
>
@@ -97,7 +97,7 @@ const theButtonStyle = computed(() =>
cn(
theButtonStyle,
'relative',
'size-8 flex justify-center items-center border-l rounded-r-lg border-node-component-border'
'size-8 flex justify-center items-center border-l rounded-r-lg border-zinc-300/10'
)
"
>

View File

@@ -62,23 +62,13 @@ type LayoutConfig = {
}
const LAYOUT_CONFIGS: Record<LayoutMode, LayoutConfig> = {
grid: {
maxColumns: 4,
itemHeight: 120,
itemWidth: 89,
gap: 'var(--spacing-4) var(--spacing-2)'
},
list: {
maxColumns: 1,
itemHeight: 64,
itemWidth: 380,
gap: 'var(--spacing-2)'
},
grid: { maxColumns: 4, itemHeight: 120, itemWidth: 89, gap: '1rem 0.5rem' },
list: { maxColumns: 1, itemHeight: 64, itemWidth: 380, gap: '0.5rem' },
'list-small': {
maxColumns: 1,
itemHeight: 40,
itemWidth: 380,
gap: 'var(--spacing-1)'
gap: '0.25rem'
}
}

View File

@@ -42,7 +42,7 @@ const baseModelSelected = defineModel<Set<string>>('baseModelSelected', {
})
const actionButtonStyle = cn(
'h-8 bg-zinc-500/20 rounded-lg outline-1 -outline-offset-1 outline-node-component-border transition-all duration-150'
'h-8 bg-zinc-500/20 rounded-lg outline outline-1 outline-offset-[-1px] outline-node-component-border transition-all duration-150'
)
const layoutSwitchItemStyle =
@@ -157,7 +157,7 @@ function toggleBaseModelSelection(item: FilterOption) {
cn(
'flex flex-col gap-2 p-2 min-w-32',
'bg-component-node-background',
'rounded-lg outline -outline-offset-1 outline-component-node-border'
'rounded-lg outline outline-offset-[-1px] outline-component-node-border'
)
"
>
@@ -219,7 +219,7 @@ function toggleBaseModelSelection(item: FilterOption) {
cn(
'flex flex-col gap-2 p-2 min-w-32',
'bg-component-node-background',
'rounded-lg outline -outline-offset-1 outline-component-node-border'
'rounded-lg outline outline-offset-[-1px] outline-component-node-border'
)
"
>
@@ -281,7 +281,7 @@ function toggleBaseModelSelection(item: FilterOption) {
cn(
'flex flex-col gap-2 p-2 min-w-32',
'bg-component-node-background',
'rounded-lg outline -outline-offset-1 outline-component-node-border'
'rounded-lg outline outline-offset-[-1px] outline-component-node-border'
)
"
>

View File

@@ -1,7 +1,7 @@
import { cn } from '@comfyorg/tailwind-utils'
const sharedButtonClasses = cn(
'inline-flex items-center justify-center border-0 bg-transparent text-inherit transition-colors duration-150 ease-in-out',
'inline-flex items-center justify-center border-0 bg-transparent text-inherit transition-colors duration-150 ease-in-out ',
'hover:bg-node-component-surface-hovered active:bg-node-component-surface-selected',
'disabled:bg-node-component-disabled disabled:text-node-icon-disabled disabled:cursor-not-allowed'
)

View File

@@ -411,7 +411,6 @@ const zSettings = z.object({
'Comfy.VueNodes.AutoScaleLayout': z.boolean(),
'Comfy.Assets.UseAssetAPI': z.boolean(),
'Comfy.Queue.QPOV2': z.boolean(),
'Comfy.Queue.ShowRunProgressBar': z.boolean(),
'Comfy-Desktop.AutoUpdate': z.boolean(),
'Comfy-Desktop.SendStatistics': z.boolean(),
'Comfy-Desktop.WindowStyle': z.string(),

View File

@@ -66,7 +66,9 @@ function createMockCanvas(): Partial<LGraphCanvas> {
return {
graph: mockGraph as LGraph,
selectItems: vi.fn()
selectItems: vi.fn(),
emitBeforeChange: vi.fn(),
emitAfterChange: vi.fn()
}
}
@@ -91,7 +93,10 @@ describe('ComfyApp', () => {
const mockNode2 = createMockNode({ id: 2 })
const mockBatchNode = createMockNode({ id: 3, type: 'BatchImagesNode' })
vi.mocked(pasteImageNodes).mockResolvedValue([mockNode1, mockNode2])
vi.mocked(pasteImageNodes).mockResolvedValue({
nodes: [mockNode1, mockNode2],
completion: Promise.resolve()
})
vi.mocked(createNode).mockResolvedValue(mockBatchNode)
const file1 = createTestFile('test1.png', 'image/png')
@@ -102,18 +107,21 @@ describe('ComfyApp', () => {
expect(pasteImageNodes).toHaveBeenCalledWith(mockCanvas, files)
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'BatchImagesNode')
expect(mockNode1.connect).toHaveBeenCalledWith(0, mockBatchNode, 0)
expect(mockNode2.connect).toHaveBeenCalledWith(0, mockBatchNode, 1)
expect(mockCanvas.selectItems).toHaveBeenCalledWith([
mockNode1,
mockNode2,
mockBatchNode
])
expect(mockNode1.connect).toHaveBeenCalledWith(0, mockBatchNode, 0)
expect(mockNode2.connect).toHaveBeenCalledWith(0, mockBatchNode, 1)
})
it('should select single image node without batch node', async () => {
const mockNode1 = createMockNode({ id: 1 })
vi.mocked(pasteImageNodes).mockResolvedValue([mockNode1])
vi.mocked(pasteImageNodes).mockResolvedValue({
nodes: [mockNode1],
completion: Promise.resolve()
})
const file = createTestFile('test.png', 'image/png')
@@ -199,7 +207,7 @@ describe('ComfyApp', () => {
it('should position batch node to the right of first node', () => {
const mockNode1 = createMockNode({
pos: [100, 200],
getBounding: vi.fn(() => new Float64Array([100, 200, 300, 400]))
size: [300, 400]
})
const mockBatchNode = createMockNode({ pos: [0, 0] })
@@ -211,8 +219,8 @@ describe('ComfyApp', () => {
it('should stack multiple image nodes vertically', () => {
const mockNode1 = createMockNode({
pos: [100, 200],
type: 'LoadImage',
getBounding: vi.fn(() => new Float64Array([100, 200, 300, 400]))
size: [300, 400],
type: 'LoadImage'
})
const mockNode2 = createMockNode({ pos: [0, 0], type: 'LoadImage' })
const mockNode3 = createMockNode({ pos: [0, 0], type: 'LoadImage' })
@@ -227,7 +235,8 @@ describe('ComfyApp', () => {
it('should call graph change once for all nodes', () => {
const mockNode1 = createMockNode({
getBounding: vi.fn(() => new Float64Array([100, 200, 300, 400]))
pos: [100, 200],
size: [300, 400]
})
const mockBatchNode = createMockNode()

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