Compare commits

..

19 Commits

Author SHA1 Message Date
bymyself
7cabe98c20 fix: remove redundant spread in renderDraggingLink call
renderDraggingLink already applies linkMarkerShape: None internally,
so the spread + override at the call site was unnecessary allocation.

https://github.com/Comfy-Org/ComfyUI_frontend/pull/9405#discussion_r2887883450
2026-03-15 22:16:02 -07:00
bymyself
664ee8fcfc fix: simplify slot iteration with for-of loop
Replace indexed slotArrays loop with idiomatic for-of over the two
concrete slot arrays.

https://github.com/Comfy-Org/ComfyUI_frontend/pull/9405#discussion_r2887926847
2026-03-15 22:15:58 -07:00
bymyself
8f7f4bcc19 fix: reset link render context cache at frame start
Amp-Thread-ID: https://ampcode.com/threads/T-019cbb8f-0894-7504-87c1-057e8818587e
2026-03-04 18:50:27 -08:00
GitHub Action
b0fd4fe4c1 [automated] Apply ESLint and Oxfmt fixes 2026-03-05 01:04:50 +00:00
bymyself
b14d083c5f fix: reduce per-frame allocations in canvas draw loop
Amp-Thread-ID: https://ampcode.com/threads/T-019cbb69-3404-726a-8888-182193115b88
2026-03-04 17:02:14 -08:00
Deep Mehta
0f8473db35 feat: add model type mappings for cloud custom nodes (#9392)
## Summary

Adds model-to-node backlinks in `modelToNodeStore.ts` for all
cloud-deployed custom node models that were missing mappings. Without
these, clicking "Use" on a model in the model browser throws an error.

**17 new backlinks added** covering ~340 models across deployed node
packs:

| Category | Directories | Node | Models |
|----------|-------------|------|--------|
| Vision-Language | LLM/Qwen-VL/* (12 specific paths) | AILab_QwenVL /
AILab_QwenVL_PromptEnhancer | ~186 |
| TTS | qwen-tts/* | FB_Qwen3TTSVoiceClone | ~68 |
| Video | SEEDVR2, liveportrait/*, mimicmotion, rife | various | ~33 |
| Depth | depthanything3 | DownloadAndLoadDepthAnythingV3Model | 7 |
| Segmentation | face_parsing, sam3 | various | 4 |
| Diffusers | diffusers/* (Kolors) | DownloadAndLoadKolorsModel | 16 |
| Other | clip/*, dwpose, onnx, detection, UltraShape, sharp | various |
~26 |

**Key fix:** Replaced the top-level `LLM` fallback with specific
`LLM/Qwen-VL/*` paths. The old fallback incorrectly mapped `LLM/llava-*`
models to `AILab_QwenVL`.

Models without deployed node packs (llava/HyVideo, latentsync, sam3d,
sam3dbody, inpaint, vae_approx) are excluded — those are being removed
from `supported_models.json` in Comfy-Org/cloud#2652.

## Test plan
- [ ] Verify "Use" button works for QwenVL models in model browser
- [ ] Verify "Use" button works for TTS, video, depth, segmentation
models
- [ ] Verify no `No node provider registered for category` errors for
deployed models

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-03-04 16:51:42 -08:00
Kelly Yang
120524faa1 feat(minimap): add node execution status visualization (#9187)
## Summary

Added visual indicators (colored borders) to the MiniMap to display the
real-time execution status (running, executed, or error) of nodes.

## Changes

- **What**: Added visual feedback to the MiniMap to show node execution
states (green for running/executed, red for errors) by integrating with
`useExecutionStore` and updating the canvas renderer.

## Review Focus

Confirmed that relying on the array `.includes()` check for
`executingNodeIds` in the data sources avoids unnecessary `Set`
allocations during frequent redraws.

## Screenshots 

<img width="540" height="446" alt="14949d48035db5c64cceb11f7f7f94a3"
src="https://github.com/user-attachments/assets/cac53a80-9882-43fd-a725-7003fe3fd21a"
/>

<img width="562" height="464" alt="7e922f54dea2cea4e6b66202d2ad0dd3"
src="https://github.com/user-attachments/assets/e178b981-3af0-417f-8e21-a706f192fabf"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9187-feat-minimap-add-node-execution-status-visualization-3126d73d3650816eb7b3ca415cf6a8f1)
by [Unito](https://www.unito.io)
2026-03-04 16:43:12 -08:00
Kelly Yang
fd9e774a29 feat(ui): add copy button to read-only textarea widget on hover (#9331)
## Summary

Added a `copy-to-clipboard` button that appears when hovering over
read-only textarea widgets to improve user experience.

## Changes

- **What**: Added a copy button utilizing `useCopyToClipboard` to
[WidgetTextarea.vue](cci:7://file:///Users/kelly/Documents/comfyui/ComfyUI_frontend/src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.vue:0:0-0:0)
that only displays when the widget is read-only and hovered.

## Screenshots 
<img width="670" height="498" alt="e30362fdc6792f3a955f3415f0f42afb"
src="https://github.com/user-attachments/assets/1b7ec5dc-3733-48b6-9708-6ae56926054a"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9331-feat-ui-add-copy-button-to-read-only-textarea-widget-on-hover-3176d73d36508159a339d567b5c33591)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Terry Jia <terryjia88@gmail.com>
Co-authored-by: Alexander Brown <DrJKL0424@gmail.com>
Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-05 09:35:38 +09:00
Christian Byrne
7b316eb9a2 feat: add statistical significance to perf report with z-score thresholds (#9305)
## Summary

Replace fixed 10%/20% perf delta thresholds with dynamic σ-based
classification using z-scores, eliminating false alarms from naturally
noisy duration metrics (10-17% CV).

## Changes

- **What**:
- Run each perf test 3× (`--repeat-each=3`) and report the mean,
reducing single-run noise
- Download last 5 successful main branch perf artifacts to compute
historical μ/σ per metric
- Replace fixed threshold flags with z-score significance: `⚠️
regression` (z>2), ` neutral/improvement`, `🔇 noisy` (CV>50%)
  - Add collapsible historical variance table (μ, σ, CV) to PR comment
- Graceful cold start: falls back to simple delta table until ≥2
historical runs exist
- New `scripts/perf-stats.ts` module with `computeStats`, `zScore`,
`classifyChange`
  - 18 unit tests for stats functions

- **CI time impact**: ~3 min → ~5-6 min (repeat-each adds ~2 min,
historical download <10s)

## Review Focus

- The `gh api` call in the new "Download historical perf baselines"
step: it queries the last 5 successful push runs on the base branch. The
`gh` CLI is available natively on `ubuntu-latest` runners and
auto-authenticates with `GITHUB_TOKEN`.
- `getHistoricalStats` averages per-run measurements before computing
cross-run σ — this is intentional since historical artifacts may also
contain repeated measurements after this change lands.
- The `noisy` classification (CV>50%) suppresses metrics like `layouts`
that hover near 0 and have meaningless percentage swings.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9305-feat-add-statistical-significance-to-perf-report-with-z-score-thresholds-3156d73d3650818d9360eeafd9ae7dc1)
by [Unito](https://www.unito.io)
2026-03-04 16:16:53 -08:00
Christian Byrne
b8edb11ac1 feat: add eslint-plugin-better-tailwindcss for Tailwind v4 linting (#9245)
## Summary

Add `eslint-plugin-better-tailwindcss` to the ESLint toolchain for
Tailwind CSS v4 class linting.

## Changes

- **What**: Integrate `eslint-plugin-better-tailwindcss` (v4.3.1) with
the recommended config, pointed at the design-system CSS entry point for
v4 theme resolution. Five rules are enabled initially:
`enforce-canonical-classes`, `no-deprecated-classes`,
`no-conflicting-classes`, `no-duplicate-classes`,
`no-unnecessary-whitespace`. Three rules are disabled pending follow-up:
`no-unknown-classes` (needs PrimeIcon/custom class whitelisting),
`enforce-consistent-line-wrapping` (oxfmt conflict risk),
`enforce-consistent-class-order` (large batch change).
- **Dependencies**: `eslint-plugin-better-tailwindcss` ^4.3.1
- Fix conflicting `outline outline-1` classes in
`FormDropdownMenuActions.vue` (caught by the new
`no-conflicting-classes` rule).

## Review Focus

- Is the rule severity/enablement strategy appropriate for incremental
adoption?
- The 700 warnings (mostly `enforce-canonical-classes` and
`no-deprecated-classes`) are all auto-fixable via `eslint --fix` —
should we batch-fix them in this PR or a follow-up?

Fixes COM-15518

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9245-feat-add-eslint-plugin-better-tailwindcss-for-Tailwind-v4-linting-3136d73d365081df8a64dd55962d073f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-04 15:34:23 -08:00
AustinMroz
57a919fad2 Split selection into an inputs and outputs step (#9362)
When building an app, selecting inputs and selecting outputs are now 2
separate steps. This prevents confusion where clicking on the widget of
an output node will select that widget instead of the entire output.

<img width="1673" height="773" alt="image"
src="https://github.com/user-attachments/assets/e5994479-6fcf-4572-b58b-bf8cecfb7d55"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9362-Split-selection-into-an-inputs-and-outputs-step-3196d73d36508187b4a1e51c73f1c54c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-04 15:18:16 -08:00
Johnpaul Chiwetelu
316a05c77f fix: replace hardcoded styles with design tokens and cache StatusBadge variants (#9349)
## Summary

Replace hardcoded color and spacing values with semantic design tokens
and cache a computed variant class in StatusBadge.

## Changes

- **What**: Use Tailwind 4 CSS spacing variables in FormDropdownMenu
layout configs, replace zinc color utilities with semantic
`node-component-border` tokens in FormDropdownInput, wrap
`statusBadgeVariants()` in a `computed` for caching in StatusBadge.

## Review Focus

Straightforward token replacements and a computed caching change -- no
behavioral differences expected.

Fixes #9087
Fixes #9086
Fixes #7910

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9349-fix-replace-hardcoded-styles-with-design-tokens-and-cache-StatusBadge-variants-3186d73d36508185aae2e0753c9d1694)
by [Unito](https://www.unito.io)
2026-03-04 14:23:47 -08:00
Comfy Org PR Bot
4b70ca298a 1.41.11 (#9361)
Patch version increment to 1.41.11

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9361-1-41-11-3196d73d365081cc9b9ef730251b07b4)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-04 14:22:12 -08:00
Benjamin Lu
1cee6272c1 fix: add run progress toggle to job history menu (#9176)
Summary
- Add hidden setting `Comfy.Queue.ShowRunProgressBar` (default `true`).
- Add `Show run progress bar` toggle to the shared `...` job history
menu (`JobHistoryActionsMenu`), placed next to `Docked Job History`.
- Use that setting to control both the inline run progress bar and the
inline summary text under it.
- Keep queue button right-click context menu focused on queue actions.
- Add/update tests for the new toggle behavior and summary visibility.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9176-fix-add-run-progress-toggle-to-job-history-menu-3116d73d365081118202d8d67a857367)
by [Unito](https://www.unito.io)
2026-03-04 14:15:11 -08:00
Christian Byrne
bcc470642f fix: cache canvas cursor style to avoid redundant DOM writes (#9171)
## Summary

Cache `canvas.style.cursor` to avoid redundant DOM writes that dirty
Firefox's style tree.

## Changes

- **What**: Add `_lastCursor` field to
`LGraphCanvas._updateCursorStyle()` — only writes `canvas.style.cursor`
when the value changes. Eliminates ~347 redundant style mutations per
profiling session.

## Review Focus

- The fix is 2 lines (cache field + comparison). The unit test validates
the caching pattern without requiring full LGraphCanvas instantiation.
- This is one of several contributors to Firefox's cascading style
recalculation freeze. Each `canvas.style.cursor` write dirties the style
tree, which is flushed during the next paint in the canvas render loop.

## Stack

2 of 4 in Firefox perf fix stack. Depends on #9170.

<!-- Fixes #ISSUE_NUMBER -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9171-fix-cache-canvas-cursor-style-to-avoid-redundant-DOM-writes-3116d73d36508139827fe1d644fa1bd0)
by [Unito](https://www.unito.io)
2026-03-04 14:06:31 -08:00
Johnpaul Chiwetelu
df712953a3 [fix] Replace eval() with safe math expression parser (#9263)
## Summary
Replace `eval()` in `evaluateInput()` with a custom recursive descent
math parser, eliminating a security concern and enabling the `no-eval`
lint rule.

## Changes
- **New**: `mathParser.ts` — recursive descent parser for `+`, `-`, `*`,
`/`, `%`, `()`, decimals, unary operators. Zero new dependencies.
- **Modified**: `widget.ts` — replaced `eval()` call with
`evaluateMathExpression()`, use `isFinite()` instead of `isNaN()` to
reject `Infinity`
- **Modified**: `.oxlintrc.json` — `no-eval` rule changed from `"off"`
to `"error"`
- **Tests**: 59 parser tests + 23 integration tests covering complex
expressions, edge cases, and invalid input

## Review Feedback Addressed
- Renamed `unit()` → `primary()` for clarity
- Added modulo (`%`) operator support
- Normalized negative zero to positive zero
- Added depth limit (200) for nested parentheses
- Used `isFinite()` instead of `isNaN()` to reject
`Infinity`/`-Infinity`
- Added tests for edge-case number formats, unary-after-binary
operators, modulo, depth limits, scientific/hex notation, and `Infinity`

Fixes #8032
Fixes #9272
Fixes #9273
Fixes #9274
Fixes #9275

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9263-fix-Replace-eval-with-safe-math-expression-parser-3136d73d3650812f9f8dea21d1ea4f06)
by [Unito](https://www.unito.io)
2026-03-04 14:04:37 -08:00
Johnpaul Chiwetelu
82750d629d [refactor] Type createNode options parameter (#9262)
## Summary
Narrow `CreateNodeOptions` from `Partial<Omit<LGraphNode, ...>>`
(exposing hundreds of properties/methods) to an explicit interface
listing only creation-time properties.

## Changes
- Replace `Partial<Omit<LGraphNode, 'constructor' | 'inputs' |
'outputs'>>` with explicit `CreateNodeOptions` interface containing
only: `pos`, `size`, `properties`, `flags`, `mode`, `color`, `bgcolor`,
`boxcolor`, `title`, `shape`, `inputs`, `outputs`
- Rename local `CreateNodeOptions` in `createModelNodeFromAsset.ts` to
`ModelNodeCreateOptions` to avoid collision

## Ecosystem verification
GitHub code search across ~50 repos confirms only `pos` and `outputs`
are used externally. All covered by the narrowed interface.

Fixes #9276
Fixes #4740
2026-03-04 14:01:18 -08:00
jaeone94
9e2299ca65 feat(error-groups): sort execution error cards by node execution ID (#9334)
## Summary

Sort execution error cards within each error group by their node
execution ID in ascending numeric order, ensuring consistent and
predictable display order.

## Changes

- **What**: Added `compareExecutionId` utility to
`src/types/nodeIdentification.ts` that splits node IDs on `:` and
compares segments numerically left-to-right; applied it as a sort
comparator when building `ErrorGroup.cards` in `useErrorGroups.ts`

## Review Focus

- The comparison treats missing segments as `0`, so `"1"` sorts before
`"1:20"` (subgraph nodes follow their parent); confirm this ordering
matches user expectations
- All comparisons are purely numeric — non-numeric segment values would
sort as `NaN` (treated as `0`)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9334-feat-error-groups-sort-execution-error-cards-by-node-execution-ID-3176d73d365081e1b3e4e4fa8831fe16)
by [Unito](https://www.unito.io)
2026-03-04 13:59:58 -08:00
Christian Byrne
69076f35f8 test: add subgraph workflow round to performance tests (#9306)
## Summary

Add subgraph workflow performance tests to track style recalculations
and layout thrashing for nested subgraph workflows.

## Changes

- **What**: Add 3 new perf test cases (`subgraph-idle`,
`subgraph-mouse-sweep`, `subgraph-dom-widget-clipping`) that mirror the
existing default workflow tests but load the `subgraphs/nested-subgraph`
workflow. The existing perfReporter pipeline automatically picks up the
new measurements.

## Review Focus

The new tests are structurally identical to the existing 3 default
workflow tests — only the workflow loaded and measurement names differ.
No CI or config changes needed.

Fixes
https://www.notion.so/comfy-org/Implement-Add-subgraph-workflow-round-in-performance-testing-process-3156d73d365081d094efdee58215e15b

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9306-test-add-subgraph-workflow-round-to-performance-tests-3156d73d36508133b85cc53a748bc75f)
by [Unito](https://www.unito.io)
2026-03-04 13:38:48 -08:00
115 changed files with 2646 additions and 906 deletions

View File

@@ -45,7 +45,7 @@ jobs:
- name: Run performance tests
id: perf
continue-on-error: true
run: pnpm exec playwright test --project=performance --workers=1
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

View File

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

View File

@@ -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 } = {}

View File

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

View File

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

View File

@@ -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

View File

@@ -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
View File

@@ -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

View File

@@ -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

View File

@@ -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
View 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
View 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'
}

View File

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

View File

@@ -296,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')

View File

@@ -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

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

View File

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

View File

@@ -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>

View File

@@ -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 () => {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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()

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

View File

@@ -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>

View File

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

View File

@@ -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', () => {

View File

@@ -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) => {

View File

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

View File

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

View File

@@ -17,13 +17,13 @@ export const useNodePaste = <T>(
) => {
const { onPaste, fileFilter = () => true, allow_batch = false } = options
node.pasteFiles = 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
}
}

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

View File

@@ -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,

View File

@@ -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([])
})
})

View File

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

View File

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

View File

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

View File

@@ -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
}
/**

View File

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

View File

@@ -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(

View File

@@ -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

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

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

View File

@@ -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

View File

@@ -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'

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

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

View File

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

View File

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

View File

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

View File

@@ -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}",

View File

@@ -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"

View File

@@ -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",

View File

@@ -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"

View File

@@ -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}",

View File

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

View File

@@ -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} اضافه شدید",

View File

@@ -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"

View File

@@ -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 souvrira désormais par défaut en mode Application.",
"defaultModeAppliedAppPrompt": "Voulez-vous le voir maintenant ?",
"defaultModeAppliedGraphBody": "Ce workflow souvrira désormais par défaut sous forme de graphe de nœuds.",
"defaultModeAppliedGraphPrompt": "Voulez-vous tout de même voir lapplication ?",
"defaultModeAppliedTitle": "Défini avec succès",
"defaultView": "Définir une vue par défaut",
"defaultViewDescription": "Choisissez comment cela s'ouvre",
"defaultViewLabel": "Par défaut, ce workflow s'ouvrira comme :",
"defaultViewTitle": "Définir la vue par défaut pour ce workflow",
"emptyWorkflowExplanation": "Votre workflow est vide. Vous devez dabord ajouter des nœuds pour commencer à créer une application.",
"emptyWorkflowPrompt": "Voulez-vous commencer avec un modèle ?",
"emptyWorkflowTitle": "Ce workflow ne contient aucun nœud",
"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 lapplication"
},
"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 limage » ou « Enregistrer la vidéo »",
"promptAddInputs": "Cliquez sur les paramètres du nœud pour les ajouter ici comme entrées",
"promptAddOutputs": "Cliquez sur les nœuds de sortie pour les ajouter ici. Ce seront les résultats générés.",
"title": "Mode créateur dapplication"
"title": "Mode créateur dapplication",
"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 dexé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}",

View File

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

View File

@@ -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}に追加されました",

View File

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

View File

@@ -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} 워크스페이스에 추가되었습니다",

View File

@@ -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"

View File

@@ -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}",

View File

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

View File

@@ -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}",

View File

@@ -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"

View File

@@ -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",

View File

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

View File

@@ -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}",

View File

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

View File

@@ -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}",

View File

@@ -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',

View File

@@ -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
}
/**

View File

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

View File

@@ -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'],

View File

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

View File

@@ -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

View File

@@ -37,7 +37,7 @@ const { hasOutputs } = storeToRefs(useAppModeStore())
</p>
<div class="flex flex-col gap-1 text-muted-foreground w-lg text-[14px]">
<p class="mt-0 p-0">{{ t('linearMode.arrange.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>

View File

@@ -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'

View File

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

View File

@@ -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(),

View File

@@ -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 = {

View File

@@ -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(),

View File

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

View File

@@ -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 {

View File

@@ -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>()

View File

@@ -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 = {

View File

@@ -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()
}
/**

View File

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

View File

@@ -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 } =

View File

@@ -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()

View File

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

View File

@@ -1,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 () => {

View File

@@ -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>

View File

@@ -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'
)
"
>

View File

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

View File

@@ -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