mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-28 18:47:16 +00:00
Compare commits
19 Commits
feat/batch
...
perf/reduc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cabe98c20 | ||
|
|
664ee8fcfc | ||
|
|
8f7f4bcc19 | ||
|
|
b0fd4fe4c1 | ||
|
|
b14d083c5f | ||
|
|
0f8473db35 | ||
|
|
120524faa1 | ||
|
|
fd9e774a29 | ||
|
|
7b316eb9a2 | ||
|
|
b8edb11ac1 | ||
|
|
57a919fad2 | ||
|
|
316a05c77f | ||
|
|
4b70ca298a | ||
|
|
1cee6272c1 | ||
|
|
bcc470642f | ||
|
|
df712953a3 | ||
|
|
82750d629d | ||
|
|
9e2299ca65 | ||
|
|
69076f35f8 |
28
.github/workflows/ci-perf-report.yaml
vendored
28
.github/workflows/ci-perf-report.yaml
vendored
@@ -45,7 +45,7 @@ jobs:
|
||||
- name: Run performance tests
|
||||
id: perf
|
||||
continue-on-error: true
|
||||
run: pnpm exec playwright test --project=performance --workers=1
|
||||
run: pnpm exec playwright test --project=performance --workers=1 --repeat-each=3
|
||||
|
||||
- name: Upload perf metrics
|
||||
if: always()
|
||||
@@ -61,6 +61,7 @@ jobs:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
@@ -90,6 +91,31 @@ 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
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
}
|
||||
],
|
||||
"no-control-regex": "off",
|
||||
"no-eval": "off",
|
||||
"no-eval": "error",
|
||||
"no-redeclare": "error",
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
|
||||
@@ -4,17 +4,6 @@ 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,
|
||||
@@ -59,6 +48,17 @@ 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,104 +155,6 @@ 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 } = {}
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -67,4 +67,66 @@ 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`)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 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'
|
||||
@@ -111,6 +112,28 @@ 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.41.10",
|
||||
"version": "1.41.11",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -146,6 +146,7 @@
|
||||
"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
75
pnpm-lock.yaml
generated
@@ -180,6 +180,9 @@ 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
|
||||
@@ -636,6 +639,9 @@ 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))
|
||||
@@ -1916,6 +1922,10 @@ 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}
|
||||
@@ -5314,6 +5324,19 @@ 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}
|
||||
@@ -6558,6 +6581,9 @@ 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==}
|
||||
|
||||
@@ -7780,6 +7806,10 @@ 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'}
|
||||
@@ -7788,6 +7818,10 @@ 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==}
|
||||
|
||||
@@ -7918,6 +7952,10 @@ 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==}
|
||||
|
||||
@@ -9708,6 +9746,11 @@ 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
|
||||
@@ -13457,6 +13500,23 @@ 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
|
||||
@@ -14893,6 +14953,8 @@ snapshots:
|
||||
|
||||
mdn-data@2.12.2: {}
|
||||
|
||||
mdn-data@2.23.0: {}
|
||||
|
||||
mdurl@2.0.0: {}
|
||||
|
||||
media-encoder-host-broker@8.0.19:
|
||||
@@ -16571,6 +16633,10 @@ 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
|
||||
@@ -16581,6 +16647,8 @@ 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):
|
||||
@@ -16691,6 +16759,13 @@ 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
|
||||
|
||||
@@ -61,6 +61,7 @@ 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
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
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'
|
||||
|
||||
interface PerfMeasurement {
|
||||
name: string
|
||||
@@ -20,12 +30,76 @@ 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'
|
||||
|
||||
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)}% 🟢`
|
||||
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 formatBytes(bytes: number): string {
|
||||
@@ -34,18 +108,167 @@ function formatBytes(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function calcDelta(
|
||||
baseline: number,
|
||||
current: number
|
||||
): { pct: number; isNew: boolean } {
|
||||
if (baseline > 0) {
|
||||
return { pct: ((current - baseline) / baseline) * 100, isNew: false }
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
return current > 0 ? { pct: Infinity, isNew: true } : { pct: 0, 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
|
||||
}
|
||||
|
||||
function formatDeltaCell(delta: { pct: number; isNew: boolean }): string {
|
||||
return delta.isNew ? 'new 🔴' : formatDelta(delta.pct)
|
||||
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 main() {
|
||||
@@ -62,55 +285,18 @@ 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) {
|
||||
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)} |`
|
||||
)
|
||||
}
|
||||
if (baseline && historical.length >= 2) {
|
||||
lines.push(...renderFullReport(prGroups, baseline, historical))
|
||||
} else if (baseline) {
|
||||
lines.push(...renderColdStartReport(prGroups, baseline, historical.length))
|
||||
} else {
|
||||
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(...renderNoBaselineReport(prGroups))
|
||||
}
|
||||
|
||||
lines.push('\n<details><summary>Raw data</summary>\n')
|
||||
|
||||
133
scripts/perf-stats.test.ts
Normal file
133
scripts/perf-stats.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
63
scripts/perf-stats.ts
Normal file
63
scripts/perf-stats.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
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'
|
||||
}
|
||||
@@ -64,7 +64,7 @@
|
||||
layout="vertical"
|
||||
:pt:gutter="
|
||||
cn(
|
||||
'rounded-tl-lg rounded-tr-lg ',
|
||||
'rounded-tl-lg rounded-tr-lg',
|
||||
!(bottomPanelVisible && !focusMode) && 'hidden'
|
||||
)
|
||||
"
|
||||
|
||||
@@ -296,11 +296,13 @@ describe('TopMenuSection', () => {
|
||||
describe('inline progress summary', () => {
|
||||
const configureSettings = (
|
||||
pinia: ReturnType<typeof createTestingPinia>,
|
||||
qpoV2Enabled: boolean
|
||||
qpoV2Enabled: boolean,
|
||||
showRunProgressBar = true
|
||||
) => {
|
||||
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
|
||||
})
|
||||
@@ -332,6 +334,19 @@ 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')
|
||||
|
||||
@@ -125,6 +125,7 @@ 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'
|
||||
@@ -164,14 +165,16 @@ const isActionbarFloating = computed(
|
||||
const isIntegratedTabBar = computed(
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
|
||||
)
|
||||
const isQueuePanelV2Enabled = computed(() =>
|
||||
settingStore.get('Comfy.Queue.QPOV2')
|
||||
)
|
||||
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
|
||||
useQueueFeatureFlags()
|
||||
const isQueueProgressOverlayEnabled = computed(
|
||||
() => !isQueuePanelV2Enabled.value
|
||||
)
|
||||
const shouldShowInlineProgressSummary = computed(
|
||||
() => isQueuePanelV2Enabled.value && isActionbarEnabled.value
|
||||
() =>
|
||||
isQueuePanelV2Enabled.value &&
|
||||
isActionbarEnabled.value &&
|
||||
isRunProgressBarEnabled.value
|
||||
)
|
||||
const shouldShowQueueNotificationBanners = computed(
|
||||
() => isActionbarEnabled.value
|
||||
|
||||
101
src/components/actionbar/ComfyActionbar.test.ts
Normal file
101
src/components/actionbar/ComfyActionbar.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -107,6 +107,7 @@ 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'
|
||||
@@ -127,7 +128,7 @@ const emit = defineEmits<{
|
||||
(event: 'update:progressTarget', target: HTMLElement | null): void
|
||||
}>()
|
||||
|
||||
const settingsStore = useSettingStore()
|
||||
const settingStore = useSettingStore()
|
||||
const commandStore = useCommandStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const queueStore = useQueueStore()
|
||||
@@ -137,11 +138,10 @@ const { isIdle: isExecutionIdle } = storeToRefs(executionStore)
|
||||
const { activeJobsCount } = storeToRefs(queueStore)
|
||||
const { activeSidebarTabId } = storeToRefs(sidebarTabStore)
|
||||
|
||||
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
|
||||
const position = computed(() => settingStore.get('Comfy.UseNewMenu'))
|
||||
const visible = computed(() => position.value !== 'Disabled')
|
||||
const isQueuePanelV2Enabled = computed(() =>
|
||||
settingsStore.get('Comfy.Queue.QPOV2')
|
||||
)
|
||||
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
|
||||
useQueueFeatureFlags()
|
||||
|
||||
const panelRef = ref<ComponentPublicInstance | null>(null)
|
||||
const panelElement = computed<HTMLElement | null>(() => {
|
||||
@@ -325,7 +325,13 @@ const onMouseLeaveDropZone = () => {
|
||||
}
|
||||
|
||||
const inlineProgressTarget = computed(() => {
|
||||
if (!visible.value || !isQueuePanelV2Enabled.value) return null
|
||||
if (
|
||||
!visible.value ||
|
||||
!isQueuePanelV2Enabled.value ||
|
||||
!isRunProgressBarEnabled.value
|
||||
) {
|
||||
return null
|
||||
}
|
||||
if (isDocked.value) return topMenuContainer ?? null
|
||||
return panelElement.value
|
||||
})
|
||||
|
||||
@@ -39,7 +39,8 @@ const workflowStore = useWorkflowStore()
|
||||
const { t } = useI18n()
|
||||
const canvas: LGraphCanvas = canvasStore.getCanvas()
|
||||
|
||||
const { isSelectMode, isArrangeMode } = useAppMode()
|
||||
const { isSelectMode, isSelectInputsMode, isSelectOutputsMode, isArrangeMode } =
|
||||
useAppMode()
|
||||
const hoveringSelectable = ref(false)
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
@@ -161,6 +162,7 @@ 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)
|
||||
@@ -168,6 +170,7 @@ 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
|
||||
@@ -234,7 +237,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
</div>
|
||||
</DraggableList>
|
||||
<PropertiesAccordionItem
|
||||
v-else
|
||||
v-if="isSelectInputsMode"
|
||||
:label="t('nodeHelpPage.inputs')"
|
||||
enable-empty-state
|
||||
:disabled="!appModeStore.selectedInputs.length"
|
||||
@@ -283,7 +286,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
</DraggableList>
|
||||
</PropertiesAccordionItem>
|
||||
<PropertiesAccordionItem
|
||||
v-if="!isArrangeMode"
|
||||
v-if="isSelectOutputsMode"
|
||||
:label="t('nodeHelpPage.outputs')"
|
||||
enable-empty-state
|
||||
:disabled="!appModeStore.selectedOutputs.length"
|
||||
@@ -344,42 +347,46 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
@wheel="canvasInteractions.forwardEventToCanvas"
|
||||
>
|
||||
<TransformPane :canvas="canvasStore.getCanvas()">
|
||||
<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" />
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
</TransformPane>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
@@ -63,7 +63,7 @@ describe('BuilderFooterToolbar', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockState.mode = 'builder:select'
|
||||
mockState.mode = 'builder:inputs'
|
||||
mockHasOutputs.value = true
|
||||
mockState.settingView = false
|
||||
})
|
||||
@@ -87,7 +87,7 @@ describe('BuilderFooterToolbar', () => {
|
||||
}
|
||||
|
||||
it('disables back on the first step', () => {
|
||||
mockState.mode = 'builder:select'
|
||||
mockState.mode = 'builder:inputs'
|
||||
const { back } = getButtons(mountComponent())
|
||||
expect(back.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
@@ -111,8 +111,8 @@ describe('BuilderFooterToolbar', () => {
|
||||
expect(next.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('enables next on select step', () => {
|
||||
mockState.mode = 'builder:select'
|
||||
it('enables next on inputs step', () => {
|
||||
mockState.mode = 'builder:inputs'
|
||||
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:select')
|
||||
expect(mockSetMode).toHaveBeenCalledWith('builder:outputs')
|
||||
})
|
||||
|
||||
it('calls setMode on next click from select step', async () => {
|
||||
mockState.mode = 'builder:select'
|
||||
it('calls setMode on next click from inputs step', async () => {
|
||||
mockState.mode = 'builder:inputs'
|
||||
const { next } = getButtons(mountComponent())
|
||||
await next.trigger('click')
|
||||
expect(mockSetMode).toHaveBeenCalledWith('builder:arrange')
|
||||
expect(mockSetMode).toHaveBeenCalledWith('builder:outputs')
|
||||
})
|
||||
|
||||
it('opens default view dialog on next click from arrange step', async () => {
|
||||
|
||||
@@ -6,17 +6,14 @@
|
||||
<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 [selectStep, arrangeStep]"
|
||||
:key="step.id"
|
||||
>
|
||||
<template v-for="(step, index) in steps" :key="step.id">
|
||||
<button
|
||||
:class="
|
||||
cn(
|
||||
stepClasses,
|
||||
activeStep === step.id && 'bg-interface-builder-mode-background',
|
||||
activeStep !== step.id &&
|
||||
'hover:bg-secondary-background bg-transparent'
|
||||
activeStep === step.id
|
||||
? 'bg-interface-builder-mode-background'
|
||||
: 'hover:bg-secondary-background bg-transparent'
|
||||
)
|
||||
"
|
||||
:aria-current="activeStep === step.id ? 'step' : undefined"
|
||||
@@ -32,13 +29,13 @@
|
||||
<!-- Default view -->
|
||||
<ConnectOutputPopover
|
||||
v-if="!hasOutputs"
|
||||
:is-select-active="activeStep === 'builder:select'"
|
||||
@switch="navigateToStep('builder:select')"
|
||||
:is-select-active="isSelectStep"
|
||||
@switch="navigateToStep('builder:outputs')"
|
||||
>
|
||||
<button :class="cn(stepClasses, 'opacity-30 bg-transparent')">
|
||||
<StepBadge
|
||||
:step="defaultViewStep"
|
||||
:index="2"
|
||||
:index="steps.length"
|
||||
:model-value="activeStep"
|
||||
/>
|
||||
<StepLabel :step="defaultViewStep" />
|
||||
@@ -58,7 +55,7 @@
|
||||
>
|
||||
<StepBadge
|
||||
:step="defaultViewStep"
|
||||
:index="2"
|
||||
:index="steps.length"
|
||||
:model-value="activeStep"
|
||||
/>
|
||||
<StepLabel :step="defaultViewStep" />
|
||||
@@ -84,15 +81,22 @@ import { useBuilderSteps } from './useBuilderSteps'
|
||||
const { t } = useI18n()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { hasOutputs } = storeToRefs(appModeStore)
|
||||
const { activeStep, navigateToStep } = useBuilderSteps()
|
||||
const { activeStep, isSelectStep, 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 selectStep: BuilderToolbarStep<BuilderStepId> = {
|
||||
id: 'builder:select',
|
||||
title: t('builderToolbar.select'),
|
||||
subtitle: t('builderToolbar.selectDescription'),
|
||||
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'),
|
||||
icon: 'icon-[lucide--mouse-pointer-click]'
|
||||
}
|
||||
|
||||
@@ -109,4 +113,5 @@ const defaultViewStep: BuilderToolbarStep<BuilderStepId> = {
|
||||
subtitle: t('builderToolbar.defaultViewDescription'),
|
||||
icon: 'icon-[lucide--eye]'
|
||||
}
|
||||
const steps = [selectInputsStep, selectOutputsStep, arrangeStep]
|
||||
</script>
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
</PopoverClose>
|
||||
<PopoverClose as-child>
|
||||
<Button variant="secondary" size="md" @click="emit('switch')">
|
||||
{{ t('builderToolbar.switchToSelect') }}
|
||||
{{ t('builderToolbar.switchToOutputs') }}
|
||||
</Button>
|
||||
</PopoverClose>
|
||||
</template>
|
||||
|
||||
@@ -7,7 +7,8 @@ import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAppSetDefaultView } from './useAppSetDefaultView'
|
||||
|
||||
const BUILDER_STEPS = [
|
||||
'builder:select',
|
||||
'builder:inputs',
|
||||
'builder:outputs',
|
||||
'builder:arrange',
|
||||
'setDefaultView'
|
||||
] as const
|
||||
@@ -25,7 +26,7 @@ export function useBuilderSteps(options?: { hasOutputs?: Ref<boolean> }) {
|
||||
if (isBuilderMode.value) {
|
||||
return mode.value as BuilderStepId
|
||||
}
|
||||
return 'builder:select'
|
||||
return 'builder:inputs'
|
||||
})
|
||||
|
||||
const activeStepIndex = computed(() =>
|
||||
@@ -40,6 +41,12 @@ 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')
|
||||
@@ -64,6 +71,7 @@ export function useBuilderSteps(options?: { hasOutputs?: Ref<boolean> }) {
|
||||
activeStepIndex,
|
||||
isFirstStep,
|
||||
isLastStep,
|
||||
isSelectStep,
|
||||
navigateToStep,
|
||||
goBack,
|
||||
goNext
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { statusBadgeVariants } from './statusBadge.variants'
|
||||
import type { StatusBadgeVariants } from './statusBadge.variants'
|
||||
|
||||
@@ -11,17 +13,17 @@ const {
|
||||
severity?: StatusBadgeVariants['severity']
|
||||
variant?: StatusBadgeVariants['variant']
|
||||
}>()
|
||||
|
||||
const badgeClass = computed(() =>
|
||||
statusBadgeVariants({
|
||||
severity,
|
||||
variant: variant ?? (label == null ? 'dot' : 'label')
|
||||
})
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
:class="
|
||||
statusBadgeVariants({
|
||||
severity,
|
||||
variant: variant ?? (label == null ? 'dot' : 'label')
|
||||
})
|
||||
"
|
||||
>
|
||||
<span :class="badgeClass">
|
||||
{{ label }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -39,8 +39,8 @@
|
||||
<BottomPanel />
|
||||
</template>
|
||||
<template v-if="showUI" #right-side-panel>
|
||||
<AppBuilder v-if="mode === 'builder:select'" />
|
||||
<NodePropertiesPanel v-else-if="!isBuilderMode" />
|
||||
<AppBuilder v-if="isBuilderMode" />
|
||||
<NodePropertiesPanel v-else />
|
||||
</template>
|
||||
<template #graph-canvas-panel>
|
||||
<GraphCanvasMenu
|
||||
@@ -204,7 +204,7 @@ const nodeSearchboxPopoverRef = shallowRef<InstanceType<
|
||||
const settingStore = useSettingStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const { mode, isBuilderMode } = useAppMode()
|
||||
const { isBuilderMode } = useAppMode()
|
||||
const canvasStore = useCanvasStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
123
src/components/queue/JobHistoryActionsMenu.test.ts
Normal file
123
src/components/queue/JobHistoryActionsMenu.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -19,7 +19,7 @@
|
||||
data-testid="docked-job-history-action"
|
||||
class="w-full justify-between text-sm font-light"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
size="md"
|
||||
@click="onToggleDockedJobHistory(close)"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
@@ -35,14 +35,32 @@
|
||||
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-0 w-full items-start justify-start whitespace-normal"
|
||||
class="h-auto min-h-8 w-full items-start justify-start whitespace-normal"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
size="md"
|
||||
@click="onClearHistoryFromMenu(close)"
|
||||
>
|
||||
<i
|
||||
@@ -76,6 +94,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
@@ -90,9 +109,8 @@ const settingStore = useSettingStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
|
||||
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
|
||||
const isQueuePanelV2Enabled = computed(() =>
|
||||
settingStore.get('Comfy.Queue.QPOV2')
|
||||
)
|
||||
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
|
||||
useQueueFeatureFlags()
|
||||
const showClearHistoryAction = computed(() => !isCloud)
|
||||
|
||||
const onClearHistoryFromMenu = (close: () => void) => {
|
||||
@@ -118,4 +136,11 @@ const onToggleDockedJobHistory = async (close: () => void) => {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const onToggleRunProgressBar = async () => {
|
||||
await settingStore.set(
|
||||
'Comfy.Queue.ShowRunProgressBar',
|
||||
!isRunProgressBarEnabled.value
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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', () => {
|
||||
@@ -24,7 +25,9 @@ vi.mock('@/components/ui/Popover.vue', () => {
|
||||
})
|
||||
|
||||
const mockGetSetting = vi.fn<(key: string) => boolean | undefined>((key) =>
|
||||
key === 'Comfy.Queue.QPOV2' ? true : undefined
|
||||
key === 'Comfy.Queue.QPOV2' || key === 'Comfy.Queue.ShowRunProgressBar'
|
||||
? true
|
||||
: undefined
|
||||
)
|
||||
const mockSetSetting = vi.fn()
|
||||
const mockSetMany = vi.fn()
|
||||
@@ -52,27 +55,6 @@ 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: {
|
||||
@@ -88,6 +70,7 @@ const mountHeader = (props = {}) =>
|
||||
|
||||
describe('QueueOverlayHeader', () => {
|
||||
beforeEach(() => {
|
||||
i18n.global.locale.value = 'en'
|
||||
popoverCloseSpy.mockClear()
|
||||
mockSetSetting.mockClear()
|
||||
mockSetMany.mockClear()
|
||||
@@ -207,4 +190,19 @@ 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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -323,6 +323,90 @@ 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', () => {
|
||||
|
||||
@@ -23,7 +23,10 @@ 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 } from '@/types/nodeIdentification'
|
||||
import {
|
||||
isNodeExecutionId,
|
||||
compareExecutionId
|
||||
} from '@/types/nodeIdentification'
|
||||
|
||||
const PROMPT_CARD_ID = '__prompt__'
|
||||
const SINGLE_GROUP_KEY = '__single__'
|
||||
@@ -151,12 +154,16 @@ 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()),
|
||||
cards: Array.from(groupData.cards.values()).sort(compareNodeId),
|
||||
priority: groupData.priority
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
"
|
||||
>
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
"
|
||||
|
||||
@@ -17,13 +17,13 @@ export const useNodePaste = <T>(
|
||||
) => {
|
||||
const { onPaste, fileFilter = () => true, allow_batch = false } = options
|
||||
|
||||
node.pasteFiles = async function (files: File[]) {
|
||||
node.pasteFiles = function (files: File[]) {
|
||||
const filteredFiles = Array.from(files).filter(fileFilter)
|
||||
if (!filteredFiles.length) return false
|
||||
|
||||
const paste = allow_batch ? filteredFiles : filteredFiles.slice(0, 1)
|
||||
|
||||
await onPaste(paste)
|
||||
void onPaste(paste)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
19
src/composables/queue/useQueueFeatureFlags.ts
Normal file
19
src/composables/queue/useQueueFeatureFlags.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,12 @@ import { computed, ref } from 'vue'
|
||||
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
|
||||
export type AppMode = 'graph' | 'app' | 'builder:select' | 'builder:arrange'
|
||||
export type AppMode =
|
||||
| 'graph'
|
||||
| 'app'
|
||||
| 'builder:inputs'
|
||||
| 'builder:outputs'
|
||||
| 'builder:arrange'
|
||||
|
||||
const enableAppBuilder = ref(true)
|
||||
|
||||
@@ -18,13 +23,17 @@ export function useAppMode() {
|
||||
const isBuilderMode = computed(
|
||||
() => isSelectMode.value || isArrangeMode.value
|
||||
)
|
||||
const isSelectMode = computed(() => mode.value === 'builder:select')
|
||||
const isSelectInputsMode = computed(() => mode.value === 'builder:inputs')
|
||||
const isSelectOutputsMode = computed(() => mode.value === 'builder:outputs')
|
||||
const isSelectMode = computed(
|
||||
() => isSelectInputsMode.value || isSelectOutputsMode.value
|
||||
)
|
||||
const isArrangeMode = computed(() => mode.value === 'builder:arrange')
|
||||
const isAppMode = computed(
|
||||
() => mode.value === 'app' || mode.value === 'builder:arrange'
|
||||
)
|
||||
const isGraphMode = computed(
|
||||
() => mode.value === 'graph' || mode.value === 'builder:select'
|
||||
() => mode.value === 'graph' || isSelectMode.value
|
||||
)
|
||||
|
||||
function setMode(newMode: AppMode) {
|
||||
@@ -39,6 +48,8 @@ export function useAppMode() {
|
||||
enableAppBuilder,
|
||||
isBuilderMode,
|
||||
isSelectMode,
|
||||
isSelectInputsMode,
|
||||
isSelectOutputsMode,
|
||||
isArrangeMode,
|
||||
isAppMode,
|
||||
isGraphMode,
|
||||
|
||||
@@ -28,7 +28,7 @@ function createMockNode(): LGraphNode {
|
||||
return createMockLGraphNode({
|
||||
pos: [0, 0],
|
||||
pasteFile: vi.fn(),
|
||||
pasteFiles: vi.fn().mockResolvedValue(true)
|
||||
pasteFiles: vi.fn()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -201,21 +201,20 @@ 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.nodes).toEqual([mockNode1, mockNode2])
|
||||
expect(result).toEqual([mockNode1, mockNode2])
|
||||
})
|
||||
|
||||
it('should handle empty file list', async () => {
|
||||
const result = await pasteImageNodes(mockCanvas, [])
|
||||
|
||||
expect(createNode).not.toHaveBeenCalled()
|
||||
expect(result.nodes).toEqual([])
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -57,11 +57,11 @@ function pasteClipboardItems(data: DataTransfer): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
async function pasteItemsOnNode(
|
||||
function pasteItemsOnNode(
|
||||
items: DataTransferItemList,
|
||||
node: LGraphNode | null,
|
||||
contentType: string
|
||||
): Promise<void> {
|
||||
): void {
|
||||
if (!node) return
|
||||
|
||||
const filteredItems = Array.from(items).filter((item) =>
|
||||
@@ -72,12 +72,10 @@ async function pasteItemsOnNode(
|
||||
if (!blob) return
|
||||
|
||||
node.pasteFile?.(blob)
|
||||
await Promise.resolve(
|
||||
node.pasteFiles?.(
|
||||
Array.from(filteredItems)
|
||||
.map((i) => i.getAsFile())
|
||||
.filter((f) => f !== null)
|
||||
)
|
||||
node.pasteFiles?.(
|
||||
Array.from(filteredItems)
|
||||
.map((i) => i.getAsFile())
|
||||
.filter((f) => f !== null)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -91,37 +89,27 @@ export async function pasteImageNode(
|
||||
imageNode = await createNode(canvas, 'LoadImage')
|
||||
}
|
||||
|
||||
await pasteItemsOnNode(items, imageNode, 'image')
|
||||
pasteItemsOnNode(items, imageNode, 'image')
|
||||
return imageNode
|
||||
}
|
||||
|
||||
interface PasteNodesResult {
|
||||
nodes: LGraphNode[]
|
||||
completion: Promise<void>
|
||||
}
|
||||
|
||||
export async function pasteImageNodes(
|
||||
canvas: LGraphCanvas,
|
||||
fileList: File[]
|
||||
): Promise<PasteNodesResult> {
|
||||
): Promise<LGraphNode[]> {
|
||||
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)
|
||||
uploads.push(pasteItemsOnNode(transfer.items, node, 'image'))
|
||||
const imageNode = await pasteImageNode(canvas, transfer.items)
|
||||
|
||||
if (imageNode) {
|
||||
nodes.push(imageNode)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodes,
|
||||
completion: Promise.all(uploads).then(() => {})
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
export async function pasteAudioNode(
|
||||
@@ -132,7 +120,7 @@ export async function pasteAudioNode(
|
||||
if (!audioNode) {
|
||||
audioNode = await createNode(canvas, 'LoadAudio')
|
||||
}
|
||||
await pasteItemsOnNode(items, audioNode, 'audio')
|
||||
pasteItemsOnNode(items, audioNode, 'audio')
|
||||
return audioNode
|
||||
}
|
||||
|
||||
@@ -163,7 +151,7 @@ export async function pasteVideoNode(
|
||||
if (!videoNode) {
|
||||
videoNode = await createNode(canvas, 'LoadVideo')
|
||||
}
|
||||
await pasteItemsOnNode(items, videoNode, 'video')
|
||||
pasteItemsOnNode(items, videoNode, 'video')
|
||||
return videoNode
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ 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'
|
||||
@@ -364,6 +365,8 @@ 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
|
||||
|
||||
@@ -386,7 +389,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
cursor = 'grab'
|
||||
}
|
||||
|
||||
this.canvas.style.cursor = cursor
|
||||
this._setCursor(cursor)
|
||||
}
|
||||
|
||||
// Whether the canvas was previously being dragged prior to pressing space key.
|
||||
@@ -664,6 +667,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
* performant than {@link visible_nodes} for visibility checks.
|
||||
*/
|
||||
private _visible_node_ids: Set<NodeId> = new Set()
|
||||
|
||||
/** Cached per-frame link render context to avoid rebuilding per-link. */
|
||||
private _cachedLinkRenderContext: LinkRenderContext | null = null
|
||||
node_over?: LGraphNode
|
||||
node_capturing_input?: LGraphNode | null
|
||||
highlighted_links: Dictionary<boolean> = {}
|
||||
@@ -1911,6 +1917,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
this.pointer.element = element
|
||||
|
||||
if (!element) return
|
||||
this._setCursor = createCursorCache(element)
|
||||
|
||||
// TODO: classList.add
|
||||
element.className += ' lgraphcanvas'
|
||||
@@ -2970,7 +2977,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
// Set appropriate cursor for resize direction
|
||||
this.canvas.style.cursor = cursors[resizeDirection]
|
||||
this._setCursor(cursors[resizeDirection])
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -3972,41 +3979,14 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
/** @todo Refactor to where it belongs - e.g. Deleting / creating nodes is not actually canvas event. */
|
||||
emitBeforeChange(): void {
|
||||
this.emitEvent({
|
||||
subType: 'before-change'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
*/
|
||||
/** @todo See {@link emitBeforeChange} */
|
||||
emitAfterChange(): void {
|
||||
this.emitEvent({
|
||||
subType: 'after-change'
|
||||
@@ -4829,6 +4809,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
if (!this.canvas || this.canvas.width == 0 || this.canvas.height == 0)
|
||||
return
|
||||
|
||||
// Reset per-frame caches so stale data is never used if a code path is skipped.
|
||||
this._cachedLinkRenderContext = null
|
||||
|
||||
// fps counting
|
||||
const now = LiteGraph.getTime()
|
||||
this.render_time = (now - this.last_draw_time) * 0.001
|
||||
@@ -4839,10 +4822,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
// Compute node size before drawing links.
|
||||
if (this.dirty_canvas || force_canvas) {
|
||||
this.computeVisibleNodes(undefined, this.visible_nodes)
|
||||
// Update visible node IDs
|
||||
this._visible_node_ids = new Set(
|
||||
this.visible_nodes.map((node) => node.id)
|
||||
)
|
||||
// Update visible node IDs (reuse existing Set to avoid allocation)
|
||||
this._visible_node_ids.clear()
|
||||
for (const node of this.visible_nodes) {
|
||||
this._visible_node_ids.add(node.id)
|
||||
}
|
||||
|
||||
// Arrange subgraph IO nodes
|
||||
const { subgraph } = this
|
||||
@@ -5037,10 +5021,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
colour,
|
||||
fromDirection,
|
||||
dragDirection,
|
||||
{
|
||||
...this.buildLinkRenderContext(),
|
||||
linkMarkerShape: LinkMarkerShape.None
|
||||
}
|
||||
this._cachedLinkRenderContext ?? this.buildLinkRenderContext()
|
||||
)
|
||||
}
|
||||
if (renderLink instanceof MovingInputLink) this.setDirty(false, true)
|
||||
@@ -5788,6 +5769,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
this.renderedPaths.clear()
|
||||
if (this.links_render_mode === LinkRenderType.HIDDEN_LINK) return
|
||||
|
||||
// Cache the link render context once per frame
|
||||
this._cachedLinkRenderContext = this.buildLinkRenderContext()
|
||||
|
||||
// Skip link rendering while waiting for slot positions to sync after reconfigure
|
||||
if (LiteGraph.vueNodesMode && layoutStore.pendingSlotSync) {
|
||||
this._visibleReroutes.clear()
|
||||
@@ -6039,19 +6023,28 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
// Get all points this link passes through
|
||||
const reroutes = LLink.getReroutes(graph, link)
|
||||
const points: [Point, ...Point[], Point] = [
|
||||
startPos,
|
||||
...reroutes.map((x) => x.pos),
|
||||
endPos
|
||||
]
|
||||
|
||||
// Bounding box of all points (bezier overshoot on long links will be cut)
|
||||
const pointsX = points.map((x) => x[0])
|
||||
const pointsY = points.map((x) => x[1])
|
||||
link_bounding[0] = Math.min(...pointsX)
|
||||
link_bounding[1] = Math.min(...pointsY)
|
||||
link_bounding[2] = Math.max(...pointsX) - link_bounding[0]
|
||||
link_bounding[3] = Math.max(...pointsY) - link_bounding[1]
|
||||
// Compute bounding box inline to avoid allocating temporary arrays
|
||||
let minX = startPos[0]
|
||||
let minY = startPos[1]
|
||||
let maxX = minX
|
||||
let maxY = minY
|
||||
for (let i = 0; i < reroutes.length; i++) {
|
||||
const pos = reroutes[i].pos
|
||||
if (pos[0] < minX) minX = pos[0]
|
||||
else if (pos[0] > maxX) maxX = pos[0]
|
||||
if (pos[1] < minY) minY = pos[1]
|
||||
else if (pos[1] > maxY) maxY = pos[1]
|
||||
}
|
||||
if (endPos[0] < minX) minX = endPos[0]
|
||||
else if (endPos[0] > maxX) maxX = endPos[0]
|
||||
if (endPos[1] < minY) minY = endPos[1]
|
||||
else if (endPos[1] > maxY) maxY = endPos[1]
|
||||
|
||||
link_bounding[0] = minX
|
||||
link_bounding[1] = minY
|
||||
link_bounding[2] = maxX - minX
|
||||
link_bounding[3] = maxY - minY
|
||||
|
||||
// skip links outside of the visible area of the canvas
|
||||
if (!overlapBounding(link_bounding, margin_area)) return
|
||||
@@ -6119,8 +6112,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
// Skip the last segment if it is being dragged
|
||||
if (link._dragging) return
|
||||
|
||||
// Use runtime fallback; TypeScript cannot evaluate this correctly.
|
||||
const segmentStartPos = points.at(-2) ?? startPos
|
||||
const segmentStartPos = reroutes.at(-1)?.pos ?? startPos
|
||||
|
||||
// Render final link segment
|
||||
this.renderLink(
|
||||
@@ -6241,7 +6233,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
} = {}
|
||||
): void {
|
||||
if (this.linkRenderer) {
|
||||
const context = this.buildLinkRenderContext()
|
||||
const context =
|
||||
this._cachedLinkRenderContext ?? this.buildLinkRenderContext()
|
||||
this.linkRenderer.renderLinkDirect(
|
||||
ctx,
|
||||
a,
|
||||
@@ -6600,7 +6593,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 = [nodeX, nodeY]
|
||||
const pos: [number, number] = [nodeX, nodeY]
|
||||
const newNode = LiteGraph.createNode(nodeTypeStr, nodeNewOpts?.title, {
|
||||
pos
|
||||
})
|
||||
|
||||
@@ -653,4 +653,70 @@ describe('LGraphNode', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('_slotsDirty flag', () => {
|
||||
test('starts dirty', () => {
|
||||
const n = new LGraphNode('Test')
|
||||
expect(n._slotsDirty).toBe(true)
|
||||
})
|
||||
|
||||
test('is cleared by _setConcreteSlots', () => {
|
||||
const n = new LGraphNode('Test')
|
||||
n._setConcreteSlots()
|
||||
expect(n._slotsDirty).toBe(false)
|
||||
})
|
||||
|
||||
test('skips work when clean', () => {
|
||||
const n = new LGraphNode('Test')
|
||||
n.addInput('in', 'number')
|
||||
n._setConcreteSlots()
|
||||
expect(n._slotsDirty).toBe(false)
|
||||
|
||||
const mapSpy = vi.spyOn(n.inputs, 'map')
|
||||
n._setConcreteSlots()
|
||||
expect(mapSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('is set by addInput', () => {
|
||||
const n = new LGraphNode('Test')
|
||||
n._setConcreteSlots()
|
||||
n.addInput('in', 'number')
|
||||
expect(n._slotsDirty).toBe(true)
|
||||
})
|
||||
|
||||
test('is set by removeInput', () => {
|
||||
const n = new LGraphNode('Test')
|
||||
n.addInput('in', 'number')
|
||||
n._setConcreteSlots()
|
||||
n.removeInput(0)
|
||||
expect(n._slotsDirty).toBe(true)
|
||||
})
|
||||
|
||||
test('is set by addOutput', () => {
|
||||
const n = new LGraphNode('Test')
|
||||
n._setConcreteSlots()
|
||||
n.addOutput('out', 'number')
|
||||
expect(n._slotsDirty).toBe(true)
|
||||
})
|
||||
|
||||
test('is set by removeOutput', () => {
|
||||
const n = new LGraphNode('Test')
|
||||
n.addOutput('out', 'number')
|
||||
n._setConcreteSlots()
|
||||
n.removeOutput(0)
|
||||
expect(n._slotsDirty).toBe(true)
|
||||
})
|
||||
|
||||
test('is set by configure', () => {
|
||||
const n = new LGraphNode('Test')
|
||||
n._setConcreteSlots()
|
||||
n.configure(
|
||||
getMockISerialisedNode({
|
||||
inputs: [{ name: 'a', type: 'number', link: null }],
|
||||
outputs: [{ name: 'b', type: 'number', links: null }]
|
||||
})
|
||||
)
|
||||
expect(n._slotsDirty).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -279,6 +279,8 @@ export class LGraphNode
|
||||
|
||||
private _concreteInputs: NodeInputSlot[] = []
|
||||
private _concreteOutputs: NodeOutputSlot[] = []
|
||||
/** @internal Set when inputs/outputs change; cleared by {@link _setConcreteSlots}. */
|
||||
_slotsDirty: boolean = true
|
||||
|
||||
properties: Dictionary<NodeProperty | undefined> = {}
|
||||
properties_info: INodePropertyInfo[] = []
|
||||
@@ -864,6 +866,7 @@ export class LGraphNode
|
||||
this.inputs = this.inputs.map((input) =>
|
||||
toClass(NodeInputSlot, input, this)
|
||||
)
|
||||
this._slotsDirty = true
|
||||
for (const [i, input] of this.inputs.entries()) {
|
||||
const link =
|
||||
this.graph && input.link != null
|
||||
@@ -1630,6 +1633,7 @@ export class LGraphNode
|
||||
|
||||
this.outputs ||= []
|
||||
this.outputs.push(output)
|
||||
this._slotsDirty = true
|
||||
this.onOutputAdded?.(output)
|
||||
|
||||
if (LiteGraph.auto_load_slot_types)
|
||||
@@ -1650,6 +1654,7 @@ export class LGraphNode
|
||||
}
|
||||
const { outputs } = this
|
||||
outputs.splice(slot, 1)
|
||||
this._slotsDirty = true
|
||||
|
||||
for (let i = slot; i < outputs.length; ++i) {
|
||||
const output = outputs[i]
|
||||
@@ -1687,6 +1692,7 @@ export class LGraphNode
|
||||
|
||||
this.inputs ||= []
|
||||
this.inputs.push(input)
|
||||
this._slotsDirty = true
|
||||
this.expandToFitContent()
|
||||
|
||||
this.onInputAdded?.(input)
|
||||
@@ -1706,6 +1712,7 @@ export class LGraphNode
|
||||
}
|
||||
const { inputs } = this
|
||||
const slot_info = inputs.splice(slot, 1)
|
||||
this._slotsDirty = true
|
||||
|
||||
for (let i = slot; i < inputs.length; ++i) {
|
||||
const input = inputs[i]
|
||||
@@ -4080,33 +4087,36 @@ export class LGraphNode
|
||||
ctx: CanvasRenderingContext2D,
|
||||
{ fromSlot, colorContext, editorAlpha, lowQuality }: DrawSlotsOptions
|
||||
) {
|
||||
for (const slot of [...this._concreteInputs, ...this._concreteOutputs]) {
|
||||
const isValidTarget = fromSlot && slot.isValidTarget(fromSlot)
|
||||
const isMouseOverSlot = this._isMouseOverSlot(slot)
|
||||
for (const slots of [this._concreteInputs, this._concreteOutputs]) {
|
||||
for (let s = 0; s < slots.length; s++) {
|
||||
const slot = slots[s]
|
||||
const isValidTarget = fromSlot && slot.isValidTarget(fromSlot)
|
||||
const isMouseOverSlot = this._isMouseOverSlot(slot)
|
||||
|
||||
// change opacity of incompatible slots when dragging a connection
|
||||
const isValid = !fromSlot || isValidTarget
|
||||
const highlight = isValid && isMouseOverSlot
|
||||
// change opacity of incompatible slots when dragging a connection
|
||||
const isValid = !fromSlot || isValidTarget
|
||||
const highlight = isValid && isMouseOverSlot
|
||||
|
||||
// Show slot if it's not a widget input slot
|
||||
// or if it's a widget input slot and satisfies one of the following:
|
||||
// - the mouse is over the widget
|
||||
// - the slot is valid during link drop
|
||||
// - the slot is connected
|
||||
if (
|
||||
isMouseOverSlot ||
|
||||
isValidTarget ||
|
||||
!slot.isWidgetInputSlot ||
|
||||
this._isMouseOverWidget(this.getWidgetFromSlot(slot)) ||
|
||||
slot.isConnected ||
|
||||
slot.alwaysVisible
|
||||
) {
|
||||
ctx.globalAlpha = isValid ? editorAlpha : 0.4 * editorAlpha
|
||||
slot.draw(ctx, {
|
||||
colorContext,
|
||||
lowQuality,
|
||||
highlight
|
||||
})
|
||||
// Show slot if it's not a widget input slot
|
||||
// or if it's a widget input slot and satisfies one of the following:
|
||||
// - the mouse is over the widget
|
||||
// - the slot is valid during link drop
|
||||
// - the slot is connected
|
||||
if (
|
||||
isMouseOverSlot ||
|
||||
isValidTarget ||
|
||||
!slot.isWidgetInputSlot ||
|
||||
this._isMouseOverWidget(this.getWidgetFromSlot(slot)) ||
|
||||
slot.isConnected ||
|
||||
slot.alwaysVisible
|
||||
) {
|
||||
ctx.globalAlpha = isValid ? editorAlpha : 0.4 * editorAlpha
|
||||
slot.draw(ctx, {
|
||||
colorContext,
|
||||
lowQuality,
|
||||
highlight
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4247,12 +4257,15 @@ export class LGraphNode
|
||||
* have been removed from the ecosystem.
|
||||
*/
|
||||
_setConcreteSlots(): void {
|
||||
if (!this._slotsDirty) return
|
||||
|
||||
this._concreteInputs = this.inputs.map((slot) =>
|
||||
toClass(NodeInputSlot, slot, this)
|
||||
)
|
||||
this._concreteOutputs = this.outputs.map((slot) =>
|
||||
toClass(NodeOutputSlot, slot, this)
|
||||
)
|
||||
this._slotsDirty = false
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,4 +14,18 @@ describe('LLink', () => {
|
||||
const link = new LLink(1, 'float', 4, 2, 5, 3)
|
||||
expect(link.serialize()).toMatchSnapshot('Basic')
|
||||
})
|
||||
|
||||
describe('getReroutes', () => {
|
||||
test('returns the same empty array instance for links without reroutes', () => {
|
||||
const network = { reroutes: new Map() }
|
||||
const link1 = new LLink(1, 'float', 4, 2, 5, 3)
|
||||
const link2 = new LLink(2, 'float', 4, 2, 5, 3)
|
||||
|
||||
const result1 = LLink.getReroutes(network, link1)
|
||||
const result2 = LLink.getReroutes(network, link2)
|
||||
|
||||
expect(result1).toHaveLength(0)
|
||||
expect(result1).toBe(result2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,6 +23,8 @@ import type { Serialisable, SerialisableLLink } from './types/serialisation'
|
||||
|
||||
const layoutMutations = useLayoutMutations()
|
||||
|
||||
const EMPTY_REROUTES: Reroute[] = [] as Reroute[]
|
||||
|
||||
export type LinkId = number
|
||||
|
||||
export type SerialisedLLinkArray = [
|
||||
@@ -204,8 +206,11 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
|
||||
network: Pick<ReadonlyLinkNetwork, 'reroutes'>,
|
||||
linkSegment: LinkSegment
|
||||
): Reroute[] {
|
||||
if (linkSegment.parentId === undefined) return []
|
||||
return network.reroutes.get(linkSegment.parentId)?.getReroutes() ?? []
|
||||
if (linkSegment.parentId === undefined) return EMPTY_REROUTES
|
||||
return (
|
||||
network.reroutes.get(linkSegment.parentId)?.getReroutes() ??
|
||||
EMPTY_REROUTES
|
||||
)
|
||||
}
|
||||
|
||||
static getFirstReroute(
|
||||
|
||||
@@ -10,7 +10,13 @@ import { Reroute } from './Reroute'
|
||||
import { InputIndicators } from './canvas/InputIndicators'
|
||||
import { LabelPosition, SlotDirection, SlotShape, SlotType } from './draw'
|
||||
import { Rectangle } from './infrastructure/Rectangle'
|
||||
import type { Dictionary, ISlotType, Rect, WhenNullish } from './interfaces'
|
||||
import type {
|
||||
CreateNodeOptions,
|
||||
Dictionary,
|
||||
ISlotType,
|
||||
Rect,
|
||||
WhenNullish
|
||||
} from './interfaces'
|
||||
import { distance, isInsideRectangle, overlapBounding } from './measure'
|
||||
import { SubgraphIONodeBase } from './subgraph/SubgraphIONodeBase'
|
||||
import { SubgraphSlot } from './subgraph/SubgraphSlotBase'
|
||||
@@ -525,7 +531,7 @@ export class LiteGraphGlobal {
|
||||
createNode(
|
||||
type: string,
|
||||
title?: string,
|
||||
options?: Dictionary<unknown>
|
||||
options?: CreateNodeOptions
|
||||
): LGraphNode | null {
|
||||
const base_class = this.registered_node_types[type]
|
||||
if (!base_class) {
|
||||
@@ -561,10 +567,7 @@ export class LiteGraphGlobal {
|
||||
|
||||
// extra options
|
||||
if (options) {
|
||||
for (const i in options) {
|
||||
// @ts-expect-error #577 Requires interface
|
||||
node[i] = options[i]
|
||||
}
|
||||
Object.assign(node, options)
|
||||
}
|
||||
|
||||
// callback
|
||||
|
||||
59
src/lib/litegraph/src/cursorCache.test.ts
Normal file
59
src/lib/litegraph/src/cursorCache.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
8
src/lib/litegraph/src/cursorCache.ts
Normal file
8
src/lib/litegraph/src/cursorCache.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export function createCursorCache(element: HTMLElement) {
|
||||
let lastCursor = ''
|
||||
return function setCursor(cursor: string) {
|
||||
if (cursor === lastCursor) return
|
||||
lastCursor = cursor
|
||||
element.style.cursor = cursor
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,17 @@ 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 } from './LGraphNode'
|
||||
import type { LGraphNode, NodeId, NodeProperty } 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 { LinkDirection, RenderShape } from './types/globalEnums'
|
||||
import type {
|
||||
LGraphEventMode,
|
||||
LinkDirection,
|
||||
RenderShape
|
||||
} from './types/globalEnums'
|
||||
import type { IBaseWidget } from './types/widgets'
|
||||
|
||||
export type Dictionary<T> = { [key: string]: T }
|
||||
@@ -373,6 +377,22 @@ 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
|
||||
|
||||
@@ -91,6 +91,7 @@ export { RecursionError } from './infrastructure/RecursionError'
|
||||
export type {
|
||||
CanvasColour,
|
||||
ColorOption,
|
||||
CreateNodeOptions,
|
||||
IContextMenuOptions,
|
||||
IContextMenuValue,
|
||||
INodeInputSlot,
|
||||
@@ -144,7 +145,11 @@ export { isColorable } from './utils/type'
|
||||
export { createUuidv4 } from './utils/uuid'
|
||||
export type { UUID } from './utils/uuid'
|
||||
export { truncateText } from './utils/textUtils'
|
||||
export { getWidgetStep, resolveNodeRootGraphId } from './utils/widget'
|
||||
export {
|
||||
evaluateInput,
|
||||
getWidgetStep,
|
||||
resolveNodeRootGraphId
|
||||
} from './utils/widget'
|
||||
export { distributeSpace, type SpaceRequest } from './utils/spaceDistribution'
|
||||
|
||||
export { BaseWidget } from './widgets/BaseWidget'
|
||||
|
||||
121
src/lib/litegraph/src/utils/mathParser.test.ts
Normal file
121
src/lib/litegraph/src/utils/mathParser.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
116
src/lib/litegraph/src/utils/mathParser.ts
Normal file
116
src/lib/litegraph/src/utils/mathParser.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
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
|
||||
}
|
||||
@@ -3,6 +3,7 @@ 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'
|
||||
@@ -70,3 +71,57 @@ 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()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -2,6 +2,8 @@ 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
|
||||
@@ -12,17 +14,13 @@ export function getWidgetStep(options: IWidgetOptions<unknown>): number {
|
||||
}
|
||||
|
||||
export function evaluateInput(input: string): number | undefined {
|
||||
// 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 result = evaluateMathExpression(input)
|
||||
if (result !== undefined) {
|
||||
if (!isFinite(result)) return undefined
|
||||
return result
|
||||
}
|
||||
const newValue = Number(input)
|
||||
if (isNaN(newValue)) return undefined
|
||||
if (!isFinite(newValue)) return undefined
|
||||
return newValue
|
||||
}
|
||||
|
||||
|
||||
@@ -344,6 +344,10 @@
|
||||
"Workspace_ToggleFocusMode": {
|
||||
"label": "تبديل وضع التركيز"
|
||||
},
|
||||
"Workspace_ToggleSidebarTab_apps": {
|
||||
"label": "تبديل الشريط الجانبي للتطبيقات",
|
||||
"tooltip": "التطبيقات"
|
||||
},
|
||||
"Workspace_ToggleSidebarTab_assets": {
|
||||
"label": "تبديل الشريط الجانبي للأصول",
|
||||
"tooltip": "الأصول"
|
||||
|
||||
@@ -326,35 +326,40 @@
|
||||
"deleteWorkflow": "حذف سير العمل",
|
||||
"duplicate": "تكرار",
|
||||
"enterAppMode": "الدخول إلى وضع التطبيق",
|
||||
"enterBuilderMode": "دخول وضع بناء التطبيق",
|
||||
"enterNewName": "أدخل اسمًا جديدًا",
|
||||
"exitAppMode": "الخروج من وضع التطبيق",
|
||||
"missingNodesWarning": "يحتوي سير العمل على عقد غير مدعومة (مظللة باللون الأحمر).",
|
||||
"workflowActions": "إجراءات سير العمل"
|
||||
},
|
||||
"builderMenu": {
|
||||
"exitAppBuilder": "الخروج من مُنشئ التطبيق",
|
||||
"saveApp": "حفظ التطبيق"
|
||||
"exitAppBuilder": "الخروج من مُنشئ التطبيق"
|
||||
},
|
||||
"builderToolbar": {
|
||||
"app": "تطبيق",
|
||||
"appDescription": "يفتح كتطبيق بشكل افتراضي",
|
||||
"arrange": "معاينة",
|
||||
"arrangeDescription": "مراجعة تخطيط التطبيق",
|
||||
"backToWorkflow": "العودة إلى سير العمل",
|
||||
"connectOutput": "توصيل مخرج",
|
||||
"connectOutputBody1": "يجب توصيل مخرج واحد على الأقل قبل حفظ التطبيق.",
|
||||
"connectOutputBody2": "انتقل إلى خطوة 'تحديد' وانقر على عقد المخرجات لإضافتها هنا.",
|
||||
"filename": "اسم الملف",
|
||||
"defaultModeAppliedAppBody": "سيفتح سير العمل هذا في وضع التطبيق بشكل افتراضي من الآن فصاعدًا.",
|
||||
"defaultModeAppliedAppPrompt": "هل ترغب في عرضه الآن؟",
|
||||
"defaultModeAppliedGraphBody": "سيفتح سير العمل هذا كـمخطط عقد بشكل افتراضي من الآن فصاعدًا.",
|
||||
"defaultModeAppliedGraphPrompt": "هل ترغب في عرض التطبيق مع ذلك؟",
|
||||
"defaultModeAppliedTitle": "تم التعيين بنجاح",
|
||||
"defaultView": "تعيين العرض الافتراضي",
|
||||
"defaultViewDescription": "اختر كيفية الفتح",
|
||||
"defaultViewLabel": "افتراضيًا، سيتم فتح سير العمل هذا كـ:",
|
||||
"defaultViewTitle": "تعيين العرض الافتراضي لهذا سير العمل",
|
||||
"emptyWorkflowExplanation": "سير العمل الخاص بك فارغ. تحتاج إلى بعض العقد أولاً للبدء في بناء تطبيق.",
|
||||
"emptyWorkflowPrompt": "هل ترغب في البدء بقالب؟",
|
||||
"emptyWorkflowTitle": "لا يحتوي سير العمل هذا على أي عقد",
|
||||
"label": "منشئ التطبيقات",
|
||||
"loadTemplate": "تحميل قالب",
|
||||
"nodeGraph": "رسم العقد",
|
||||
"nodeGraphDescription": "يفتح كرسم عقد بشكل افتراضي",
|
||||
"save": "حفظ",
|
||||
"saveAs": "حفظ باسم",
|
||||
"saveAsLabel": "احفظ سير العمل هذا كـ ...",
|
||||
"saveDescription": "حفظ وإنهاء",
|
||||
"saveSuccess": "تم الحفظ بنجاح",
|
||||
"saveSuccessAppMessage": "تم حفظ '{name}'. سيفتح في وضع التطبيق بشكل افتراضي من الآن فصاعدًا.",
|
||||
"saveSuccessAppPrompt": "هل ترغب في عرضه الآن؟",
|
||||
"saveSuccessGraphMessage": "تم حفظ '{name}'. سيفتح كرسم عقد بشكل افتراضي.",
|
||||
"select": "تحديد",
|
||||
"selectDescription": "اختيار المدخلات/المخرجات",
|
||||
"switchToSelect": "الانتقال إلى التحديد",
|
||||
@@ -1334,7 +1339,9 @@
|
||||
"linearMode": {
|
||||
"appModeToolbar": {
|
||||
"appBuilder": "منشئ التطبيقات",
|
||||
"apps": "التطبيقات"
|
||||
"apps": "التطبيقات",
|
||||
"appsEmptyMessage": "سيتم عرض التطبيقات المحفوظة هنا.\nانقر أدناه لبناء تطبيقك الأول.",
|
||||
"enterAppMode": "الدخول إلى وضع التطبيق"
|
||||
},
|
||||
"arrange": {
|
||||
"atLeastOne": "عقدة واحدة على الأقل",
|
||||
@@ -1359,13 +1366,16 @@
|
||||
"outputsExample": "أمثلة: \"حفظ صورة\" أو \"حفظ فيديو\"",
|
||||
"promptAddInputs": "انقر على معلمات العقدة لإضافتها هنا كمدخلات",
|
||||
"promptAddOutputs": "انقر على عقد الإخراج لإضافتها هنا. هذه ستكون النتائج المُولدة.",
|
||||
"title": "وضع بناء التطبيق"
|
||||
"title": "وضع بناء التطبيق",
|
||||
"unknownWidget": "عنصر الواجهة غير مرئي"
|
||||
},
|
||||
"downloadAll": "تنزيل الكل",
|
||||
"dragAndDropImage": "اسحب وأسقط صورة",
|
||||
"enterNodeGraph": "دخول مخطط العقد",
|
||||
"giveFeedback": "إعطاء ملاحظات",
|
||||
"graphMode": "وضع الرسم البياني",
|
||||
"linearMode": "وضع التطبيق",
|
||||
"mobileControls": "تعديل وتشغيل",
|
||||
"queue": {
|
||||
"clear": "مسح قائمة الانتظار",
|
||||
"clickToClear": "انقر لمسح قائمة الانتظار"
|
||||
@@ -1373,6 +1383,7 @@
|
||||
"rerun": "تشغيل مجدد",
|
||||
"reuseParameters": "إعادة استخدام المعلمات",
|
||||
"runCount": "عدد مرات التشغيل:",
|
||||
"viewJob": "عرض المهمة",
|
||||
"welcome": {
|
||||
"backToWorkflow": "العودة إلى سير العمل",
|
||||
"buildApp": "إنشاء تطبيق",
|
||||
@@ -1749,6 +1760,7 @@
|
||||
"disabled": "معطل",
|
||||
"disabledTooltip": "لن يتم وضع سير العمل في قائمة الانتظار تلقائيًا",
|
||||
"execute": "تنفيذ",
|
||||
"fullscreen": "ملء الشاشة",
|
||||
"help": "مساعدة",
|
||||
"helpAndFeedback": "المساعدة والتعليقات",
|
||||
"hideMenu": "إخفاء القائمة",
|
||||
@@ -1896,7 +1908,8 @@
|
||||
"Workflows": "سير العمل",
|
||||
"Zoom In": "تكبير",
|
||||
"Zoom Out": "تصغير",
|
||||
"Zoom to fit": "تكبير لتناسب"
|
||||
"Zoom to fit": "تكبير لتناسب",
|
||||
"linearMode_appModeToolbar_apps": "linearMode.appModeToolbar.apps"
|
||||
},
|
||||
"minimap": {
|
||||
"nodeColors": "ألوان العقد",
|
||||
@@ -3202,7 +3215,9 @@
|
||||
"enterFilename": "أدخل اسم الملف",
|
||||
"enterFilenamePrompt": "أدخل اسم الملف:",
|
||||
"exportWorkflow": "تصدير سير العمل",
|
||||
"saveWorkflow": "حفظ سير العمل"
|
||||
"saveWorkflow": "حفظ سير العمل",
|
||||
"savedAsApp": "تم التحويل إلى سير عمل تطبيق",
|
||||
"savedAsWorkflow": "تم التحويل إلى سير عمل مخطط العقد فقط"
|
||||
},
|
||||
"workspace": {
|
||||
"addedToWorkspace": "تمت إضافتك إلى {workspaceName}",
|
||||
|
||||
@@ -344,6 +344,10 @@
|
||||
"Workspace_ToggleFocusMode": {
|
||||
"label": "Toggle Focus Mode"
|
||||
},
|
||||
"Workspace_ToggleSidebarTab_apps": {
|
||||
"label": "Toggle Apps Sidebar",
|
||||
"tooltip": "Apps"
|
||||
},
|
||||
"Workspace_ToggleSidebarTab_assets": {
|
||||
"label": "Toggle Assets Sidebar",
|
||||
"tooltip": "Assets"
|
||||
|
||||
@@ -854,6 +854,7 @@
|
||||
"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",
|
||||
@@ -1280,7 +1281,6 @@
|
||||
"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,6 +1351,7 @@
|
||||
"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",
|
||||
@@ -3050,11 +3051,11 @@
|
||||
},
|
||||
"arrange": {
|
||||
"noOutputs": "No outputs added yet",
|
||||
"switchToSelect": "Switch to the 'Select' step and click on output nodes to add them here.",
|
||||
"switchToOutputs": "Switch to the 'Output' 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'",
|
||||
"switchToSelectButton": "Switch to Select",
|
||||
"switchToOutputsButton": "Switch to Outputs",
|
||||
"outputs": "Outputs",
|
||||
"resultsLabel": "Results generated from the selected output node(s) will be shown here after running this app"
|
||||
},
|
||||
@@ -3358,16 +3359,18 @@
|
||||
},
|
||||
"builderToolbar": {
|
||||
"label": "App Builder",
|
||||
"select": "Select",
|
||||
"selectDescription": "Choose inputs/outputs",
|
||||
"inputs": "Inputs",
|
||||
"outputs": "Outputs",
|
||||
"inputsDescription": "Choose inputs",
|
||||
"outputsDescription": "Choose 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 'Select' step and click on output nodes to add them here.",
|
||||
"switchToSelect": "Switch to Select",
|
||||
"connectOutputBody2": "Switch to the 'Output' step and click on output nodes to add them here.",
|
||||
"switchToOutputs": "Switch to Outputs",
|
||||
"defaultViewTitle": "Set the default view for this workflow",
|
||||
"defaultViewLabel": "By default, this workflow will open as:",
|
||||
"app": "App",
|
||||
|
||||
@@ -344,6 +344,10 @@
|
||||
"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"
|
||||
|
||||
@@ -326,35 +326,40 @@
|
||||
"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",
|
||||
"saveApp": "Guardar aplicación"
|
||||
"exitAppBuilder": "Salir del constructor de aplicaciones"
|
||||
},
|
||||
"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í.",
|
||||
"filename": "Nombre de archivo",
|
||||
"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",
|
||||
"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",
|
||||
@@ -1334,7 +1339,9 @@
|
||||
"linearMode": {
|
||||
"appModeToolbar": {
|
||||
"appBuilder": "Constructor de aplicaciones",
|
||||
"apps": "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"
|
||||
},
|
||||
"arrange": {
|
||||
"atLeastOne": "al menos uno",
|
||||
@@ -1359,13 +1366,16 @@
|
||||
"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"
|
||||
"title": "Modo constructor de aplicaciones",
|
||||
"unknownWidget": "Widget no visible"
|
||||
},
|
||||
"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"
|
||||
@@ -1373,6 +1383,7 @@
|
||||
"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",
|
||||
@@ -1749,6 +1760,7 @@
|
||||
"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ú",
|
||||
@@ -1896,7 +1908,8 @@
|
||||
"Workflows": "Flujos de trabajo",
|
||||
"Zoom In": "Acercar",
|
||||
"Zoom Out": "Alejar",
|
||||
"Zoom to fit": "Ajustar al tamaño"
|
||||
"Zoom to fit": "Ajustar al tamaño",
|
||||
"linearMode_appModeToolbar_apps": "linearMode.appModeToolbar.apps"
|
||||
},
|
||||
"minimap": {
|
||||
"nodeColors": "Colores de nodos",
|
||||
@@ -3202,7 +3215,9 @@
|
||||
"enterFilename": "Introduzca el nombre del archivo",
|
||||
"enterFilenamePrompt": "Introduce el nombre del archivo:",
|
||||
"exportWorkflow": "Exportar flujo de trabajo",
|
||||
"saveWorkflow": "Guardar 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"
|
||||
},
|
||||
"workspace": {
|
||||
"addedToWorkspace": "Has sido añadido a {workspaceName}",
|
||||
|
||||
@@ -344,6 +344,10 @@
|
||||
"Workspace_ToggleFocusMode": {
|
||||
"label": "تغییر حالت تمرکز"
|
||||
},
|
||||
"Workspace_ToggleSidebarTab_apps": {
|
||||
"label": "نمایش/پنهان کردن نوار کناری اپلیکیشنها",
|
||||
"tooltip": "اپلیکیشنها"
|
||||
},
|
||||
"Workspace_ToggleSidebarTab_assets": {
|
||||
"label": "تغییر نوار کناری داراییها",
|
||||
"tooltip": "داراییها"
|
||||
|
||||
@@ -326,35 +326,40 @@
|
||||
"deleteWorkflow": "حذف workflow",
|
||||
"duplicate": "تکرار",
|
||||
"enterAppMode": "ورود به حالت اپلیکیشن",
|
||||
"enterBuilderMode": "ورود به حالت سازنده اپلیکیشن",
|
||||
"enterNewName": "نام جدید را وارد کنید",
|
||||
"exitAppMode": "خروج از حالت اپلیکیشن",
|
||||
"missingNodesWarning": "workflow شامل نودهای پشتیبانینشده است (با رنگ قرمز مشخص شدهاند).",
|
||||
"workflowActions": "عملیات گردشکار"
|
||||
},
|
||||
"builderMenu": {
|
||||
"exitAppBuilder": "خروج از سازنده برنامه",
|
||||
"saveApp": "ذخیره برنامه"
|
||||
"exitAppBuilder": "خروج از سازنده برنامه"
|
||||
},
|
||||
"builderToolbar": {
|
||||
"app": "اپلیکیشن",
|
||||
"appDescription": "به طور پیشفرض به صورت اپلیکیشن باز میشود",
|
||||
"arrange": "پیشنمایش",
|
||||
"arrangeDescription": "بررسی چیدمان اپلیکیشن",
|
||||
"backToWorkflow": "بازگشت به ورکفلو",
|
||||
"connectOutput": "اتصال خروجی",
|
||||
"connectOutputBody1": "اپلیکیشن شما باید حداقل یک خروجی متصل داشته باشد تا بتوان آن را ذخیره کرد.",
|
||||
"connectOutputBody2": "به مرحله «انتخاب» بروید و روی nodeهای خروجی کلیک کنید تا اینجا اضافه شوند.",
|
||||
"filename": "نام فایل",
|
||||
"defaultModeAppliedAppBody": "این ورکفلو از این پس به طور پیشفرض در حالت اپلیکیشن باز خواهد شد.",
|
||||
"defaultModeAppliedAppPrompt": "آیا مایل هستید اکنون آن را مشاهده کنید؟",
|
||||
"defaultModeAppliedGraphBody": "این ورکفلو از این پس به طور پیشفرض به صورت گراف node باز خواهد شد.",
|
||||
"defaultModeAppliedGraphPrompt": "آیا همچنان مایل به مشاهده اپلیکیشن هستید؟",
|
||||
"defaultModeAppliedTitle": "با موفقیت تنظیم شد",
|
||||
"defaultView": "تنظیم نمای پیشفرض",
|
||||
"defaultViewDescription": "انتخاب نحوه باز شدن",
|
||||
"defaultViewLabel": "به طور پیشفرض، این گردشکار به صورت زیر باز میشود:",
|
||||
"defaultViewTitle": "تنظیم نمای پیشفرض برای این گردشکار",
|
||||
"emptyWorkflowExplanation": "ورکفلو شما خالی است. ابتدا باید چند node اضافه کنید تا بتوانید اپلیکیشن بسازید.",
|
||||
"emptyWorkflowPrompt": "آیا میخواهید با یک قالب شروع کنید؟",
|
||||
"emptyWorkflowTitle": "این ورکفلو هیچ node ندارد",
|
||||
"label": "سازنده اپلیکیشن",
|
||||
"loadTemplate": "بارگذاری قالب",
|
||||
"nodeGraph": "گراف node",
|
||||
"nodeGraphDescription": "به طور پیشفرض به صورت گراف node باز میشود",
|
||||
"save": "ذخیره",
|
||||
"saveAs": "ذخیره به عنوان",
|
||||
"saveAsLabel": "این گردشکار را ذخیره کنید به عنوان ...",
|
||||
"saveDescription": "ذخیره و پایان",
|
||||
"saveSuccess": "با موفقیت ذخیره شد",
|
||||
"saveSuccessAppMessage": "'{name}' ذخیره شد. از این پس به طور پیشفرض در حالت اپلیکیشن باز خواهد شد.",
|
||||
"saveSuccessAppPrompt": "آیا مایلید اکنون آن را مشاهده کنید؟",
|
||||
"saveSuccessGraphMessage": "'{name}' ذخیره شد. به طور پیشفرض به صورت گراف node باز خواهد شد.",
|
||||
"select": "انتخاب",
|
||||
"selectDescription": "انتخاب ورودی/خروجیها",
|
||||
"switchToSelect": "رفتن به انتخاب",
|
||||
@@ -1334,7 +1339,9 @@
|
||||
"linearMode": {
|
||||
"appModeToolbar": {
|
||||
"appBuilder": "سازنده اپلیکیشن",
|
||||
"apps": "اپلیکیشنها"
|
||||
"apps": "اپلیکیشنها",
|
||||
"appsEmptyMessage": "اپلیکیشنهای ذخیرهشده اینجا نمایش داده میشوند.\nبرای ساخت اولین اپلیکیشن خود، روی دکمه زیر کلیک کنید.",
|
||||
"enterAppMode": "ورود به حالت اپلیکیشن"
|
||||
},
|
||||
"arrange": {
|
||||
"atLeastOne": "یک",
|
||||
@@ -1359,13 +1366,16 @@
|
||||
"outputsExample": "مثالها: «ذخیره تصویر» یا «ذخیره ویدیو»",
|
||||
"promptAddInputs": "برای افزودن پارامترها به عنوان ورودی، روی پارامترهای گره کلیک کنید",
|
||||
"promptAddOutputs": "برای افزودن خروجی، روی گرههای خروجی کلیک کنید. اینها نتایج تولیدشده خواهند بود.",
|
||||
"title": "حالت ساخت اپلیکیشن"
|
||||
"title": "حالت ساخت اپلیکیشن",
|
||||
"unknownWidget": "ویجت قابل مشاهده نیست"
|
||||
},
|
||||
"downloadAll": "دانلود همه",
|
||||
"dragAndDropImage": "تصویر را بکشید و رها کنید",
|
||||
"enterNodeGraph": "ورود به گراف node",
|
||||
"giveFeedback": "ارسال بازخورد",
|
||||
"graphMode": "حالت گراف",
|
||||
"linearMode": "حالت برنامه",
|
||||
"mobileControls": "ویرایش و اجرا",
|
||||
"queue": {
|
||||
"clear": "پاکسازی صف",
|
||||
"clickToClear": "برای پاکسازی صف کلیک کنید"
|
||||
@@ -1373,6 +1383,7 @@
|
||||
"rerun": "اجرای مجدد",
|
||||
"reuseParameters": "استفاده مجدد از پارامترها",
|
||||
"runCount": "تعداد اجرا: ",
|
||||
"viewJob": "مشاهده وظیفه",
|
||||
"welcome": {
|
||||
"backToWorkflow": "بازگشت به گردشکار",
|
||||
"buildApp": "ساخت اپلیکیشن",
|
||||
@@ -1749,6 +1760,7 @@
|
||||
"disabled": "غیرفعال",
|
||||
"disabledTooltip": "workflow به صورت خودکار در صف قرار نمیگیرد",
|
||||
"execute": "اجرا",
|
||||
"fullscreen": "تمامصفحه",
|
||||
"help": "راهنما",
|
||||
"helpAndFeedback": "راهنما و بازخورد",
|
||||
"hideMenu": "مخفی کردن منو",
|
||||
@@ -1896,7 +1908,8 @@
|
||||
"Workflows": "Workflowها",
|
||||
"Zoom In": "بزرگنمایی",
|
||||
"Zoom Out": "کوچکنمایی",
|
||||
"Zoom to fit": "بزرگنمایی برای تطبیق"
|
||||
"Zoom to fit": "بزرگنمایی برای تطبیق",
|
||||
"linearMode_appModeToolbar_apps": "linearMode.appModeToolbar.apps"
|
||||
},
|
||||
"minimap": {
|
||||
"nodeColors": "رنگهای نود",
|
||||
@@ -3214,7 +3227,9 @@
|
||||
"enterFilename": "نام فایل را وارد کنید",
|
||||
"enterFilenamePrompt": "نام فایل را وارد کنید:",
|
||||
"exportWorkflow": "خروجی گرفتن از workflow",
|
||||
"saveWorkflow": "ذخیره workflow"
|
||||
"saveWorkflow": "ذخیره workflow",
|
||||
"savedAsApp": "به گردشکار اپلیکیشن تبدیل شد",
|
||||
"savedAsWorkflow": "به گردشکار فقط گراف node تبدیل شد"
|
||||
},
|
||||
"workspace": {
|
||||
"addedToWorkspace": "شما به {workspaceName} اضافه شدید",
|
||||
|
||||
@@ -344,6 +344,10 @@
|
||||
"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"
|
||||
|
||||
@@ -326,39 +326,44 @@
|
||||
"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",
|
||||
"saveApp": "Enregistrer l'application"
|
||||
"exitAppBuilder": "Quitter le constructeur d'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.",
|
||||
"filename": "Nom du fichier",
|
||||
"defaultModeAppliedAppBody": "Ce workflow s’ouvrira désormais par défaut en mode Application.",
|
||||
"defaultModeAppliedAppPrompt": "Voulez-vous le voir maintenant ?",
|
||||
"defaultModeAppliedGraphBody": "Ce workflow s’ouvrira désormais par défaut sous forme de graphe de nœuds.",
|
||||
"defaultModeAppliedGraphPrompt": "Voulez-vous tout de même voir l’application ?",
|
||||
"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 d’abord 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",
|
||||
"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 l'application"
|
||||
"viewApp": "Voir l’application"
|
||||
},
|
||||
"clipboard": {
|
||||
"errorMessage": "Échec de la copie dans le presse-papiers",
|
||||
@@ -1334,7 +1339,9 @@
|
||||
"linearMode": {
|
||||
"appModeToolbar": {
|
||||
"appBuilder": "Créateur d'applications",
|
||||
"apps": "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"
|
||||
},
|
||||
"arrange": {
|
||||
"atLeastOne": "au moins un",
|
||||
@@ -1359,13 +1366,16 @@
|
||||
"outputsExample": "Exemples : « Enregistrer l’image » 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 d’application"
|
||||
"title": "Mode créateur d’application",
|
||||
"unknownWidget": "Widget non visible"
|
||||
},
|
||||
"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"
|
||||
@@ -1373,6 +1383,7 @@
|
||||
"rerun": "Relancer",
|
||||
"reuseParameters": "Réutiliser les paramètres",
|
||||
"runCount": "Nombre d’exécutions :",
|
||||
"viewJob": "Voir la tâche",
|
||||
"welcome": {
|
||||
"backToWorkflow": "Retour au workflow",
|
||||
"buildApp": "Créer une application",
|
||||
@@ -1749,6 +1760,7 @@
|
||||
"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",
|
||||
@@ -1896,7 +1908,8 @@
|
||||
"Workflows": "Flux de travail",
|
||||
"Zoom In": "Zoom avant",
|
||||
"Zoom Out": "Zoom arrière",
|
||||
"Zoom to fit": "Ajuster à l'écran"
|
||||
"Zoom to fit": "Ajuster à l'écran",
|
||||
"linearMode_appModeToolbar_apps": "linearMode.appModeToolbar.apps"
|
||||
},
|
||||
"minimap": {
|
||||
"nodeColors": "Couleurs des nœuds",
|
||||
@@ -3202,7 +3215,9 @@
|
||||
"enterFilename": "Entrez le nom du fichier",
|
||||
"enterFilenamePrompt": "Entrez le nom du fichier :",
|
||||
"exportWorkflow": "Exporter le flux de travail",
|
||||
"saveWorkflow": "Enregistrer 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"
|
||||
},
|
||||
"workspace": {
|
||||
"addedToWorkspace": "Vous avez été ajouté à {workspaceName}",
|
||||
|
||||
@@ -344,6 +344,10 @@
|
||||
"Workspace_ToggleFocusMode": {
|
||||
"label": "フォーカスモードの切り替え"
|
||||
},
|
||||
"Workspace_ToggleSidebarTab_apps": {
|
||||
"label": "アプリサイドバーを切り替え",
|
||||
"tooltip": "アプリ"
|
||||
},
|
||||
"Workspace_ToggleSidebarTab_assets": {
|
||||
"label": "アセットサイドバーの表示切り替え",
|
||||
"tooltip": "アセット"
|
||||
|
||||
@@ -326,35 +326,40 @@
|
||||
"deleteWorkflow": "ワークフローを削除",
|
||||
"duplicate": "複製",
|
||||
"enterAppMode": "アプリモードに入る",
|
||||
"enterBuilderMode": "アプリビルダーに入る",
|
||||
"enterNewName": "新しい名前を入力",
|
||||
"exitAppMode": "アプリモードを終了",
|
||||
"missingNodesWarning": "ワークフローに未対応のノードが含まれています(赤でハイライト)。",
|
||||
"workflowActions": "ワークフロー操作"
|
||||
},
|
||||
"builderMenu": {
|
||||
"exitAppBuilder": "アプリビルダーを終了",
|
||||
"saveApp": "アプリを保存"
|
||||
"exitAppBuilder": "アプリビルダーを終了"
|
||||
},
|
||||
"builderToolbar": {
|
||||
"app": "アプリ",
|
||||
"appDescription": "デフォルトでアプリとして開きます",
|
||||
"arrange": "プレビュー",
|
||||
"arrangeDescription": "アプリのレイアウトを確認",
|
||||
"backToWorkflow": "ワークフローに戻る",
|
||||
"connectOutput": "出力を接続",
|
||||
"connectOutputBody1": "アプリを保存するには、少なくとも1つの出力を接続する必要があります。",
|
||||
"connectOutputBody2": "「選択」ステップに切り替えて、出力ノードをクリックしてここに追加してください。",
|
||||
"filename": "ファイル名",
|
||||
"defaultModeAppliedAppBody": "このワークフローは今後デフォルトでアプリモードで開きます。",
|
||||
"defaultModeAppliedAppPrompt": "今すぐ表示しますか?",
|
||||
"defaultModeAppliedGraphBody": "このワークフローは今後デフォルトでノードグラフとして開きます。",
|
||||
"defaultModeAppliedGraphPrompt": "アプリを引き続き表示しますか?",
|
||||
"defaultModeAppliedTitle": "正常に設定されました",
|
||||
"defaultView": "デフォルトビューを設定",
|
||||
"defaultViewDescription": "開き方を選択してください",
|
||||
"defaultViewLabel": "デフォルトでは、このワークフローは次のように開きます:",
|
||||
"defaultViewTitle": "このワークフローのデフォルトビューを設定",
|
||||
"emptyWorkflowExplanation": "ワークフローが空です。アプリを作成するにはまずノードが必要です。",
|
||||
"emptyWorkflowPrompt": "テンプレートから始めますか?",
|
||||
"emptyWorkflowTitle": "このワークフローにはノードがありません",
|
||||
"label": "アプリビルダー",
|
||||
"loadTemplate": "テンプレートを読み込む",
|
||||
"nodeGraph": "ノードグラフ",
|
||||
"nodeGraphDescription": "デフォルトでノードグラフとして開きます",
|
||||
"save": "保存",
|
||||
"saveAs": "名前を付けて保存",
|
||||
"saveAsLabel": "このワークフローを次の形式で保存...",
|
||||
"saveDescription": "保存して終了",
|
||||
"saveSuccess": "保存に成功しました",
|
||||
"saveSuccessAppMessage": "「{name}」が保存されました。今後はデフォルトでアプリモードで開きます。",
|
||||
"saveSuccessAppPrompt": "今すぐ表示しますか?",
|
||||
"saveSuccessGraphMessage": "「{name}」が保存されました。今後はデフォルトでノードグラフとして開きます。",
|
||||
"select": "選択",
|
||||
"selectDescription": "入力/出力を選択",
|
||||
"switchToSelect": "選択に切り替え",
|
||||
@@ -1334,7 +1339,9 @@
|
||||
"linearMode": {
|
||||
"appModeToolbar": {
|
||||
"appBuilder": "アプリビルダー",
|
||||
"apps": "アプリ"
|
||||
"apps": "アプリ",
|
||||
"appsEmptyMessage": "保存されたアプリはここに表示されます。\n下をクリックして最初のアプリを作成しましょう。",
|
||||
"enterAppMode": "アプリモードに入る"
|
||||
},
|
||||
"arrange": {
|
||||
"atLeastOne": "少なくとも1つ",
|
||||
@@ -1359,13 +1366,16 @@
|
||||
"outputsExample": "例:「画像を保存」「動画を保存」",
|
||||
"promptAddInputs": "ノードのパラメータをクリックして、ここに入力として追加してください",
|
||||
"promptAddOutputs": "出力ノードをクリックしてここに追加してください。これが生成される結果となります。",
|
||||
"title": "アプリビルダーモード"
|
||||
"title": "アプリビルダーモード",
|
||||
"unknownWidget": "ウィジェットが表示されていません"
|
||||
},
|
||||
"downloadAll": "すべてダウンロード",
|
||||
"dragAndDropImage": "画像をドラッグ&ドロップ",
|
||||
"enterNodeGraph": "ノードグラフに入る",
|
||||
"giveFeedback": "フィードバックを送る",
|
||||
"graphMode": "グラフモード",
|
||||
"linearMode": "アプリモード",
|
||||
"mobileControls": "編集と実行",
|
||||
"queue": {
|
||||
"clear": "キューをクリア",
|
||||
"clickToClear": "クリックしてキューをクリア"
|
||||
@@ -1373,6 +1383,7 @@
|
||||
"rerun": "再実行",
|
||||
"reuseParameters": "パラメータを再利用",
|
||||
"runCount": "実行回数:",
|
||||
"viewJob": "ジョブを表示",
|
||||
"welcome": {
|
||||
"backToWorkflow": "ワークフローに戻る",
|
||||
"buildApp": "アプリを作成",
|
||||
@@ -1749,6 +1760,7 @@
|
||||
"disabled": "無効",
|
||||
"disabledTooltip": "ワークフローは自動的にキューに追加されません",
|
||||
"execute": "実行",
|
||||
"fullscreen": "全画面表示",
|
||||
"help": "ヘルプ",
|
||||
"helpAndFeedback": "ヘルプとフィードバック",
|
||||
"hideMenu": "メニューを隠す",
|
||||
@@ -1896,7 +1908,8 @@
|
||||
"Workflows": "ワークフロー",
|
||||
"Zoom In": "ズームイン",
|
||||
"Zoom Out": "ズームアウト",
|
||||
"Zoom to fit": "全体表示にズーム"
|
||||
"Zoom to fit": "全体表示にズーム",
|
||||
"linearMode_appModeToolbar_apps": "linearMode.appModeToolbar.apps"
|
||||
},
|
||||
"minimap": {
|
||||
"nodeColors": "ノードの色",
|
||||
@@ -3202,7 +3215,9 @@
|
||||
"enterFilename": "ファイル名を入力",
|
||||
"enterFilenamePrompt": "ファイル名を入力してください:",
|
||||
"exportWorkflow": "ワークフローをエクスポート",
|
||||
"saveWorkflow": "ワークフローを保存"
|
||||
"saveWorkflow": "ワークフローを保存",
|
||||
"savedAsApp": "アプリワークフローに変換されました",
|
||||
"savedAsWorkflow": "ノードグラフのみのワークフローに変換されました"
|
||||
},
|
||||
"workspace": {
|
||||
"addedToWorkspace": "{workspaceName}に追加されました",
|
||||
|
||||
@@ -344,6 +344,10 @@
|
||||
"Workspace_ToggleFocusMode": {
|
||||
"label": "포커스 모드 토글"
|
||||
},
|
||||
"Workspace_ToggleSidebarTab_apps": {
|
||||
"label": "앱 사이드바 전환",
|
||||
"tooltip": "앱"
|
||||
},
|
||||
"Workspace_ToggleSidebarTab_assets": {
|
||||
"label": "에셋 사이드바 전환",
|
||||
"tooltip": "에셋"
|
||||
|
||||
@@ -326,35 +326,40 @@
|
||||
"deleteWorkflow": "워크플로 삭제",
|
||||
"duplicate": "복제",
|
||||
"enterAppMode": "앱 모드로 진입",
|
||||
"enterBuilderMode": "앱 빌더로 진입",
|
||||
"enterNewName": "새 이름 입력",
|
||||
"exitAppMode": "앱 모드 종료",
|
||||
"missingNodesWarning": "워크플로우에 지원되지 않는 노드가 포함되어 있습니다(빨간색으로 표시됨).",
|
||||
"workflowActions": "워크플로우 작업"
|
||||
},
|
||||
"builderMenu": {
|
||||
"exitAppBuilder": "앱 빌더 종료",
|
||||
"saveApp": "앱 저장"
|
||||
"exitAppBuilder": "앱 빌더 종료"
|
||||
},
|
||||
"builderToolbar": {
|
||||
"app": "앱",
|
||||
"appDescription": "기본적으로 앱으로 열립니다",
|
||||
"arrange": "미리보기",
|
||||
"arrangeDescription": "앱 레이아웃 검토",
|
||||
"backToWorkflow": "워크플로우로 돌아가기",
|
||||
"connectOutput": "출력 연결",
|
||||
"connectOutputBody1": "앱을 저장하려면 최소 한 개의 출력이 연결되어야 합니다.",
|
||||
"connectOutputBody2": "'선택' 단계로 전환한 후 출력 노드를 클릭하여 여기에 추가하세요.",
|
||||
"filename": "파일명",
|
||||
"defaultModeAppliedAppBody": "이 워크플로우는 앞으로 기본적으로 앱 모드에서 열립니다.",
|
||||
"defaultModeAppliedAppPrompt": "지금 확인하시겠습니까?",
|
||||
"defaultModeAppliedGraphBody": "이 워크플로우는 앞으로 기본적으로 노드 그래프로 열립니다.",
|
||||
"defaultModeAppliedGraphPrompt": "앱을 계속 보시겠습니까?",
|
||||
"defaultModeAppliedTitle": "성공적으로 설정됨",
|
||||
"defaultView": "기본 보기 설정",
|
||||
"defaultViewDescription": "이 워크플로우가 어떻게 열릴지 선택하세요",
|
||||
"defaultViewLabel": "기본적으로 이 워크플로우는 다음과 같이 열립니다:",
|
||||
"defaultViewTitle": "이 워크플로우의 기본 보기 설정",
|
||||
"emptyWorkflowExplanation": "워크플로우가 비어 있습니다. 앱을 만들려면 먼저 노드가 필요합니다.",
|
||||
"emptyWorkflowPrompt": "템플릿으로 시작하시겠습니까?",
|
||||
"emptyWorkflowTitle": "이 워크플로우에는 노드가 없습니다",
|
||||
"label": "앱 빌더",
|
||||
"loadTemplate": "템플릿 불러오기",
|
||||
"nodeGraph": "노드 그래프",
|
||||
"nodeGraphDescription": "기본적으로 노드 그래프로 열립니다",
|
||||
"save": "저장",
|
||||
"saveAs": "다른 이름으로 저장",
|
||||
"saveAsLabel": "이 워크플로우를 다음으로 저장 ...",
|
||||
"saveDescription": "저장 및 완료",
|
||||
"saveSuccess": "성공적으로 저장되었습니다",
|
||||
"saveSuccessAppMessage": "'{name}'이(가) 저장되었습니다. 앞으로 기본적으로 앱 모드로 열립니다.",
|
||||
"saveSuccessAppPrompt": "지금 확인하시겠습니까?",
|
||||
"saveSuccessGraphMessage": "'{name}'이(가) 저장되었습니다. 앞으로 기본적으로 노드 그래프로 열립니다.",
|
||||
"select": "선택",
|
||||
"selectDescription": "입력/출력 선택",
|
||||
"switchToSelect": "선택으로 전환",
|
||||
@@ -1334,7 +1339,9 @@
|
||||
"linearMode": {
|
||||
"appModeToolbar": {
|
||||
"appBuilder": "앱 빌더",
|
||||
"apps": "앱"
|
||||
"apps": "앱",
|
||||
"appsEmptyMessage": "저장된 앱이 여기에 표시됩니다.\n아래를 클릭하여 첫 번째 앱을 만들어보세요.",
|
||||
"enterAppMode": "앱 모드로 들어가기"
|
||||
},
|
||||
"arrange": {
|
||||
"atLeastOne": "최소 한 개",
|
||||
@@ -1359,13 +1366,16 @@
|
||||
"outputsExample": "예시: “이미지 저장” 또는 “비디오 저장”",
|
||||
"promptAddInputs": "노드 파라미터를 클릭하여 입력값으로 추가하세요",
|
||||
"promptAddOutputs": "출력 노드를 클릭하여 여기에 추가하세요. 이들이 생성된 결과가 됩니다.",
|
||||
"title": "앱 빌더 모드"
|
||||
"title": "앱 빌더 모드",
|
||||
"unknownWidget": "위젯이 표시되지 않습니다"
|
||||
},
|
||||
"downloadAll": "모두 다운로드",
|
||||
"dragAndDropImage": "이미지를 드래그 앤 드롭하세요",
|
||||
"enterNodeGraph": "노드 그래프로 진입",
|
||||
"giveFeedback": "피드백 보내기",
|
||||
"graphMode": "그래프 모드",
|
||||
"linearMode": "앱 모드",
|
||||
"mobileControls": "편집 및 실행",
|
||||
"queue": {
|
||||
"clear": "대기열 비우기",
|
||||
"clickToClear": "클릭하여 대기열 비우기"
|
||||
@@ -1373,6 +1383,7 @@
|
||||
"rerun": "다시 실행",
|
||||
"reuseParameters": "파라미터 재사용",
|
||||
"runCount": "실행 횟수:",
|
||||
"viewJob": "작업 보기",
|
||||
"welcome": {
|
||||
"backToWorkflow": "워크플로우로 돌아가기",
|
||||
"buildApp": "앱 만들기",
|
||||
@@ -1749,6 +1760,7 @@
|
||||
"disabled": "비활성화됨",
|
||||
"disabledTooltip": "워크플로 작업을 자동으로 실행 대기열에 추가하지 않습니다.",
|
||||
"execute": "실행",
|
||||
"fullscreen": "전체 화면",
|
||||
"help": "도움말",
|
||||
"helpAndFeedback": "도움말 및 피드백",
|
||||
"hideMenu": "메뉴 숨기기",
|
||||
@@ -1896,7 +1908,8 @@
|
||||
"Workflows": "워크플로",
|
||||
"Zoom In": "확대",
|
||||
"Zoom Out": "축소",
|
||||
"Zoom to fit": "화면에 맞추기"
|
||||
"Zoom to fit": "화면에 맞추기",
|
||||
"linearMode_appModeToolbar_apps": "linearMode.appModeToolbar.apps"
|
||||
},
|
||||
"minimap": {
|
||||
"nodeColors": "노드 색상",
|
||||
@@ -3202,7 +3215,9 @@
|
||||
"enterFilename": "파일 이름 입력",
|
||||
"enterFilenamePrompt": "파일 이름을 입력하세요:",
|
||||
"exportWorkflow": "워크플로 내보내기",
|
||||
"saveWorkflow": "워크플로 저장"
|
||||
"saveWorkflow": "워크플로 저장",
|
||||
"savedAsApp": "앱 워크플로우로 변환됨",
|
||||
"savedAsWorkflow": "노드 그래프 전용 워크플로우로 변환됨"
|
||||
},
|
||||
"workspace": {
|
||||
"addedToWorkspace": "{workspaceName} 워크스페이스에 추가되었습니다",
|
||||
|
||||
@@ -344,6 +344,10 @@
|
||||
"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"
|
||||
|
||||
@@ -326,35 +326,40 @@
|
||||
"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",
|
||||
"saveApp": "Salvar aplicativo"
|
||||
"exitAppBuilder": "Sair do construtor de aplicativos"
|
||||
},
|
||||
"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.",
|
||||
"filename": "Nome do arquivo",
|
||||
"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",
|
||||
"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",
|
||||
@@ -1334,7 +1339,9 @@
|
||||
"linearMode": {
|
||||
"appModeToolbar": {
|
||||
"appBuilder": "Construtor de aplicativos",
|
||||
"apps": "Aplicativos"
|
||||
"apps": "Aplicativos",
|
||||
"appsEmptyMessage": "Os aplicativos salvos aparecerão aqui.\nClique abaixo para criar seu primeiro aplicativo.",
|
||||
"enterAppMode": "Entrar no modo de aplicativo"
|
||||
},
|
||||
"arrange": {
|
||||
"atLeastOne": "pelo menos um",
|
||||
@@ -1359,13 +1366,16 @@
|
||||
"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"
|
||||
"title": "Modo construtor de app",
|
||||
"unknownWidget": "Widget não visível"
|
||||
},
|
||||
"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"
|
||||
@@ -1373,6 +1383,7 @@
|
||||
"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",
|
||||
@@ -1749,6 +1760,7 @@
|
||||
"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",
|
||||
@@ -1896,7 +1908,8 @@
|
||||
"Workflows": "Fluxos de trabalho",
|
||||
"Zoom In": "Aumentar zoom",
|
||||
"Zoom Out": "Diminuir zoom",
|
||||
"Zoom to fit": "Ajustar zoom"
|
||||
"Zoom to fit": "Ajustar zoom",
|
||||
"linearMode_appModeToolbar_apps": "linearMode.appModeToolbar.apps"
|
||||
},
|
||||
"minimap": {
|
||||
"nodeColors": "Cores dos Nós",
|
||||
@@ -3214,7 +3227,9 @@
|
||||
"enterFilename": "Digite o nome do arquivo",
|
||||
"enterFilenamePrompt": "Digite o nome do arquivo:",
|
||||
"exportWorkflow": "Exportar Fluxo de Trabalho",
|
||||
"saveWorkflow": "Salvar fluxo de trabalho"
|
||||
"saveWorkflow": "Salvar fluxo de trabalho",
|
||||
"savedAsApp": "Convertido para fluxo de aplicativo",
|
||||
"savedAsWorkflow": "Convertido para fluxo apenas com grafo de nós"
|
||||
},
|
||||
"workspace": {
|
||||
"addedToWorkspace": "Você foi adicionado ao {workspaceName}",
|
||||
|
||||
@@ -344,6 +344,10 @@
|
||||
"Workspace_ToggleFocusMode": {
|
||||
"label": "Переключить режим фокуса"
|
||||
},
|
||||
"Workspace_ToggleSidebarTab_apps": {
|
||||
"label": "Переключить боковую панель приложений",
|
||||
"tooltip": "Приложения"
|
||||
},
|
||||
"Workspace_ToggleSidebarTab_assets": {
|
||||
"label": "Переключить боковую панель ресурсов",
|
||||
"tooltip": "Ресурсы"
|
||||
|
||||
@@ -326,35 +326,40 @@
|
||||
"deleteWorkflow": "Удалить рабочий процесс",
|
||||
"duplicate": "Дублировать",
|
||||
"enterAppMode": "Войти в режим приложения",
|
||||
"enterBuilderMode": "Войти в режим конструктора приложения",
|
||||
"enterNewName": "Введите новое имя",
|
||||
"exitAppMode": "Выйти из режима приложения",
|
||||
"missingNodesWarning": "В рабочем процессе есть неподдерживаемые узлы (выделены красным).",
|
||||
"workflowActions": "Действия с рабочим процессом"
|
||||
},
|
||||
"builderMenu": {
|
||||
"exitAppBuilder": "Выйти из конструктора приложений",
|
||||
"saveApp": "Сохранить приложение"
|
||||
"exitAppBuilder": "Выйти из конструктора приложений"
|
||||
},
|
||||
"builderToolbar": {
|
||||
"app": "Приложение",
|
||||
"appDescription": "Открывается как приложение по умолчанию",
|
||||
"arrange": "Предпросмотр",
|
||||
"arrangeDescription": "Проверьте макет приложения",
|
||||
"backToWorkflow": "Назад к рабочему процессу",
|
||||
"connectOutput": "Подключить выход",
|
||||
"connectOutputBody1": "Вашему приложению необходимо подключить хотя бы один выход перед сохранением.",
|
||||
"connectOutputBody2": "Переключитесь на шаг «Выбрать» и кликните по выходным узлам, чтобы добавить их сюда.",
|
||||
"filename": "Имя файла",
|
||||
"defaultModeAppliedAppBody": "Этот рабочий процесс теперь будет открываться в режиме приложения по умолчанию.",
|
||||
"defaultModeAppliedAppPrompt": "Хотите просмотреть его сейчас?",
|
||||
"defaultModeAppliedGraphBody": "Этот рабочий процесс теперь будет открываться как граф узлов по умолчанию.",
|
||||
"defaultModeAppliedGraphPrompt": "Хотите всё равно просмотреть приложение?",
|
||||
"defaultModeAppliedTitle": "Успешно установлено",
|
||||
"defaultView": "Установить вид по умолчанию",
|
||||
"defaultViewDescription": "Выберите, как это будет открываться",
|
||||
"defaultViewLabel": "По умолчанию этот рабочий процесс будет открываться как:",
|
||||
"defaultViewTitle": "Установить вид по умолчанию для этого рабочего процесса",
|
||||
"emptyWorkflowExplanation": "Ваш рабочий процесс пуст. Для начала создания приложения добавьте несколько узлов.",
|
||||
"emptyWorkflowPrompt": "Хотите начать с шаблона?",
|
||||
"emptyWorkflowTitle": "В этом рабочем процессе нет узлов",
|
||||
"label": "Конструктор приложений",
|
||||
"loadTemplate": "Загрузить шаблон",
|
||||
"nodeGraph": "Граф узлов",
|
||||
"nodeGraphDescription": "Открывается как граф узлов по умолчанию",
|
||||
"save": "Сохранить",
|
||||
"saveAs": "Сохранить как",
|
||||
"saveAsLabel": "Сохранить этот рабочий процесс как ...",
|
||||
"saveDescription": "Сохранить и завершить",
|
||||
"saveSuccess": "Успешно сохранено",
|
||||
"saveSuccessAppMessage": "«{name}» сохранено. Теперь будет открываться в режиме приложения по умолчанию.",
|
||||
"saveSuccessAppPrompt": "Хотите просмотреть сейчас?",
|
||||
"saveSuccessGraphMessage": "«{name}» сохранено. Теперь будет открываться как граф узлов по умолчанию.",
|
||||
"select": "Выбрать",
|
||||
"selectDescription": "Выберите входы/выходы",
|
||||
"switchToSelect": "Переключиться на выбор",
|
||||
@@ -1334,7 +1339,9 @@
|
||||
"linearMode": {
|
||||
"appModeToolbar": {
|
||||
"appBuilder": "Конструктор приложений",
|
||||
"apps": "Приложения"
|
||||
"apps": "Приложения",
|
||||
"appsEmptyMessage": "Сохранённые приложения появятся здесь.\nНажмите ниже, чтобы создать своё первое приложение.",
|
||||
"enterAppMode": "Войти в режим приложения"
|
||||
},
|
||||
"arrange": {
|
||||
"atLeastOne": "хотя бы один",
|
||||
@@ -1359,13 +1366,16 @@
|
||||
"outputsExample": "Примеры: «Сохранить изображение» или «Сохранить видео»",
|
||||
"promptAddInputs": "Нажмите на параметры узла, чтобы добавить их сюда как входные данные",
|
||||
"promptAddOutputs": "Нажмите на выходные узлы, чтобы добавить их сюда. Это будут сгенерированные результаты.",
|
||||
"title": "Режим конструктора приложений"
|
||||
"title": "Режим конструктора приложений",
|
||||
"unknownWidget": "Виджет не отображается"
|
||||
},
|
||||
"downloadAll": "Скачать всё",
|
||||
"dragAndDropImage": "Перетащите изображение",
|
||||
"enterNodeGraph": "Войти в граф узлов",
|
||||
"giveFeedback": "Оставить отзыв",
|
||||
"graphMode": "Графовый режим",
|
||||
"linearMode": "Режим приложения",
|
||||
"mobileControls": "Редактировать и запустить",
|
||||
"queue": {
|
||||
"clear": "Очистить очередь",
|
||||
"clickToClear": "Нажмите, чтобы очистить очередь"
|
||||
@@ -1373,6 +1383,7 @@
|
||||
"rerun": "Перезапустить",
|
||||
"reuseParameters": "Повторно использовать параметры",
|
||||
"runCount": "Количество запусков:",
|
||||
"viewJob": "Просмотреть задачу",
|
||||
"welcome": {
|
||||
"backToWorkflow": "Назад к рабочему процессу",
|
||||
"buildApp": "Создать приложение",
|
||||
@@ -1749,6 +1760,7 @@
|
||||
"disabled": "Отключено",
|
||||
"disabledTooltip": "Рабочий процесс не будет автоматически помещён в очередь",
|
||||
"execute": "Выполнить",
|
||||
"fullscreen": "Полноэкранный режим",
|
||||
"help": "Справка",
|
||||
"helpAndFeedback": "Помощь и обратная связь",
|
||||
"hideMenu": "Скрыть меню",
|
||||
@@ -1896,7 +1908,8 @@
|
||||
"Workflows": "Рабочие процессы",
|
||||
"Zoom In": "Увеличить",
|
||||
"Zoom Out": "Уменьшить",
|
||||
"Zoom to fit": "Масштабировать по размеру"
|
||||
"Zoom to fit": "Масштабировать по размеру",
|
||||
"linearMode_appModeToolbar_apps": "linearMode.appModeToolbar.apps"
|
||||
},
|
||||
"minimap": {
|
||||
"nodeColors": "Цвета узлов",
|
||||
@@ -3202,7 +3215,9 @@
|
||||
"enterFilename": "Введите название файла",
|
||||
"enterFilenamePrompt": "Введите имя файла:",
|
||||
"exportWorkflow": "Экспорт рабочего процесса",
|
||||
"saveWorkflow": "Сохранить рабочий процесс"
|
||||
"saveWorkflow": "Сохранить рабочий процесс",
|
||||
"savedAsApp": "Преобразовано в рабочий процесс приложения",
|
||||
"savedAsWorkflow": "Преобразовано только в рабочий процесс графа узлов"
|
||||
},
|
||||
"workspace": {
|
||||
"addedToWorkspace": "Вы были добавлены в {workspaceName}",
|
||||
|
||||
@@ -344,6 +344,10 @@
|
||||
"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"
|
||||
|
||||
@@ -326,35 +326,40 @@
|
||||
"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",
|
||||
"saveApp": "Uygulamayı kaydet"
|
||||
"exitAppBuilder": "Uygulama oluşturucudan çık"
|
||||
},
|
||||
"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.",
|
||||
"filename": "Dosya adı",
|
||||
"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",
|
||||
"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ç",
|
||||
@@ -1334,7 +1339,9 @@
|
||||
"linearMode": {
|
||||
"appModeToolbar": {
|
||||
"appBuilder": "Uygulama oluşturucu",
|
||||
"apps": "Uygulamalar"
|
||||
"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"
|
||||
},
|
||||
"arrange": {
|
||||
"atLeastOne": "en az bir",
|
||||
@@ -1359,13 +1366,16 @@
|
||||
"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"
|
||||
"title": "Uygulama oluşturucu modu",
|
||||
"unknownWidget": "Widget görünür değil"
|
||||
},
|
||||
"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"
|
||||
@@ -1373,6 +1383,7 @@
|
||||
"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",
|
||||
@@ -1749,6 +1760,7 @@
|
||||
"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",
|
||||
@@ -1896,7 +1908,8 @@
|
||||
"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"
|
||||
"Zoom to fit": "Sığdırmak için yakınlaştır",
|
||||
"linearMode_appModeToolbar_apps": "linearMode.appModeToolbar.apps"
|
||||
},
|
||||
"minimap": {
|
||||
"nodeColors": "Düğüm Renkleri",
|
||||
@@ -3202,7 +3215,9 @@
|
||||
"enterFilename": "Dosya adını girin",
|
||||
"enterFilenamePrompt": "Dosya adını girin:",
|
||||
"exportWorkflow": "İş Akışını Dışa Aktar",
|
||||
"saveWorkflow": "İş akışını kaydet"
|
||||
"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ü"
|
||||
},
|
||||
"workspace": {
|
||||
"addedToWorkspace": "{workspaceName} çalışma alanına eklendiniz",
|
||||
|
||||
@@ -344,6 +344,10 @@
|
||||
"Workspace_ToggleFocusMode": {
|
||||
"label": "切換專注模式"
|
||||
},
|
||||
"Workspace_ToggleSidebarTab_apps": {
|
||||
"label": "切換應用程式側邊欄",
|
||||
"tooltip": "應用程式"
|
||||
},
|
||||
"Workspace_ToggleSidebarTab_assets": {
|
||||
"label": "切換資源側邊欄",
|
||||
"tooltip": "資源"
|
||||
|
||||
@@ -326,39 +326,44 @@
|
||||
"deleteWorkflow": "刪除工作流程",
|
||||
"duplicate": "複製",
|
||||
"enterAppMode": "進入應用模式",
|
||||
"enterBuilderMode": "進入應用程式建構器",
|
||||
"enterNewName": "輸入新名稱",
|
||||
"exitAppMode": "離開應用模式",
|
||||
"missingNodesWarning": "工作流程包含不支援的節點(以紅色標示)。",
|
||||
"workflowActions": "工作流程操作"
|
||||
},
|
||||
"builderMenu": {
|
||||
"exitAppBuilder": "離開應用程式建構器",
|
||||
"saveApp": "儲存應用程式"
|
||||
"exitAppBuilder": "離開應用程式建構器"
|
||||
},
|
||||
"builderToolbar": {
|
||||
"app": "應用",
|
||||
"appDescription": "預設以應用模式開啟",
|
||||
"arrange": "預覽",
|
||||
"arrangeDescription": "檢視應用版面",
|
||||
"backToWorkflow": "返回工作流程",
|
||||
"connectOutput": "連接輸出",
|
||||
"connectOutputBody1": "您的應用必須至少連接一個輸出才能儲存。",
|
||||
"connectOutputBody2": "切換到「選擇」步驟,點擊輸出節點以新增到這裡。",
|
||||
"filename": "檔案名稱",
|
||||
"defaultModeAppliedAppBody": "此工作流程將從現在起預設以應用程式模式開啟。",
|
||||
"defaultModeAppliedAppPrompt": "您想現在查看嗎?",
|
||||
"defaultModeAppliedGraphBody": "此工作流程將從現在起預設以節點圖形模式開啟。",
|
||||
"defaultModeAppliedGraphPrompt": "您還想查看應用程式嗎?",
|
||||
"defaultModeAppliedTitle": "設定成功",
|
||||
"defaultView": "設定預設檢視",
|
||||
"defaultViewDescription": "選擇開啟方式",
|
||||
"defaultViewLabel": "預設情況下,此工作流程將以以下方式開啟:",
|
||||
"defaultViewTitle": "設定此工作流程的預設檢視",
|
||||
"emptyWorkflowExplanation": "您的工作流程是空的。您需要先新增一些節點才能開始建立應用程式。",
|
||||
"emptyWorkflowPrompt": "您想從範本開始嗎?",
|
||||
"emptyWorkflowTitle": "此工作流程沒有節點",
|
||||
"label": "應用建立器",
|
||||
"loadTemplate": "載入範本",
|
||||
"nodeGraph": "節點圖",
|
||||
"nodeGraphDescription": "預設以節點圖開啟",
|
||||
"save": "儲存",
|
||||
"saveAs": "另存新檔",
|
||||
"saveAsLabel": "將此工作流程另存為...",
|
||||
"saveDescription": "儲存並完成",
|
||||
"saveSuccess": "儲存成功",
|
||||
"saveSuccessAppMessage": "「{name}」已儲存。從現在起將預設以應用模式開啟。",
|
||||
"saveSuccessAppPrompt": "您想現在檢視嗎?",
|
||||
"saveSuccessGraphMessage": "「{name}」已儲存。將預設以節點圖開啟。",
|
||||
"select": "選擇",
|
||||
"selectDescription": "選擇輸入/輸出",
|
||||
"switchToSelect": "切換到選擇",
|
||||
"viewApp": "檢視應用"
|
||||
"viewApp": "查看應用程式"
|
||||
},
|
||||
"clipboard": {
|
||||
"errorMessage": "複製到剪貼簿失敗",
|
||||
@@ -1334,7 +1339,9 @@
|
||||
"linearMode": {
|
||||
"appModeToolbar": {
|
||||
"appBuilder": "應用建立器",
|
||||
"apps": "應用"
|
||||
"apps": "應用",
|
||||
"appsEmptyMessage": "已儲存的應用程式將顯示在這裡。\n點擊下方開始建立您的第一個應用程式。",
|
||||
"enterAppMode": "進入應用程式模式"
|
||||
},
|
||||
"arrange": {
|
||||
"atLeastOne": "至少一個",
|
||||
@@ -1359,13 +1366,16 @@
|
||||
"outputsExample": "例如:「儲存圖像」或「儲存影片」",
|
||||
"promptAddInputs": "點擊節點參數,將其新增為輸入",
|
||||
"promptAddOutputs": "點擊輸出節點,將其新增於此。這些將是產生的結果。",
|
||||
"title": "應用程式建構模式"
|
||||
"title": "應用程式建構模式",
|
||||
"unknownWidget": "元件不可見"
|
||||
},
|
||||
"downloadAll": "全部下載",
|
||||
"dragAndDropImage": "拖曳圖片到此",
|
||||
"enterNodeGraph": "進入節點圖",
|
||||
"giveFeedback": "提供回饋",
|
||||
"graphMode": "圖形模式",
|
||||
"linearMode": "App 模式",
|
||||
"mobileControls": "編輯與執行",
|
||||
"queue": {
|
||||
"clear": "清除佇列",
|
||||
"clickToClear": "點擊以清除佇列"
|
||||
@@ -1373,6 +1383,7 @@
|
||||
"rerun": "重新執行",
|
||||
"reuseParameters": "重用參數",
|
||||
"runCount": "執行次數:",
|
||||
"viewJob": "檢視任務",
|
||||
"welcome": {
|
||||
"backToWorkflow": "返回工作流程",
|
||||
"buildApp": "建立應用",
|
||||
@@ -1749,6 +1760,7 @@
|
||||
"disabled": "已停用",
|
||||
"disabledTooltip": "工作流程將不會自動排入佇列",
|
||||
"execute": "執行",
|
||||
"fullscreen": "全螢幕",
|
||||
"help": "說明",
|
||||
"helpAndFeedback": "說明與回饋",
|
||||
"hideMenu": "隱藏選單",
|
||||
@@ -1896,7 +1908,8 @@
|
||||
"Workflows": "工作流程",
|
||||
"Zoom In": "放大",
|
||||
"Zoom Out": "縮小",
|
||||
"Zoom to fit": "縮放至適合大小"
|
||||
"Zoom to fit": "縮放至適合大小",
|
||||
"linearMode_appModeToolbar_apps": "linearMode.appModeToolbar.apps"
|
||||
},
|
||||
"minimap": {
|
||||
"nodeColors": "節點顏色",
|
||||
@@ -3202,7 +3215,9 @@
|
||||
"enterFilename": "輸入檔案名稱",
|
||||
"enterFilenamePrompt": "請輸入檔案名稱:",
|
||||
"exportWorkflow": "匯出工作流程",
|
||||
"saveWorkflow": "儲存工作流程"
|
||||
"saveWorkflow": "儲存工作流程",
|
||||
"savedAsApp": "已轉換為應用程式工作流程",
|
||||
"savedAsWorkflow": "已轉換為僅節點圖工作流程"
|
||||
},
|
||||
"workspace": {
|
||||
"addedToWorkspace": "你已被加入 {workspaceName}",
|
||||
|
||||
@@ -344,6 +344,10 @@
|
||||
"Workspace_ToggleFocusMode": {
|
||||
"label": "切换焦点模式"
|
||||
},
|
||||
"Workspace_ToggleSidebarTab_apps": {
|
||||
"label": "切换应用侧边栏",
|
||||
"tooltip": "应用"
|
||||
},
|
||||
"Workspace_ToggleSidebarTab_assets": {
|
||||
"label": "切换资产侧边栏",
|
||||
"tooltip": "资产"
|
||||
|
||||
@@ -326,35 +326,40 @@
|
||||
"deleteWorkflow": "删除工作流",
|
||||
"duplicate": "复制",
|
||||
"enterAppMode": "进入应用模式",
|
||||
"enterBuilderMode": "进入应用构建器",
|
||||
"enterNewName": "输入新名称",
|
||||
"exitAppMode": "退出应用模式",
|
||||
"missingNodesWarning": "工作流包含不支持的节点(红色突出显示)。",
|
||||
"workflowActions": "工作流操作"
|
||||
},
|
||||
"builderMenu": {
|
||||
"exitAppBuilder": "退出应用构建器",
|
||||
"saveApp": "保存应用"
|
||||
"exitAppBuilder": "退出应用构建器"
|
||||
},
|
||||
"builderToolbar": {
|
||||
"app": "应用",
|
||||
"appDescription": "默认以应用方式打开",
|
||||
"arrange": "预览",
|
||||
"arrangeDescription": "查看应用布局",
|
||||
"backToWorkflow": "返回工作流",
|
||||
"connectOutput": "连接输出",
|
||||
"connectOutputBody1": "您的应用需要至少连接一个输出,才能保存。",
|
||||
"connectOutputBody2": "切换到“选择”步骤,点击输出节点将其添加到此处。",
|
||||
"filename": "文件名",
|
||||
"defaultModeAppliedAppBody": "此工作流将从现在起默认以应用模式打开。",
|
||||
"defaultModeAppliedAppPrompt": "你想现在查看吗?",
|
||||
"defaultModeAppliedGraphBody": "此工作流将从现在起默认以节点图模式打开。",
|
||||
"defaultModeAppliedGraphPrompt": "你还想查看应用吗?",
|
||||
"defaultModeAppliedTitle": "设置成功",
|
||||
"defaultView": "设置默认视图",
|
||||
"defaultViewDescription": "选择打开方式",
|
||||
"defaultViewLabel": "默认情况下,此工作流将以以下方式打开:",
|
||||
"defaultViewTitle": "为此工作流设置默认视图",
|
||||
"emptyWorkflowExplanation": "你的工作流为空。你需要先添加一些节点来开始构建应用。",
|
||||
"emptyWorkflowPrompt": "要使用模板开始吗?",
|
||||
"emptyWorkflowTitle": "此工作流没有节点",
|
||||
"label": "应用构建器",
|
||||
"loadTemplate": "加载模板",
|
||||
"nodeGraph": "节点图",
|
||||
"nodeGraphDescription": "默认以节点图方式打开",
|
||||
"save": "保存",
|
||||
"saveAs": "另存为",
|
||||
"saveAsLabel": "将此工作流另存为...",
|
||||
"saveDescription": "保存并完成",
|
||||
"saveSuccess": "保存成功",
|
||||
"saveSuccessAppMessage": "“{name}”已保存。今后将默认以应用模式打开。",
|
||||
"saveSuccessAppPrompt": "现在要查看吗?",
|
||||
"saveSuccessGraphMessage": "“{name}”已保存。今后将默认以节点图方式打开。",
|
||||
"select": "选择",
|
||||
"selectDescription": "选择输入/输出",
|
||||
"switchToSelect": "切换到选择",
|
||||
@@ -1334,7 +1339,9 @@
|
||||
"linearMode": {
|
||||
"appModeToolbar": {
|
||||
"appBuilder": "应用构建器",
|
||||
"apps": "应用"
|
||||
"apps": "应用",
|
||||
"appsEmptyMessage": "已保存的应用会显示在这里。\n点击下方开始构建你的第一个应用。",
|
||||
"enterAppMode": "进入应用模式"
|
||||
},
|
||||
"arrange": {
|
||||
"atLeastOne": "至少一个",
|
||||
@@ -1359,13 +1366,16 @@
|
||||
"outputsExample": "示例:“保存图像”或“保存视频”",
|
||||
"promptAddInputs": "点击节点参数,将其添加为输入项",
|
||||
"promptAddOutputs": "点击输出节点,将其添加到此处。这些将作为生成结果。",
|
||||
"title": "应用构建模式"
|
||||
"title": "应用构建模式",
|
||||
"unknownWidget": "组件不可见"
|
||||
},
|
||||
"downloadAll": "全部下载",
|
||||
"dragAndDropImage": "拖拽图片到此处",
|
||||
"enterNodeGraph": "进入节点图",
|
||||
"giveFeedback": "提供反馈",
|
||||
"graphMode": "图形模式",
|
||||
"linearMode": "App 模式",
|
||||
"mobileControls": "编辑与运行",
|
||||
"queue": {
|
||||
"clear": "清空队列",
|
||||
"clickToClear": "点击清空队列"
|
||||
@@ -1373,6 +1383,7 @@
|
||||
"rerun": "重新运行",
|
||||
"reuseParameters": "复用参数",
|
||||
"runCount": "运行次数:",
|
||||
"viewJob": "查看任务",
|
||||
"welcome": {
|
||||
"backToWorkflow": "返回工作流",
|
||||
"buildApp": "构建应用",
|
||||
@@ -1749,6 +1760,7 @@
|
||||
"disabled": "禁用",
|
||||
"disabledTooltip": "工作流将不会自动执行",
|
||||
"execute": "执行",
|
||||
"fullscreen": "全屏",
|
||||
"help": "说明",
|
||||
"helpAndFeedback": "帮助与反馈",
|
||||
"hideMenu": "隐藏菜单",
|
||||
@@ -1896,7 +1908,8 @@
|
||||
"Workflows": "工作流",
|
||||
"Zoom In": "放大画面",
|
||||
"Zoom Out": "缩小画面",
|
||||
"Zoom to fit": "缩放以适应"
|
||||
"Zoom to fit": "缩放以适应",
|
||||
"linearMode_appModeToolbar_apps": "linearMode.appModeToolbar.apps"
|
||||
},
|
||||
"minimap": {
|
||||
"nodeColors": "节点颜色",
|
||||
@@ -3214,7 +3227,9 @@
|
||||
"enterFilename": "输入文件名",
|
||||
"enterFilenamePrompt": "输入文件名:",
|
||||
"exportWorkflow": "导出工作流",
|
||||
"saveWorkflow": "保存工作流"
|
||||
"saveWorkflow": "保存工作流",
|
||||
"savedAsApp": "已转换为应用工作流",
|
||||
"savedAsWorkflow": "已转换为节点图工作流"
|
||||
},
|
||||
"workspace": {
|
||||
"addedToWorkspace": "您已被加入 {workspaceName}",
|
||||
|
||||
@@ -228,9 +228,9 @@ describe('assetMetadataUtils', () => {
|
||||
expected: 'checkpoints'
|
||||
},
|
||||
{
|
||||
name: 'extracts last segment from path-style tags',
|
||||
tags: ['models', 'models/loras'],
|
||||
expected: 'loras'
|
||||
name: 'returns full path for path-style tags',
|
||||
tags: ['models', 'diffusers/Kolors/text_encoder'],
|
||||
expected: 'diffusers/Kolors/text_encoder'
|
||||
},
|
||||
{
|
||||
name: 'returns null when only models tag',
|
||||
|
||||
@@ -138,8 +138,7 @@ export function getSourceName(url: string): string {
|
||||
*/
|
||||
export function getAssetModelType(asset: AssetItem): string | null {
|
||||
const typeTag = asset.tags?.find((tag) => tag && tag !== 'models')
|
||||
if (!typeTag) return null
|
||||
return typeTag.includes('/') ? (typeTag.split('/').pop() ?? null) : typeTag
|
||||
return typeTag ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,7 @@ import { app } from '@/scripts/app'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
|
||||
interface CreateNodeOptions {
|
||||
interface ModelNodeCreateOptions {
|
||||
position?: Point
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ type Result<T, E> = { success: true; value: T } | { success: false; error: E }
|
||||
*/
|
||||
export function createModelNodeFromAsset(
|
||||
asset: AssetItem,
|
||||
options?: CreateNodeOptions
|
||||
options?: ModelNodeCreateOptions
|
||||
): Result<LGraphNode, NodeCreationError> {
|
||||
const validatedAsset = assetItemSchema.safeParse(asset)
|
||||
|
||||
|
||||
@@ -1207,6 +1207,13 @@ 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'],
|
||||
|
||||
@@ -365,14 +365,14 @@ describe('useWorkflowService', () => {
|
||||
})
|
||||
const workflow2 = createModeTestWorkflow({
|
||||
path: 'workflows/two.json',
|
||||
activeMode: 'builder:select'
|
||||
activeMode: 'builder:inputs'
|
||||
})
|
||||
|
||||
workflowStore.activeWorkflow = workflow1
|
||||
expect(appMode.mode.value).toBe('app')
|
||||
|
||||
workflowStore.activeWorkflow = workflow2
|
||||
expect(appMode.mode.value).toBe('builder:select')
|
||||
expect(appMode.mode.value).toBe('builder:inputs')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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:select'
|
||||
activeMode: 'builder:inputs'
|
||||
})
|
||||
const workflow2 = createModeTestWorkflow({
|
||||
path: 'workflows/two.json',
|
||||
@@ -515,13 +515,13 @@ describe('useWorkflowService', () => {
|
||||
})
|
||||
|
||||
workflowStore.activeWorkflow = workflow1
|
||||
expect(appMode.mode.value).toBe('builder:select')
|
||||
expect(appMode.mode.value).toBe('builder:inputs')
|
||||
|
||||
workflowStore.activeWorkflow = workflow2
|
||||
expect(appMode.mode.value).toBe('app')
|
||||
|
||||
workflowStore.activeWorkflow = workflow1
|
||||
expect(appMode.mode.value).toBe('builder:select')
|
||||
expect(appMode.mode.value).toBe('builder:inputs')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { id, name } = defineProps<{
|
||||
id: string
|
||||
isSelectMode: boolean
|
||||
isSelectInputsMode: boolean
|
||||
name: string
|
||||
}>()
|
||||
|
||||
@@ -25,7 +25,7 @@ function togglePromotion() {
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
v-if="isSelectMode"
|
||||
v-if="isSelectInputsMode"
|
||||
class="col-span-2 flex flex-row pointer-events-auto cursor-pointer gap-1 relative"
|
||||
@pointerdown.capture.stop.prevent="togglePromotion"
|
||||
@click.capture.stop.prevent
|
||||
|
||||
@@ -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.switchToSelect') }}</p>
|
||||
<p class="mt-0 p-0">{{ t('linearMode.arrange.switchToOutputs') }}</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:select')">
|
||||
{{ t('linearMode.arrange.switchToSelectButton') }}
|
||||
<Button variant="primary" size="lg" @click="setMode('builder:outputs')">
|
||||
{{ t('linearMode.arrange.switchToOutputsButton') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -200,6 +200,12 @@ 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'
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ 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'
|
||||
@@ -201,6 +202,18 @@ 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)
|
||||
|
||||
@@ -17,6 +17,12 @@ 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(),
|
||||
|
||||
@@ -20,7 +20,9 @@ describe('useMinimapRenderer', () => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockContext = {
|
||||
clearRect: vi.fn()
|
||||
clearRect: vi.fn(),
|
||||
save: vi.fn(),
|
||||
restore: vi.fn()
|
||||
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D
|
||||
|
||||
mockCanvas = {
|
||||
|
||||
@@ -13,6 +13,15 @@ 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(),
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
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
|
||||
*/
|
||||
@@ -11,12 +14,19 @@ 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,
|
||||
@@ -25,7 +35,8 @@ export class LayoutStoreDataSource extends AbstractMinimapDataSource {
|
||||
height: layout.size.height,
|
||||
bgcolor: graphNode?.bgcolor,
|
||||
mode: graphNode?.mode,
|
||||
hasErrors: graphNode?.has_errors
|
||||
hasErrors: graphNode?.has_errors,
|
||||
executionState
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
|
||||
import type { MinimapNodeData } from '../types'
|
||||
import { AbstractMinimapDataSource } from './AbstractMinimapDataSource'
|
||||
|
||||
@@ -8,16 +10,25 @@ export class LiteGraphDataSource extends AbstractMinimapDataSource {
|
||||
getNodes(): MinimapNodeData[] {
|
||||
if (!this.graph?._nodes) return []
|
||||
|
||||
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
|
||||
}))
|
||||
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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
getNodeCount(): number {
|
||||
|
||||
@@ -15,6 +15,13 @@ 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>()
|
||||
|
||||
@@ -22,6 +22,12 @@ 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
|
||||
@@ -42,7 +48,9 @@ describe('minimapCanvasRenderer', () => {
|
||||
fill: vi.fn(),
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 1
|
||||
lineWidth: 1,
|
||||
save: vi.fn(),
|
||||
restore: vi.fn()
|
||||
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D
|
||||
|
||||
mockCanvas = {
|
||||
|
||||
@@ -26,6 +26,8 @@ function getMinimapColors() {
|
||||
groupColorDefault: isLightTheme ? '#283640' : '#B3C1CB',
|
||||
bypassColor: isLightTheme ? '#DBDBDB' : '#4B184B',
|
||||
errorColor: '#FF0000',
|
||||
runningColor: '#00FF00',
|
||||
successColor: '#239B23',
|
||||
isLightTheme
|
||||
}
|
||||
}
|
||||
@@ -103,10 +105,19 @@ 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 }>
|
||||
Array<{
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
hasErrors?: boolean
|
||||
executionState?: 'pending' | 'running' | 'finished' | 'error' | null
|
||||
}>
|
||||
>()
|
||||
|
||||
for (const node of nodes) {
|
||||
@@ -121,7 +132,14 @@ function renderNodes(
|
||||
nodesByColor.set(color, [])
|
||||
}
|
||||
|
||||
nodesByColor.get(color)!.push({ x, y, w, h, hasErrors: node.hasErrors })
|
||||
nodesByColor.get(color)!.push({
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
hasErrors: node.hasErrors,
|
||||
executionState: node.executionState
|
||||
})
|
||||
}
|
||||
|
||||
// Batch render nodes by color
|
||||
@@ -132,18 +150,29 @@ function renderNodes(
|
||||
}
|
||||
}
|
||||
|
||||
// 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.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -80,6 +80,7 @@ export interface MinimapNodeData {
|
||||
bgcolor?: string
|
||||
mode?: number
|
||||
hasErrors?: boolean
|
||||
executionState?: 'pending' | 'running' | 'finished' | 'error' | null
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -51,7 +51,9 @@
|
||||
@drop.stop.prevent="handleDrop"
|
||||
>
|
||||
<AppOutput
|
||||
v-if="lgraphNode?.constructor?.nodeData?.output_node && isSelectMode"
|
||||
v-if="
|
||||
lgraphNode?.constructor?.nodeData?.output_node && isSelectOutputsMode
|
||||
"
|
||||
:id="nodeData.id"
|
||||
/>
|
||||
<div
|
||||
@@ -337,7 +339,7 @@ const { nodeData, error = null } = defineProps<LGraphNodeProps>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { isSelectMode } = useAppMode()
|
||||
const { isSelectMode, isSelectOutputsMode } = useAppMode()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const { handleNodeCollapse, handleNodeTitleUpdate, handleNodeRightClick } =
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
/>
|
||||
</div>
|
||||
<!-- Widget Component -->
|
||||
<AppInput :id="widget.id" :name="widget.name" :is-select-mode>
|
||||
<AppInput :id="widget.id" :name="widget.name" :is-select-inputs-mode>
|
||||
<component
|
||||
:is="widget.vueComponent"
|
||||
v-model="widget.value"
|
||||
@@ -123,7 +123,7 @@ const { nodeData } = defineProps<NodeWidgetsProps>()
|
||||
|
||||
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
|
||||
useCanvasInteractions()
|
||||
const { isSelectMode } = useAppMode()
|
||||
const { isSelectInputsMode } = useAppMode()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { bringNodeToFront } = useNodeZIndex()
|
||||
const promotionStore = usePromotionStore()
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } 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'] = {},
|
||||
@@ -31,6 +39,11 @@ function mountComponent(
|
||||
modelValue,
|
||||
readonly,
|
||||
placeholder
|
||||
},
|
||||
global: {
|
||||
mocks: {
|
||||
$t: (msg: string) => msg
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -190,6 +203,41 @@ 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 () => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'relative rounded-lg focus-within:ring focus-within:ring-component-node-widget-background-highlighted transition-all',
|
||||
'group relative rounded-lg focus-within:ring focus-within:ring-component-node-widget-background-highlighted transition-all',
|
||||
widget.borderStyle
|
||||
)
|
||||
"
|
||||
@@ -33,13 +33,27 @@
|
||||
@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'
|
||||
@@ -58,6 +72,7 @@ const { widget, placeholder = '' } = defineProps<{
|
||||
const modelValue = defineModel<string>({ default: '' })
|
||||
|
||||
const hideLayoutField = useHideLayoutField()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(widget.options, INPUT_EXCLUDED_PROPS)
|
||||
@@ -69,4 +84,8 @@ const id = useId()
|
||||
const isReadOnly = computed(
|
||||
() => widget.options?.read_only ?? widget.options?.disabled ?? false
|
||||
)
|
||||
|
||||
function handleCopy() {
|
||||
copyToClipboard(modelValue.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -56,7 +56,7 @@ const theButtonStyle = computed(() =>
|
||||
<div
|
||||
:class="
|
||||
cn(WidgetInputBaseClass, 'flex text-base leading-none', {
|
||||
'opacity-50 cursor-not-allowed outline-zinc-300/10': disabled
|
||||
'opacity-50 cursor-not-allowed outline-node-component-border': disabled
|
||||
})
|
||||
"
|
||||
>
|
||||
@@ -97,7 +97,7 @@ const theButtonStyle = computed(() =>
|
||||
cn(
|
||||
theButtonStyle,
|
||||
'relative',
|
||||
'size-8 flex justify-center items-center border-l rounded-r-lg border-zinc-300/10'
|
||||
'size-8 flex justify-center items-center border-l rounded-r-lg border-node-component-border'
|
||||
)
|
||||
"
|
||||
>
|
||||
|
||||
@@ -62,13 +62,23 @@ type LayoutConfig = {
|
||||
}
|
||||
|
||||
const LAYOUT_CONFIGS: Record<LayoutMode, LayoutConfig> = {
|
||||
grid: { maxColumns: 4, itemHeight: 120, itemWidth: 89, gap: '1rem 0.5rem' },
|
||||
list: { maxColumns: 1, itemHeight: 64, itemWidth: 380, gap: '0.5rem' },
|
||||
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)'
|
||||
},
|
||||
'list-small': {
|
||||
maxColumns: 1,
|
||||
itemHeight: 40,
|
||||
itemWidth: 380,
|
||||
gap: '0.25rem'
|
||||
gap: 'var(--spacing-1)'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ const baseModelSelected = defineModel<Set<string>>('baseModelSelected', {
|
||||
})
|
||||
|
||||
const actionButtonStyle = cn(
|
||||
'h-8 bg-zinc-500/20 rounded-lg outline outline-1 outline-offset-[-1px] outline-node-component-border transition-all duration-150'
|
||||
'h-8 bg-zinc-500/20 rounded-lg outline-1 -outline-offset-1 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-[-1px] outline-component-node-border'
|
||||
'rounded-lg outline -outline-offset-1 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-[-1px] outline-component-node-border'
|
||||
'rounded-lg outline -outline-offset-1 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-[-1px] outline-component-node-border'
|
||||
'rounded-lg outline -outline-offset-1 outline-component-node-border'
|
||||
)
|
||||
"
|
||||
>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user