Compare commits
2 Commits
ever-prese
...
refactor/e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
160c615bc4 | ||
|
|
eb61c0bb4d |
110
.github/workflows/ci-perf-report.yaml
vendored
@@ -1,110 +0,0 @@
|
||||
name: 'CI: Performance Report'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, core/*]
|
||||
paths-ignore: ['**/*.md']
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
paths-ignore: ['**/*.md']
|
||||
|
||||
concurrency:
|
||||
group: perf-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
perf-tests:
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.12
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
with:
|
||||
include_build_step: true
|
||||
|
||||
- name: Start ComfyUI server
|
||||
uses: ./.github/actions/start-comfyui-server
|
||||
|
||||
- name: Run performance tests
|
||||
id: perf
|
||||
continue-on-error: true
|
||||
run: pnpm exec playwright test --project=performance --workers=1
|
||||
|
||||
- name: Upload perf metrics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: perf-metrics
|
||||
path: test-results/perf-metrics.json
|
||||
retention-days: 30
|
||||
if-no-files-found: warn
|
||||
|
||||
report:
|
||||
needs: perf-tests
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Download PR perf metrics
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: perf-metrics
|
||||
path: test-results/
|
||||
|
||||
- name: Download baseline perf metrics
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
branch: ${{ github.event.pull_request.base.ref }}
|
||||
workflow: ci-perf-report.yaml
|
||||
event: push
|
||||
name: perf-metrics
|
||||
path: temp/perf-baseline/
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Generate perf report
|
||||
run: npx --yes tsx scripts/perf-report.ts > perf-report.md
|
||||
|
||||
- name: Read perf report
|
||||
id: perf-report
|
||||
uses: juliangruber/read-file-action@b549046febe0fe86f8cb4f93c24e284433f9ab58 # v1.1.7
|
||||
with:
|
||||
path: ./perf-report.md
|
||||
|
||||
- name: Create or update PR comment
|
||||
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
number: ${{ github.event.pull_request.number }}
|
||||
body: |
|
||||
${{ steps.perf-report.outputs.content }}
|
||||
<!-- COMFYUI_FRONTEND_PERF -->
|
||||
body-include: '<!-- COMFYUI_FRONTEND_PERF -->'
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
} from './components/SidebarTab'
|
||||
import { Topbar } from './components/Topbar'
|
||||
import { CanvasHelper } from './helpers/CanvasHelper'
|
||||
import { PerformanceHelper } from './helpers/PerformanceHelper'
|
||||
import { ClipboardHelper } from './helpers/ClipboardHelper'
|
||||
import { CommandHelper } from './helpers/CommandHelper'
|
||||
import { DragDropHelper } from './helpers/DragDropHelper'
|
||||
@@ -186,7 +185,6 @@ export class ComfyPage {
|
||||
public readonly dragDrop: DragDropHelper
|
||||
public readonly command: CommandHelper
|
||||
public readonly bottomPanel: BottomPanel
|
||||
public readonly perf: PerformanceHelper
|
||||
|
||||
/** Worker index to test user ID */
|
||||
public readonly userIds: string[] = []
|
||||
@@ -231,7 +229,6 @@ export class ComfyPage {
|
||||
this.dragDrop = new DragDropHelper(page, this.assetPath.bind(this))
|
||||
this.command = new CommandHelper(page)
|
||||
this.bottomPanel = new BottomPanel(page)
|
||||
this.perf = new PerformanceHelper(page)
|
||||
}
|
||||
|
||||
get visibleToasts() {
|
||||
@@ -439,13 +436,7 @@ export const comfyPageFixture = base.extend<{
|
||||
}
|
||||
|
||||
await comfyPage.setup()
|
||||
|
||||
const isPerf = testInfo.tags.includes('@perf')
|
||||
if (isPerf) await comfyPage.perf.init()
|
||||
|
||||
await use(comfyPage)
|
||||
|
||||
if (isPerf) await comfyPage.perf.dispose()
|
||||
},
|
||||
comfyMouse: async ({ comfyPage }, use) => {
|
||||
const comfyMouse = new ComfyMouse(comfyPage)
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import type { CDPSession, Page } from '@playwright/test'
|
||||
|
||||
interface PerfSnapshot {
|
||||
RecalcStyleCount: number
|
||||
RecalcStyleDuration: number
|
||||
LayoutCount: number
|
||||
LayoutDuration: number
|
||||
TaskDuration: number
|
||||
JSHeapUsedSize: number
|
||||
Timestamp: number
|
||||
}
|
||||
|
||||
export interface PerfMeasurement {
|
||||
name: string
|
||||
durationMs: number
|
||||
styleRecalcs: number
|
||||
styleRecalcDurationMs: number
|
||||
layouts: number
|
||||
layoutDurationMs: number
|
||||
taskDurationMs: number
|
||||
heapDeltaBytes: number
|
||||
}
|
||||
|
||||
export class PerformanceHelper {
|
||||
private cdp: CDPSession | null = null
|
||||
private snapshot: PerfSnapshot | null = null
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async init(): Promise<void> {
|
||||
this.cdp = await this.page.context().newCDPSession(this.page)
|
||||
await this.cdp.send('Performance.enable')
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.snapshot = null
|
||||
if (this.cdp) {
|
||||
try {
|
||||
await this.cdp.send('Performance.disable')
|
||||
} finally {
|
||||
await this.cdp.detach()
|
||||
this.cdp = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async getSnapshot(): Promise<PerfSnapshot> {
|
||||
if (!this.cdp) throw new Error('PerformanceHelper not initialized')
|
||||
const { metrics } = (await this.cdp.send('Performance.getMetrics')) as {
|
||||
metrics: { name: string; value: number }[]
|
||||
}
|
||||
function get(name: string): number {
|
||||
return metrics.find((m) => m.name === name)?.value ?? 0
|
||||
}
|
||||
return {
|
||||
RecalcStyleCount: get('RecalcStyleCount'),
|
||||
RecalcStyleDuration: get('RecalcStyleDuration'),
|
||||
LayoutCount: get('LayoutCount'),
|
||||
LayoutDuration: get('LayoutDuration'),
|
||||
TaskDuration: get('TaskDuration'),
|
||||
JSHeapUsedSize: get('JSHeapUsedSize'),
|
||||
Timestamp: get('Timestamp')
|
||||
}
|
||||
}
|
||||
|
||||
async startMeasuring(): Promise<void> {
|
||||
if (this.snapshot) {
|
||||
throw new Error(
|
||||
'Measurement already in progress — call stopMeasuring() first'
|
||||
)
|
||||
}
|
||||
this.snapshot = await this.getSnapshot()
|
||||
}
|
||||
|
||||
async stopMeasuring(name: string): Promise<PerfMeasurement> {
|
||||
if (!this.snapshot) throw new Error('Call startMeasuring() first')
|
||||
const after = await this.getSnapshot()
|
||||
const before = this.snapshot
|
||||
this.snapshot = null
|
||||
|
||||
function delta(key: keyof PerfSnapshot): number {
|
||||
return after[key] - before[key]
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
durationMs: delta('Timestamp') * 1000,
|
||||
styleRecalcs: delta('RecalcStyleCount'),
|
||||
styleRecalcDurationMs: delta('RecalcStyleDuration') * 1000,
|
||||
layouts: delta('LayoutCount'),
|
||||
layoutDurationMs: delta('LayoutDuration') * 1000,
|
||||
taskDurationMs: delta('TaskDuration') * 1000,
|
||||
heapDeltaBytes: delta('JSHeapUsedSize')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
import type { FullConfig } from '@playwright/test'
|
||||
import dotenv from 'dotenv'
|
||||
|
||||
import { writePerfReport } from './helpers/perfReporter'
|
||||
import { restorePath } from './utils/backupUtils'
|
||||
|
||||
dotenv.config()
|
||||
|
||||
export default function globalTeardown(_config: FullConfig) {
|
||||
writePerfReport()
|
||||
|
||||
if (!process.env.CI && process.env.TEST_COMFYUI_DIR) {
|
||||
restorePath([process.env.TEST_COMFYUI_DIR, 'user'])
|
||||
restorePath([process.env.TEST_COMFYUI_DIR, 'models'])
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
import type { PerfMeasurement } from '../fixtures/helpers/PerformanceHelper'
|
||||
|
||||
export interface PerfReport {
|
||||
timestamp: string
|
||||
gitSha: string
|
||||
branch: string
|
||||
measurements: PerfMeasurement[]
|
||||
}
|
||||
|
||||
const TEMP_DIR = join('test-results', 'perf-temp')
|
||||
|
||||
export function recordMeasurement(m: PerfMeasurement) {
|
||||
mkdirSync(TEMP_DIR, { recursive: true })
|
||||
const filename = `${m.name}-${Date.now()}.json`
|
||||
writeFileSync(join(TEMP_DIR, filename), JSON.stringify(m))
|
||||
}
|
||||
|
||||
export function writePerfReport(
|
||||
gitSha = process.env.GITHUB_SHA ?? 'local',
|
||||
branch = process.env.GITHUB_HEAD_REF ?? 'local'
|
||||
) {
|
||||
if (!readdirSync('test-results', { withFileTypes: true }).length) return
|
||||
|
||||
let tempFiles: string[]
|
||||
try {
|
||||
tempFiles = readdirSync(TEMP_DIR).filter((f) => f.endsWith('.json'))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (tempFiles.length === 0) return
|
||||
|
||||
const measurements: PerfMeasurement[] = tempFiles.map((f) =>
|
||||
JSON.parse(readFileSync(join(TEMP_DIR, f), 'utf-8'))
|
||||
)
|
||||
|
||||
const report: PerfReport = {
|
||||
timestamp: new Date().toISOString(),
|
||||
gitSha,
|
||||
branch,
|
||||
measurements
|
||||
}
|
||||
writeFileSync(
|
||||
join('test-results', 'perf-metrics.json'),
|
||||
JSON.stringify(report, null, 2)
|
||||
)
|
||||
}
|
||||
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 93 KiB |
@@ -1,70 +0,0 @@
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { recordMeasurement } from '../helpers/perfReporter'
|
||||
|
||||
test.describe('Performance', { tag: ['@perf'] }, () => {
|
||||
test('canvas idle style recalculations', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.perf.startMeasuring()
|
||||
|
||||
// Let the canvas idle for 2 seconds — no user interaction.
|
||||
// Measures baseline style recalcs from reactive state + render loop.
|
||||
for (let i = 0; i < 120; i++) {
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
const m = await comfyPage.perf.stopMeasuring('canvas-idle')
|
||||
recordMeasurement(m)
|
||||
console.log(
|
||||
`Canvas idle: ${m.styleRecalcs} style recalcs, ${m.layouts} layouts`
|
||||
)
|
||||
})
|
||||
|
||||
test('canvas mouse interaction style recalculations', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.perf.startMeasuring()
|
||||
|
||||
const canvas = comfyPage.canvas
|
||||
const box = await canvas.boundingBox()
|
||||
if (!box) throw new Error('Canvas bounding box not available')
|
||||
|
||||
// Sweep mouse across the canvas — crosses nodes, empty space, slots
|
||||
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('canvas-mouse-sweep')
|
||||
recordMeasurement(m)
|
||||
console.log(
|
||||
`Mouse sweep: ${m.styleRecalcs} style recalcs, ${m.layouts} layouts`
|
||||
)
|
||||
})
|
||||
|
||||
test('DOM widget clipping during node selection', async ({ comfyPage }) => {
|
||||
// Load default workflow which has DOM widgets (text inputs, combos)
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.perf.startMeasuring()
|
||||
|
||||
// Select and deselect nodes rapidly to trigger clipping recalculation
|
||||
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++) {
|
||||
// Click on canvas area (nodes occupy various positions)
|
||||
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('dom-widget-clipping')
|
||||
recordMeasurement(m)
|
||||
console.log(`Clipping: ${m.layouts} forced layouts`)
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 94 KiB |
@@ -22,9 +22,7 @@ const extraFileExtensions = ['.vue']
|
||||
|
||||
const commonGlobals = {
|
||||
...globals.browser,
|
||||
__COMFYUI_FRONTEND_VERSION__: 'readonly',
|
||||
__DISTRIBUTION__: 'readonly',
|
||||
__IS_NIGHTLY__: 'readonly'
|
||||
__COMFYUI_FRONTEND_VERSION__: 'readonly'
|
||||
} as const
|
||||
|
||||
const settings = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.41.7",
|
||||
"version": "1.41.5",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -36,18 +36,7 @@ export default defineConfig({
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
timeout: 15000,
|
||||
grepInvert: /@mobile|@perf/ // Run all tests except those tagged with @mobile or @perf
|
||||
},
|
||||
|
||||
{
|
||||
name: 'performance',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
trace: 'retain-on-failure'
|
||||
},
|
||||
timeout: 60_000,
|
||||
grep: /@perf/,
|
||||
fullyParallel: false
|
||||
grepInvert: /@mobile/ // Run all tests except those tagged with @mobile
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
|
||||
interface PerfMeasurement {
|
||||
name: string
|
||||
durationMs: number
|
||||
styleRecalcs: number
|
||||
styleRecalcDurationMs: number
|
||||
layouts: number
|
||||
layoutDurationMs: number
|
||||
taskDurationMs: number
|
||||
heapDeltaBytes: number
|
||||
}
|
||||
|
||||
interface PerfReport {
|
||||
timestamp: string
|
||||
gitSha: string
|
||||
branch: string
|
||||
measurements: PerfMeasurement[]
|
||||
}
|
||||
|
||||
const CURRENT_PATH = 'test-results/perf-metrics.json'
|
||||
const BASELINE_PATH = 'temp/perf-baseline/perf-metrics.json'
|
||||
|
||||
function formatDelta(pct: number): string {
|
||||
if (pct >= 20) return `+${pct.toFixed(0)}% 🔴`
|
||||
if (pct >= 10) return `+${pct.toFixed(0)}% 🟠`
|
||||
if (pct > -10) return `${pct >= 0 ? '+' : ''}${pct.toFixed(0)}% ⚪`
|
||||
return `${pct.toFixed(0)}% 🟢`
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (Math.abs(bytes) < 1024) return `${bytes} B`
|
||||
if (Math.abs(bytes) < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
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 }
|
||||
}
|
||||
return current > 0 ? { pct: Infinity, isNew: true } : { pct: 0, isNew: false }
|
||||
}
|
||||
|
||||
function formatDeltaCell(delta: { pct: number; isNew: boolean }): string {
|
||||
return delta.isNew ? 'new 🔴' : formatDelta(delta.pct)
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (!existsSync(CURRENT_PATH)) {
|
||||
process.stdout.write(
|
||||
'## ⚡ Performance Report\n\nNo perf metrics found. Perf tests may not have run.\n'
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const current: PerfReport = JSON.parse(readFileSync(CURRENT_PATH, 'utf-8'))
|
||||
|
||||
const baseline: PerfReport | null = existsSync(BASELINE_PATH)
|
||||
? JSON.parse(readFileSync(BASELINE_PATH, 'utf-8'))
|
||||
: null
|
||||
|
||||
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)} |`
|
||||
)
|
||||
}
|
||||
} 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('\n<details><summary>Raw data</summary>\n')
|
||||
lines.push('```json')
|
||||
lines.push(JSON.stringify(current, null, 2))
|
||||
lines.push('```')
|
||||
lines.push('\n</details>')
|
||||
|
||||
process.stdout.write(lines.join('\n') + '\n')
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -51,6 +51,7 @@ onMounted(() => {
|
||||
// See: https://vite.dev/guide/build#load-error-handling
|
||||
window.addEventListener('vite:preloadError', (event) => {
|
||||
event.preventDefault()
|
||||
// eslint-disable-next-line no-undef
|
||||
if (__DISTRIBUTION__ === 'cloud') {
|
||||
captureException(event.payload, {
|
||||
tags: { error_type: 'vite_preload_error' }
|
||||
|
||||
@@ -18,14 +18,14 @@
|
||||
<Splitter
|
||||
:key="splitterRefreshKey"
|
||||
class="bg-transparent pointer-events-none border-none flex-1 overflow-hidden"
|
||||
:state-key="isSelectMode ? 'builder-splitter' : sidebarStateKey"
|
||||
:state-key="sidebarStateKey"
|
||||
state-storage="local"
|
||||
@resizestart="onResizestart"
|
||||
>
|
||||
<!-- First panel: sidebar when left, properties when right -->
|
||||
<SplitterPanel
|
||||
v-if="
|
||||
!focusMode && (sidebarLocation === 'left' || showOffsideSplitter)
|
||||
!focusMode && (sidebarLocation === 'left' || rightSidePanelVisible)
|
||||
"
|
||||
:class="
|
||||
sidebarLocation === 'left'
|
||||
@@ -35,10 +35,8 @@
|
||||
)
|
||||
: 'bg-comfy-menu-bg pointer-events-auto'
|
||||
"
|
||||
:min-size="
|
||||
sidebarLocation === 'left' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:min-size="sidebarLocation === 'left' ? 10 : 15"
|
||||
:size="20"
|
||||
:style="firstPanelStyle"
|
||||
:role="sidebarLocation === 'left' ? 'complementary' : undefined"
|
||||
:aria-label="
|
||||
@@ -56,7 +54,7 @@
|
||||
</SplitterPanel>
|
||||
|
||||
<!-- Main panel (always present) -->
|
||||
<SplitterPanel :size="CENTER_PANEL_SIZE" class="flex flex-col">
|
||||
<SplitterPanel :size="80" class="flex flex-col">
|
||||
<slot name="topmenu" :sidebar-panel-visible />
|
||||
|
||||
<Splitter
|
||||
@@ -87,7 +85,7 @@
|
||||
<!-- Last panel: properties when left, sidebar when right -->
|
||||
<SplitterPanel
|
||||
v-if="
|
||||
!focusMode && (sidebarLocation === 'right' || showOffsideSplitter)
|
||||
!focusMode && (sidebarLocation === 'right' || rightSidePanelVisible)
|
||||
"
|
||||
:class="
|
||||
sidebarLocation === 'right'
|
||||
@@ -97,10 +95,8 @@
|
||||
)
|
||||
: 'bg-comfy-menu-bg pointer-events-auto'
|
||||
"
|
||||
:min-size="
|
||||
sidebarLocation === 'right' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:min-size="sidebarLocation === 'right' ? 10 : 15"
|
||||
:size="20"
|
||||
:style="lastPanelStyle"
|
||||
:role="sidebarLocation === 'right' ? 'complementary' : undefined"
|
||||
:aria-label="
|
||||
@@ -127,13 +123,6 @@ import SplitterPanel from 'primevue/splitterpanel'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import {
|
||||
BUILDER_MIN_SIZE,
|
||||
CENTER_PANEL_SIZE,
|
||||
SIDEBAR_MIN_SIZE,
|
||||
SIDE_PANEL_SIZE
|
||||
} from '@/constants/splitterConstants'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
@@ -155,13 +144,9 @@ const unifiedWidth = computed(() =>
|
||||
|
||||
const { focusMode } = storeToRefs(workspaceStore)
|
||||
|
||||
const { isSelectMode } = useAppMode()
|
||||
const { activeSidebarTabId, activeSidebarTab } = storeToRefs(sidebarTabStore)
|
||||
const { bottomPanelVisible } = storeToRefs(useBottomPanelStore())
|
||||
const { isOpen: rightSidePanelVisible } = storeToRefs(rightSidePanelStore)
|
||||
const showOffsideSplitter = computed(
|
||||
() => rightSidePanelVisible.value || isSelectMode.value
|
||||
)
|
||||
|
||||
const sidebarPanelVisible = computed(() => activeSidebarTab.value !== null)
|
||||
|
||||
@@ -184,7 +169,7 @@ function onResizestart({ originalEvent: event }: SplitterResizeStartEvent) {
|
||||
* to recalculate the width and panel order
|
||||
*/
|
||||
const splitterRefreshKey = computed(() => {
|
||||
return `main-splitter${rightSidePanelVisible.value ? '-with-right-panel' : ''}${isSelectMode.value ? '-builder' : ''}-${sidebarLocation.value}`
|
||||
return `main-splitter${rightSidePanelVisible.value ? '-with-right-panel' : ''}-${sidebarLocation.value}`
|
||||
})
|
||||
|
||||
const firstPanelStyle = computed(() => {
|
||||
|
||||
@@ -262,7 +262,7 @@ describe('TopMenuSection', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('opens the job history sidebar tab when QPO V2 is enabled', async () => {
|
||||
it('opens the assets sidebar tab when QPO V2 is enabled', async () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
|
||||
const settingStore = useSettingStore(pinia)
|
||||
vi.mocked(settingStore.get).mockImplementation((key) =>
|
||||
@@ -273,10 +273,10 @@ describe('TopMenuSection', () => {
|
||||
|
||||
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
|
||||
|
||||
expect(sidebarTabStore.activeSidebarTabId).toBe('job-history')
|
||||
expect(sidebarTabStore.activeSidebarTabId).toBe('assets')
|
||||
})
|
||||
|
||||
it('toggles the job history sidebar tab when QPO V2 is enabled', async () => {
|
||||
it('toggles the assets sidebar tab when QPO V2 is enabled', async () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
|
||||
const settingStore = useSettingStore(pinia)
|
||||
vi.mocked(settingStore.get).mockImplementation((key) =>
|
||||
@@ -287,7 +287,7 @@ describe('TopMenuSection', () => {
|
||||
const toggleButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
|
||||
|
||||
await toggleButton.trigger('click')
|
||||
expect(sidebarTabStore.activeSidebarTabId).toBe('job-history')
|
||||
expect(sidebarTabStore.activeSidebarTabId).toBe('assets')
|
||||
|
||||
await toggleButton.trigger('click')
|
||||
expect(sidebarTabStore.activeSidebarTabId).toBe(null)
|
||||
|
||||
@@ -56,6 +56,43 @@
|
||||
:queue-overlay-expanded="isQueueOverlayExpanded"
|
||||
@update:progress-target="updateProgressTarget"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.bottom="queueHistoryTooltipConfig"
|
||||
type="destructive"
|
||||
size="md"
|
||||
:aria-pressed="
|
||||
isQueuePanelV2Enabled
|
||||
? activeSidebarTabId === 'assets'
|
||||
: isQueueProgressOverlayEnabled
|
||||
? isQueueOverlayExpanded
|
||||
: undefined
|
||||
"
|
||||
class="relative px-3"
|
||||
data-testid="queue-overlay-toggle"
|
||||
@click="toggleQueueOverlay"
|
||||
@contextmenu.stop.prevent="showQueueContextMenu"
|
||||
>
|
||||
<span class="text-sm font-normal tabular-nums">
|
||||
{{ activeJobsLabel }}
|
||||
</span>
|
||||
<StatusBadge
|
||||
v-if="activeJobsCount > 0"
|
||||
data-testid="active-jobs-indicator"
|
||||
variant="dot"
|
||||
class="pointer-events-none absolute -top-0.5 -right-0.5 animate-pulse"
|
||||
/>
|
||||
<span class="sr-only">
|
||||
{{
|
||||
isQueuePanelV2Enabled
|
||||
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
|
||||
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
|
||||
}}
|
||||
</span>
|
||||
</Button>
|
||||
<ContextMenu
|
||||
ref="queueContextMenu"
|
||||
:model="queueContextMenuItems"
|
||||
/>
|
||||
<CurrentUserButton
|
||||
v-if="isLoggedIn && !isIntegratedTabBar"
|
||||
class="shrink-0"
|
||||
@@ -90,15 +127,13 @@
|
||||
<div
|
||||
class="pointer-events-none absolute left-0 right-0 top-full mt-1 flex justify-end pr-1"
|
||||
>
|
||||
<QueueInlineProgressSummary
|
||||
:hidden="shouldHideInlineProgressSummary"
|
||||
/>
|
||||
<QueueInlineProgressSummary :hidden="isQueueOverlayExpanded" />
|
||||
</div>
|
||||
</Teleport>
|
||||
<QueueInlineProgressSummary
|
||||
v-else-if="shouldShowInlineProgressSummary && !isActionbarFloating"
|
||||
class="pr-1"
|
||||
:hidden="shouldHideInlineProgressSummary"
|
||||
:hidden="isQueueOverlayExpanded"
|
||||
/>
|
||||
<QueueNotificationBannerHost
|
||||
v-if="shouldShowQueueNotificationBanners"
|
||||
@@ -111,11 +146,14 @@
|
||||
<script setup lang="ts">
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import ContextMenu from 'primevue/contextmenu'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
|
||||
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
|
||||
import StatusBadge from '@/components/common/StatusBadge.vue'
|
||||
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
|
||||
import QueueNotificationBannerHost from '@/components/queue/QueueNotificationBannerHost.vue'
|
||||
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
|
||||
@@ -129,9 +167,12 @@ import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useQueueUIStore } from '@/stores/queueStore'
|
||||
import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
||||
@@ -144,11 +185,17 @@ const workspaceStore = useWorkspaceStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const managerState = useManagerState()
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
const { t } = useI18n()
|
||||
const { t, n } = useI18n()
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const commandStore = useCommandStore()
|
||||
const queueStore = useQueueStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const queueUIStore = useQueueUIStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
const { activeJobsCount } = storeToRefs(queueStore)
|
||||
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
|
||||
const { activeSidebarTabId } = storeToRefs(sidebarTabStore)
|
||||
const { shouldShowRedDot: shouldShowConflictRedDot } =
|
||||
useConflictAcknowledgment()
|
||||
const isTopMenuHovered = ref(false)
|
||||
@@ -161,6 +208,14 @@ const isActionbarEnabled = computed(
|
||||
const isActionbarFloating = computed(
|
||||
() => isActionbarEnabled.value && !isActionbarDocked.value
|
||||
)
|
||||
const activeJobsLabel = computed(() => {
|
||||
const count = activeJobsCount.value
|
||||
return t(
|
||||
'sideToolbar.queueProgressOverlay.activeJobsShort',
|
||||
{ count: n(count) },
|
||||
count
|
||||
)
|
||||
})
|
||||
const isIntegratedTabBar = computed(
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
|
||||
)
|
||||
@@ -186,12 +241,24 @@ const inlineProgressSummaryTarget = computed(() => {
|
||||
}
|
||||
return progressTarget.value
|
||||
})
|
||||
const shouldHideInlineProgressSummary = computed(
|
||||
() => isQueueProgressOverlayEnabled.value && isQueueOverlayExpanded.value
|
||||
const queueHistoryTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
|
||||
)
|
||||
const customNodesManagerTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('menu.manageExtensions'))
|
||||
)
|
||||
const queueContextMenu = ref<InstanceType<typeof ContextMenu> | null>(null)
|
||||
const queueContextMenuItems = computed<MenuItem[]>(() => [
|
||||
{
|
||||
label: t('sideToolbar.queueProgressOverlay.clearQueueTooltip'),
|
||||
icon: 'icon-[lucide--list-x] text-destructive-background',
|
||||
class: '*:text-destructive-background',
|
||||
disabled: queueStore.pendingTasks.length === 0,
|
||||
command: () => {
|
||||
void handleClearQueue()
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
const shouldShowRedDot = computed((): boolean => {
|
||||
return shouldShowConflictRedDot.value
|
||||
@@ -214,6 +281,27 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const toggleQueueOverlay = () => {
|
||||
if (isQueuePanelV2Enabled.value) {
|
||||
sidebarTabStore.toggleSidebarTab('assets')
|
||||
return
|
||||
}
|
||||
commandStore.execute('Comfy.Queue.ToggleOverlay')
|
||||
}
|
||||
|
||||
const showQueueContextMenu = (event: MouseEvent) => {
|
||||
queueContextMenu.value?.show(event)
|
||||
}
|
||||
|
||||
const handleClearQueue = async () => {
|
||||
const pendingJobIds = queueStore.pendingTasks
|
||||
.map((task) => task.jobId)
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
||||
|
||||
await commandStore.execute('Comfy.ClearPendingTasks')
|
||||
executionStore.clearInitializationByJobIds(pendingJobIds)
|
||||
}
|
||||
|
||||
const openCustomNodeManager = async () => {
|
||||
try {
|
||||
await managerState.openManager({
|
||||
|
||||
@@ -42,44 +42,12 @@
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.bottom="queueHistoryTooltipConfig"
|
||||
variant="secondary"
|
||||
size="md"
|
||||
:aria-pressed="
|
||||
isQueuePanelV2Enabled
|
||||
? activeSidebarTabId === 'job-history'
|
||||
: queueOverlayExpanded
|
||||
"
|
||||
class="relative px-3"
|
||||
data-testid="queue-overlay-toggle"
|
||||
@click="toggleQueueOverlay"
|
||||
@contextmenu.stop.prevent="showQueueContextMenu"
|
||||
>
|
||||
<span class="text-sm font-normal tabular-nums">
|
||||
{{ activeJobsLabel }}
|
||||
</span>
|
||||
<StatusBadge
|
||||
v-if="activeJobsCount > 0"
|
||||
data-testid="active-jobs-indicator"
|
||||
variant="dot"
|
||||
class="pointer-events-none absolute -top-0.5 -right-0.5 animate-pulse"
|
||||
/>
|
||||
<span class="sr-only">
|
||||
{{
|
||||
isQueuePanelV2Enabled
|
||||
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
|
||||
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
|
||||
}}
|
||||
</span>
|
||||
</Button>
|
||||
<ContextMenu ref="queueContextMenu" :model="queueContextMenuItems" />
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Teleport v-if="inlineProgressTarget" :to="inlineProgressTarget">
|
||||
<QueueInlineProgress
|
||||
:hidden="shouldHideInlineProgress"
|
||||
:hidden="queueOverlayExpanded"
|
||||
:radius-class="cn(isDocked ? 'rounded-[7px]' : 'rounded-[5px]')"
|
||||
data-testid="queue-inline-progress"
|
||||
/>
|
||||
@@ -97,14 +65,11 @@ import {
|
||||
} from '@vueuse/core'
|
||||
import { clamp } from 'es-toolkit/compat'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import ContextMenu from 'primevue/contextmenu'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import Panel from 'primevue/panel'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
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 { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
@@ -112,8 +77,6 @@ import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import ComfyRunButton from './ComfyRunButton'
|
||||
@@ -129,13 +92,8 @@ const emit = defineEmits<{
|
||||
|
||||
const settingsStore = useSettingStore()
|
||||
const commandStore = useCommandStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const queueStore = useQueueStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
const { t, n } = useI18n()
|
||||
const { isIdle: isExecutionIdle } = storeToRefs(executionStore)
|
||||
const { activeJobsCount } = storeToRefs(queueStore)
|
||||
const { activeSidebarTabId } = storeToRefs(sidebarTabStore)
|
||||
const { t } = useI18n()
|
||||
const { isIdle: isExecutionIdle } = storeToRefs(useExecutionStore())
|
||||
|
||||
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
|
||||
const visible = computed(() => position.value !== 'Disabled')
|
||||
@@ -329,9 +287,6 @@ const inlineProgressTarget = computed(() => {
|
||||
if (isDocked.value) return topMenuContainer ?? null
|
||||
return panelElement.value
|
||||
})
|
||||
const shouldHideInlineProgress = computed(
|
||||
() => !isQueuePanelV2Enabled.value && queueOverlayExpanded
|
||||
)
|
||||
watch(
|
||||
panelElement,
|
||||
(target) => {
|
||||
@@ -360,52 +315,11 @@ watch(isDragging, (dragging) => {
|
||||
const cancelJobTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('menu.interrupt'))
|
||||
)
|
||||
const queueHistoryTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
|
||||
)
|
||||
const activeJobsLabel = computed(() => {
|
||||
const count = activeJobsCount.value
|
||||
return t(
|
||||
'sideToolbar.queueProgressOverlay.activeJobsShort',
|
||||
{ count: n(count) },
|
||||
count
|
||||
)
|
||||
})
|
||||
const queueContextMenu = ref<InstanceType<typeof ContextMenu> | null>(null)
|
||||
const queueContextMenuItems = computed<MenuItem[]>(() => [
|
||||
{
|
||||
label: t('sideToolbar.queueProgressOverlay.clearQueueTooltip'),
|
||||
icon: 'icon-[lucide--list-x] text-destructive-background',
|
||||
class: '*:text-destructive-background',
|
||||
disabled: queueStore.pendingTasks.length === 0,
|
||||
command: () => {
|
||||
void handleClearQueue()
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
const cancelCurrentJob = async () => {
|
||||
if (isExecutionIdle.value) return
|
||||
await commandStore.execute('Comfy.Interrupt')
|
||||
}
|
||||
const toggleQueueOverlay = () => {
|
||||
if (isQueuePanelV2Enabled.value) {
|
||||
sidebarTabStore.toggleSidebarTab('job-history')
|
||||
return
|
||||
}
|
||||
commandStore.execute('Comfy.Queue.ToggleOverlay')
|
||||
}
|
||||
const showQueueContextMenu = (event: MouseEvent) => {
|
||||
queueContextMenu.value?.show(event)
|
||||
}
|
||||
const handleClearQueue = async () => {
|
||||
const pendingJobIds = queueStore.pendingTasks
|
||||
.map((task) => task.jobId)
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
||||
|
||||
await commandStore.execute('Comfy.ClearPendingTasks')
|
||||
executionStore.clearInitializationByJobIds(pendingJobIds)
|
||||
}
|
||||
|
||||
const actionbarClass = computed(() =>
|
||||
cn(
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<ComfyQueueButton />
|
||||
<SubscribeToRunButton
|
||||
v-if="!isActiveSubscription"
|
||||
class="absolute inset-0 z-10"
|
||||
/>
|
||||
</div>
|
||||
<component
|
||||
:is="currentButton"
|
||||
:key="isActiveSubscription ? 'queue' : 'subscribe'"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import ComfyQueueButton from '@/components/actionbar/ComfyRunButton/ComfyQueueButton.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
|
||||
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
|
||||
const currentButton = computed(() =>
|
||||
isActiveSubscription.value ? ComfyQueueButton : SubscribeToRunButton
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -8,12 +8,12 @@ import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemp
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const { enableAppBuilder, setMode } = useAppMode()
|
||||
const appModeStore = useAppModeStore()
|
||||
const tooltipOptions = { showDelay: 300, hideDelay: 300 }
|
||||
|
||||
const isAssetsActive = computed(
|
||||
@@ -24,7 +24,7 @@ const isWorkflowsActive = computed(
|
||||
)
|
||||
|
||||
function enterBuilderMode() {
|
||||
setMode('builder:select')
|
||||
appModeStore.setMode('builder:select')
|
||||
}
|
||||
|
||||
function openAssets() {
|
||||
@@ -61,7 +61,7 @@ function openTemplates() {
|
||||
</WorkflowActionsDropdown>
|
||||
|
||||
<Button
|
||||
v-if="enableAppBuilder"
|
||||
v-if="appModeStore.enableAppBuilder"
|
||||
v-tooltip.right="{
|
||||
value: t('linearMode.appModeToolbar.appBuilder'),
|
||||
...tooltipOptions
|
||||
|
||||
@@ -1,378 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { remove } from 'es-toolkit'
|
||||
import { computed, provide, ref, toValue, watchEffect } from 'vue'
|
||||
import type { MaybeRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import DraggableList from '@/components/common/DraggableList.vue'
|
||||
import IoItem from '@/components/builder/IoItem.vue'
|
||||
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
|
||||
import WidgetItem from '@/components/rightSidePanel/parameters/WidgetItem.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
|
||||
import { app } from '@/scripts/app'
|
||||
import { DOMWidgetImpl } from '@/scripts/domWidget'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
|
||||
type BoundStyle = { top: string; left: string; width: string; height: string }
|
||||
|
||||
const appModeStore = useAppModeStore()
|
||||
const canvasInteractions = useCanvasInteractions()
|
||||
const canvasStore = useCanvasStore()
|
||||
const settingStore = useSettingStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { t } = useI18n()
|
||||
const canvas: LGraphCanvas = canvasStore.getCanvas()
|
||||
|
||||
const { mode, isArrangeMode } = useAppMode()
|
||||
const hoveringSelectable = ref(false)
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
|
||||
workflowStore.activeWorkflow?.changeTracker?.reset()
|
||||
|
||||
// Prune stale entries whose node/widget no longer exists, so the
|
||||
// DraggableList model always matches the rendered items.
|
||||
watchEffect(() => {
|
||||
const valid = appModeStore.selectedInputs.filter(([nodeId, widgetName]) => {
|
||||
const node = app.rootGraph.getNodeById(nodeId)
|
||||
return node?.widgets?.some((w) => w.name === widgetName)
|
||||
})
|
||||
if (valid.length < appModeStore.selectedInputs.length) {
|
||||
appModeStore.selectedInputs = valid
|
||||
}
|
||||
})
|
||||
|
||||
const arrangeInputs = computed(() =>
|
||||
appModeStore.selectedInputs
|
||||
.map(([nodeId, widgetName]) => {
|
||||
const node = app.rootGraph.getNodeById(nodeId)
|
||||
const widget = node?.widgets?.find((w) => w.name === widgetName)
|
||||
if (!node || !widget) return null
|
||||
return { nodeId, widgetName, node, widget }
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null)
|
||||
)
|
||||
|
||||
const inputsWithState = computed(() =>
|
||||
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
|
||||
const node = app.rootGraph.getNodeById(nodeId)
|
||||
const widget = node?.widgets?.find((w) => w.name === widgetName)
|
||||
if (!node || !widget) return { nodeId, widgetName }
|
||||
|
||||
const input = node.inputs.find((i) => i.widget?.name === widget.name)
|
||||
const rename = input && (() => renameWidget(widget, input))
|
||||
|
||||
return {
|
||||
nodeId,
|
||||
widgetName,
|
||||
label: widget.label,
|
||||
subLabel: node.title,
|
||||
rename
|
||||
}
|
||||
})
|
||||
)
|
||||
const outputsWithState = computed<[NodeId, string][]>(() =>
|
||||
appModeStore.selectedOutputs.map((nodeId) => [
|
||||
nodeId,
|
||||
app.rootGraph.getNodeById(nodeId)?.title ?? String(nodeId)
|
||||
])
|
||||
)
|
||||
|
||||
async function renameWidget(widget: IBaseWidget, input: INodeInputSlot) {
|
||||
const newLabel = await useDialogService().prompt({
|
||||
title: t('g.rename'),
|
||||
message: t('g.enterNewNamePrompt'),
|
||||
defaultValue: widget.label,
|
||||
placeholder: widget.name
|
||||
})
|
||||
if (newLabel === null) return
|
||||
widget.label = newLabel || undefined
|
||||
input.label = newLabel || undefined
|
||||
widget.callback?.(widget.value)
|
||||
useCanvasStore().canvas?.setDirty(true)
|
||||
}
|
||||
|
||||
function getHovered(
|
||||
e: MouseEvent
|
||||
): undefined | [LGraphNode, undefined] | [LGraphNode, IBaseWidget] {
|
||||
const { graph } = canvas
|
||||
if (!canvas || !graph) return
|
||||
|
||||
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
|
||||
if (!e) return
|
||||
|
||||
canvas.adjustMouseEvent(e)
|
||||
const node = graph.getNodeOnPos(e.canvasX, e.canvasY)
|
||||
if (!node) return
|
||||
|
||||
const widget = node.getWidgetOnPos(e.canvasX, e.canvasY, false)
|
||||
|
||||
if (widget || node.constructor.nodeData?.output_node) return [node, widget]
|
||||
}
|
||||
|
||||
function getBounding(nodeId: NodeId, widgetName?: string) {
|
||||
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
|
||||
const node = app.rootGraph.getNodeById(nodeId)
|
||||
if (!node) return
|
||||
|
||||
const titleOffset =
|
||||
node.title_mode === TitleMode.NORMAL_TITLE ? LiteGraph.NODE_TITLE_HEIGHT : 0
|
||||
|
||||
if (!widgetName)
|
||||
return {
|
||||
width: `${node.size[0]}px`,
|
||||
height: `${node.size[1] + titleOffset}px`,
|
||||
left: `${node.pos[0]}px`,
|
||||
top: `${node.pos[1] - titleOffset}px`
|
||||
}
|
||||
const widget = node.widgets?.find((w) => w.name === widgetName)
|
||||
if (!widget) return
|
||||
|
||||
const margin = widget instanceof DOMWidgetImpl ? widget.margin : undefined
|
||||
const marginX = margin ?? BaseWidget.margin
|
||||
const height =
|
||||
(widget.computedHeight !== undefined
|
||||
? widget.computedHeight - 4
|
||||
: LiteGraph.NODE_WIDGET_HEIGHT) - (margin ? 2 * margin - 4 : 0)
|
||||
return {
|
||||
width: `${node.size[0] - marginX * 2}px`,
|
||||
height: `${height}px`,
|
||||
left: `${node.pos[0] + marginX}px`,
|
||||
top: `${node.pos[1] + widget.y + (margin ?? 0)}px`
|
||||
}
|
||||
}
|
||||
|
||||
function handleDown(e: MouseEvent) {
|
||||
const [node] = getHovered(e) ?? []
|
||||
if (!node || e.button > 0) canvasInteractions.forwardEventToCanvas(e)
|
||||
}
|
||||
function handleClick(e: MouseEvent) {
|
||||
const [node, widget] = getHovered(e) ?? []
|
||||
if (!node) return canvasInteractions.forwardEventToCanvas(e)
|
||||
|
||||
if (!widget) {
|
||||
if (!node.constructor.nodeData?.output_node)
|
||||
return canvasInteractions.forwardEventToCanvas(e)
|
||||
const index = appModeStore.selectedOutputs.findIndex((id) => id === node.id)
|
||||
if (index === -1) appModeStore.selectedOutputs.push(node.id)
|
||||
else appModeStore.selectedOutputs.splice(index, 1)
|
||||
return
|
||||
}
|
||||
|
||||
const index = appModeStore.selectedInputs.findIndex(
|
||||
([nodeId, widgetName]) => node.id === nodeId && widget.name === widgetName
|
||||
)
|
||||
if (index === -1) appModeStore.selectedInputs.push([node.id, widget.name])
|
||||
else appModeStore.selectedInputs.splice(index, 1)
|
||||
}
|
||||
|
||||
function nodeToDisplayTuple(
|
||||
n: LGraphNode
|
||||
): [NodeId, MaybeRef<BoundStyle> | undefined, boolean] {
|
||||
return [
|
||||
n.id,
|
||||
getBounding(n.id),
|
||||
appModeStore.selectedOutputs.some((id) => n.id === id)
|
||||
]
|
||||
}
|
||||
|
||||
const renderedOutputs = computed(() => {
|
||||
void appModeStore.selectedOutputs.length
|
||||
return canvas
|
||||
.graph!.nodes.filter((n) => n.constructor.nodeData?.output_node)
|
||||
.map(nodeToDisplayTuple)
|
||||
})
|
||||
const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
() =>
|
||||
appModeStore.selectedInputs.map(([nodeId, widgetName]) => [
|
||||
`${nodeId}: ${widgetName}`,
|
||||
getBounding(nodeId, widgetName)
|
||||
])
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex font-bold p-2 border-border-subtle border-b items-center">
|
||||
{{
|
||||
isArrangeMode ? t('nodeHelpPage.inputs') : t('linearMode.builder.title')
|
||||
}}
|
||||
<Button class="ml-auto" @click="appModeStore.exitBuilder">
|
||||
{{ t('linearMode.builder.exit') }}
|
||||
</Button>
|
||||
</div>
|
||||
<DraggableList
|
||||
v-if="isArrangeMode"
|
||||
v-slot="{ dragClass }"
|
||||
v-model="appModeStore.selectedInputs"
|
||||
>
|
||||
<div
|
||||
v-for="{ nodeId, widgetName, node, widget } in arrangeInputs"
|
||||
:key="`${nodeId}: ${widgetName}`"
|
||||
:class="cn(dragClass, 'p-2 my-2 pointer-events-auto')"
|
||||
:aria-label="`${widget.label ?? widgetName} — ${node.title}`"
|
||||
>
|
||||
<div class="pointer-events-none" inert>
|
||||
<WidgetItem
|
||||
:widget="widget"
|
||||
:node="node"
|
||||
show-node-name
|
||||
hidden-widget-actions
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DraggableList>
|
||||
<PropertiesAccordionItem
|
||||
v-else
|
||||
:label="t('nodeHelpPage.inputs')"
|
||||
enable-empty-state
|
||||
:disabled="!appModeStore.selectedInputs.length"
|
||||
class="border-border-subtle border-b"
|
||||
:tooltip="`${t('linearMode.builder.inputsDesc')}\n${t('linearMode.builder.inputsExample')}`"
|
||||
>
|
||||
<template #label>
|
||||
<div class="flex gap-3">
|
||||
{{ t('nodeHelpPage.inputs') }}
|
||||
<i class="bg-muted-foreground icon-[lucide--circle-alert]" />
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
<div
|
||||
class="w-full p-4 pt-2 text-muted-foreground"
|
||||
v-text="t('linearMode.builder.promptAddInputs')"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
class="w-full p-4 pt-2 text-muted-foreground"
|
||||
v-text="t('linearMode.builder.promptAddInputs')"
|
||||
/>
|
||||
<DraggableList v-slot="{ dragClass }" v-model="appModeStore.selectedInputs">
|
||||
<IoItem
|
||||
v-for="{
|
||||
nodeId,
|
||||
widgetName,
|
||||
label,
|
||||
subLabel,
|
||||
rename
|
||||
} in inputsWithState"
|
||||
:key="`${nodeId}: ${widgetName}`"
|
||||
:class="cn(dragClass, 'bg-primary-background/30 p-2 my-2 rounded-lg')"
|
||||
:title="label ?? widgetName"
|
||||
:sub-title="subLabel"
|
||||
:rename
|
||||
:remove="
|
||||
() =>
|
||||
remove(
|
||||
appModeStore.selectedInputs,
|
||||
([id, name]) => nodeId === id && widgetName === name
|
||||
)
|
||||
"
|
||||
/>
|
||||
</DraggableList>
|
||||
</PropertiesAccordionItem>
|
||||
<PropertiesAccordionItem
|
||||
v-if="!isArrangeMode"
|
||||
:label="t('nodeHelpPage.outputs')"
|
||||
enable-empty-state
|
||||
:disabled="!appModeStore.selectedOutputs.length"
|
||||
:tooltip="`${t('linearMode.builder.outputsDesc')}\n${t('linearMode.builder.outputsExample')}`"
|
||||
>
|
||||
<template #label>
|
||||
<div class="flex gap-3">
|
||||
{{ t('nodeHelpPage.outputs') }}
|
||||
<i class="bg-muted-foreground icon-[lucide--circle-alert]" />
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
<div
|
||||
class="w-full p-4 pt-2 text-muted-foreground"
|
||||
v-text="t('linearMode.builder.promptAddOutputs')"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
class="w-full p-4 pt-2 text-muted-foreground"
|
||||
v-text="t('linearMode.builder.promptAddOutputs')"
|
||||
/>
|
||||
<DraggableList
|
||||
v-slot="{ dragClass }"
|
||||
v-model="appModeStore.selectedOutputs"
|
||||
>
|
||||
<IoItem
|
||||
v-for="([key, title], index) in outputsWithState"
|
||||
:key
|
||||
:class="
|
||||
cn(
|
||||
dragClass,
|
||||
'bg-warning-background/40 p-2 my-2 rounded-lg',
|
||||
index === 0 && 'ring-warning-background ring-2'
|
||||
)
|
||||
"
|
||||
:title
|
||||
:sub-title="String(key)"
|
||||
:remove="() => remove(appModeStore.selectedOutputs, (k) => k === key)"
|
||||
/>
|
||||
</DraggableList>
|
||||
</PropertiesAccordionItem>
|
||||
|
||||
<Teleport v-if="mode === 'builder:select'" to="body">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'absolute w-full h-full pointer-events-auto',
|
||||
hoveringSelectable ? 'cursor-pointer' : 'cursor-grab'
|
||||
)
|
||||
"
|
||||
@pointerdown="handleDown"
|
||||
@pointermove="hoveringSelectable = !!getHovered($event)"
|
||||
@click="handleClick"
|
||||
@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"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TransformPane>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -20,7 +20,7 @@
|
||||
)
|
||||
"
|
||||
:aria-current="activeStep === step.id ? 'step' : undefined"
|
||||
@click="setMode(step.id)"
|
||||
@click="appModeStore.setMode(step.id)"
|
||||
>
|
||||
<StepBadge :step :index :model-value="activeStep" />
|
||||
<StepLabel :step />
|
||||
@@ -31,9 +31,9 @@
|
||||
|
||||
<!-- Save -->
|
||||
<ConnectOutputPopover
|
||||
v-if="!hasOutputs"
|
||||
v-if="!appModeStore.hasOutputs"
|
||||
:is-select-active="activeStep === 'builder:select'"
|
||||
@switch="setMode('builder:select')"
|
||||
@switch="appModeStore.setMode('builder:select')"
|
||||
>
|
||||
<button :class="cn(stepClasses, 'opacity-30 bg-transparent')">
|
||||
<StepBadge :step="saveStep" :index="2" :model-value="activeStep" />
|
||||
@@ -50,7 +50,7 @@
|
||||
: 'hover:bg-secondary-background bg-transparent'
|
||||
)
|
||||
"
|
||||
@click="setSaving(true)"
|
||||
@click="appModeStore.setBuilderSaving(true)"
|
||||
>
|
||||
<StepBadge :step="saveStep" :index="2" :model-value="activeStep" />
|
||||
<StepLabel :step="saveStep" />
|
||||
@@ -63,24 +63,21 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import type { AppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import type { AppMode } from '@/stores/appModeStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { useBuilderSave } from './useBuilderSave'
|
||||
import ConnectOutputPopover from './ConnectOutputPopover.vue'
|
||||
import StepBadge from './StepBadge.vue'
|
||||
import StepLabel from './StepLabel.vue'
|
||||
import type { BuilderToolbarStep } from './types'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { mode, setMode } = useAppMode()
|
||||
const { hasOutputs } = storeToRefs(useAppModeStore())
|
||||
const { saving, setSaving } = useBuilderSave()
|
||||
const appModeStore = useAppModeStore()
|
||||
|
||||
const activeStep = computed(() => (saving.value ? 'save' : mode.value))
|
||||
const activeStep = computed(() =>
|
||||
appModeStore.isBuilderSaving ? 'save' : appModeStore.mode
|
||||
)
|
||||
|
||||
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'
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { rename, remove } = defineProps<{
|
||||
title: string
|
||||
subTitle?: string
|
||||
rename?: () => void
|
||||
remove?: () => void
|
||||
}>()
|
||||
|
||||
const entries = computed(() => {
|
||||
const items = []
|
||||
if (rename)
|
||||
items.push({
|
||||
label: t('g.rename'),
|
||||
command: rename,
|
||||
icon: 'icon-[lucide--pencil]'
|
||||
})
|
||||
if (remove)
|
||||
items.push({
|
||||
label: t('g.delete'),
|
||||
command: remove,
|
||||
icon: 'icon-[lucide--trash-2]'
|
||||
})
|
||||
return items
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div class="p-2 my-2 rounded-lg flex items-center-safe">
|
||||
<span class="mr-auto" v-text="title" />
|
||||
<span class="text-muted-foreground mr-2 text-end" v-text="subTitle" />
|
||||
<Popover :entries>
|
||||
<template #button>
|
||||
<Button variant="muted-textonly">
|
||||
<i class="icon-[lucide--ellipsis]" />
|
||||
</Button>
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,36 +1,30 @@
|
||||
import { ref } from 'vue'
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
import BuilderSaveDialogContent from './BuilderSaveDialogContent.vue'
|
||||
import BuilderSaveSuccessDialogContent from './BuilderSaveSuccessDialogContent.vue'
|
||||
import { whenever } from '@vueuse/core'
|
||||
|
||||
const SAVE_DIALOG_KEY = 'builder-save'
|
||||
const SUCCESS_DIALOG_KEY = 'builder-save-success'
|
||||
|
||||
export function useBuilderSave() {
|
||||
const { setMode } = useAppMode()
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const appModeStore = useAppModeStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const dialogService = useDialogService()
|
||||
const appModeStore = useAppModeStore()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
const saving = ref(false)
|
||||
|
||||
whenever(saving, onBuilderSave)
|
||||
|
||||
function setSaving(value: boolean) {
|
||||
saving.value = value
|
||||
}
|
||||
watch(
|
||||
() => appModeStore.isBuilderSaving,
|
||||
(saving) => {
|
||||
if (saving) void onBuilderSave()
|
||||
}
|
||||
)
|
||||
|
||||
async function onBuilderSave() {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
@@ -39,14 +33,15 @@ export function useBuilderSave() {
|
||||
return
|
||||
}
|
||||
|
||||
if (!workflow.isTemporary && workflow.initialMode != null) {
|
||||
// Re-save with the previously chosen mode — no dialog needed.
|
||||
// TODO: Update this to show the save dialog if it is temp OR if the user has not saved app mode before.
|
||||
// If they have saved app mode before, just save the workflow, but use the initial app mode state not current.
|
||||
|
||||
if (!workflow.isTemporary) {
|
||||
try {
|
||||
appModeStore.flushSelections()
|
||||
workflow.changeTracker?.checkState()
|
||||
await workflowService.saveWorkflow(workflow)
|
||||
showSuccessDialog(workflow.filename, workflow.initialMode === 'app')
|
||||
} catch (e) {
|
||||
toastErrorHandler(e)
|
||||
showSuccessDialog(workflow.filename, appModeStore.isAppMode)
|
||||
} catch {
|
||||
resetSaving()
|
||||
}
|
||||
return
|
||||
@@ -80,19 +75,16 @@ export function useBuilderSave() {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow) return
|
||||
|
||||
appModeStore.flushSelections()
|
||||
const mode = openAsApp ? 'app' : 'graph'
|
||||
const saved = await workflowService.saveWorkflowAs(workflow, {
|
||||
filename,
|
||||
initialMode: mode
|
||||
openAsApp
|
||||
})
|
||||
|
||||
if (!saved) return
|
||||
|
||||
closeSaveDialog()
|
||||
showSuccessDialog(filename, openAsApp)
|
||||
} catch (e) {
|
||||
toastErrorHandler(e)
|
||||
} catch {
|
||||
closeSaveDialog()
|
||||
resetSaving()
|
||||
}
|
||||
@@ -106,7 +98,7 @@ export function useBuilderSave() {
|
||||
workflowName,
|
||||
savedAsApp,
|
||||
onViewApp: () => {
|
||||
setMode('app')
|
||||
appModeStore.setMode('app')
|
||||
closeSuccessDialog()
|
||||
},
|
||||
onClose: closeSuccessDialog
|
||||
@@ -127,8 +119,6 @@ export function useBuilderSave() {
|
||||
}
|
||||
|
||||
function resetSaving() {
|
||||
saving.value = false
|
||||
appModeStore.setBuilderSaving(false)
|
||||
}
|
||||
|
||||
return { saving, setSaving }
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
<script setup lang="ts" generic="T">
|
||||
import { onBeforeUnmount, ref, useTemplateRef, watchPostEffect } from 'vue'
|
||||
|
||||
import { DraggableList } from '@/scripts/ui/draggableList'
|
||||
|
||||
const modelValue = defineModel<T[]>({ required: true })
|
||||
const draggableList = ref<DraggableList>()
|
||||
const draggableItems = useTemplateRef('draggableItems')
|
||||
|
||||
watchPostEffect(() => {
|
||||
void modelValue.value.length
|
||||
draggableList.value?.dispose()
|
||||
if (!draggableItems.value?.children?.length) return
|
||||
draggableList.value = new DraggableList(
|
||||
draggableItems.value,
|
||||
'.draggable-item'
|
||||
)
|
||||
draggableList.value.applyNewItemsOrder = function () {
|
||||
const reorderedItems = []
|
||||
|
||||
let oldPosition = -1
|
||||
this.getAllItems().forEach((item, index) => {
|
||||
if (item === this.draggableItem) {
|
||||
oldPosition = index
|
||||
return
|
||||
}
|
||||
if (!this.isItemToggled(item)) {
|
||||
reorderedItems[index] = item
|
||||
return
|
||||
}
|
||||
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
|
||||
reorderedItems[newIndex] = item
|
||||
})
|
||||
|
||||
for (let index = 0; index < this.getAllItems().length; index++) {
|
||||
const item = reorderedItems[index]
|
||||
if (typeof item === 'undefined') {
|
||||
reorderedItems[index] = this.draggableItem
|
||||
}
|
||||
}
|
||||
const newPosition = reorderedItems.indexOf(this.draggableItem)
|
||||
const itemList = modelValue.value
|
||||
const [item] = itemList.splice(oldPosition, 1)
|
||||
itemList.splice(newPosition, 0, item)
|
||||
modelValue.value = [...itemList]
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
draggableList.value?.dispose()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div ref="draggableItems" class="pb-2 px-2 space-y-0.5 mt-0.5">
|
||||
<slot
|
||||
drag-class="draggable-item drag-handle cursor-grab [&.is-draggable]:cursor-grabbing"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { CurvePoint } from './types'
|
||||
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import CurveEditor from './CurveEditor.vue'
|
||||
|
||||
|
||||
@@ -77,8 +77,7 @@
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
|
||||
import { useCurveEditor } from '@/composables/useCurveEditor'
|
||||
|
||||
import type { CurvePoint } from './types'
|
||||
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { histogramToPath } from './curveUtils'
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CurvePoint } from './types'
|
||||
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import CurveEditor from './CurveEditor.vue'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { CurvePoint } from './types'
|
||||
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import {
|
||||
createMonotoneInterpolator,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CurvePoint } from './types'
|
||||
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
/**
|
||||
* Monotone cubic Hermite interpolation.
|
||||
@@ -95,15 +95,15 @@ export function histogramToPath(histogram: Uint32Array): string {
|
||||
const max = sorted[Math.floor(255 * 0.995)]
|
||||
if (max === 0) return ''
|
||||
|
||||
const invMax = 1 / max
|
||||
const parts: string[] = ['M0,1']
|
||||
const step = 1 / 255
|
||||
let d = 'M0,1'
|
||||
for (let i = 0; i < 256; i++) {
|
||||
const x = i / 255
|
||||
const y = 1 - Math.min(1, histogram[i] * invMax)
|
||||
parts.push(`L${x},${y}`)
|
||||
const x = i * step
|
||||
const y = 1 - Math.min(1, histogram[i] / max)
|
||||
d += ` L${x.toFixed(4)},${y.toFixed(4)}`
|
||||
}
|
||||
parts.push('L1,1 Z')
|
||||
return parts.join(' ')
|
||||
d += ' L1,1 Z'
|
||||
return d
|
||||
}
|
||||
|
||||
export function curvesToLUT(points: CurvePoint[]): Uint8Array {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export type CurvePoint = [x: number, y: number]
|
||||
@@ -438,6 +438,7 @@ onMounted(() => {
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
|
||||
const distributions = computed(() => {
|
||||
// eslint-disable-next-line no-undef
|
||||
switch (__DISTRIBUTION__) {
|
||||
case 'cloud':
|
||||
return [TemplateIncludeOnDistributionEnum.Cloud]
|
||||
|
||||
@@ -137,7 +137,7 @@ const authStore = useFirebaseAuthStore()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const commandStore = useCommandStore()
|
||||
const telemetry = useTelemetry()
|
||||
const { isActiveSubscription, subscription } = useBillingContext()
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
const loading = computed(() => authStore.loading)
|
||||
const balanceLoading = computed(() => authStore.isFetchingBalance)
|
||||
|
||||
@@ -160,9 +160,7 @@ watch(
|
||||
|
||||
const handlePurchaseCreditsClick = () => {
|
||||
// Track purchase credits entry from Settings > Credits panel
|
||||
useTelemetry()?.trackAddApiCreditButtonClicked({
|
||||
current_tier: subscription.value?.tier?.toLowerCase()
|
||||
})
|
||||
useTelemetry()?.trackAddApiCreditButtonClicked()
|
||||
dialogService.showTopUpCreditsDialog()
|
||||
}
|
||||
|
||||
|
||||
@@ -18,11 +18,10 @@
|
||||
>
|
||||
<WorkflowTabs />
|
||||
<TopbarBadges />
|
||||
<TopbarSubscribeButton />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="showUI && !isBuilderMode" #side-toolbar>
|
||||
<template v-if="showUI && !appModeStore.isBuilderMode" #side-toolbar>
|
||||
<SideToolbar />
|
||||
</template>
|
||||
<template v-if="showUI" #side-bar-panel>
|
||||
@@ -32,24 +31,26 @@
|
||||
<ExtensionSlot v-if="activeSidebarTab" :extension="activeSidebarTab" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="showUI && !isBuilderMode" #topmenu>
|
||||
<template v-if="showUI && !appModeStore.isBuilderMode" #topmenu>
|
||||
<TopMenuSection />
|
||||
</template>
|
||||
<template v-if="showUI" #bottom-panel>
|
||||
<BottomPanel />
|
||||
</template>
|
||||
<template v-if="showUI" #right-side-panel>
|
||||
<AppBuilder v-if="mode === 'builder:select'" />
|
||||
<NodePropertiesPanel v-else-if="!isBuilderMode" />
|
||||
<NodePropertiesPanel v-if="!appModeStore.isBuilderMode" />
|
||||
</template>
|
||||
<template #graph-canvas-panel>
|
||||
<GraphCanvasMenu
|
||||
v-if="canvasMenuEnabled && !isBuilderMode"
|
||||
v-if="canvasMenuEnabled && !appModeStore.isBuilderMode"
|
||||
class="pointer-events-auto"
|
||||
/>
|
||||
<MiniMap
|
||||
v-if="
|
||||
comfyAppReady && minimapEnabled && betaMenuEnabled && !isBuilderMode
|
||||
comfyAppReady &&
|
||||
minimapEnabled &&
|
||||
betaMenuEnabled &&
|
||||
!appModeStore.isBuilderMode
|
||||
"
|
||||
class="pointer-events-auto"
|
||||
/>
|
||||
@@ -128,7 +129,6 @@ import { isMiddlePointerInput } from '@/base/pointerUtils'
|
||||
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
|
||||
import TopMenuSection from '@/components/TopMenuSection.vue'
|
||||
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
|
||||
import AppBuilder from '@/components/builder/AppBuilder.vue'
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import DomWidgets from '@/components/graph/DomWidgets.vue'
|
||||
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
|
||||
@@ -141,7 +141,6 @@ import NodePropertiesPanel from '@/components/rightSidePanel/RightSidePanel.vue'
|
||||
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
|
||||
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
|
||||
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
|
||||
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
|
||||
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
@@ -183,7 +182,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isNativeWindow } from '@/utils/envUtil'
|
||||
import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||
@@ -204,7 +203,7 @@ const nodeSearchboxPopoverRef = shallowRef<InstanceType<
|
||||
const settingStore = useSettingStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const { mode, isBuilderMode } = useAppMode()
|
||||
const appModeStore = useAppModeStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
@@ -1,359 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="widget-expands flex h-full w-full flex-col gap-1"
|
||||
@pointerdown.stop
|
||||
@pointermove.stop
|
||||
@pointerup.stop
|
||||
>
|
||||
<div
|
||||
class="flex min-h-0 flex-1 items-center justify-center overflow-hidden rounded-lg bg-node-component-surface"
|
||||
>
|
||||
<div class="relative max-h-full w-full" :style="canvasContainerStyle">
|
||||
<img
|
||||
v-if="inputImageUrl"
|
||||
:src="inputImageUrl"
|
||||
class="absolute inset-0 size-full"
|
||||
draggable="false"
|
||||
@load="handleInputImageLoad"
|
||||
@dragstart.prevent
|
||||
/>
|
||||
<canvas
|
||||
ref="canvasEl"
|
||||
class="absolute inset-0 size-full cursor-none touch-none"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@pointerup="handlePointerUp"
|
||||
@pointerenter="handlePointerEnter"
|
||||
@pointerleave="handlePointerLeave"
|
||||
/>
|
||||
<div
|
||||
v-show="cursorVisible"
|
||||
class="pointer-events-none absolute left-0 top-0 rounded-full border border-black/60 shadow-[0_0_0_1px_rgba(255,255,255,0.8)]"
|
||||
:style="cursorStyle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isImageInputConnected"
|
||||
class="text-center text-xs text-muted-foreground"
|
||||
>
|
||||
{{ canvasWidth }} x {{ canvasHeight }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="controlsEl"
|
||||
:class="
|
||||
cn(
|
||||
'grid shrink-0 gap-x-1 gap-y-1',
|
||||
compact ? 'grid-cols-1' : 'grid-cols-[auto_1fr]'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div
|
||||
v-if="!compact"
|
||||
class="flex w-28 items-center truncate text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('painter.tool') }}
|
||||
</div>
|
||||
<div
|
||||
class="flex h-8 items-center gap-1 rounded-sm bg-component-node-widget-background p-1"
|
||||
>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:class="
|
||||
cn(
|
||||
'flex-1 self-stretch px-2 text-xs transition-colors',
|
||||
tool === PAINTER_TOOLS.BRUSH
|
||||
? 'rounded-sm bg-component-node-widget-background-selected text-base-foreground'
|
||||
: 'text-node-text-muted hover:text-node-text'
|
||||
)
|
||||
"
|
||||
@click="tool = PAINTER_TOOLS.BRUSH"
|
||||
>
|
||||
{{ $t('painter.brush') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:class="
|
||||
cn(
|
||||
'flex-1 self-stretch px-2 text-xs transition-colors',
|
||||
tool === PAINTER_TOOLS.ERASER
|
||||
? 'rounded-sm bg-component-node-widget-background-selected text-base-foreground'
|
||||
: 'text-node-text-muted hover:text-node-text'
|
||||
)
|
||||
"
|
||||
@click="tool = PAINTER_TOOLS.ERASER"
|
||||
>
|
||||
{{ $t('painter.eraser') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!compact"
|
||||
class="flex w-28 items-center truncate text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('painter.size') }}
|
||||
</div>
|
||||
<div
|
||||
class="flex h-8 items-center gap-2 rounded-lg bg-component-node-widget-background pr-2 pl-3"
|
||||
>
|
||||
<Slider
|
||||
:model-value="[brushSize]"
|
||||
:min="1"
|
||||
:max="200"
|
||||
:step="1"
|
||||
class="flex-1"
|
||||
@update:model-value="(v) => v?.length && (brushSize = v[0])"
|
||||
/>
|
||||
<span class="w-8 text-center text-xs text-node-text-muted">{{
|
||||
brushSize
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<template v-if="tool === PAINTER_TOOLS.BRUSH">
|
||||
<div
|
||||
class="flex w-28 items-center truncate text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('painter.color') }}
|
||||
</div>
|
||||
<div
|
||||
class="flex h-8 w-full items-center gap-2 rounded-lg bg-component-node-widget-background px-4"
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
:value="brushColorDisplay"
|
||||
class="h-4 w-8 cursor-pointer appearance-none overflow-hidden rounded-full border-none bg-transparent [&::-webkit-color-swatch-wrapper]:p-0 [&::-webkit-color-swatch]:border-none [&::-webkit-color-swatch]:rounded-full [&::-moz-color-swatch]:border-none [&::-moz-color-swatch]:rounded-full"
|
||||
@input="
|
||||
(e) => (brushColorDisplay = (e.target as HTMLInputElement).value)
|
||||
"
|
||||
/>
|
||||
<span class="min-w-[4ch] truncate text-xs">{{
|
||||
brushColorDisplay
|
||||
}}</span>
|
||||
<span class="ml-auto flex items-center text-xs text-node-text-muted">
|
||||
<input
|
||||
type="number"
|
||||
:value="brushOpacityPercent"
|
||||
min="0"
|
||||
max="100"
|
||||
step="1"
|
||||
class="w-7 appearance-none border-0 bg-transparent text-right text-xs text-node-text-muted outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none [-moz-appearance:textfield]"
|
||||
@click.prevent
|
||||
@change="
|
||||
(e) => {
|
||||
const val = Math.min(
|
||||
100,
|
||||
Math.max(0, Number((e.target as HTMLInputElement).value))
|
||||
)
|
||||
brushOpacityPercent = val
|
||||
;(e.target as HTMLInputElement).value = String(val)
|
||||
}
|
||||
"
|
||||
/>%</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex w-28 items-center truncate text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('painter.hardness') }}
|
||||
</div>
|
||||
<div
|
||||
class="flex h-8 items-center gap-2 rounded-lg bg-component-node-widget-background pr-2 pl-3"
|
||||
>
|
||||
<Slider
|
||||
:model-value="[brushHardnessPercent]"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="1"
|
||||
class="flex-1"
|
||||
@update:model-value="
|
||||
(v) => v?.length && (brushHardnessPercent = v[0])
|
||||
"
|
||||
/>
|
||||
<span class="w-8 text-center text-xs text-node-text-muted"
|
||||
>{{ brushHardnessPercent }}%</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="!isImageInputConnected">
|
||||
<div
|
||||
class="flex w-28 items-center truncate text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('painter.width') }}
|
||||
</div>
|
||||
<div
|
||||
class="flex h-8 items-center gap-2 rounded-lg bg-component-node-widget-background pr-2 pl-3"
|
||||
>
|
||||
<Slider
|
||||
:model-value="[canvasWidth]"
|
||||
:min="64"
|
||||
:max="4096"
|
||||
:step="64"
|
||||
class="flex-1"
|
||||
@update:model-value="(v) => v?.length && (canvasWidth = v[0])"
|
||||
/>
|
||||
<span class="w-10 text-center text-xs text-node-text-muted">{{
|
||||
canvasWidth
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex w-28 items-center truncate text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('painter.height') }}
|
||||
</div>
|
||||
<div
|
||||
class="flex h-8 items-center gap-2 rounded-lg bg-component-node-widget-background pr-2 pl-3"
|
||||
>
|
||||
<Slider
|
||||
:model-value="[canvasHeight]"
|
||||
:min="64"
|
||||
:max="4096"
|
||||
:step="64"
|
||||
class="flex-1"
|
||||
@update:model-value="(v) => v?.length && (canvasHeight = v[0])"
|
||||
/>
|
||||
<span class="w-10 text-center text-xs text-node-text-muted">{{
|
||||
canvasHeight
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex w-28 items-center truncate text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('painter.background') }}
|
||||
</div>
|
||||
<div
|
||||
class="flex h-8 w-full items-center gap-2 rounded-lg bg-component-node-widget-background px-4"
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
:value="backgroundColorDisplay"
|
||||
class="h-4 w-8 cursor-pointer appearance-none overflow-hidden rounded-full border-none bg-transparent [&::-webkit-color-swatch-wrapper]:p-0 [&::-webkit-color-swatch]:border-none [&::-webkit-color-swatch]:rounded-full [&::-moz-color-swatch]:border-none [&::-moz-color-swatch]:rounded-full"
|
||||
@input="
|
||||
(e) =>
|
||||
(backgroundColorDisplay = (e.target as HTMLInputElement).value)
|
||||
"
|
||||
/>
|
||||
<span class="min-w-[4ch] truncate text-xs">{{
|
||||
backgroundColorDisplay
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
:class="
|
||||
cn(
|
||||
'gap-2 rounded-lg border border-component-node-border bg-component-node-background text-xs text-muted-foreground hover:text-base-foreground',
|
||||
!compact && 'col-span-2'
|
||||
)
|
||||
"
|
||||
@click="handleClear"
|
||||
>
|
||||
<i class="icon-[lucide--undo-2]" />
|
||||
{{ $t('painter.clear') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import { PAINTER_TOOLS, usePainter } from '@/composables/painter/usePainter'
|
||||
import { toHexFromFormat } from '@/utils/colorUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { nodeId } = defineProps<{
|
||||
nodeId: string
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string>({ default: '' })
|
||||
|
||||
const canvasEl = useTemplateRef<HTMLCanvasElement>('canvasEl')
|
||||
const controlsEl = useTemplateRef<HTMLDivElement>('controlsEl')
|
||||
const { width: controlsWidth } = useElementSize(controlsEl)
|
||||
const compact = computed(
|
||||
() => controlsWidth.value > 0 && controlsWidth.value < 350
|
||||
)
|
||||
|
||||
const {
|
||||
tool,
|
||||
brushSize,
|
||||
brushColor,
|
||||
brushOpacity,
|
||||
brushHardness,
|
||||
backgroundColor,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
cursorX,
|
||||
cursorY,
|
||||
cursorVisible,
|
||||
displayBrushSize,
|
||||
inputImageUrl,
|
||||
isImageInputConnected,
|
||||
handlePointerDown,
|
||||
handlePointerMove,
|
||||
handlePointerUp,
|
||||
handlePointerEnter,
|
||||
handlePointerLeave,
|
||||
handleInputImageLoad,
|
||||
handleClear
|
||||
} = usePainter(nodeId, { canvasEl, modelValue })
|
||||
|
||||
const canvasContainerStyle = computed(() => ({
|
||||
aspectRatio: `${canvasWidth.value} / ${canvasHeight.value}`,
|
||||
backgroundColor: isImageInputConnected.value
|
||||
? undefined
|
||||
: backgroundColor.value
|
||||
}))
|
||||
|
||||
const cursorStyle = computed(() => {
|
||||
const size = displayBrushSize.value
|
||||
const x = cursorX.value - size / 2
|
||||
const y = cursorY.value - size / 2
|
||||
return {
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
transform: `translate(${x}px, ${y}px)`
|
||||
}
|
||||
})
|
||||
|
||||
const brushOpacityPercent = computed({
|
||||
get: () => Math.round(brushOpacity.value * 100),
|
||||
set: (val: number) => {
|
||||
brushOpacity.value = val / 100
|
||||
}
|
||||
})
|
||||
|
||||
const brushHardnessPercent = computed({
|
||||
get: () => Math.round(brushHardness.value * 100),
|
||||
set: (val: number) => {
|
||||
brushHardness.value = val / 100
|
||||
}
|
||||
})
|
||||
|
||||
const brushColorDisplay = computed({
|
||||
get: () => toHexFromFormat(brushColor.value, 'hex'),
|
||||
set: (val: unknown) => {
|
||||
brushColor.value = toHexFromFormat(val, 'hex')
|
||||
}
|
||||
})
|
||||
|
||||
const backgroundColorDisplay = computed({
|
||||
get: () => toHexFromFormat(backgroundColor.value, 'hex'),
|
||||
set: (val: unknown) => {
|
||||
backgroundColor.value = toHexFromFormat(val, 'hex')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -20,7 +20,7 @@
|
||||
class="w-full justify-between text-sm font-light"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
@click="onToggleDockedJobHistory(close)"
|
||||
@click="onToggleDockedJobHistory"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<i
|
||||
@@ -79,7 +79,6 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'clearHistory'): void
|
||||
@@ -87,7 +86,6 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n()
|
||||
const settingStore = useSettingStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
|
||||
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
|
||||
const isQueuePanelV2Enabled = computed(() =>
|
||||
@@ -100,22 +98,7 @@ const onClearHistoryFromMenu = (close: () => void) => {
|
||||
emit('clearHistory')
|
||||
}
|
||||
|
||||
const onToggleDockedJobHistory = async (close: () => void) => {
|
||||
close()
|
||||
|
||||
try {
|
||||
if (isQueuePanelV2Enabled.value) {
|
||||
await settingStore.setMany({
|
||||
'Comfy.Queue.QPOV2': false,
|
||||
'Comfy.Queue.History.Expanded': true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
sidebarTabStore.activeSidebarTabId = 'job-history'
|
||||
await settingStore.set('Comfy.Queue.QPOV2', true)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const onToggleDockedJobHistory = async () => {
|
||||
await settingStore.set('Comfy.Queue.QPOV2', !isQueuePanelV2Enabled.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -23,27 +23,18 @@ vi.mock('@/components/ui/Popover.vue', () => {
|
||||
return { default: PopoverStub }
|
||||
})
|
||||
|
||||
const mockGetSetting = vi.fn<(key: string) => boolean | undefined>((key) =>
|
||||
const mockGetSetting = vi.fn((key: string) =>
|
||||
key === 'Comfy.Queue.QPOV2' ? 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
|
||||
set: mockSetSetting
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
|
||||
useSidebarTabStore: () => mockSidebarTabStore
|
||||
}))
|
||||
|
||||
import QueueOverlayHeader from './QueueOverlayHeader.vue'
|
||||
import * as tooltipConfig from '@/composables/useTooltipConfig'
|
||||
|
||||
@@ -90,11 +81,6 @@ describe('QueueOverlayHeader', () => {
|
||||
beforeEach(() => {
|
||||
popoverCloseSpy.mockClear()
|
||||
mockSetSetting.mockClear()
|
||||
mockSetMany.mockClear()
|
||||
mockSidebarTabStore.activeSidebarTabId = null
|
||||
mockGetSetting.mockImplementation((key: string) =>
|
||||
key === 'Comfy.Queue.QPOV2' ? true : undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('renders header title', () => {
|
||||
@@ -139,7 +125,7 @@ describe('QueueOverlayHeader', () => {
|
||||
expect(wrapper.emitted('clearHistory')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('opens floating queue progress overlay when disabling from the menu', async () => {
|
||||
it('toggles docked job history setting from the menu', async () => {
|
||||
const wrapper = mountHeader()
|
||||
|
||||
const dockedJobHistoryButton = wrapper.get(
|
||||
@@ -147,64 +133,7 @@ describe('QueueOverlayHeader', () => {
|
||||
)
|
||||
await dockedJobHistoryButton.trigger('click')
|
||||
|
||||
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetMany).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetMany).toHaveBeenCalledWith({
|
||||
'Comfy.Queue.QPOV2': false,
|
||||
'Comfy.Queue.History.Expanded': true
|
||||
})
|
||||
expect(mockSetSetting).not.toHaveBeenCalled()
|
||||
expect(mockSidebarTabStore.activeSidebarTabId).toBe(null)
|
||||
})
|
||||
|
||||
it('opens docked job history sidebar when enabling from the menu', async () => {
|
||||
mockGetSetting.mockImplementation((key: string) =>
|
||||
key === 'Comfy.Queue.QPOV2' ? false : undefined
|
||||
)
|
||||
const wrapper = mountHeader()
|
||||
|
||||
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('keeps docked target open even when enabling persistence fails', async () => {
|
||||
mockGetSetting.mockImplementation((key: string) =>
|
||||
key === 'Comfy.Queue.QPOV2' ? false : undefined
|
||||
)
|
||||
mockSetSetting.mockRejectedValueOnce(new Error('persistence failed'))
|
||||
const wrapper = mountHeader()
|
||||
|
||||
const dockedJobHistoryButton = wrapper.get(
|
||||
'[data-testid="docked-job-history-action"]'
|
||||
)
|
||||
await dockedJobHistoryButton.trigger('click')
|
||||
|
||||
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetSetting).toHaveBeenCalledWith('Comfy.Queue.QPOV2', true)
|
||||
expect(mockSidebarTabStore.activeSidebarTabId).toBe('job-history')
|
||||
})
|
||||
|
||||
it('closes the menu when disabling persistence fails', async () => {
|
||||
mockSetMany.mockRejectedValueOnce(new Error('persistence failed'))
|
||||
const wrapper = mountHeader()
|
||||
|
||||
const dockedJobHistoryButton = wrapper.get(
|
||||
'[data-testid="docked-job-history-action"]'
|
||||
)
|
||||
await dockedJobHistoryButton.trigger('click')
|
||||
|
||||
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetMany).toHaveBeenCalledWith({
|
||||
'Comfy.Queue.QPOV2': false,
|
||||
'Comfy.Queue.History.Expanded': true
|
||||
})
|
||||
expect(mockSetSetting).toHaveBeenCalledWith('Comfy.Queue.QPOV2', false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
|
||||
import type { JobListItem as ApiJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
|
||||
|
||||
import JobAssetsList from './JobAssetsList.vue'
|
||||
|
||||
vi.mock('vue-i18n', () => {
|
||||
return {
|
||||
createI18n: () => ({
|
||||
global: {
|
||||
t: (key: string) => key,
|
||||
te: () => true,
|
||||
d: (value: string) => value
|
||||
}
|
||||
}),
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const createResultItem = (
|
||||
filename: string,
|
||||
mediaType: string = 'images'
|
||||
): ResultItemImpl => {
|
||||
const item = new ResultItemImpl({
|
||||
filename,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: 'node-1',
|
||||
mediaType
|
||||
})
|
||||
Object.defineProperty(item, 'url', {
|
||||
get: () => `/api/view/${filename}`
|
||||
})
|
||||
return item
|
||||
}
|
||||
|
||||
const createTaskRef = (preview?: ResultItemImpl): TaskItemImpl => {
|
||||
const job: ApiJobListItem = {
|
||||
id: `task-${Math.random().toString(36).slice(2)}`,
|
||||
status: 'completed',
|
||||
create_time: Date.now(),
|
||||
preview_output: null,
|
||||
outputs_count: preview ? 1 : 0,
|
||||
priority: 0
|
||||
}
|
||||
const flatOutputs = preview ? [preview] : []
|
||||
return new TaskItemImpl(job, {}, flatOutputs)
|
||||
}
|
||||
|
||||
const buildJob = (overrides: Partial<JobListItem> = {}): JobListItem => ({
|
||||
id: 'job-1',
|
||||
title: 'Job 1',
|
||||
meta: 'meta',
|
||||
state: 'completed',
|
||||
taskRef: createTaskRef(createResultItem('job-1.png')),
|
||||
...overrides
|
||||
})
|
||||
|
||||
const mountJobAssetsList = (jobs: JobListItem[]) => {
|
||||
const displayedJobGroups: JobGroup[] = [
|
||||
{
|
||||
key: 'group-1',
|
||||
label: 'Group 1',
|
||||
items: jobs
|
||||
}
|
||||
]
|
||||
|
||||
return mount(JobAssetsList, {
|
||||
props: { displayedJobGroups }
|
||||
})
|
||||
}
|
||||
|
||||
describe('JobAssetsList', () => {
|
||||
it('emits viewItem on preview-click for completed jobs with preview', async () => {
|
||||
const job = buildJob()
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
|
||||
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
|
||||
listItem.vm.$emit('preview-click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.emitted('viewItem')).toEqual([[job]])
|
||||
})
|
||||
|
||||
it('emits viewItem on double-click for completed jobs with preview', async () => {
|
||||
const job = buildJob()
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
|
||||
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
|
||||
await listItem.trigger('dblclick')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.emitted('viewItem')).toEqual([[job]])
|
||||
})
|
||||
|
||||
it('emits viewItem on double-click for completed video jobs without icon image', async () => {
|
||||
const job = buildJob({
|
||||
iconImageUrl: undefined,
|
||||
taskRef: createTaskRef(createResultItem('job-1.webm', 'video'))
|
||||
})
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
|
||||
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
|
||||
expect(listItem.props('previewUrl')).toBe('/api/view/job-1.webm')
|
||||
expect(listItem.props('isVideoPreview')).toBe(true)
|
||||
|
||||
await listItem.trigger('dblclick')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.emitted('viewItem')).toEqual([[job]])
|
||||
})
|
||||
|
||||
it('emits viewItem on icon click for completed 3D jobs without preview tile', async () => {
|
||||
const job = buildJob({
|
||||
iconImageUrl: undefined,
|
||||
taskRef: createTaskRef(createResultItem('job-1.glb', 'model'))
|
||||
})
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
|
||||
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
|
||||
|
||||
await listItem.find('i').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.emitted('viewItem')).toEqual([[job]])
|
||||
})
|
||||
|
||||
it('does not emit viewItem on double-click for non-completed jobs', async () => {
|
||||
const job = buildJob({
|
||||
state: 'running',
|
||||
taskRef: createTaskRef(createResultItem('job-1.png'))
|
||||
})
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
|
||||
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
|
||||
await listItem.trigger('dblclick')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.emitted('viewItem')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -12,8 +12,7 @@
|
||||
v-for="job in group.items"
|
||||
:key="job.id"
|
||||
class="w-full shrink-0 cursor-default text-text-primary transition-colors hover:bg-secondary-background-hover"
|
||||
:preview-url="getJobPreviewUrl(job)"
|
||||
:is-video-preview="isVideoPreviewJob(job)"
|
||||
:preview-url="job.iconImageUrl"
|
||||
:preview-alt="job.title"
|
||||
:icon-name="job.iconName ?? iconForJobState(job.state)"
|
||||
:icon-class="getJobIconClass(job)"
|
||||
@@ -24,8 +23,6 @@
|
||||
@mouseenter="hoveredJobId = job.id"
|
||||
@mouseleave="onJobLeave(job.id)"
|
||||
@contextmenu.prevent.stop="$emit('menu', job, $event)"
|
||||
@dblclick.stop="emitViewItem(job)"
|
||||
@preview-click="emitViewItem(job)"
|
||||
@click.stop
|
||||
>
|
||||
<template v-if="hoveredJobId === job.id" #actions>
|
||||
@@ -81,7 +78,7 @@ import { isActiveJobState } from '@/utils/queueUtil'
|
||||
|
||||
defineProps<{ displayedJobGroups: JobGroup[] }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
defineEmits<{
|
||||
(e: 'cancelItem', item: JobListItem): void
|
||||
(e: 'deleteItem', item: JobListItem): void
|
||||
(e: 'menu', item: JobListItem, ev: MouseEvent): void
|
||||
@@ -103,28 +100,6 @@ const isCancelable = (job: JobListItem) =>
|
||||
const isFailedDeletable = (job: JobListItem) =>
|
||||
job.showClear !== false && job.state === 'failed'
|
||||
|
||||
const getPreviewOutput = (job: JobListItem) => job.taskRef?.previewOutput
|
||||
|
||||
const getJobPreviewUrl = (job: JobListItem) => {
|
||||
const preview = getPreviewOutput(job)
|
||||
if (preview?.isImage || preview?.isVideo) {
|
||||
return preview.url
|
||||
}
|
||||
return job.iconImageUrl
|
||||
}
|
||||
|
||||
const isVideoPreviewJob = (job: JobListItem) =>
|
||||
job.state === 'completed' && !!getPreviewOutput(job)?.isVideo
|
||||
|
||||
const isPreviewableCompletedJob = (job: JobListItem) =>
|
||||
job.state === 'completed' && !!getPreviewOutput(job)
|
||||
|
||||
const emitViewItem = (job: JobListItem) => {
|
||||
if (isPreviewableCompletedJob(job)) {
|
||||
emit('viewItem', job)
|
||||
}
|
||||
}
|
||||
|
||||
const getJobIconClass = (job: JobListItem): string | undefined => {
|
||||
const iconName = job.iconName ?? iconForJobState(job.state)
|
||||
if (!job.iconImageUrl && iconName === iconForJobState('pending')) {
|
||||
|
||||
@@ -131,11 +131,11 @@ export const Queued: Story = {
|
||||
const exec = useExecutionStore()
|
||||
|
||||
const jobId = 'job-queued-1'
|
||||
const priority = 104
|
||||
const queueIndex = 104
|
||||
|
||||
// Current job in pending
|
||||
queue.pendingTasks = [
|
||||
makePendingTask(jobId, priority, Date.now() - 90_000)
|
||||
makePendingTask(jobId, queueIndex, Date.now() - 90_000)
|
||||
]
|
||||
// Add some other pending jobs to give context
|
||||
queue.pendingTasks.push(
|
||||
@@ -179,13 +179,13 @@ export const QueuedParallel: Story = {
|
||||
const exec = useExecutionStore()
|
||||
|
||||
const jobId = 'job-queued-parallel'
|
||||
const priority = 210
|
||||
const queueIndex = 210
|
||||
|
||||
// Current job in pending with some ahead
|
||||
queue.pendingTasks = [
|
||||
makePendingTask('job-ahead-1', 200, Date.now() - 180_000),
|
||||
makePendingTask('job-ahead-2', 205, Date.now() - 150_000),
|
||||
makePendingTask(jobId, priority, Date.now() - 120_000)
|
||||
makePendingTask(jobId, queueIndex, Date.now() - 120_000)
|
||||
]
|
||||
|
||||
// Seen 2 minutes ago - set via prompt metadata above
|
||||
@@ -238,9 +238,9 @@ export const Running: Story = {
|
||||
const exec = useExecutionStore()
|
||||
|
||||
const jobId = 'job-running-1'
|
||||
const priority = 300
|
||||
const queueIndex = 300
|
||||
queue.runningTasks = [
|
||||
makeRunningTask(jobId, priority, Date.now() - 65_000)
|
||||
makeRunningTask(jobId, queueIndex, Date.now() - 65_000)
|
||||
]
|
||||
queue.historyTasks = [
|
||||
makeHistoryTask('hist-r1', 250, 30, true),
|
||||
@@ -279,10 +279,10 @@ export const QueuedZeroAheadSingleRunning: Story = {
|
||||
const exec = useExecutionStore()
|
||||
|
||||
const jobId = 'job-queued-zero-ahead-single'
|
||||
const priority = 510
|
||||
const queueIndex = 510
|
||||
|
||||
queue.pendingTasks = [
|
||||
makePendingTask(jobId, priority, Date.now() - 45_000)
|
||||
makePendingTask(jobId, queueIndex, Date.now() - 45_000)
|
||||
]
|
||||
|
||||
queue.historyTasks = [
|
||||
@@ -324,10 +324,10 @@ export const QueuedZeroAheadMultiRunning: Story = {
|
||||
const exec = useExecutionStore()
|
||||
|
||||
const jobId = 'job-queued-zero-ahead-multi'
|
||||
const priority = 520
|
||||
const queueIndex = 520
|
||||
|
||||
queue.pendingTasks = [
|
||||
makePendingTask(jobId, priority, Date.now() - 20_000)
|
||||
makePendingTask(jobId, queueIndex, Date.now() - 20_000)
|
||||
]
|
||||
|
||||
queue.historyTasks = [
|
||||
@@ -380,8 +380,8 @@ export const Completed: Story = {
|
||||
const queue = useQueueStore()
|
||||
|
||||
const jobId = 'job-completed-1'
|
||||
const priority = 400
|
||||
queue.historyTasks = [makeHistoryTask(jobId, priority, 37, true)]
|
||||
const queueIndex = 400
|
||||
queue.historyTasks = [makeHistoryTask(jobId, queueIndex, 37, true)]
|
||||
|
||||
return { args: { ...args, jobId } }
|
||||
},
|
||||
@@ -401,11 +401,11 @@ export const Failed: Story = {
|
||||
const queue = useQueueStore()
|
||||
|
||||
const jobId = 'job-failed-1'
|
||||
const priority = 410
|
||||
const queueIndex = 410
|
||||
queue.historyTasks = [
|
||||
makeHistoryTask(
|
||||
jobId,
|
||||
priority,
|
||||
queueIndex,
|
||||
12,
|
||||
false,
|
||||
'Example error: invalid inputs for node X'
|
||||
|
||||
@@ -166,16 +166,16 @@ const queuedAtValue = computed(() =>
|
||||
: ''
|
||||
)
|
||||
|
||||
const currentJobPriority = computed<number | null>(() => {
|
||||
const currentQueueIndex = computed<number | null>(() => {
|
||||
const task = taskForJob.value
|
||||
return task ? Number(task.job.priority) : null
|
||||
return task ? Number(task.queueIndex) : null
|
||||
})
|
||||
|
||||
const jobsAhead = computed<number | null>(() => {
|
||||
const idx = currentJobPriority.value
|
||||
const idx = currentQueueIndex.value
|
||||
if (idx == null) return null
|
||||
const ahead = queueStore.pendingTasks.filter(
|
||||
(t: TaskItemImpl) => Number(t.job.priority) < idx
|
||||
(t: TaskItemImpl) => Number(t.queueIndex) < idx
|
||||
)
|
||||
return ahead.length
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
v-for="tab in visibleJobTabs"
|
||||
:key="tab"
|
||||
:variant="selectedJobTab === tab ? 'secondary' : 'muted-textonly'"
|
||||
size="md"
|
||||
size="sm"
|
||||
@click="$emit('update:selectedJobTab', tab)"
|
||||
>
|
||||
{{ tabLabel(tab) }}
|
||||
|
||||
@@ -40,8 +40,7 @@ const rightSidePanelStore = useRightSidePanelStore()
|
||||
const settingStore = useSettingStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const { hasAnyError, allErrorExecutionIds, activeMissingNodeGraphIds } =
|
||||
storeToRefs(executionErrorStore)
|
||||
const { hasAnyError, allErrorExecutionIds } = storeToRefs(executionErrorStore)
|
||||
|
||||
const { findParentGroup } = useGraphHierarchy()
|
||||
|
||||
@@ -110,21 +109,9 @@ const hasContainerInternalError = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const hasMissingNodeSelected = computed(
|
||||
() =>
|
||||
hasSelection.value &&
|
||||
selectedNodes.value.some((node) =>
|
||||
activeMissingNodeGraphIds.value.has(String(node.id))
|
||||
)
|
||||
)
|
||||
|
||||
const hasRelevantErrors = computed(() => {
|
||||
if (!hasSelection.value) return hasAnyError.value
|
||||
return (
|
||||
hasDirectNodeError.value ||
|
||||
hasContainerInternalError.value ||
|
||||
hasMissingNodeSelected.value
|
||||
)
|
||||
return hasDirectNodeError.value || hasContainerInternalError.value
|
||||
})
|
||||
|
||||
const tabs = computed<RightSidePanelTabList>(() => {
|
||||
|
||||
@@ -17,26 +17,24 @@
|
||||
>
|
||||
{{ card.nodeTitle }}
|
||||
</span>
|
||||
<div class="flex items-center shrink-0">
|
||||
<Button
|
||||
v-if="card.isSubgraphNode"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="rounded-lg text-sm shrink-0 h-8"
|
||||
@click.stop="handleEnterSubgraph"
|
||||
>
|
||||
{{ t('rightSidePanel.enterSubgraph') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 text-muted-foreground hover:text-base-foreground shrink-0"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click.stop="handleLocateNode"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
v-if="card.isSubgraphNode"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="rounded-lg text-sm shrink-0"
|
||||
@click.stop="handleEnterSubgraph"
|
||||
>
|
||||
{{ t('rightSidePanel.enterSubgraph') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-7 text-muted-foreground hover:text-base-foreground shrink-0"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click.stop="handleLocateNode"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Multiple Errors within one Card -->
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
<template>
|
||||
<div class="px-4 pb-2">
|
||||
<!-- Sub-label: cloud or OSS message shown above all pack groups -->
|
||||
<p class="m-0 pb-5 text-sm text-muted-foreground leading-relaxed">
|
||||
{{
|
||||
isCloud
|
||||
? t('rightSidePanel.missingNodePacks.cloudMessage')
|
||||
: t('rightSidePanel.missingNodePacks.ossMessage')
|
||||
}}
|
||||
</p>
|
||||
<MissingPackGroupRow
|
||||
v-for="group in missingPackGroups"
|
||||
:key="group.packId ?? '__unknown__'"
|
||||
:group="group"
|
||||
:show-info-button="showInfoButton"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-node="emit('locateNode', $event)"
|
||||
@open-manager-info="emit('openManagerInfo', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Apply Changes: shown when manager enabled and at least one pack install succeeded -->
|
||||
<div v-if="shouldShowManagerButtons" class="px-4">
|
||||
<Button
|
||||
v-if="hasInstalledPacksPendingRestart"
|
||||
variant="primary"
|
||||
:disabled="isRestarting"
|
||||
class="w-full h-9 justify-center gap-2 text-sm font-semibold mt-2"
|
||||
@click="applyChanges()"
|
||||
>
|
||||
<DotSpinner v-if="isRestarting" duration="1s" :size="14" />
|
||||
<i v-else class="icon-[lucide--refresh-cw] size-4 shrink-0" />
|
||||
<span class="truncate min-w-0">{{
|
||||
t('rightSidePanel.missingNodePacks.applyChanges')
|
||||
}}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
import { useApplyChanges } from '@/workbench/extensions/manager/composables/useApplyChanges'
|
||||
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
import MissingPackGroupRow from '@/components/rightSidePanel/errors/MissingPackGroupRow.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
showInfoButton: boolean
|
||||
showNodeIdBadge: boolean
|
||||
missingPackGroups: MissingPackGroup[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
locateNode: [nodeId: string]
|
||||
openManagerInfo: [packId: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
const { isRestarting, applyChanges } = useApplyChanges()
|
||||
const { shouldShowManagerButtons } = useManagerState()
|
||||
|
||||
/**
|
||||
* Show Apply Changes when any pack from the error group is already installed
|
||||
* on disk but ComfyUI hasn't restarted yet to load it.
|
||||
* This is server-state based → persists across browser refreshes.
|
||||
*/
|
||||
const hasInstalledPacksPendingRestart = computed(() =>
|
||||
props.missingPackGroups.some(
|
||||
(g) => g.packId !== null && comfyManagerStore.isPackInstalled(g.packId)
|
||||
)
|
||||
)
|
||||
</script>
|
||||
@@ -1,250 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col w-full mb-2">
|
||||
<!-- Pack header row: pack name + info + chevron -->
|
||||
<div class="flex h-8 items-center w-full">
|
||||
<!-- Warning icon for unknown packs -->
|
||||
<i
|
||||
v-if="group.packId === null && !group.isResolving"
|
||||
class="icon-[lucide--triangle-alert] size-4 text-warning-background shrink-0 mr-1.5"
|
||||
/>
|
||||
<p
|
||||
class="flex-1 min-w-0 text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
:class="
|
||||
group.packId === null && !group.isResolving
|
||||
? 'text-warning-background'
|
||||
: 'text-foreground'
|
||||
"
|
||||
>
|
||||
<span v-if="group.isResolving" class="text-muted-foreground italic">
|
||||
{{ t('g.loading') }}...
|
||||
</span>
|
||||
<span v-else>
|
||||
{{
|
||||
`${group.packId ?? t('rightSidePanel.missingNodePacks.unknownPack')} (${group.nodeTypes.length})`
|
||||
}}
|
||||
</span>
|
||||
</p>
|
||||
<Button
|
||||
v-if="showInfoButton && group.packId !== null"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 text-muted-foreground hover:text-base-foreground shrink-0"
|
||||
:aria-label="t('rightSidePanel.missingNodePacks.viewInManager')"
|
||||
@click="emit('openManagerInfo', group.packId ?? '')"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:class="
|
||||
cn(
|
||||
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
|
||||
{ 'rotate-180': expanded }
|
||||
)
|
||||
"
|
||||
:aria-label="
|
||||
expanded
|
||||
? t('rightSidePanel.missingNodePacks.collapse')
|
||||
: t('rightSidePanel.missingNodePacks.expand')
|
||||
"
|
||||
@click="toggleExpand"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--chevron-down] size-4 text-muted-foreground group-hover:text-base-foreground"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Sub-labels: individual node instances, each with their own Locate button -->
|
||||
<TransitionCollapse>
|
||||
<div
|
||||
v-if="expanded"
|
||||
class="flex flex-col gap-0.5 pl-2 mb-1 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
v-for="nodeType in group.nodeTypes"
|
||||
:key="getKey(nodeType)"
|
||||
class="flex h-7 items-center"
|
||||
>
|
||||
<span
|
||||
v-if="
|
||||
showNodeIdBadge &&
|
||||
typeof nodeType !== 'string' &&
|
||||
nodeType.nodeId != null
|
||||
"
|
||||
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 text-xs font-mono text-muted-foreground font-bold mr-1"
|
||||
>
|
||||
#{{ nodeType.nodeId }}
|
||||
</span>
|
||||
<p class="flex-1 min-w-0 text-xs text-muted-foreground truncate">
|
||||
{{ getLabel(nodeType) }}
|
||||
</p>
|
||||
<Button
|
||||
v-if="typeof nodeType !== 'string' && nodeType.nodeId != null"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-6 text-muted-foreground hover:text-base-foreground shrink-0 mr-1"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click="handleLocateNode(nodeType)"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
|
||||
<!-- Install button: shown when manager enabled, registry knows the pack or it's already installed -->
|
||||
<div
|
||||
v-if="
|
||||
shouldShowManagerButtons &&
|
||||
group.packId !== null &&
|
||||
(nodePack || comfyManagerStore.isPackInstalled(group.packId))
|
||||
"
|
||||
class="flex items-start w-full pt-1 pb-1"
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="flex flex-1 w-full"
|
||||
:disabled="
|
||||
comfyManagerStore.isPackInstalled(group.packId) || isInstalling
|
||||
"
|
||||
@click="handlePackInstallClick"
|
||||
>
|
||||
<DotSpinner
|
||||
v-if="isInstalling"
|
||||
duration="1s"
|
||||
:size="12"
|
||||
class="mr-1.5 shrink-0"
|
||||
/>
|
||||
<i
|
||||
v-else-if="comfyManagerStore.isPackInstalled(group.packId)"
|
||||
class="icon-[lucide--check] size-4 text-foreground shrink-0 mr-1"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
class="icon-[lucide--download] size-4 text-foreground shrink-0 mr-1"
|
||||
/>
|
||||
<span class="text-sm text-foreground truncate min-w-0">
|
||||
{{
|
||||
isInstalling
|
||||
? t('rightSidePanel.missingNodePacks.installing')
|
||||
: comfyManagerStore.isPackInstalled(group.packId)
|
||||
? t('rightSidePanel.missingNodePacks.installed')
|
||||
: t('rightSidePanel.missingNodePacks.installNodePack')
|
||||
}}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Registry still loading: packId known but result not yet available -->
|
||||
<div
|
||||
v-else-if="group.packId !== null && shouldShowManagerButtons && isLoading"
|
||||
class="flex items-start w-full pt-1 pb-1"
|
||||
>
|
||||
<div
|
||||
class="flex flex-1 h-8 items-center justify-center overflow-hidden p-2 rounded-lg min-w-0 bg-secondary-background opacity-60 cursor-not-allowed select-none"
|
||||
>
|
||||
<DotSpinner duration="1s" :size="12" class="mr-1.5 shrink-0" />
|
||||
<span class="text-sm text-foreground truncate min-w-0">
|
||||
{{ t('g.loading') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search in Manager: fetch done but pack not found in registry -->
|
||||
<div
|
||||
v-else-if="group.packId !== null && shouldShowManagerButtons"
|
||||
class="flex items-start w-full pt-1 pb-1"
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="flex flex-1 w-full"
|
||||
@click="
|
||||
openManager({
|
||||
initialTab: ManagerTab.All,
|
||||
initialPackId: group.packId!
|
||||
})
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--search] size-4 text-foreground shrink-0 mr-1" />
|
||||
<span class="text-sm text-foreground truncate min-w-0">
|
||||
{{ t('rightSidePanel.missingNodePacks.searchInManager') }}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
|
||||
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
|
||||
const props = defineProps<{
|
||||
group: MissingPackGroup
|
||||
showInfoButton: boolean
|
||||
showNodeIdBadge: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
locateNode: [nodeId: string]
|
||||
openManagerInfo: [packId: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { missingNodePacks, isLoading } = useMissingNodes()
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
const { shouldShowManagerButtons, openManager } = useManagerState()
|
||||
|
||||
const nodePack = computed(() => {
|
||||
if (!props.group.packId) return null
|
||||
return missingNodePacks.value.find((p) => p.id === props.group.packId) ?? null
|
||||
})
|
||||
|
||||
const { isInstalling, installAllPacks } = usePackInstall(() =>
|
||||
nodePack.value ? [nodePack.value] : []
|
||||
)
|
||||
|
||||
function handlePackInstallClick() {
|
||||
if (!props.group.packId) return
|
||||
if (!comfyManagerStore.isPackInstalled(props.group.packId)) {
|
||||
void installAllPacks()
|
||||
}
|
||||
}
|
||||
|
||||
const expanded = ref(false)
|
||||
|
||||
function toggleExpand() {
|
||||
expanded.value = !expanded.value
|
||||
}
|
||||
|
||||
function getKey(nodeType: MissingNodeType): string {
|
||||
if (typeof nodeType === 'string') return nodeType
|
||||
return nodeType.nodeId != null ? String(nodeType.nodeId) : nodeType.type
|
||||
}
|
||||
|
||||
function getLabel(nodeType: MissingNodeType): string {
|
||||
return typeof nodeType === 'string' ? nodeType : nodeType.type
|
||||
}
|
||||
|
||||
function handleLocateNode(nodeType: MissingNodeType) {
|
||||
if (typeof nodeType === 'string') return
|
||||
if (nodeType.nodeId != null) {
|
||||
emit('locateNode', String(nodeType.nodeId))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,150 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col w-full mb-4">
|
||||
<!-- Type header row: type name + chevron -->
|
||||
<div class="flex h-8 items-center w-full">
|
||||
<p
|
||||
class="flex-1 min-w-0 text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap text-foreground"
|
||||
>
|
||||
{{ `${group.type} (${group.nodeTypes.length})` }}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:class="
|
||||
cn(
|
||||
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
|
||||
{ 'rotate-180': expanded }
|
||||
)
|
||||
"
|
||||
:aria-label="
|
||||
expanded
|
||||
? t('rightSidePanel.missingNodePacks.collapse', 'Collapse')
|
||||
: t('rightSidePanel.missingNodePacks.expand', 'Expand')
|
||||
"
|
||||
@click="toggleExpand"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--chevron-down] size-4 text-muted-foreground group-hover:text-base-foreground"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Sub-labels: individual node instances, each with their own Locate button -->
|
||||
<TransitionCollapse>
|
||||
<div
|
||||
v-if="expanded"
|
||||
class="flex flex-col gap-0.5 pl-2 mb-2 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
v-for="nodeType in group.nodeTypes"
|
||||
:key="getKey(nodeType)"
|
||||
class="flex h-7 items-center"
|
||||
>
|
||||
<span
|
||||
v-if="
|
||||
showNodeIdBadge &&
|
||||
typeof nodeType !== 'string' &&
|
||||
nodeType.nodeId != null
|
||||
"
|
||||
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 text-xs font-mono text-muted-foreground font-bold mr-1"
|
||||
>
|
||||
#{{ nodeType.nodeId }}
|
||||
</span>
|
||||
<p class="flex-1 min-w-0 text-xs text-muted-foreground truncate">
|
||||
{{ getLabel(nodeType) }}
|
||||
</p>
|
||||
<Button
|
||||
v-if="typeof nodeType !== 'string' && nodeType.nodeId != null"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-6 text-muted-foreground hover:text-base-foreground shrink-0 mr-1"
|
||||
:aria-label="t('rightSidePanel.locateNode', 'Locate Node')"
|
||||
@click="handleLocateNode(nodeType)"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
|
||||
<!-- Description rows: what it is replaced by -->
|
||||
<div class="flex flex-col text-[13px] mb-2 mt-1 px-1 gap-0.5">
|
||||
<span class="text-muted-foreground">{{
|
||||
t('nodeReplacement.willBeReplacedBy', 'This node will be replaced by:')
|
||||
}}</span>
|
||||
<span class="font-bold text-foreground">{{
|
||||
group.newNodeId ?? t('nodeReplacement.unknownNode', 'Unknown')
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<!-- Replace Action Button -->
|
||||
<div class="flex items-start w-full pt-1 pb-1">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="flex flex-1 w-full"
|
||||
@click="handleReplaceNode"
|
||||
>
|
||||
<i class="icon-[lucide--repeat] size-4 text-foreground shrink-0 mr-1" />
|
||||
<span class="text-sm text-foreground truncate min-w-0">
|
||||
{{ t('nodeReplacement.replaceNode', 'Replace Node') }}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import type { SwapNodeGroup } from './useErrorGroups'
|
||||
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
|
||||
const props = defineProps<{
|
||||
group: SwapNodeGroup
|
||||
showNodeIdBadge: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'locate-node': [nodeId: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { replaceNodesInPlace } = useNodeReplacement()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
|
||||
const expanded = ref(false)
|
||||
|
||||
function toggleExpand() {
|
||||
expanded.value = !expanded.value
|
||||
}
|
||||
|
||||
function getKey(nodeType: MissingNodeType): string {
|
||||
if (typeof nodeType === 'string') return nodeType
|
||||
return nodeType.nodeId != null ? String(nodeType.nodeId) : nodeType.type
|
||||
}
|
||||
|
||||
function getLabel(nodeType: MissingNodeType): string {
|
||||
return typeof nodeType === 'string' ? nodeType : nodeType.type
|
||||
}
|
||||
|
||||
function handleLocateNode(nodeType: MissingNodeType) {
|
||||
if (typeof nodeType === 'string') return
|
||||
if (nodeType.nodeId != null) {
|
||||
emit('locate-node', String(nodeType.nodeId))
|
||||
}
|
||||
}
|
||||
|
||||
function handleReplaceNode() {
|
||||
const replaced = replaceNodesInPlace(props.group.nodeTypes)
|
||||
if (replaced.length > 0) {
|
||||
executionErrorStore.removeMissingNodesByType([props.group.type])
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,38 +0,0 @@
|
||||
<template>
|
||||
<div class="px-4 pb-2 mt-2">
|
||||
<!-- Sub-label: guidance message shown above all swap groups -->
|
||||
<p class="m-0 pb-5 text-sm text-muted-foreground leading-relaxed">
|
||||
{{
|
||||
t(
|
||||
'nodeReplacement.swapNodesGuide',
|
||||
'The following nodes can be automatically replaced with compatible alternatives.'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<!-- Group Rows -->
|
||||
<SwapNodeGroupRow
|
||||
v-for="group in swapNodeGroups"
|
||||
:key="group.type"
|
||||
:group="group"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-node="emit('locate-node', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { SwapNodeGroup } from './useErrorGroups'
|
||||
import SwapNodeGroupRow from './SwapNodeGroupRow.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { swapNodeGroups, showNodeIdBadge } = defineProps<{
|
||||
swapNodeGroups: SwapNodeGroup[]
|
||||
showNodeIdBadge: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'locate-node': [nodeId: string]
|
||||
}>()
|
||||
</script>
|
||||
@@ -18,8 +18,7 @@ vi.mock('@/scripts/app', () => ({
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
getNodeByExecutionId: vi.fn(),
|
||||
getRootParentNode: vi.fn(() => null),
|
||||
forEachNode: vi.fn(),
|
||||
mapAllNodes: vi.fn(() => [])
|
||||
forEachNode: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useCopyToClipboard', () => ({
|
||||
|
||||
@@ -27,11 +27,6 @@
|
||||
:key="group.title"
|
||||
:collapse="collapseState[group.title] ?? false"
|
||||
class="border-b border-interface-stroke"
|
||||
:size="
|
||||
group.type === 'missing_node' || group.type === 'swap_nodes'
|
||||
? 'lg'
|
||||
: 'default'
|
||||
"
|
||||
@update:collapse="collapseState[group.title] = $event"
|
||||
>
|
||||
<template #label>
|
||||
@@ -41,78 +36,20 @@
|
||||
class="icon-[lucide--octagon-alert] size-4 text-destructive-background-hover shrink-0"
|
||||
/>
|
||||
<span class="text-destructive-background-hover truncate">
|
||||
{{
|
||||
group.type === 'missing_node'
|
||||
? `${group.title} (${missingPackGroups.length})`
|
||||
: group.type === 'swap_nodes'
|
||||
? `${group.title} (${swapNodeGroups.length})`
|
||||
: group.title
|
||||
}}
|
||||
{{ group.title }}
|
||||
</span>
|
||||
<span
|
||||
v-if="group.type === 'execution' && group.cards.length > 1"
|
||||
v-if="group.cards.length > 1"
|
||||
class="text-destructive-background-hover"
|
||||
>
|
||||
({{ group.cards.length }})
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
v-if="
|
||||
group.type === 'missing_node' &&
|
||||
missingNodePacks.length > 0 &&
|
||||
shouldShowInstallButton
|
||||
"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0 mr-2 h-8 rounded-lg text-sm"
|
||||
:disabled="isInstallingAll"
|
||||
@click.stop="installAll"
|
||||
>
|
||||
<DotSpinner v-if="isInstallingAll" duration="1s" :size="12" />
|
||||
{{
|
||||
isInstallingAll
|
||||
? t('rightSidePanel.missingNodePacks.installing')
|
||||
: t('rightSidePanel.missingNodePacks.installAll')
|
||||
}}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="group.type === 'swap_nodes'"
|
||||
v-tooltip.top="
|
||||
t(
|
||||
'nodeReplacement.replaceAllWarning',
|
||||
'Replaces all available nodes in this group.'
|
||||
)
|
||||
"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0 mr-2 h-8 rounded-lg text-sm"
|
||||
@click.stop="handleReplaceAll()"
|
||||
>
|
||||
{{ t('nodeReplacement.replaceAll', 'Replace All') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Missing Node Packs -->
|
||||
<MissingNodeCard
|
||||
v-if="group.type === 'missing_node'"
|
||||
:show-info-button="shouldShowManagerButtons"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
:missing-pack-groups="missingPackGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@open-manager-info="handleOpenManagerInfo"
|
||||
/>
|
||||
|
||||
<!-- Swap Nodes -->
|
||||
<SwapNodesCard
|
||||
v-else-if="group.type === 'swap_nodes'"
|
||||
:swap-node-groups="swapNodeGroups"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
/>
|
||||
|
||||
<!-- Execution Errors -->
|
||||
<div v-else-if="group.type === 'execution'" class="px-4 space-y-3">
|
||||
<!-- Cards in Group (default slot) -->
|
||||
<div class="px-4 space-y-3">
|
||||
<ErrorNodeCard
|
||||
v-for="card in group.cards"
|
||||
:key="card.id"
|
||||
@@ -171,22 +108,13 @@ import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
|
||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import MissingNodeCard from './MissingNodeCard.vue'
|
||||
import SwapNodesCard from './SwapNodesCard.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { useErrorGroups } from './useErrorGroups'
|
||||
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
@@ -194,13 +122,6 @@ const { focusNode, enterSubgraph } = useFocusNode()
|
||||
const { staticUrls } = useExternalLink()
|
||||
const settingStore = useSettingStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
|
||||
useManagerState()
|
||||
const { missingNodePacks } = useMissingNodes()
|
||||
const { isInstalling: isInstallingAll, installAllPacks: installAll } =
|
||||
usePackInstall(() => missingNodePacks.value)
|
||||
const { replaceNodesInPlace } = useNodeReplacement()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
|
||||
const searchQuery = ref('')
|
||||
|
||||
@@ -215,10 +136,7 @@ const {
|
||||
filteredGroups,
|
||||
collapseState,
|
||||
isSingleNodeSelected,
|
||||
errorNodeCache,
|
||||
missingNodeCache,
|
||||
missingPackGroups,
|
||||
swapNodeGroups
|
||||
errorNodeCache
|
||||
} = useErrorGroups(searchQuery, t)
|
||||
|
||||
/**
|
||||
@@ -233,13 +151,11 @@ watch(
|
||||
if (!graphNodeId) return
|
||||
const prefix = `${graphNodeId}:`
|
||||
for (const group of allErrorGroups.value) {
|
||||
const hasMatch =
|
||||
group.type === 'execution' &&
|
||||
group.cards.some(
|
||||
(card) =>
|
||||
card.graphNodeId === graphNodeId ||
|
||||
(card.nodeId?.startsWith(prefix) ?? false)
|
||||
)
|
||||
const hasMatch = group.cards.some(
|
||||
(card) =>
|
||||
card.graphNodeId === graphNodeId ||
|
||||
(card.nodeId?.startsWith(prefix) ?? false)
|
||||
)
|
||||
collapseState[group.title] = !hasMatch
|
||||
}
|
||||
rightSidePanelStore.focusedErrorNodeId = null
|
||||
@@ -251,27 +167,6 @@ function handleLocateNode(nodeId: string) {
|
||||
focusNode(nodeId, errorNodeCache.value)
|
||||
}
|
||||
|
||||
function handleLocateMissingNode(nodeId: string) {
|
||||
focusNode(nodeId, missingNodeCache.value)
|
||||
}
|
||||
|
||||
function handleOpenManagerInfo(packId: string) {
|
||||
const isKnownToRegistry = missingNodePacks.value.some((p) => p.id === packId)
|
||||
if (isKnownToRegistry) {
|
||||
openManager({ initialTab: ManagerTab.Missing, initialPackId: packId })
|
||||
} else {
|
||||
openManager({ initialTab: ManagerTab.All, initialPackId: packId })
|
||||
}
|
||||
}
|
||||
|
||||
function handleReplaceAll() {
|
||||
const allNodeTypes = swapNodeGroups.value.flatMap((g) => g.nodeTypes)
|
||||
const replaced = replaceNodesInPlace(allNodeTypes)
|
||||
if (replaced.length > 0) {
|
||||
executionErrorStore.removeMissingNodesByType(replaced)
|
||||
}
|
||||
}
|
||||
|
||||
function handleEnterSubgraph(nodeId: string) {
|
||||
enterSubgraph(nodeId, errorNodeCache.value)
|
||||
}
|
||||
|
||||
@@ -14,12 +14,8 @@ export interface ErrorCardData {
|
||||
errors: ErrorItem[]
|
||||
}
|
||||
|
||||
export type ErrorGroup =
|
||||
| {
|
||||
type: 'execution'
|
||||
title: string
|
||||
cards: ErrorCardData[]
|
||||
priority: number
|
||||
}
|
||||
| { type: 'missing_node'; title: string; priority: number }
|
||||
| { type: 'swap_nodes'; title: string; priority: number }
|
||||
export interface ErrorGroup {
|
||||
title: string
|
||||
cards: ErrorCardData[]
|
||||
priority: number
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { computed, reactive } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import Fuse from 'fuse.js'
|
||||
import type { IFuseOptions } from 'fuse.js'
|
||||
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
import { app } from '@/scripts/app'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -20,7 +20,6 @@ import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { isGroupNode } from '@/utils/executableGroupNodeDto'
|
||||
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'
|
||||
@@ -33,23 +32,7 @@ const KNOWN_PROMPT_ERROR_TYPES = new Set([
|
||||
'server_error'
|
||||
])
|
||||
|
||||
/** Sentinel: distinguishes "fetch in-flight" from "fetch done, pack not found (null)". */
|
||||
const RESOLVING = '__RESOLVING__'
|
||||
|
||||
export interface MissingPackGroup {
|
||||
packId: string | null
|
||||
nodeTypes: MissingNodeType[]
|
||||
isResolving: boolean
|
||||
}
|
||||
|
||||
export interface SwapNodeGroup {
|
||||
type: string
|
||||
newNodeId: string | undefined
|
||||
nodeTypes: MissingNodeType[]
|
||||
}
|
||||
|
||||
interface GroupEntry {
|
||||
type: 'execution'
|
||||
priority: number
|
||||
cards: Map<string, ErrorCardData>
|
||||
}
|
||||
@@ -93,7 +76,7 @@ function getOrCreateGroup(
|
||||
): Map<string, ErrorCardData> {
|
||||
let entry = groupsMap.get(title)
|
||||
if (!entry) {
|
||||
entry = { type: 'execution', priority, cards: new Map() }
|
||||
entry = { priority, cards: new Map() }
|
||||
groupsMap.set(title, entry)
|
||||
}
|
||||
return entry.cards
|
||||
@@ -154,7 +137,6 @@ function addCardErrorToGroup(
|
||||
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()),
|
||||
priority: groupData.priority
|
||||
@@ -171,7 +153,6 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
|
||||
const searchableList: ErrorSearchItem[] = []
|
||||
for (let gi = 0; gi < groups.length; gi++) {
|
||||
const group = groups[gi]
|
||||
if (group.type !== 'execution') continue
|
||||
for (let ci = 0; ci < group.cards.length; ci++) {
|
||||
const card = group.cards[ci]
|
||||
searchableList.push({
|
||||
@@ -179,12 +160,8 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
|
||||
cardIndex: ci,
|
||||
searchableNodeId: card.nodeId ?? '',
|
||||
searchableNodeTitle: card.nodeTitle ?? '',
|
||||
searchableMessage: card.errors
|
||||
.map((e: ErrorItem) => e.message)
|
||||
.join(' '),
|
||||
searchableDetails: card.errors
|
||||
.map((e: ErrorItem) => e.details ?? '')
|
||||
.join(' ')
|
||||
searchableMessage: card.errors.map((e) => e.message).join(' '),
|
||||
searchableDetails: card.errors.map((e) => e.details ?? '').join(' ')
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -207,16 +184,11 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
|
||||
)
|
||||
|
||||
return groups
|
||||
.map((group, gi) => {
|
||||
if (group.type !== 'execution') return group
|
||||
return {
|
||||
...group,
|
||||
cards: group.cards.filter((_: ErrorCardData, ci: number) =>
|
||||
matchedCardKeys.has(`${gi}:${ci}`)
|
||||
)
|
||||
}
|
||||
})
|
||||
.filter((group) => group.type !== 'execution' || group.cards.length > 0)
|
||||
.map((group, gi) => ({
|
||||
...group,
|
||||
cards: group.cards.filter((_, ci) => matchedCardKeys.has(`${gi}:${ci}`))
|
||||
}))
|
||||
.filter((group) => group.cards.length > 0)
|
||||
}
|
||||
|
||||
export function useErrorGroups(
|
||||
@@ -225,7 +197,6 @@ export function useErrorGroups(
|
||||
) {
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { inferPackFromNodeName } = useComfyRegistryStore()
|
||||
const collapseState = reactive<Record<string, boolean>>({})
|
||||
|
||||
const selectedNodeInfo = computed(() => {
|
||||
@@ -266,19 +237,6 @@ export function useErrorGroups(
|
||||
return map
|
||||
})
|
||||
|
||||
const missingNodeCache = computed(() => {
|
||||
const map = new Map<string, LGraphNode>()
|
||||
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
|
||||
for (const nodeType of nodeTypes) {
|
||||
if (typeof nodeType === 'string') continue
|
||||
if (nodeType.nodeId == null) continue
|
||||
const nodeId = String(nodeType.nodeId)
|
||||
const node = getNodeByExecutionId(app.rootGraph, nodeId)
|
||||
if (node) map.set(nodeId, node)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
function isErrorInSelection(executionNodeId: string): boolean {
|
||||
const nodeIds = selectedNodeInfo.value.nodeIds
|
||||
if (!nodeIds) return true
|
||||
@@ -385,173 +343,6 @@ export function useErrorGroups(
|
||||
)
|
||||
}
|
||||
|
||||
// Async pack-ID resolution for missing node types that lack a cnrId
|
||||
const asyncResolvedIds = ref<Map<string, string | null>>(new Map())
|
||||
|
||||
const pendingTypes = computed(() =>
|
||||
(executionErrorStore.missingNodesError?.nodeTypes ?? []).filter(
|
||||
(n): n is Exclude<MissingNodeType, string> =>
|
||||
typeof n !== 'string' && !n.cnrId
|
||||
)
|
||||
)
|
||||
|
||||
watch(
|
||||
pendingTypes,
|
||||
async (pending, _, onCleanup) => {
|
||||
const toResolve = pending.filter(
|
||||
(n) => asyncResolvedIds.value.get(n.type) === undefined
|
||||
)
|
||||
if (!toResolve.length) return
|
||||
|
||||
const resolvingTypes = toResolve.map((n) => n.type)
|
||||
let cancelled = false
|
||||
onCleanup(() => {
|
||||
cancelled = true
|
||||
const next = new Map(asyncResolvedIds.value)
|
||||
for (const type of resolvingTypes) {
|
||||
if (next.get(type) === RESOLVING) next.delete(type)
|
||||
}
|
||||
asyncResolvedIds.value = next
|
||||
})
|
||||
|
||||
const updated = new Map(asyncResolvedIds.value)
|
||||
for (const type of resolvingTypes) updated.set(type, RESOLVING)
|
||||
asyncResolvedIds.value = updated
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
toResolve.map(async (n) => ({
|
||||
type: n.type,
|
||||
packId: (await inferPackFromNodeName.call(n.type))?.id ?? null
|
||||
}))
|
||||
)
|
||||
if (cancelled) return
|
||||
|
||||
const final = new Map(asyncResolvedIds.value)
|
||||
for (const r of results) {
|
||||
if (r.status === 'fulfilled') {
|
||||
final.set(r.value.type, r.value.packId)
|
||||
}
|
||||
}
|
||||
// Clear any remaining RESOLVING markers for failed lookups
|
||||
for (const type of resolvingTypes) {
|
||||
if (final.get(type) === RESOLVING) final.set(type, null)
|
||||
}
|
||||
asyncResolvedIds.value = final
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const missingPackGroups = computed<MissingPackGroup[]>(() => {
|
||||
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
|
||||
const map = new Map<
|
||||
string | null,
|
||||
{ nodeTypes: MissingNodeType[]; isResolving: boolean }
|
||||
>()
|
||||
const resolvingKeys = new Set<string | null>()
|
||||
|
||||
for (const nodeType of nodeTypes) {
|
||||
if (typeof nodeType !== 'string' && nodeType.isReplaceable) continue
|
||||
|
||||
let packId: string | null
|
||||
|
||||
if (typeof nodeType === 'string') {
|
||||
packId = null
|
||||
} else if (nodeType.cnrId) {
|
||||
packId = nodeType.cnrId
|
||||
} else {
|
||||
const resolved = asyncResolvedIds.value.get(nodeType.type)
|
||||
if (resolved === undefined || resolved === RESOLVING) {
|
||||
packId = null
|
||||
resolvingKeys.add(null)
|
||||
} else {
|
||||
packId = resolved
|
||||
}
|
||||
}
|
||||
|
||||
const existing = map.get(packId)
|
||||
if (existing) {
|
||||
existing.nodeTypes.push(nodeType)
|
||||
} else {
|
||||
map.set(packId, { nodeTypes: [nodeType], isResolving: false })
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of resolvingKeys) {
|
||||
const group = map.get(key)
|
||||
if (group) group.isResolving = true
|
||||
}
|
||||
|
||||
return Array.from(map.entries())
|
||||
.sort(([packIdA], [packIdB]) => {
|
||||
// null (Unknown Pack) always goes last
|
||||
if (packIdA === null) return 1
|
||||
if (packIdB === null) return -1
|
||||
return packIdA.localeCompare(packIdB)
|
||||
})
|
||||
.map(([packId, { nodeTypes, isResolving }]) => ({
|
||||
packId,
|
||||
nodeTypes: [...nodeTypes].sort((a, b) => {
|
||||
const typeA = typeof a === 'string' ? a : a.type
|
||||
const typeB = typeof b === 'string' ? b : b.type
|
||||
const typeCmp = typeA.localeCompare(typeB)
|
||||
if (typeCmp !== 0) return typeCmp
|
||||
const idA = typeof a === 'string' ? '' : String(a.nodeId ?? '')
|
||||
const idB = typeof b === 'string' ? '' : String(b.nodeId ?? '')
|
||||
return idA.localeCompare(idB, undefined, { numeric: true })
|
||||
}),
|
||||
isResolving
|
||||
}))
|
||||
})
|
||||
|
||||
const swapNodeGroups = computed<SwapNodeGroup[]>(() => {
|
||||
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
|
||||
const map = new Map<string, SwapNodeGroup>()
|
||||
|
||||
for (const nodeType of nodeTypes) {
|
||||
if (typeof nodeType === 'string' || !nodeType.isReplaceable) continue
|
||||
|
||||
const typeName = nodeType.type
|
||||
const existing = map.get(typeName)
|
||||
if (existing) {
|
||||
existing.nodeTypes.push(nodeType)
|
||||
} else {
|
||||
map.set(typeName, {
|
||||
type: typeName,
|
||||
newNodeId: nodeType.replacement?.new_node_id,
|
||||
nodeTypes: [nodeType]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(map.values()).sort((a, b) => a.type.localeCompare(b.type))
|
||||
})
|
||||
|
||||
/** Builds an ErrorGroup from missingNodesError. Returns [] when none present. */
|
||||
function buildMissingNodeGroups(): ErrorGroup[] {
|
||||
const error = executionErrorStore.missingNodesError
|
||||
if (!error) return []
|
||||
|
||||
const groups: ErrorGroup[] = []
|
||||
|
||||
if (swapNodeGroups.value.length > 0) {
|
||||
groups.push({
|
||||
type: 'swap_nodes' as const,
|
||||
title: st('nodeReplacement.swapNodesTitle', 'Swap Nodes'),
|
||||
priority: 0
|
||||
})
|
||||
}
|
||||
|
||||
if (missingPackGroups.value.length > 0) {
|
||||
groups.push({
|
||||
type: 'missing_node' as const,
|
||||
title: error.message,
|
||||
priority: 1
|
||||
})
|
||||
}
|
||||
|
||||
return groups.sort((a, b) => a.priority - b.priority)
|
||||
}
|
||||
|
||||
const allErrorGroups = computed<ErrorGroup[]>(() => {
|
||||
const groupsMap = new Map<string, GroupEntry>()
|
||||
|
||||
@@ -559,7 +350,7 @@ export function useErrorGroups(
|
||||
processNodeErrors(groupsMap)
|
||||
processExecutionError(groupsMap)
|
||||
|
||||
return [...buildMissingNodeGroups(), ...toSortedGroups(groupsMap)]
|
||||
return toSortedGroups(groupsMap)
|
||||
})
|
||||
|
||||
const tabErrorGroups = computed<ErrorGroup[]>(() => {
|
||||
@@ -569,11 +360,9 @@ export function useErrorGroups(
|
||||
processNodeErrors(groupsMap, true)
|
||||
processExecutionError(groupsMap, true)
|
||||
|
||||
const executionGroups = isSingleNodeSelected.value
|
||||
return isSingleNodeSelected.value
|
||||
? toSortedGroups(regroupByErrorMessage(groupsMap))
|
||||
: toSortedGroups(groupsMap)
|
||||
|
||||
return [...buildMissingNodeGroups(), ...executionGroups]
|
||||
})
|
||||
|
||||
const filteredGroups = computed<ErrorGroup[]>(() => {
|
||||
@@ -584,15 +373,10 @@ export function useErrorGroups(
|
||||
const groupedErrorMessages = computed<string[]>(() => {
|
||||
const messages = new Set<string>()
|
||||
for (const group of allErrorGroups.value) {
|
||||
if (group.type === 'execution') {
|
||||
for (const card of group.cards) {
|
||||
for (const err of card.errors) {
|
||||
messages.add(err.message)
|
||||
}
|
||||
for (const card of group.cards) {
|
||||
for (const err of card.errors) {
|
||||
messages.add(err.message)
|
||||
}
|
||||
} else {
|
||||
// Groups without cards (e.g. missing_node) surface their title as the message.
|
||||
messages.add(group.title)
|
||||
}
|
||||
}
|
||||
return Array.from(messages)
|
||||
@@ -605,9 +389,6 @@ export function useErrorGroups(
|
||||
collapseState,
|
||||
isSingleNodeSelected,
|
||||
errorNodeCache,
|
||||
missingNodeCache,
|
||||
groupedErrorMessages,
|
||||
missingPackGroups,
|
||||
swapNodeGroups
|
||||
groupedErrorMessages
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,14 +10,12 @@ const {
|
||||
label,
|
||||
enableEmptyState,
|
||||
tooltip,
|
||||
size = 'default',
|
||||
class: className
|
||||
} = defineProps<{
|
||||
disabled?: boolean
|
||||
label?: string
|
||||
enableEmptyState?: boolean
|
||||
tooltip?: string
|
||||
size?: 'default' | 'lg'
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
@@ -41,8 +39,7 @@ const tooltipConfig = computed(() => {
|
||||
type="button"
|
||||
:class="
|
||||
cn(
|
||||
'group bg-transparent border-0 outline-0 ring-0 w-full text-left flex items-center justify-between pl-4 pr-3',
|
||||
size === 'lg' ? 'min-h-16' : 'min-h-12',
|
||||
'group min-h-12 bg-transparent border-0 outline-0 ring-0 w-full text-left flex items-center justify-between pl-4 pr-3',
|
||||
!disabled && 'cursor-pointer'
|
||||
)
|
||||
"
|
||||
|
||||
@@ -131,12 +131,6 @@ const nodeHasError = computed(() => {
|
||||
return hasDirectError.value || hasContainerInternalError.value
|
||||
})
|
||||
|
||||
const showSeeError = computed(
|
||||
() =>
|
||||
nodeHasError.value &&
|
||||
useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')
|
||||
)
|
||||
|
||||
const parentGroup = computed<LGraphGroup | null>(() => {
|
||||
if (!targetNode.value || !getNodeParentGroup) return null
|
||||
return getNodeParentGroup(targetNode.value)
|
||||
@@ -200,7 +194,6 @@ defineExpose({
|
||||
:enable-empty-state
|
||||
:disabled="isEmpty"
|
||||
:tooltip
|
||||
:size="showSeeError ? 'lg' : 'default'"
|
||||
>
|
||||
<template #label>
|
||||
<div class="flex flex-wrap items-center gap-2 flex-1 min-w-0">
|
||||
@@ -230,10 +223,13 @@ defineExpose({
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
v-if="showSeeError"
|
||||
v-if="
|
||||
nodeHasError &&
|
||||
useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')
|
||||
"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0 rounded-lg text-sm h-8"
|
||||
class="shrink-0 rounded-lg text-sm"
|
||||
@click.stop="navigateToErrorTab"
|
||||
>
|
||||
{{ t('rightSidePanel.seeError') }}
|
||||
|
||||
@@ -34,7 +34,6 @@ const {
|
||||
node,
|
||||
isDraggable = false,
|
||||
hiddenFavoriteIndicator = false,
|
||||
hiddenWidgetActions = false,
|
||||
showNodeName = false,
|
||||
parents = [],
|
||||
isShownOnParents = false
|
||||
@@ -43,7 +42,6 @@ const {
|
||||
node: LGraphNode
|
||||
isDraggable?: boolean
|
||||
hiddenFavoriteIndicator?: boolean
|
||||
hiddenWidgetActions?: boolean
|
||||
showNodeName?: boolean
|
||||
parents?: SubgraphNode[]
|
||||
isShownOnParents?: boolean
|
||||
@@ -172,10 +170,7 @@ const displayLabel = customRef((track, trigger) => {
|
||||
>
|
||||
{{ sourceNodeName }}
|
||||
</span>
|
||||
<div
|
||||
v-if="!hiddenWidgetActions"
|
||||
class="flex items-center gap-1 shrink-0 pointer-events-auto"
|
||||
>
|
||||
<div class="flex items-center gap-1 shrink-0 pointer-events-auto">
|
||||
<WidgetActions
|
||||
v-model:label="displayLabel"
|
||||
:widget="widget"
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import DraggableList from '@/components/common/DraggableList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import {
|
||||
demoteWidget,
|
||||
@@ -18,10 +17,10 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import { DraggableList } from '@/scripts/ui/draggableList'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import SubgraphNodeWidget from './SubgraphNodeWidget.vue'
|
||||
|
||||
@@ -31,6 +30,9 @@ const promotionStore = usePromotionStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { searchQuery } = storeToRefs(rightSidePanelStore)
|
||||
|
||||
const draggableList = ref<DraggableList | undefined>(undefined)
|
||||
const draggableItems = ref()
|
||||
|
||||
const promotionEntries = computed(() => {
|
||||
const node = activeNode.value
|
||||
if (!node) return []
|
||||
@@ -193,9 +195,54 @@ function showRecommended() {
|
||||
}
|
||||
}
|
||||
|
||||
function setDraggableState() {
|
||||
draggableList.value?.dispose()
|
||||
if (searchQuery.value || !draggableItems.value?.children?.length) return
|
||||
draggableList.value = new DraggableList(
|
||||
draggableItems.value,
|
||||
'.draggable-item'
|
||||
)
|
||||
draggableList.value.applyNewItemsOrder = function () {
|
||||
const reorderedItems = []
|
||||
|
||||
let oldPosition = -1
|
||||
this.getAllItems().forEach((item, index) => {
|
||||
if (item === this.draggableItem) {
|
||||
oldPosition = index
|
||||
return
|
||||
}
|
||||
if (!this.isItemToggled(item)) {
|
||||
reorderedItems[index] = item
|
||||
return
|
||||
}
|
||||
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
|
||||
reorderedItems[newIndex] = item
|
||||
})
|
||||
|
||||
for (let index = 0; index < this.getAllItems().length; index++) {
|
||||
const item = reorderedItems[index]
|
||||
if (typeof item === 'undefined') {
|
||||
reorderedItems[index] = this.draggableItem
|
||||
}
|
||||
}
|
||||
const newPosition = reorderedItems.indexOf(this.draggableItem)
|
||||
const aw = activeWidgets.value
|
||||
const [w] = aw.splice(oldPosition, 1)
|
||||
aw.splice(newPosition, 0, w)
|
||||
activeWidgets.value = aw
|
||||
}
|
||||
}
|
||||
watch(filteredActive, () => {
|
||||
setDraggableState()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
setDraggableState()
|
||||
if (activeNode.value) pruneDisconnected(activeNode.value)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
draggableList.value?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -233,18 +280,19 @@ onMounted(() => {
|
||||
{{ $t('subgraphStore.hideAll') }}</a
|
||||
>
|
||||
</div>
|
||||
<DraggableList v-slot="{ dragClass }" v-model="activeWidgets">
|
||||
<div ref="draggableItems" class="pb-2 px-2 space-y-0.5 mt-0.5">
|
||||
<SubgraphNodeWidget
|
||||
v-for="[node, widget] in filteredActive"
|
||||
:key="toKey([node, widget])"
|
||||
:class="cn(!searchQuery && dragClass, 'bg-comfy-menu-bg')"
|
||||
class="bg-comfy-menu-bg"
|
||||
:node-title="node.title"
|
||||
:widget-name="widget.name"
|
||||
:is-physical="node.id === -1"
|
||||
:is-shown="true"
|
||||
:is-draggable="!searchQuery"
|
||||
:is-physical="node.id === -1"
|
||||
@toggle-visibility="demote([node, widget])"
|
||||
/>
|
||||
</DraggableList>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@@ -29,7 +29,8 @@ function getIcon() {
|
||||
cn(
|
||||
'flex py-1 px-2 break-all rounded items-center gap-1',
|
||||
'bg-node-component-surface',
|
||||
props.isDraggable && 'hover:ring-1 ring-accent-background',
|
||||
props.isDraggable &&
|
||||
'draggable-item drag-handle cursor-grab [&.is-draggable]:cursor-grabbing hover:ring-1 ring-accent-background',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
|
||||
@@ -106,42 +106,4 @@ describe('AssetsSidebarListView', () => {
|
||||
expect(assetListItem?.props('previewUrl')).toBe('')
|
||||
expect(assetListItem?.props('isVideoPreview')).toBe(false)
|
||||
})
|
||||
|
||||
it('emits preview-asset when item preview is clicked', async () => {
|
||||
const imageAsset = {
|
||||
...buildAsset('image-asset', 'image.png'),
|
||||
preview_url: '/api/view/image.png',
|
||||
user_metadata: {}
|
||||
} satisfies AssetItem
|
||||
|
||||
const wrapper = mountListView([buildOutputItem(imageAsset)])
|
||||
const listItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
|
||||
const assetListItem = listItems.at(-1)
|
||||
|
||||
expect(assetListItem).toBeDefined()
|
||||
|
||||
assetListItem!.vm.$emit('preview-click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.emitted('preview-asset')).toEqual([[imageAsset]])
|
||||
})
|
||||
|
||||
it('emits preview-asset when item is double-clicked', async () => {
|
||||
const imageAsset = {
|
||||
...buildAsset('image-asset-dbl', 'image.png'),
|
||||
preview_url: '/api/view/image.png',
|
||||
user_metadata: {}
|
||||
} satisfies AssetItem
|
||||
|
||||
const wrapper = mountListView([buildOutputItem(imageAsset)])
|
||||
const listItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
|
||||
const assetListItem = listItems.at(-1)
|
||||
|
||||
expect(assetListItem).toBeDefined()
|
||||
|
||||
await assetListItem!.trigger('dblclick')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.emitted('preview-asset')).toEqual([[imageAsset]])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -56,8 +56,6 @@
|
||||
@mouseleave="onAssetLeave(item.asset.id)"
|
||||
@contextmenu.prevent.stop="emit('context-menu', $event, item.asset)"
|
||||
@click.stop="emit('select-asset', item.asset, selectableAssets)"
|
||||
@dblclick.stop="emit('preview-asset', item.asset)"
|
||||
@preview-click="emit('preview-asset', item.asset)"
|
||||
@stack-toggle="void toggleStack(item.asset)"
|
||||
>
|
||||
<template v-if="hoveredAssetId === item.asset.id" #actions>
|
||||
@@ -118,7 +116,6 @@ const assetsStore = useAssetsStore()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select-asset', asset: AssetItem, assets?: AssetItem[]): void
|
||||
(e: 'preview-asset', asset: AssetItem): void
|
||||
(e: 'context-menu', event: MouseEvent, asset: AssetItem): void
|
||||
(e: 'approach-end'): void
|
||||
}>()
|
||||
|
||||
@@ -95,7 +95,6 @@
|
||||
:toggle-stack="toggleListViewStack"
|
||||
:asset-type="activeTab"
|
||||
@select-asset="handleAssetSelect"
|
||||
@preview-asset="handleZoomClick"
|
||||
@context-menu="handleAssetContextMenu"
|
||||
@approach-end="handleApproachEnd"
|
||||
/>
|
||||
@@ -217,6 +216,10 @@ import {
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
// Lazy-loaded to avoid pulling THREE.js into the main bundle
|
||||
const Load3dViewerContent = defineAsyncComponent(
|
||||
() => import('@/components/load3d/Load3dViewerContent.vue')
|
||||
)
|
||||
import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridView.vue'
|
||||
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
|
||||
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
||||
@@ -248,10 +251,6 @@ import {
|
||||
} from '@/utils/formatUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const Load3dViewerContent = defineAsyncComponent(
|
||||
() => import('@/components/load3d/Load3dViewerContent.vue')
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits<{ assetSelected: [asset: AssetItem] }>()
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import JobFilterActions from '@/components/queue/job/JobFilterActions.vue'
|
||||
@@ -86,17 +86,11 @@ import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue
|
||||
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
|
||||
const Load3dViewerContent = defineAsyncComponent(
|
||||
() => import('@/components/load3d/Load3dViewerContent.vue')
|
||||
)
|
||||
|
||||
const { t, n } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const dialogStore = useDialogStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const queueStore = useQueueStore()
|
||||
const { showQueueClearHistoryDialog } = useQueueClearHistoryDialog()
|
||||
@@ -157,24 +151,6 @@ const {
|
||||
} = useResultGallery(() => filteredTasks.value)
|
||||
|
||||
const onViewItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
|
||||
const previewOutput = item.taskRef?.previewOutput
|
||||
|
||||
if (previewOutput?.is3D) {
|
||||
dialogStore.showDialog({
|
||||
key: 'asset-3d-viewer',
|
||||
title: item.title,
|
||||
component: Load3dViewerContent,
|
||||
props: {
|
||||
modelUrl: previewOutput.url || ''
|
||||
},
|
||||
dialogComponentProps: {
|
||||
style: 'width: 80vw; height: 80vh;',
|
||||
maximizable: true
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await openResultGallery(item)
|
||||
})
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
ref="containerRef"
|
||||
:class="
|
||||
cn(
|
||||
'comfy-vue-side-bar-container group/sidebar-tab flex h-full flex-col w-full',
|
||||
'comfy-vue-side-bar-container group/sidebar-tab flex h-full flex-col',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { TopbarBadge as TopbarBadgeType } from '@/types/comfy'
|
||||
|
||||
@@ -27,8 +28,10 @@ const {
|
||||
backgroundColor?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const cloudBadge = computed<TopbarBadgeType>(() => ({
|
||||
icon: 'icon-[lucide--cloud]',
|
||||
label: t('g.beta'),
|
||||
text: 'Comfy Cloud'
|
||||
}))
|
||||
</script>
|
||||
|
||||
@@ -27,20 +27,6 @@
|
||||
>
|
||||
{{ subscriptionTierName }}
|
||||
</span>
|
||||
<Button
|
||||
v-if="isFreeTier"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
class="mt-2 whitespace-nowrap"
|
||||
:style="{
|
||||
background: 'var(--color-subscription-button-gradient)',
|
||||
color: 'var(--color-white)',
|
||||
borderColor: 'transparent'
|
||||
}"
|
||||
@click="handleSubscribeForMore"
|
||||
>
|
||||
{{ $t('subscription.subscribeForMore') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Credits Section -->
|
||||
@@ -184,7 +170,6 @@ const settingsDialog = useSettingsDialog()
|
||||
const dialogService = useDialogService()
|
||||
const {
|
||||
isActiveSubscription,
|
||||
isFreeTier,
|
||||
subscriptionTierName,
|
||||
subscriptionTier,
|
||||
fetchStatus
|
||||
@@ -223,9 +208,7 @@ const handleOpenUserSettings = () => {
|
||||
}
|
||||
|
||||
const handleOpenPlansAndPricing = () => {
|
||||
subscriptionDialog.showPricingTable({
|
||||
entry_point: 'popover_plans_and_pricing'
|
||||
})
|
||||
subscriptionDialog.showPricingTable()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
@@ -241,9 +224,7 @@ const handleOpenPlanAndCreditsSettings = () => {
|
||||
|
||||
const handleTopUp = () => {
|
||||
// Track purchase credits entry from avatar popover
|
||||
useTelemetry()?.trackAddApiCreditButtonClicked({
|
||||
current_tier: subscriptionTier.value?.toLowerCase()
|
||||
})
|
||||
useTelemetry()?.trackAddApiCreditButtonClicked()
|
||||
dialogService.showTopUpCreditsDialog()
|
||||
emit('close')
|
||||
}
|
||||
@@ -261,11 +242,6 @@ const handleLogout = async () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleSubscribeForMore = () => {
|
||||
subscriptionDialog.show({ entry_point: 'popover_upgrade' })
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleSubscribed = async () => {
|
||||
await fetchStatus()
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
<template>
|
||||
<Button
|
||||
v-if="isFreeTier"
|
||||
class="mr-2 shrink-0 whitespace-nowrap"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
:style="{
|
||||
background: 'var(--color-subscription-button-gradient)',
|
||||
color: 'var(--color-white)',
|
||||
borderColor: 'transparent'
|
||||
}"
|
||||
data-testid="topbar-subscribe-button"
|
||||
@click="handleClick"
|
||||
>
|
||||
{{ $t('subscription.subscribeForMore') }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
|
||||
const { isFreeTier, showSubscriptionDialog } = useBillingContext()
|
||||
|
||||
function handleClick() {
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackSubscription('subscribe_clicked', {
|
||||
current_tier: 'free'
|
||||
})
|
||||
}
|
||||
showSubscriptionDialog()
|
||||
}
|
||||
</script>
|
||||
@@ -10,7 +10,7 @@
|
||||
@click="handleClick"
|
||||
>
|
||||
<i
|
||||
v-if="workflowOption.workflow.initialMode === 'app'"
|
||||
v-if="workflowOption.workflow.activeState?.extra?.linearMode"
|
||||
class="icon-[lucide--panels-top-left] bg-primary-background"
|
||||
/>
|
||||
<span
|
||||
|
||||
@@ -25,19 +25,15 @@ whenever(feedbackRef, () => {
|
||||
:href="`https://form.typeform.com/to/${dataTfWidget}`"
|
||||
target="_blank"
|
||||
variant="inverted"
|
||||
class="flex h-10 items-center justify-center gap-2.5 px-3 py-2"
|
||||
class="rounded-full size-12"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<i class="icon-[lucide--circle-help] size-4" />
|
||||
<i class="icon-[lucide--circle-question-mark] size-6" />
|
||||
</Button>
|
||||
<Popover v-else>
|
||||
<template #button>
|
||||
<Button
|
||||
variant="inverted"
|
||||
class="flex h-10 items-center justify-center gap-2.5 px-3 py-2"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<i class="icon-[lucide--circle-help] size-4" />
|
||||
<Button variant="inverted" class="rounded-full size-12" v-bind="$attrs">
|
||||
<i class="icon-[lucide--circle-question-mark] size-6" />
|
||||
</Button>
|
||||
</template>
|
||||
<div ref="feedbackRef" data-tf-auto-resize :data-tf-widget />
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
import { useDomClipping } from './useDomClipping'
|
||||
|
||||
function createMockElement(rect: {
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
}): HTMLElement {
|
||||
return {
|
||||
getBoundingClientRect: vi.fn(
|
||||
() =>
|
||||
({
|
||||
...rect,
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
right: rect.left + rect.width,
|
||||
bottom: rect.top + rect.height,
|
||||
toJSON: () => ({})
|
||||
}) as DOMRect
|
||||
)
|
||||
} as unknown as HTMLElement
|
||||
}
|
||||
|
||||
function createMockCanvas(rect: {
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
}): HTMLCanvasElement {
|
||||
return {
|
||||
getBoundingClientRect: vi.fn(
|
||||
() =>
|
||||
({
|
||||
...rect,
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
right: rect.left + rect.width,
|
||||
bottom: rect.top + rect.height,
|
||||
toJSON: () => ({})
|
||||
}) as DOMRect
|
||||
)
|
||||
} as unknown as HTMLCanvasElement
|
||||
}
|
||||
|
||||
describe('useDomClipping', () => {
|
||||
let rafCallbacks: Map<number, FrameRequestCallback>
|
||||
let nextRafId: number
|
||||
|
||||
beforeEach(() => {
|
||||
rafCallbacks = new Map()
|
||||
nextRafId = 1
|
||||
|
||||
vi.stubGlobal(
|
||||
'requestAnimationFrame',
|
||||
vi.fn((cb: FrameRequestCallback) => {
|
||||
const id = nextRafId++
|
||||
rafCallbacks.set(id, cb)
|
||||
return id
|
||||
})
|
||||
)
|
||||
|
||||
vi.stubGlobal(
|
||||
'cancelAnimationFrame',
|
||||
vi.fn((id: number) => {
|
||||
rafCallbacks.delete(id)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function flushRaf() {
|
||||
const callbacks = [...rafCallbacks.values()]
|
||||
rafCallbacks.clear()
|
||||
for (const cb of callbacks) {
|
||||
cb(performance.now())
|
||||
}
|
||||
}
|
||||
|
||||
it('coalesces multiple rapid calls into a single getBoundingClientRect read', () => {
|
||||
const { updateClipPath } = useDomClipping()
|
||||
const element = createMockElement({
|
||||
left: 10,
|
||||
top: 10,
|
||||
width: 100,
|
||||
height: 50
|
||||
})
|
||||
const canvas = createMockCanvas({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 800,
|
||||
height: 600
|
||||
})
|
||||
|
||||
updateClipPath(element, canvas, true)
|
||||
updateClipPath(element, canvas, true)
|
||||
updateClipPath(element, canvas, true)
|
||||
|
||||
expect(element.getBoundingClientRect).not.toHaveBeenCalled()
|
||||
|
||||
flushRaf()
|
||||
|
||||
expect(element.getBoundingClientRect).toHaveBeenCalledTimes(1)
|
||||
expect(canvas.getBoundingClientRect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('updates style ref after RAF fires', () => {
|
||||
const { style, updateClipPath } = useDomClipping()
|
||||
const element = createMockElement({
|
||||
left: 10,
|
||||
top: 10,
|
||||
width: 100,
|
||||
height: 50
|
||||
})
|
||||
const canvas = createMockCanvas({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 800,
|
||||
height: 600
|
||||
})
|
||||
|
||||
updateClipPath(element, canvas, true)
|
||||
|
||||
expect(style.value).toEqual({})
|
||||
|
||||
flushRaf()
|
||||
|
||||
expect(style.value).toEqual({
|
||||
clipPath: 'none',
|
||||
willChange: 'clip-path'
|
||||
})
|
||||
})
|
||||
|
||||
it('cancels previous RAF when called again before it fires', () => {
|
||||
const { style, updateClipPath } = useDomClipping()
|
||||
const element1 = createMockElement({
|
||||
left: 10,
|
||||
top: 10,
|
||||
width: 100,
|
||||
height: 50
|
||||
})
|
||||
const element2 = createMockElement({
|
||||
left: 20,
|
||||
top: 20,
|
||||
width: 200,
|
||||
height: 100
|
||||
})
|
||||
const canvas = createMockCanvas({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 800,
|
||||
height: 600
|
||||
})
|
||||
|
||||
updateClipPath(element1, canvas, true)
|
||||
updateClipPath(element2, canvas, true)
|
||||
|
||||
expect(cancelAnimationFrame).toHaveBeenCalledTimes(1)
|
||||
|
||||
flushRaf()
|
||||
|
||||
expect(element1.getBoundingClientRect).not.toHaveBeenCalled()
|
||||
expect(element2.getBoundingClientRect).toHaveBeenCalledTimes(1)
|
||||
expect(style.value).toEqual({
|
||||
clipPath: 'none',
|
||||
willChange: 'clip-path'
|
||||
})
|
||||
})
|
||||
|
||||
it('generates clip-path polygon when element intersects unselected area', () => {
|
||||
const { style, updateClipPath } = useDomClipping()
|
||||
const element = createMockElement({
|
||||
left: 50,
|
||||
top: 50,
|
||||
width: 100,
|
||||
height: 100
|
||||
})
|
||||
const canvas = createMockCanvas({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 800,
|
||||
height: 600
|
||||
})
|
||||
const selectedArea = {
|
||||
x: 40,
|
||||
y: 40,
|
||||
width: 200,
|
||||
height: 200,
|
||||
scale: 1,
|
||||
offset: [0, 0] as [number, number]
|
||||
}
|
||||
|
||||
updateClipPath(element, canvas, false, selectedArea)
|
||||
flushRaf()
|
||||
|
||||
expect(style.value.clipPath).toContain('polygon')
|
||||
expect(style.value.willChange).toBe('clip-path')
|
||||
})
|
||||
|
||||
it('does not read layout before RAF fires', () => {
|
||||
const { updateClipPath } = useDomClipping()
|
||||
const element = createMockElement({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 50,
|
||||
height: 50
|
||||
})
|
||||
const canvas = createMockCanvas({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 800,
|
||||
height: 600
|
||||
})
|
||||
|
||||
updateClipPath(element, canvas, true)
|
||||
|
||||
expect(element.getBoundingClientRect).not.toHaveBeenCalled()
|
||||
expect(canvas.getBoundingClientRect).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -85,12 +85,8 @@ export const useDomClipping = (options: ClippingOptions = {}) => {
|
||||
return ''
|
||||
}
|
||||
|
||||
let pendingRaf = 0
|
||||
|
||||
/**
|
||||
* Updates the clip-path style based on element and selection information.
|
||||
* Batched via requestAnimationFrame to avoid forcing synchronous layout
|
||||
* from getBoundingClientRect() on every reactive state change.
|
||||
* Updates the clip-path style based on element and selection information
|
||||
*/
|
||||
const updateClipPath = (
|
||||
element: HTMLElement,
|
||||
@@ -105,24 +101,20 @@ export const useDomClipping = (options: ClippingOptions = {}) => {
|
||||
offset: [number, number]
|
||||
}
|
||||
) => {
|
||||
if (pendingRaf) cancelAnimationFrame(pendingRaf)
|
||||
pendingRaf = requestAnimationFrame(() => {
|
||||
pendingRaf = 0
|
||||
const elementRect = element.getBoundingClientRect()
|
||||
const canvasRect = canvasElement.getBoundingClientRect()
|
||||
const elementRect = element.getBoundingClientRect()
|
||||
const canvasRect = canvasElement.getBoundingClientRect()
|
||||
|
||||
const clipPath = calculateClipPath(
|
||||
elementRect,
|
||||
canvasRect,
|
||||
isSelected,
|
||||
selectedArea
|
||||
)
|
||||
const clipPath = calculateClipPath(
|
||||
elementRect,
|
||||
canvasRect,
|
||||
isSelected,
|
||||
selectedArea
|
||||
)
|
||||
|
||||
style.value = {
|
||||
clipPath: clipPath || 'none',
|
||||
willChange: 'clip-path'
|
||||
}
|
||||
})
|
||||
style.value = {
|
||||
clipPath: clipPath || 'none',
|
||||
willChange: 'clip-path'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, nextTick, watch } from 'vue'
|
||||
|
||||
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
|
||||
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
@@ -83,163 +82,6 @@ describe('Node Reactivity', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
function createWidgetInputGraph() {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
|
||||
// Add a widget and an associated input slot (simulates "widget converted to input")
|
||||
node.addWidget('string', 'prompt', 'hello', () => undefined, {})
|
||||
const input = node.addInput('prompt', 'STRING')
|
||||
// Associate the input slot with the widget (as widgetInputs extension does)
|
||||
input.widget = { name: 'prompt' }
|
||||
|
||||
// Start with a connected link
|
||||
input.link = 42
|
||||
|
||||
graph.add(node)
|
||||
return { graph, node }
|
||||
}
|
||||
|
||||
it('sets slotMetadata.linked to true when input has a link', () => {
|
||||
const { graph, node } = createWidgetInputGraph()
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
const nodeData = vueNodeData.get(String(node.id))
|
||||
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
|
||||
|
||||
expect(widgetData?.slotMetadata).toBeDefined()
|
||||
expect(widgetData?.slotMetadata?.linked).toBe(true)
|
||||
})
|
||||
|
||||
it('updates slotMetadata.linked to false after link disconnect event', async () => {
|
||||
const { graph, node } = createWidgetInputGraph()
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
const nodeData = vueNodeData.get(String(node.id))
|
||||
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
|
||||
|
||||
// Verify initially linked
|
||||
expect(widgetData?.slotMetadata?.linked).toBe(true)
|
||||
|
||||
// Simulate link disconnection (as LiteGraph does before firing the event)
|
||||
node.inputs[0].link = null
|
||||
|
||||
// Fire the trigger event that LiteGraph fires on disconnect
|
||||
graph.trigger('node:slot-links:changed', {
|
||||
nodeId: node.id,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: 0,
|
||||
connected: false,
|
||||
linkId: 42
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
// slotMetadata.linked should now be false
|
||||
expect(widgetData?.slotMetadata?.linked).toBe(false)
|
||||
})
|
||||
|
||||
it('reactively updates disabled state in a derived computed after disconnect', async () => {
|
||||
const { graph, node } = createWidgetInputGraph()
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
const nodeData = vueNodeData.get(String(node.id))!
|
||||
|
||||
// Mimic what processedWidgets does in NodeWidgets.vue:
|
||||
// derive disabled from slotMetadata.linked
|
||||
const derivedDisabled = computed(() => {
|
||||
const widgets = nodeData.widgets ?? []
|
||||
const widget = widgets.find((w) => w.name === 'prompt')
|
||||
return widget?.slotMetadata?.linked ? true : false
|
||||
})
|
||||
|
||||
// Initially linked → disabled
|
||||
expect(derivedDisabled.value).toBe(true)
|
||||
|
||||
// Track changes
|
||||
const onChange = vi.fn()
|
||||
watch(derivedDisabled, onChange)
|
||||
|
||||
// Simulate disconnect
|
||||
node.inputs[0].link = null
|
||||
graph.trigger('node:slot-links:changed', {
|
||||
nodeId: node.id,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: 0,
|
||||
connected: false,
|
||||
linkId: 42
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
// The derived computed should now return false
|
||||
expect(derivedDisabled.value).toBe(false)
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('updates slotMetadata for promoted widgets where SafeWidgetData.name differs from input.widget.name', async () => {
|
||||
// Set up a subgraph with an interior node that has a "prompt" widget.
|
||||
// createPromotedWidgetView resolves against this interior node.
|
||||
const subgraph = createTestSubgraph()
|
||||
const interiorNode = new LGraphNode('interior')
|
||||
interiorNode.id = 10
|
||||
interiorNode.addWidget('string', 'prompt', 'hello', () => undefined, {})
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 123 })
|
||||
|
||||
// Create a PromotedWidgetView with displayName="value" (subgraph input
|
||||
// slot name) and sourceWidgetName="prompt" (interior widget name).
|
||||
// PromotedWidgetView.name returns "value", but safeWidgetMapper sets
|
||||
// SafeWidgetData.name to sourceWidgetName ("prompt").
|
||||
const promotedView = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
'10',
|
||||
'prompt',
|
||||
'value'
|
||||
)
|
||||
|
||||
// Host the promoted view on a regular node so we can control widgets
|
||||
// directly (SubgraphNode.widgets is a synthetic getter).
|
||||
const graph = new LGraph()
|
||||
const hostNode = new LGraphNode('host')
|
||||
hostNode.widgets = [promotedView]
|
||||
const input = hostNode.addInput('value', 'STRING')
|
||||
input.widget = { name: 'value' }
|
||||
input.link = 42
|
||||
graph.add(hostNode)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(hostNode.id))
|
||||
|
||||
// SafeWidgetData.name is "prompt" (sourceWidgetName), but the
|
||||
// input slot widget name is "value" — slotName bridges this gap.
|
||||
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
|
||||
expect(widgetData).toBeDefined()
|
||||
expect(widgetData?.slotName).toBe('value')
|
||||
expect(widgetData?.slotMetadata?.linked).toBe(true)
|
||||
|
||||
// Disconnect
|
||||
hostNode.inputs[0].link = null
|
||||
graph.trigger('node:slot-links:changed', {
|
||||
nodeId: hostNode.id,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: 0,
|
||||
connected: false,
|
||||
linkId: 42
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(widgetData?.slotMetadata?.linked).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subgraph Promoted Pseudo Widgets', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
|
||||
@@ -14,7 +14,6 @@ import type {
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type { NodeId } from '@/renderer/core/layout/types'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
@@ -70,12 +69,6 @@ export interface SafeWidgetData {
|
||||
spec?: InputSpec
|
||||
/** Input slot metadata (index and link status) */
|
||||
slotMetadata?: WidgetSlotMetadata
|
||||
/**
|
||||
* Original LiteGraph widget name used for slot metadata matching.
|
||||
* For promoted widgets, `name` is `sourceWidgetName` (interior widget name)
|
||||
* which differs from the subgraph node's input slot widget name.
|
||||
*/
|
||||
slotName?: string
|
||||
}
|
||||
|
||||
export interface VueNodeData {
|
||||
@@ -245,8 +238,7 @@ function safeWidgetMapper(
|
||||
options: isPromotedPseudoWidget
|
||||
? { ...options, canvasOnly: true }
|
||||
: options,
|
||||
slotMetadata: slotInfo,
|
||||
slotName: name !== widget.name ? widget.name : undefined
|
||||
slotMetadata: slotInfo
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -384,7 +376,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
|
||||
// Update only widgets with new slot metadata, keeping other widget data intact
|
||||
for (const widget of currentData.widgets ?? []) {
|
||||
const slotInfo = slotMetadata.get(widget.slotName ?? widget.name)
|
||||
const slotInfo = slotMetadata.get(widget.name)
|
||||
if (slotInfo) widget.slotMetadata = slotInfo
|
||||
}
|
||||
}
|
||||
@@ -443,11 +435,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
const nodePosition = { x: node.pos[0], y: node.pos[1] }
|
||||
const nodeSize = { width: node.size[0], height: node.size[1] }
|
||||
|
||||
// Skip layout creation if it already exists
|
||||
// (e.g. in-place node replacement where the old node's layout is reused for the new node with the same ID).
|
||||
const existingLayout = layoutStore.getNodeLayoutRef(id).value
|
||||
if (existingLayout) return
|
||||
|
||||
// Add node to layout store with final positions
|
||||
setSource(LayoutSource.Canvas)
|
||||
void createNode(id, {
|
||||
|
||||
@@ -2,11 +2,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useImagePreviewWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useImagePreviewWidget'
|
||||
|
||||
export const CANVAS_IMAGE_PREVIEW_WIDGET = '$$canvas-image-preview'
|
||||
const CANVAS_IMAGE_PREVIEW_NODE_TYPES = new Set([
|
||||
'PreviewImage',
|
||||
'SaveImage',
|
||||
'GLSLShader'
|
||||
])
|
||||
const CANVAS_IMAGE_PREVIEW_NODE_TYPES = new Set(['PreviewImage', 'SaveImage'])
|
||||
|
||||
export function supportsVirtualCanvasImagePreview(node: LGraphNode): boolean {
|
||||
return CANVAS_IMAGE_PREVIEW_NODE_TYPES.has(node.type)
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
const { mockFetchApi, mockAddAlert, mockUpdateInputs } = vi.hoisted(() => ({
|
||||
mockFetchApi: vi.fn(),
|
||||
mockAddAlert: vi.fn(),
|
||||
mockUpdateInputs: vi.fn()
|
||||
}))
|
||||
|
||||
let capturedDragOnDrop: (files: File[]) => Promise<string[]>
|
||||
|
||||
vi.mock('@/composables/node/useNodeDragAndDrop', () => ({
|
||||
useNodeDragAndDrop: (
|
||||
_node: LGraphNode,
|
||||
opts: { onDrop: typeof capturedDragOnDrop }
|
||||
) => {
|
||||
capturedDragOnDrop = opts.onDrop
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/useNodeFileInput', () => ({
|
||||
useNodeFileInput: () => ({ openFileSelection: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/useNodePaste', () => ({
|
||||
useNodePaste: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({ addAlert: mockAddAlert })
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: { fetchApi: mockFetchApi }
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/assetsStore', () => ({
|
||||
useAssetsStore: () => ({ updateInputs: mockUpdateInputs })
|
||||
}))
|
||||
|
||||
function createMockNode(): LGraphNode {
|
||||
return {
|
||||
isUploading: false,
|
||||
imgs: [new Image()],
|
||||
graph: { setDirtyCanvas: vi.fn() },
|
||||
size: [300, 400]
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
function createFile(name = 'test.png'): File {
|
||||
return new File(['data'], name, { type: 'image/png' })
|
||||
}
|
||||
|
||||
function successResponse(name: string, subfolder?: string) {
|
||||
return {
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ name, subfolder })
|
||||
}
|
||||
}
|
||||
|
||||
function failResponse(status = 500) {
|
||||
return {
|
||||
status,
|
||||
statusText: 'Server Error'
|
||||
}
|
||||
}
|
||||
|
||||
describe('useNodeImageUpload', () => {
|
||||
let node: LGraphNode
|
||||
let onUploadComplete: (paths: string[]) => void
|
||||
let onUploadStart: (files: File[]) => void
|
||||
let onUploadError: () => void
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
node = createMockNode()
|
||||
onUploadComplete = vi.fn()
|
||||
onUploadStart = vi.fn()
|
||||
onUploadError = vi.fn()
|
||||
|
||||
const { useNodeImageUpload } = await import('./useNodeImageUpload')
|
||||
useNodeImageUpload(node, {
|
||||
onUploadComplete,
|
||||
onUploadStart,
|
||||
onUploadError,
|
||||
folder: 'input'
|
||||
})
|
||||
})
|
||||
|
||||
it('sets isUploading true during upload and false after', async () => {
|
||||
mockFetchApi.mockResolvedValueOnce(successResponse('test.png'))
|
||||
|
||||
const promise = capturedDragOnDrop([createFile()])
|
||||
expect(node.isUploading).toBe(true)
|
||||
|
||||
await promise
|
||||
expect(node.isUploading).toBe(false)
|
||||
})
|
||||
|
||||
it('clears node.imgs on upload start', async () => {
|
||||
mockFetchApi.mockResolvedValueOnce(successResponse('test.png'))
|
||||
|
||||
const promise = capturedDragOnDrop([createFile()])
|
||||
expect(node.imgs).toBeUndefined()
|
||||
|
||||
await promise
|
||||
})
|
||||
|
||||
it('calls onUploadStart with files', async () => {
|
||||
mockFetchApi.mockResolvedValueOnce(successResponse('test.png'))
|
||||
const files = [createFile()]
|
||||
|
||||
await capturedDragOnDrop(files)
|
||||
expect(onUploadStart).toHaveBeenCalledWith(files)
|
||||
})
|
||||
|
||||
it('calls onUploadComplete with valid paths on success', async () => {
|
||||
mockFetchApi.mockResolvedValueOnce(successResponse('test.png'))
|
||||
|
||||
await capturedDragOnDrop([createFile()])
|
||||
expect(onUploadComplete).toHaveBeenCalledWith(['test.png'])
|
||||
})
|
||||
|
||||
it('includes subfolder in returned path', async () => {
|
||||
mockFetchApi.mockResolvedValueOnce(successResponse('test.png', 'pasted'))
|
||||
|
||||
await capturedDragOnDrop([createFile()])
|
||||
expect(onUploadComplete).toHaveBeenCalledWith(['pasted/test.png'])
|
||||
})
|
||||
|
||||
it('calls onUploadError when all uploads fail', async () => {
|
||||
mockFetchApi.mockResolvedValueOnce(failResponse())
|
||||
|
||||
await capturedDragOnDrop([createFile()])
|
||||
expect(onUploadError).toHaveBeenCalled()
|
||||
expect(onUploadComplete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resets isUploading even when upload fails', async () => {
|
||||
mockFetchApi.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
await capturedDragOnDrop([createFile()])
|
||||
expect(node.isUploading).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects concurrent uploads with a toast', async () => {
|
||||
mockFetchApi.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
setTimeout(() => resolve(successResponse('a.png')), 50)
|
||||
)
|
||||
)
|
||||
|
||||
const first = capturedDragOnDrop([createFile('a.png')])
|
||||
const second = await capturedDragOnDrop([createFile('b.png')])
|
||||
|
||||
expect(second).toEqual([])
|
||||
expect(mockAddAlert).toHaveBeenCalledWith('g.uploadAlreadyInProgress')
|
||||
|
||||
await first
|
||||
})
|
||||
|
||||
it('calls setDirtyCanvas on start and finish', async () => {
|
||||
mockFetchApi.mockResolvedValueOnce(successResponse('test.png'))
|
||||
|
||||
await capturedDragOnDrop([createFile()])
|
||||
expect(node.graph?.setDirtyCanvas).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop'
|
||||
import { useNodeFileInput } from '@/composables/node/useNodeFileInput'
|
||||
import { useNodePaste } from '@/composables/node/useNodePaste'
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
@@ -63,8 +62,6 @@ interface ImageUploadOptions {
|
||||
* @example 'input', 'output', 'temp'
|
||||
*/
|
||||
folder?: ResultItemType
|
||||
onUploadStart?: (files: File[]) => void
|
||||
onUploadError?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,29 +90,10 @@ export const useNodeImageUpload = (
|
||||
}
|
||||
|
||||
const handleUploadBatch = async (files: File[]) => {
|
||||
if (node.isUploading) {
|
||||
useToastStore().addAlert(t('g.uploadAlreadyInProgress'))
|
||||
return []
|
||||
}
|
||||
node.isUploading = true
|
||||
|
||||
try {
|
||||
node.imgs = undefined
|
||||
node.graph?.setDirtyCanvas(true)
|
||||
options.onUploadStart?.(files)
|
||||
|
||||
const paths = await Promise.all(files.map(handleUpload))
|
||||
const validPaths = paths.filter((p): p is string => !!p)
|
||||
if (validPaths.length) {
|
||||
onUploadComplete(validPaths)
|
||||
} else {
|
||||
options.onUploadError?.()
|
||||
}
|
||||
return validPaths
|
||||
} finally {
|
||||
node.isUploading = false
|
||||
node.graph?.setDirtyCanvas(true)
|
||||
}
|
||||
const paths = await Promise.all(files.map(handleUpload))
|
||||
const validPaths = paths.filter((p): p is string => !!p)
|
||||
if (validPaths.length) onUploadComplete(validPaths)
|
||||
return validPaths
|
||||
}
|
||||
|
||||
// Handle drag & drop
|
||||
|
||||
@@ -1,780 +0,0 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
getEffectiveBrushSize,
|
||||
getEffectiveHardness
|
||||
} from '@/composables/maskeditor/brushUtils'
|
||||
import { StrokeProcessor } from '@/composables/maskeditor/StrokeProcessor'
|
||||
import { hexToRgb } from '@/utils/colorUtil'
|
||||
import type { Point } from '@/extensions/core/maskeditor/types'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
|
||||
type PainterTool = 'brush' | 'eraser'
|
||||
|
||||
export const PAINTER_TOOLS: Record<string, PainterTool> = {
|
||||
BRUSH: 'brush',
|
||||
ERASER: 'eraser'
|
||||
} as const
|
||||
|
||||
interface UsePainterOptions {
|
||||
canvasEl: Ref<HTMLCanvasElement | null>
|
||||
modelValue: Ref<string>
|
||||
}
|
||||
|
||||
export function usePainter(nodeId: string, options: UsePainterOptions) {
|
||||
const { canvasEl, modelValue } = options
|
||||
const { t } = useI18n()
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const isDirty = ref(false)
|
||||
|
||||
const canvasWidth = ref(512)
|
||||
const canvasHeight = ref(512)
|
||||
|
||||
const cursorX = ref(0)
|
||||
const cursorY = ref(0)
|
||||
const cursorVisible = ref(false)
|
||||
|
||||
const inputImageUrl = ref<string | null>(null)
|
||||
const isImageInputConnected = ref(false)
|
||||
|
||||
let isDrawing = false
|
||||
let strokeProcessor: StrokeProcessor | null = null
|
||||
let lastPoint: Point | null = null
|
||||
|
||||
let cachedRect: DOMRect | null = null
|
||||
|
||||
let mainCtx: CanvasRenderingContext2D | null = null
|
||||
|
||||
let strokeCanvas: HTMLCanvasElement | null = null
|
||||
let strokeCtx: CanvasRenderingContext2D | null = null
|
||||
|
||||
let baseCanvas: HTMLCanvasElement | null = null
|
||||
let baseCtx: CanvasRenderingContext2D | null = null
|
||||
let hasBaseSnapshot = false
|
||||
let hasStrokes = false
|
||||
|
||||
let dirtyX0 = 0
|
||||
let dirtyY0 = 0
|
||||
let dirtyX1 = 0
|
||||
let dirtyY1 = 0
|
||||
let hasDirtyRect = false
|
||||
|
||||
let strokeBrush: {
|
||||
radius: number
|
||||
effectiveRadius: number
|
||||
effectiveHardness: number
|
||||
hardness: number
|
||||
r: number
|
||||
g: number
|
||||
b: number
|
||||
} | null = null
|
||||
|
||||
const litegraphNode = computed(() => {
|
||||
if (!nodeId || !app.canvas.graph) return null
|
||||
return app.canvas.graph.getNodeById(nodeId) as LGraphNode | null
|
||||
})
|
||||
|
||||
function getWidgetByName(name: string): IBaseWidget | undefined {
|
||||
return litegraphNode.value?.widgets?.find(
|
||||
(w: IBaseWidget) => w.name === name
|
||||
)
|
||||
}
|
||||
|
||||
const tool = ref<PainterTool>(PAINTER_TOOLS.BRUSH)
|
||||
const brushSize = ref(20)
|
||||
const brushColor = ref('#ffffff')
|
||||
const brushOpacity = ref(1)
|
||||
const brushHardness = ref(1)
|
||||
const backgroundColor = ref('#000000')
|
||||
|
||||
function restoreSettingsFromProperties() {
|
||||
const node = litegraphNode.value
|
||||
if (!node) return
|
||||
|
||||
const props = node.properties
|
||||
if (props.painterTool != null) tool.value = props.painterTool as PainterTool
|
||||
if (props.painterBrushSize != null)
|
||||
brushSize.value = props.painterBrushSize as number
|
||||
if (props.painterBrushColor != null)
|
||||
brushColor.value = props.painterBrushColor as string
|
||||
if (props.painterBrushOpacity != null)
|
||||
brushOpacity.value = props.painterBrushOpacity as number
|
||||
if (props.painterBrushHardness != null)
|
||||
brushHardness.value = props.painterBrushHardness as number
|
||||
|
||||
const bgColorWidget = getWidgetByName('bg_color')
|
||||
if (bgColorWidget) backgroundColor.value = bgColorWidget.value as string
|
||||
}
|
||||
|
||||
function saveSettingsToProperties() {
|
||||
const node = litegraphNode.value
|
||||
if (!node) return
|
||||
|
||||
node.properties.painterTool = tool.value
|
||||
node.properties.painterBrushSize = brushSize.value
|
||||
node.properties.painterBrushColor = brushColor.value
|
||||
node.properties.painterBrushOpacity = brushOpacity.value
|
||||
node.properties.painterBrushHardness = brushHardness.value
|
||||
}
|
||||
|
||||
function syncCanvasSizeToWidgets() {
|
||||
const widthWidget = getWidgetByName('width')
|
||||
const heightWidget = getWidgetByName('height')
|
||||
|
||||
if (widthWidget && widthWidget.value !== canvasWidth.value) {
|
||||
widthWidget.value = canvasWidth.value
|
||||
widthWidget.callback?.(canvasWidth.value)
|
||||
}
|
||||
if (heightWidget && heightWidget.value !== canvasHeight.value) {
|
||||
heightWidget.value = canvasHeight.value
|
||||
heightWidget.callback?.(canvasHeight.value)
|
||||
}
|
||||
}
|
||||
|
||||
function syncBackgroundColorToWidget() {
|
||||
const bgColorWidget = getWidgetByName('bg_color')
|
||||
if (bgColorWidget && bgColorWidget.value !== backgroundColor.value) {
|
||||
bgColorWidget.value = backgroundColor.value
|
||||
bgColorWidget.callback?.(backgroundColor.value)
|
||||
}
|
||||
}
|
||||
|
||||
function updateInputImageUrl() {
|
||||
const node = litegraphNode.value
|
||||
if (!node) {
|
||||
inputImageUrl.value = null
|
||||
isImageInputConnected.value = false
|
||||
return
|
||||
}
|
||||
|
||||
isImageInputConnected.value = node.isInputConnected(0)
|
||||
|
||||
const inputNode = node.getInputNode(0)
|
||||
if (!inputNode) {
|
||||
inputImageUrl.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const urls = nodeOutputStore.getNodeImageUrls(inputNode)
|
||||
inputImageUrl.value = urls?.length ? urls[0] : null
|
||||
}
|
||||
|
||||
function syncCanvasSizeFromWidgets() {
|
||||
const w = getWidgetByName('width')
|
||||
const h = getWidgetByName('height')
|
||||
canvasWidth.value = (w?.value as number) ?? 512
|
||||
canvasHeight.value = (h?.value as number) ?? 512
|
||||
}
|
||||
|
||||
function activeHardness(): number {
|
||||
return tool.value === PAINTER_TOOLS.ERASER ? 1 : brushHardness.value
|
||||
}
|
||||
|
||||
const { width: canvasDisplayWidth } = useElementSize(canvasEl)
|
||||
|
||||
const displayBrushSize = computed(() => {
|
||||
if (!canvasDisplayWidth.value || !canvasWidth.value) return brushSize.value
|
||||
|
||||
const radius = brushSize.value / 2
|
||||
const effectiveRadius = getEffectiveBrushSize(radius, activeHardness())
|
||||
const effectiveDiameter = effectiveRadius * 2
|
||||
return effectiveDiameter * (canvasDisplayWidth.value / canvasWidth.value)
|
||||
})
|
||||
|
||||
function getCtx() {
|
||||
if (!mainCtx && canvasEl.value) {
|
||||
mainCtx = canvasEl.value.getContext('2d') ?? null
|
||||
}
|
||||
return mainCtx
|
||||
}
|
||||
|
||||
function cacheCanvasRect() {
|
||||
const el = canvasEl.value
|
||||
if (el) cachedRect = el.getBoundingClientRect()
|
||||
}
|
||||
|
||||
function getCanvasPoint(e: PointerEvent): Point | null {
|
||||
const el = canvasEl.value
|
||||
if (!el) return null
|
||||
const rect = cachedRect ?? el.getBoundingClientRect()
|
||||
return {
|
||||
x: ((e.clientX - rect.left) / rect.width) * el.width,
|
||||
y: ((e.clientY - rect.top) / rect.height) * el.height
|
||||
}
|
||||
}
|
||||
|
||||
function expandDirtyRect(cx: number, cy: number, r: number) {
|
||||
const x0 = cx - r
|
||||
const y0 = cy - r
|
||||
const x1 = cx + r
|
||||
const y1 = cy + r
|
||||
if (!hasDirtyRect) {
|
||||
dirtyX0 = x0
|
||||
dirtyY0 = y0
|
||||
dirtyX1 = x1
|
||||
dirtyY1 = y1
|
||||
hasDirtyRect = true
|
||||
} else {
|
||||
if (x0 < dirtyX0) dirtyX0 = x0
|
||||
if (y0 < dirtyY0) dirtyY0 = y0
|
||||
if (x1 > dirtyX1) dirtyX1 = x1
|
||||
if (y1 > dirtyY1) dirtyY1 = y1
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotBrush() {
|
||||
const radius = brushSize.value / 2
|
||||
const hardness = activeHardness()
|
||||
const effectiveRadius = getEffectiveBrushSize(radius, hardness)
|
||||
strokeBrush = {
|
||||
radius,
|
||||
effectiveRadius,
|
||||
effectiveHardness: getEffectiveHardness(
|
||||
radius,
|
||||
hardness,
|
||||
effectiveRadius
|
||||
),
|
||||
hardness,
|
||||
...hexToRgb(brushColor.value)
|
||||
}
|
||||
}
|
||||
|
||||
function drawCircle(ctx: CanvasRenderingContext2D, point: Point) {
|
||||
const b = strokeBrush!
|
||||
|
||||
expandDirtyRect(point.x, point.y, b.effectiveRadius)
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.arc(point.x, point.y, b.effectiveRadius, 0, Math.PI * 2)
|
||||
|
||||
if (b.hardness < 1) {
|
||||
const gradient = ctx.createRadialGradient(
|
||||
point.x,
|
||||
point.y,
|
||||
0,
|
||||
point.x,
|
||||
point.y,
|
||||
b.effectiveRadius
|
||||
)
|
||||
gradient.addColorStop(0, `rgba(${b.r}, ${b.g}, ${b.b}, 1)`)
|
||||
gradient.addColorStop(
|
||||
b.effectiveHardness,
|
||||
`rgba(${b.r}, ${b.g}, ${b.b}, 1)`
|
||||
)
|
||||
gradient.addColorStop(1, `rgba(${b.r}, ${b.g}, ${b.b}, 0)`)
|
||||
ctx.fillStyle = gradient
|
||||
}
|
||||
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
function drawSegment(ctx: CanvasRenderingContext2D, from: Point, to: Point) {
|
||||
const b = strokeBrush!
|
||||
|
||||
if (b.hardness < 1) {
|
||||
const dx = to.x - from.x
|
||||
const dy = to.y - from.y
|
||||
const dist = Math.hypot(dx, dy)
|
||||
const step = Math.max(1, b.effectiveRadius / 2)
|
||||
|
||||
if (dist > 0) {
|
||||
const steps = Math.ceil(dist / step)
|
||||
const dabPoint: Point = { x: 0, y: 0 }
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const t = i / steps
|
||||
dabPoint.x = from.x + dx * t
|
||||
dabPoint.y = from.y + dy * t
|
||||
drawCircle(ctx, dabPoint)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
expandDirtyRect(from.x, from.y, b.effectiveRadius)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(from.x, from.y)
|
||||
ctx.lineTo(to.x, to.y)
|
||||
ctx.stroke()
|
||||
drawCircle(ctx, to)
|
||||
}
|
||||
}
|
||||
|
||||
function applyBrushStyle(ctx: CanvasRenderingContext2D) {
|
||||
const b = strokeBrush!
|
||||
const color = `rgb(${b.r}, ${b.g}, ${b.b})`
|
||||
|
||||
ctx.globalCompositeOperation = 'source-over'
|
||||
ctx.globalAlpha = 1
|
||||
ctx.fillStyle = color
|
||||
ctx.strokeStyle = color
|
||||
ctx.lineWidth = b.effectiveRadius * 2
|
||||
ctx.lineCap = 'round'
|
||||
ctx.lineJoin = 'round'
|
||||
}
|
||||
|
||||
function ensureStrokeCanvas() {
|
||||
const el = canvasEl.value
|
||||
if (!el) return null
|
||||
|
||||
if (
|
||||
!strokeCanvas ||
|
||||
strokeCanvas.width !== el.width ||
|
||||
strokeCanvas.height !== el.height
|
||||
) {
|
||||
strokeCanvas = document.createElement('canvas')
|
||||
strokeCanvas.width = el.width
|
||||
strokeCanvas.height = el.height
|
||||
strokeCtx = strokeCanvas.getContext('2d')
|
||||
}
|
||||
|
||||
strokeCtx?.clearRect(0, 0, strokeCanvas.width, strokeCanvas.height)
|
||||
return strokeCtx
|
||||
}
|
||||
|
||||
function ensureBaseCanvas() {
|
||||
const el = canvasEl.value
|
||||
if (!el) return null
|
||||
|
||||
if (
|
||||
!baseCanvas ||
|
||||
baseCanvas.width !== el.width ||
|
||||
baseCanvas.height !== el.height
|
||||
) {
|
||||
baseCanvas = document.createElement('canvas')
|
||||
baseCanvas.width = el.width
|
||||
baseCanvas.height = el.height
|
||||
baseCtx = baseCanvas.getContext('2d')
|
||||
}
|
||||
|
||||
return baseCtx
|
||||
}
|
||||
|
||||
function compositeStrokeToMain(isPreview: boolean = false) {
|
||||
const el = canvasEl.value
|
||||
const ctx = getCtx()
|
||||
if (!el || !ctx || !strokeCanvas) return
|
||||
|
||||
const useDirty = hasDirtyRect
|
||||
const sx = Math.max(0, Math.floor(dirtyX0))
|
||||
const sy = Math.max(0, Math.floor(dirtyY0))
|
||||
const sr = Math.min(el.width, Math.ceil(dirtyX1))
|
||||
const sb = Math.min(el.height, Math.ceil(dirtyY1))
|
||||
const sw = sr - sx
|
||||
const sh = sb - sy
|
||||
hasDirtyRect = false
|
||||
|
||||
if (hasBaseSnapshot && baseCanvas) {
|
||||
if (useDirty && sw > 0 && sh > 0) {
|
||||
ctx.clearRect(sx, sy, sw, sh)
|
||||
ctx.drawImage(baseCanvas, sx, sy, sw, sh, sx, sy, sw, sh)
|
||||
} else {
|
||||
ctx.clearRect(0, 0, el.width, el.height)
|
||||
ctx.drawImage(baseCanvas, 0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.save()
|
||||
const isEraser = tool.value === PAINTER_TOOLS.ERASER
|
||||
ctx.globalAlpha = isEraser ? 1 : brushOpacity.value
|
||||
ctx.globalCompositeOperation = isEraser ? 'destination-out' : 'source-over'
|
||||
if (useDirty && sw > 0 && sh > 0) {
|
||||
ctx.drawImage(strokeCanvas, sx, sy, sw, sh, sx, sy, sw, sh)
|
||||
} else {
|
||||
ctx.drawImage(strokeCanvas, 0, 0)
|
||||
}
|
||||
ctx.restore()
|
||||
|
||||
if (!isPreview) {
|
||||
hasBaseSnapshot = false
|
||||
}
|
||||
}
|
||||
|
||||
function startStroke(e: PointerEvent) {
|
||||
const point = getCanvasPoint(e)
|
||||
if (!point) return
|
||||
|
||||
const el = canvasEl.value
|
||||
if (!el) return
|
||||
|
||||
const bCtx = ensureBaseCanvas()
|
||||
if (bCtx) {
|
||||
bCtx.clearRect(0, 0, el.width, el.height)
|
||||
bCtx.drawImage(el, 0, 0)
|
||||
hasBaseSnapshot = true
|
||||
}
|
||||
|
||||
isDrawing = true
|
||||
isDirty.value = true
|
||||
hasStrokes = true
|
||||
snapshotBrush()
|
||||
strokeProcessor = new StrokeProcessor(Math.max(1, strokeBrush!.radius / 2))
|
||||
strokeProcessor.addPoint(point)
|
||||
lastPoint = point
|
||||
|
||||
const ctx = ensureStrokeCanvas()
|
||||
if (!ctx) return
|
||||
ctx.save()
|
||||
applyBrushStyle(ctx)
|
||||
drawCircle(ctx, point)
|
||||
ctx.restore()
|
||||
|
||||
compositeStrokeToMain(true)
|
||||
}
|
||||
|
||||
function continueStroke(e: PointerEvent) {
|
||||
if (!isDrawing || !strokeProcessor || !strokeCtx) return
|
||||
|
||||
const point = getCanvasPoint(e)
|
||||
if (!point) return
|
||||
|
||||
const points = strokeProcessor.addPoint(point)
|
||||
if (points.length === 0 && lastPoint) {
|
||||
points.push(point)
|
||||
}
|
||||
|
||||
if (points.length === 0) return
|
||||
|
||||
strokeCtx.save()
|
||||
applyBrushStyle(strokeCtx)
|
||||
|
||||
let prev = lastPoint ?? points[0]
|
||||
for (const p of points) {
|
||||
drawSegment(strokeCtx, prev, p)
|
||||
prev = p
|
||||
}
|
||||
lastPoint = prev
|
||||
|
||||
strokeCtx.restore()
|
||||
|
||||
compositeStrokeToMain(true)
|
||||
}
|
||||
|
||||
function endStroke() {
|
||||
if (!isDrawing || !strokeProcessor) return
|
||||
|
||||
const points = strokeProcessor.endStroke()
|
||||
if (strokeCtx && points.length > 0) {
|
||||
strokeCtx.save()
|
||||
applyBrushStyle(strokeCtx)
|
||||
let prev = lastPoint ?? points[0]
|
||||
for (const p of points) {
|
||||
drawSegment(strokeCtx, prev, p)
|
||||
prev = p
|
||||
}
|
||||
strokeCtx.restore()
|
||||
}
|
||||
|
||||
compositeStrokeToMain()
|
||||
|
||||
isDrawing = false
|
||||
strokeProcessor = null
|
||||
strokeBrush = null
|
||||
lastPoint = null
|
||||
}
|
||||
|
||||
function resizeCanvas() {
|
||||
const el = canvasEl.value
|
||||
if (!el) return
|
||||
|
||||
let tmp: HTMLCanvasElement | null = null
|
||||
if (el.width > 0 && el.height > 0) {
|
||||
tmp = document.createElement('canvas')
|
||||
tmp.width = el.width
|
||||
tmp.height = el.height
|
||||
tmp.getContext('2d')!.drawImage(el, 0, 0)
|
||||
}
|
||||
|
||||
el.width = canvasWidth.value
|
||||
el.height = canvasHeight.value
|
||||
mainCtx = null
|
||||
|
||||
if (tmp) {
|
||||
getCtx()?.drawImage(tmp, 0, 0)
|
||||
}
|
||||
|
||||
strokeCanvas = null
|
||||
strokeCtx = null
|
||||
baseCanvas = null
|
||||
baseCtx = null
|
||||
hasBaseSnapshot = false
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
const el = canvasEl.value
|
||||
const ctx = getCtx()
|
||||
if (!el || !ctx) return
|
||||
ctx.clearRect(0, 0, el.width, el.height)
|
||||
isDirty.value = true
|
||||
hasStrokes = false
|
||||
}
|
||||
|
||||
function updateCursorPos(e: PointerEvent) {
|
||||
cursorX.value = e.offsetX
|
||||
cursorY.value = e.offsetY
|
||||
}
|
||||
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
|
||||
cacheCanvasRect()
|
||||
updateCursorPos(e)
|
||||
startStroke(e)
|
||||
}
|
||||
|
||||
let pendingMoveEvent: PointerEvent | null = null
|
||||
let rafId: number | null = null
|
||||
|
||||
function flushPendingStroke() {
|
||||
if (pendingMoveEvent) {
|
||||
continueStroke(pendingMoveEvent)
|
||||
pendingMoveEvent = null
|
||||
}
|
||||
rafId = null
|
||||
}
|
||||
|
||||
function handlePointerMove(e: PointerEvent) {
|
||||
updateCursorPos(e)
|
||||
if (!isDrawing) return
|
||||
|
||||
pendingMoveEvent = e
|
||||
if (!rafId) {
|
||||
rafId = requestAnimationFrame(flushPendingStroke)
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerUp(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId)
|
||||
flushPendingStroke()
|
||||
}
|
||||
;(e.target as HTMLElement).releasePointerCapture(e.pointerId)
|
||||
endStroke()
|
||||
}
|
||||
|
||||
function handlePointerLeave() {
|
||||
cursorVisible.value = false
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId)
|
||||
flushPendingStroke()
|
||||
}
|
||||
endStroke()
|
||||
}
|
||||
|
||||
function handlePointerEnter() {
|
||||
cursorVisible.value = true
|
||||
}
|
||||
|
||||
function handleInputImageLoad(e: Event) {
|
||||
const img = e.target as HTMLImageElement
|
||||
const widthWidget = getWidgetByName('width')
|
||||
const heightWidget = getWidgetByName('height')
|
||||
if (widthWidget) {
|
||||
widthWidget.value = img.naturalWidth
|
||||
widthWidget.callback?.(img.naturalWidth)
|
||||
}
|
||||
if (heightWidget) {
|
||||
heightWidget.value = img.naturalHeight
|
||||
heightWidget.callback?.(img.naturalHeight)
|
||||
}
|
||||
canvasWidth.value = img.naturalWidth
|
||||
canvasHeight.value = img.naturalHeight
|
||||
}
|
||||
|
||||
function parseMaskFilename(value: string): {
|
||||
filename: string
|
||||
subfolder: string
|
||||
type: string
|
||||
} | null {
|
||||
const trimmed = value?.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
const typeMatch = trimmed.match(/^(.+?) \[([^\]]+)\]$/)
|
||||
const pathPart = typeMatch ? typeMatch[1] : trimmed
|
||||
const type = typeMatch ? typeMatch[2] : 'input'
|
||||
|
||||
const lastSlash = pathPart.lastIndexOf('/')
|
||||
const subfolder = lastSlash !== -1 ? pathPart.substring(0, lastSlash) : ''
|
||||
const filename =
|
||||
lastSlash !== -1 ? pathPart.substring(lastSlash + 1) : pathPart
|
||||
|
||||
return { filename, subfolder, type }
|
||||
}
|
||||
|
||||
function isCanvasEmpty(): boolean {
|
||||
return !hasStrokes
|
||||
}
|
||||
|
||||
async function serializeValue(): Promise<string> {
|
||||
const el = canvasEl.value
|
||||
if (!el) return ''
|
||||
|
||||
if (isCanvasEmpty()) return ''
|
||||
|
||||
if (!isDirty.value) return modelValue.value
|
||||
|
||||
const blob = await new Promise<Blob | null>((resolve) =>
|
||||
el.toBlob(resolve, 'image/png')
|
||||
)
|
||||
if (!blob) return modelValue.value
|
||||
|
||||
const name = `painter-${nodeId}-${Date.now()}.png`
|
||||
const body = new FormData()
|
||||
body.append('image', blob, name)
|
||||
if (!isCloud) body.append('subfolder', 'painter')
|
||||
body.append('type', isCloud ? 'input' : 'temp')
|
||||
|
||||
let resp: Response
|
||||
try {
|
||||
resp = await api.fetchApi('/upload/image', {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
} catch (e) {
|
||||
const err = t('painter.uploadError', {
|
||||
status: 0,
|
||||
statusText: e instanceof Error ? e.message : String(e)
|
||||
})
|
||||
toastStore.addAlert(err)
|
||||
throw new Error(err)
|
||||
}
|
||||
|
||||
if (resp.status !== 200) {
|
||||
const err = t('painter.uploadError', {
|
||||
status: resp.status,
|
||||
statusText: resp.statusText
|
||||
})
|
||||
toastStore.addAlert(err)
|
||||
throw new Error(err)
|
||||
}
|
||||
|
||||
let data: { name: string }
|
||||
try {
|
||||
data = await resp.json()
|
||||
} catch (e) {
|
||||
const err = t('painter.uploadError', {
|
||||
status: resp.status,
|
||||
statusText: e instanceof Error ? e.message : String(e)
|
||||
})
|
||||
toastStore.addAlert(err)
|
||||
throw new Error(err)
|
||||
}
|
||||
|
||||
const result = isCloud
|
||||
? `${data.name} [input]`
|
||||
: `painter/${data.name} [temp]`
|
||||
modelValue.value = result
|
||||
isDirty.value = false
|
||||
return result
|
||||
}
|
||||
|
||||
function registerWidgetSerialization() {
|
||||
const node = litegraphNode.value
|
||||
if (!node?.widgets) return
|
||||
const targetWidget = node.widgets.find(
|
||||
(w: IBaseWidget) => w.name === 'mask'
|
||||
)
|
||||
if (targetWidget) {
|
||||
targetWidget.serializeValue = serializeValue
|
||||
}
|
||||
}
|
||||
|
||||
function restoreCanvas() {
|
||||
const parsed = parseMaskFilename(modelValue.value)
|
||||
if (!parsed) return
|
||||
|
||||
const params = new URLSearchParams()
|
||||
params.set('filename', parsed.filename)
|
||||
if (parsed.subfolder) params.set('subfolder', parsed.subfolder)
|
||||
params.set('type', parsed.type)
|
||||
|
||||
const url = api.apiURL('/view?' + params.toString())
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.onload = () => {
|
||||
const el = canvasEl.value
|
||||
if (!el) return
|
||||
canvasWidth.value = img.naturalWidth
|
||||
canvasHeight.value = img.naturalHeight
|
||||
mainCtx = null
|
||||
getCtx()?.drawImage(img, 0, 0)
|
||||
isDirty.value = false
|
||||
hasStrokes = true
|
||||
}
|
||||
img.onerror = () => {
|
||||
modelValue.value = ''
|
||||
}
|
||||
img.src = url
|
||||
}
|
||||
|
||||
watch(() => nodeOutputStore.nodeOutputs, updateInputImageUrl, { deep: true })
|
||||
watch(() => nodeOutputStore.nodePreviewImages, updateInputImageUrl, {
|
||||
deep: true
|
||||
})
|
||||
watch([canvasWidth, canvasHeight], resizeCanvas)
|
||||
|
||||
watch(
|
||||
[tool, brushSize, brushColor, brushOpacity, brushHardness],
|
||||
saveSettingsToProperties
|
||||
)
|
||||
|
||||
watch([canvasWidth, canvasHeight], syncCanvasSizeToWidgets)
|
||||
|
||||
watch(backgroundColor, syncBackgroundColorToWidget)
|
||||
|
||||
function initialize() {
|
||||
syncCanvasSizeFromWidgets()
|
||||
resizeCanvas()
|
||||
registerWidgetSerialization()
|
||||
restoreSettingsFromProperties()
|
||||
updateInputImageUrl()
|
||||
restoreCanvas()
|
||||
}
|
||||
|
||||
onMounted(initialize)
|
||||
|
||||
onUnmounted(() => {
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
tool,
|
||||
brushSize,
|
||||
brushColor,
|
||||
brushOpacity,
|
||||
brushHardness,
|
||||
backgroundColor,
|
||||
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
|
||||
cursorX,
|
||||
cursorY,
|
||||
cursorVisible,
|
||||
displayBrushSize,
|
||||
|
||||
inputImageUrl,
|
||||
isImageInputConnected,
|
||||
|
||||
handlePointerDown,
|
||||
handlePointerMove,
|
||||
handlePointerUp,
|
||||
handlePointerEnter,
|
||||
handlePointerLeave,
|
||||
|
||||
handleInputImageLoad,
|
||||
handleClear
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
|
||||
type TestTask = {
|
||||
jobId: string
|
||||
job: { priority: number }
|
||||
queueIndex: number
|
||||
mockState: JobState
|
||||
executionTime?: number
|
||||
executionEndTimestamp?: number
|
||||
@@ -174,7 +174,7 @@ const createTask = (
|
||||
overrides: Partial<TestTask> & { mockState?: JobState } = {}
|
||||
): TestTask => ({
|
||||
jobId: overrides.jobId ?? `task-${Math.random().toString(36).slice(2, 7)}`,
|
||||
job: overrides.job ?? { priority: 0 },
|
||||
queueIndex: overrides.queueIndex ?? 0,
|
||||
mockState: overrides.mockState ?? 'pending',
|
||||
executionTime: overrides.executionTime,
|
||||
executionEndTimestamp: overrides.executionEndTimestamp,
|
||||
@@ -258,7 +258,7 @@ describe('useJobList', () => {
|
||||
it('tracks recently added pending jobs and clears the hint after expiry', async () => {
|
||||
vi.useFakeTimers()
|
||||
queueStoreMock.pendingTasks = [
|
||||
createTask({ jobId: '1', job: { priority: 1 }, mockState: 'pending' })
|
||||
createTask({ jobId: '1', queueIndex: 1, mockState: 'pending' })
|
||||
]
|
||||
|
||||
const { jobItems } = initComposable()
|
||||
@@ -287,7 +287,7 @@ describe('useJobList', () => {
|
||||
vi.useFakeTimers()
|
||||
const taskId = '2'
|
||||
queueStoreMock.pendingTasks = [
|
||||
createTask({ jobId: taskId, job: { priority: 1 }, mockState: 'pending' })
|
||||
createTask({ jobId: taskId, queueIndex: 1, mockState: 'pending' })
|
||||
]
|
||||
|
||||
const { jobItems } = initComposable()
|
||||
@@ -300,7 +300,7 @@ describe('useJobList', () => {
|
||||
|
||||
vi.mocked(buildJobDisplay).mockClear()
|
||||
queueStoreMock.pendingTasks = [
|
||||
createTask({ jobId: taskId, job: { priority: 2 }, mockState: 'pending' })
|
||||
createTask({ jobId: taskId, queueIndex: 2, mockState: 'pending' })
|
||||
]
|
||||
await flush()
|
||||
jobItems.value
|
||||
@@ -314,7 +314,7 @@ describe('useJobList', () => {
|
||||
it('cleans up timeouts on unmount', async () => {
|
||||
vi.useFakeTimers()
|
||||
queueStoreMock.pendingTasks = [
|
||||
createTask({ jobId: '3', job: { priority: 1 }, mockState: 'pending' })
|
||||
createTask({ jobId: '3', queueIndex: 1, mockState: 'pending' })
|
||||
]
|
||||
|
||||
initComposable()
|
||||
@@ -331,7 +331,7 @@ describe('useJobList', () => {
|
||||
queueStoreMock.pendingTasks = [
|
||||
createTask({
|
||||
jobId: 'p',
|
||||
job: { priority: 1 },
|
||||
queueIndex: 1,
|
||||
mockState: 'pending',
|
||||
createTime: 3000
|
||||
})
|
||||
@@ -339,7 +339,7 @@ describe('useJobList', () => {
|
||||
queueStoreMock.runningTasks = [
|
||||
createTask({
|
||||
jobId: 'r',
|
||||
job: { priority: 5 },
|
||||
queueIndex: 5,
|
||||
mockState: 'running',
|
||||
createTime: 2000
|
||||
})
|
||||
@@ -347,7 +347,7 @@ describe('useJobList', () => {
|
||||
queueStoreMock.historyTasks = [
|
||||
createTask({
|
||||
jobId: 'h',
|
||||
job: { priority: 3 },
|
||||
queueIndex: 3,
|
||||
mockState: 'completed',
|
||||
createTime: 1000,
|
||||
executionEndTimestamp: 5000
|
||||
@@ -366,9 +366,9 @@ describe('useJobList', () => {
|
||||
|
||||
it('filters by job tab and resets failed tab when failures disappear', async () => {
|
||||
queueStoreMock.historyTasks = [
|
||||
createTask({ jobId: 'c', job: { priority: 3 }, mockState: 'completed' }),
|
||||
createTask({ jobId: 'f', job: { priority: 2 }, mockState: 'failed' }),
|
||||
createTask({ jobId: 'p', job: { priority: 1 }, mockState: 'pending' })
|
||||
createTask({ jobId: 'c', queueIndex: 3, mockState: 'completed' }),
|
||||
createTask({ jobId: 'f', queueIndex: 2, mockState: 'failed' }),
|
||||
createTask({ jobId: 'p', queueIndex: 1, mockState: 'pending' })
|
||||
]
|
||||
|
||||
const instance = initComposable()
|
||||
@@ -384,7 +384,7 @@ describe('useJobList', () => {
|
||||
expect(instance.hasFailedJobs.value).toBe(true)
|
||||
|
||||
queueStoreMock.historyTasks = [
|
||||
createTask({ jobId: 'c', job: { priority: 3 }, mockState: 'completed' })
|
||||
createTask({ jobId: 'c', queueIndex: 3, mockState: 'completed' })
|
||||
]
|
||||
await flush()
|
||||
|
||||
@@ -396,13 +396,13 @@ describe('useJobList', () => {
|
||||
queueStoreMock.pendingTasks = [
|
||||
createTask({
|
||||
jobId: 'wf-1',
|
||||
job: { priority: 2 },
|
||||
queueIndex: 2,
|
||||
mockState: 'pending',
|
||||
workflowId: 'workflow-1'
|
||||
}),
|
||||
createTask({
|
||||
jobId: 'wf-2',
|
||||
job: { priority: 1 },
|
||||
queueIndex: 1,
|
||||
mockState: 'pending',
|
||||
workflowId: 'workflow-2'
|
||||
})
|
||||
@@ -426,14 +426,14 @@ describe('useJobList', () => {
|
||||
queueStoreMock.historyTasks = [
|
||||
createTask({
|
||||
jobId: 'alpha',
|
||||
job: { priority: 2 },
|
||||
queueIndex: 2,
|
||||
mockState: 'completed',
|
||||
createTime: 2000,
|
||||
executionEndTimestamp: 2000
|
||||
}),
|
||||
createTask({
|
||||
jobId: 'beta',
|
||||
job: { priority: 1 },
|
||||
queueIndex: 1,
|
||||
mockState: 'failed',
|
||||
createTime: 1000,
|
||||
executionEndTimestamp: 1000
|
||||
@@ -471,13 +471,13 @@ describe('useJobList', () => {
|
||||
queueStoreMock.runningTasks = [
|
||||
createTask({
|
||||
jobId: 'active',
|
||||
job: { priority: 3 },
|
||||
queueIndex: 3,
|
||||
mockState: 'running',
|
||||
executionTime: 7_200_000
|
||||
}),
|
||||
createTask({
|
||||
jobId: 'other',
|
||||
job: { priority: 2 },
|
||||
queueIndex: 2,
|
||||
mockState: 'running',
|
||||
executionTime: 3_600_000
|
||||
})
|
||||
@@ -507,7 +507,7 @@ describe('useJobList', () => {
|
||||
queueStoreMock.runningTasks = [
|
||||
createTask({
|
||||
jobId: 'live-preview',
|
||||
job: { priority: 1 },
|
||||
queueIndex: 1,
|
||||
mockState: 'running'
|
||||
})
|
||||
]
|
||||
@@ -526,7 +526,7 @@ describe('useJobList', () => {
|
||||
queueStoreMock.runningTasks = [
|
||||
createTask({
|
||||
jobId: 'disabled-preview',
|
||||
job: { priority: 1 },
|
||||
queueIndex: 1,
|
||||
mockState: 'running'
|
||||
})
|
||||
]
|
||||
@@ -567,28 +567,28 @@ describe('useJobList', () => {
|
||||
queueStoreMock.historyTasks = [
|
||||
createTask({
|
||||
jobId: 'today-small',
|
||||
job: { priority: 4 },
|
||||
queueIndex: 4,
|
||||
mockState: 'completed',
|
||||
executionEndTimestamp: Date.now(),
|
||||
executionTime: 2_000
|
||||
}),
|
||||
createTask({
|
||||
jobId: 'today-large',
|
||||
job: { priority: 3 },
|
||||
queueIndex: 3,
|
||||
mockState: 'completed',
|
||||
executionEndTimestamp: Date.now(),
|
||||
executionTime: 5_000
|
||||
}),
|
||||
createTask({
|
||||
jobId: 'yesterday',
|
||||
job: { priority: 2 },
|
||||
queueIndex: 2,
|
||||
mockState: 'failed',
|
||||
executionEndTimestamp: Date.now() - 86_400_000,
|
||||
executionTime: 1_000
|
||||
}),
|
||||
createTask({
|
||||
jobId: 'undated',
|
||||
job: { priority: 1 },
|
||||
queueIndex: 1,
|
||||
mockState: 'pending'
|
||||
})
|
||||
]
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
|
||||
export type AppMode = 'graph' | 'app' | 'builder:select' | 'builder:arrange'
|
||||
|
||||
const enableAppBuilder = ref(true)
|
||||
|
||||
export function useAppMode() {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const mode = computed(
|
||||
() =>
|
||||
workflowStore.activeWorkflow?.activeMode ??
|
||||
workflowStore.activeWorkflow?.initialMode ??
|
||||
'graph'
|
||||
)
|
||||
|
||||
const isBuilderMode = computed(
|
||||
() => isSelectMode.value || isArrangeMode.value
|
||||
)
|
||||
const isSelectMode = computed(() => mode.value === 'builder:select')
|
||||
const isArrangeMode = computed(() => mode.value === 'builder:arrange')
|
||||
const isAppMode = computed(
|
||||
() => mode.value === 'app' || mode.value === 'builder:arrange'
|
||||
)
|
||||
const isGraphMode = computed(
|
||||
() => mode.value === 'graph' || mode.value === 'builder:select'
|
||||
)
|
||||
|
||||
function setMode(newMode: AppMode) {
|
||||
if (newMode === mode.value) return
|
||||
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (workflow) workflow.activeMode = newMode
|
||||
}
|
||||
|
||||
return {
|
||||
mode,
|
||||
enableAppBuilder,
|
||||
isBuilderMode,
|
||||
isSelectMode,
|
||||
isArrangeMode,
|
||||
isAppMode,
|
||||
isGraphMode,
|
||||
setMode
|
||||
}
|
||||
}
|
||||
@@ -4,32 +4,64 @@ import { useToast } from 'primevue/usetoast'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
export function useCopyToClipboard() {
|
||||
const { copy, copied } = useClipboard({ legacy: true })
|
||||
const { copy, copied } = useClipboard()
|
||||
const toast = useToast()
|
||||
const showSuccessToast = () => {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('clipboard.successMessage'),
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
const showErrorToast = () => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('clipboard.errorMessage')
|
||||
})
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string) {
|
||||
function fallbackCopy(text: string) {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.setAttribute('readonly', '')
|
||||
textarea.value = text
|
||||
textarea.style.position = 'absolute'
|
||||
textarea.style.left = '-9999px'
|
||||
textarea.setAttribute('aria-hidden', 'true')
|
||||
textarea.setAttribute('tabindex', '-1')
|
||||
textarea.style.width = '1px'
|
||||
textarea.style.height = '1px'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
|
||||
try {
|
||||
// using legacy document.execCommand for fallback for old and linux browsers
|
||||
const successful = document.execCommand('copy')
|
||||
if (successful) {
|
||||
showSuccessToast()
|
||||
} else {
|
||||
showErrorToast()
|
||||
}
|
||||
} catch (err) {
|
||||
showErrorToast()
|
||||
} finally {
|
||||
textarea.remove()
|
||||
}
|
||||
}
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await copy(text)
|
||||
if (copied.value) {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('clipboard.successMessage'),
|
||||
life: 3000
|
||||
})
|
||||
showSuccessToast()
|
||||
} else {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('clipboard.errorMessage')
|
||||
})
|
||||
// If VueUse copy failed, try fallback
|
||||
fallbackCopy(text)
|
||||
}
|
||||
} catch {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('clipboard.errorMessage')
|
||||
})
|
||||
} catch (err) {
|
||||
// VueUse copy failed, try fallback
|
||||
fallbackCopy(text)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -71,8 +71,7 @@ import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const moveSelectedNodesVersionAdded = '1.22.2'
|
||||
export function useCoreCommands(): ComfyCommand[] {
|
||||
const { isActiveSubscription, showSubscriptionDialog, subscription } =
|
||||
useBillingContext()
|
||||
const { isActiveSubscription, showSubscriptionDialog } = useBillingContext()
|
||||
const workflowService = useWorkflowService()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const settingsDialog = useSettingsDialog()
|
||||
@@ -496,10 +495,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
subscribe_to_run?: boolean
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
}) => {
|
||||
useTelemetry()?.trackRunButton({
|
||||
...metadata,
|
||||
current_tier: subscription.value?.tier?.toLowerCase()
|
||||
})
|
||||
useTelemetry()?.trackRunButton(metadata)
|
||||
if (!isActiveSubscription.value) {
|
||||
showSubscriptionDialog()
|
||||
return
|
||||
@@ -522,10 +518,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
subscribe_to_run?: boolean
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
}) => {
|
||||
useTelemetry()?.trackRunButton({
|
||||
...metadata,
|
||||
current_tier: subscription.value?.tier?.toLowerCase()
|
||||
})
|
||||
useTelemetry()?.trackRunButton(metadata)
|
||||
if (!isActiveSubscription.value) {
|
||||
showSubscriptionDialog()
|
||||
return
|
||||
@@ -547,10 +540,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
subscribe_to_run?: boolean
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
}) => {
|
||||
useTelemetry()?.trackRunButton({
|
||||
...metadata,
|
||||
current_tier: subscription.value?.tier?.toLowerCase()
|
||||
})
|
||||
useTelemetry()?.trackRunButton(metadata)
|
||||
if (!isActiveSubscription.value) {
|
||||
showSubscriptionDialog()
|
||||
return
|
||||
@@ -1348,6 +1338,8 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
typeof metadata?.source === 'string' ? metadata.source : 'keybind'
|
||||
const newMode = !canvasStore.linearMode
|
||||
if (newMode) useTelemetry()?.trackEnterLinear({ source })
|
||||
app.rootGraph.extra.linearMode = newMode
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
canvasStore.linearMode = newMode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { createMonotoneInterpolator } from '@/components/curve/curveUtils'
|
||||
import type { CurvePoint } from '@/components/curve/types'
|
||||
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
interface UseCurveEditorOptions {
|
||||
svgRef: Ref<SVGSVGElement | null>
|
||||
@@ -21,12 +21,11 @@ export function useCurveEditor({ svgRef, modelValue }: UseCurveEditorOptions) {
|
||||
const xMin = points[0][0]
|
||||
const xMax = points[points.length - 1][0]
|
||||
const segments = 128
|
||||
const range = xMax - xMin
|
||||
const parts: string[] = []
|
||||
for (let i = 0; i <= segments; i++) {
|
||||
const x = xMin + range * (i / segments)
|
||||
const x = xMin + (xMax - xMin) * (i / segments)
|
||||
const y = 1 - interpolate(x)
|
||||
parts.push(`${i === 0 ? 'M' : 'L'}${x},${y}`)
|
||||
parts.push(`${i === 0 ? 'M' : 'L'}${x.toFixed(4)},${y.toFixed(4)}`)
|
||||
}
|
||||
return parts.join('')
|
||||
})
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacementStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import {
|
||||
collectAllNodes,
|
||||
getExecutionIdByNode
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { getCnrIdFromNode } from '@/workbench/extensions/manager/utils/missingNodeErrorUtil'
|
||||
|
||||
/** Scan the live graph for unregistered node types and build a full MissingNodeType list. */
|
||||
function scanMissingNodes(rootGraph: LGraph): MissingNodeType[] {
|
||||
const nodeReplacementStore = useNodeReplacementStore()
|
||||
const missingNodeTypes: MissingNodeType[] = []
|
||||
|
||||
const allNodes = collectAllNodes(rootGraph)
|
||||
|
||||
for (const node of allNodes) {
|
||||
const originalType = node.last_serialization?.type ?? node.type ?? 'Unknown'
|
||||
|
||||
if (originalType in LiteGraph.registered_node_types) continue
|
||||
|
||||
const cnrId = getCnrIdFromNode(node)
|
||||
const replacement = nodeReplacementStore.getReplacementFor(originalType)
|
||||
const executionId = getExecutionIdByNode(rootGraph, node)
|
||||
|
||||
missingNodeTypes.push({
|
||||
type: originalType,
|
||||
nodeId: executionId ?? String(node.id),
|
||||
cnrId,
|
||||
isReplaceable: replacement !== null,
|
||||
replacement: replacement ?? undefined
|
||||
})
|
||||
}
|
||||
|
||||
return missingNodeTypes
|
||||
}
|
||||
|
||||
/** Re-scan the graph for missing nodes and update the error store. */
|
||||
export function rescanAndSurfaceMissingNodes(rootGraph: LGraph): void {
|
||||
const types = scanMissingNodes(rootGraph)
|
||||
useExecutionErrorStore().surfaceMissingNodes(types)
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
ESSENTIALS_CATEGORIES,
|
||||
ESSENTIALS_CATEGORY_CANONICAL,
|
||||
ESSENTIALS_CATEGORY_MAP,
|
||||
ESSENTIALS_NODES,
|
||||
TOOLKIT_BLUEPRINT_MODULES,
|
||||
TOOLKIT_NOVEL_NODE_NAMES
|
||||
} from './essentialsNodes'
|
||||
|
||||
describe('essentialsNodes', () => {
|
||||
it('has no duplicate node names across categories', () => {
|
||||
const seen = new Map<string, string>()
|
||||
for (const [category, nodes] of Object.entries(ESSENTIALS_NODES)) {
|
||||
for (const node of nodes) {
|
||||
expect(
|
||||
seen.has(node),
|
||||
`"${node}" duplicated in "${category}" and "${seen.get(node)}"`
|
||||
).toBe(false)
|
||||
seen.set(node, category)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('ESSENTIALS_CATEGORY_MAP covers every node in ESSENTIALS_NODES', () => {
|
||||
for (const [category, nodes] of Object.entries(ESSENTIALS_NODES)) {
|
||||
for (const node of nodes) {
|
||||
expect(ESSENTIALS_CATEGORY_MAP[node]).toBe(category)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('TOOLKIT_NOVEL_NODE_NAMES excludes basics nodes', () => {
|
||||
for (const basicNode of ESSENTIALS_NODES.basics) {
|
||||
expect(TOOLKIT_NOVEL_NODE_NAMES.has(basicNode)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('TOOLKIT_NOVEL_NODE_NAMES excludes SubgraphBlueprint-prefixed nodes', () => {
|
||||
for (const name of TOOLKIT_NOVEL_NODE_NAMES) {
|
||||
expect(name.startsWith('SubgraphBlueprint.')).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('ESSENTIALS_NODES keys match ESSENTIALS_CATEGORIES', () => {
|
||||
const nodeKeys = Object.keys(ESSENTIALS_NODES)
|
||||
expect(nodeKeys).toEqual([...ESSENTIALS_CATEGORIES])
|
||||
})
|
||||
|
||||
it('TOOLKIT_BLUEPRINT_MODULES contains comfy_essentials', () => {
|
||||
expect(TOOLKIT_BLUEPRINT_MODULES.has('comfy_essentials')).toBe(true)
|
||||
})
|
||||
|
||||
it('ESSENTIALS_CATEGORY_CANONICAL maps every category case-insensitively', () => {
|
||||
for (const category of ESSENTIALS_CATEGORIES) {
|
||||
expect(ESSENTIALS_CATEGORY_CANONICAL.get(category.toLowerCase())).toBe(
|
||||
category
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,115 +0,0 @@
|
||||
/**
|
||||
* Single source of truth for Essentials tab node categorization and ordering.
|
||||
*
|
||||
* Adding a new node to the Essentials tab? Add it here and nowhere else.
|
||||
*
|
||||
* Source: https://www.notion.so/comfy-org/2fe6d73d365080d0a951d14cdf540778
|
||||
*/
|
||||
|
||||
export const ESSENTIALS_CATEGORIES = [
|
||||
'basics',
|
||||
'text generation',
|
||||
'image generation',
|
||||
'video generation',
|
||||
'image tools',
|
||||
'video tools',
|
||||
'audio',
|
||||
'3D'
|
||||
] as const
|
||||
|
||||
export type EssentialsCategory = (typeof ESSENTIALS_CATEGORIES)[number]
|
||||
|
||||
/**
|
||||
* Ordered list of nodes per category.
|
||||
* Array order = display order in the Essentials tab.
|
||||
* Presence in a category = the node's essentials_category mock fallback.
|
||||
*/
|
||||
export const ESSENTIALS_NODES: Record<EssentialsCategory, readonly string[]> = {
|
||||
basics: [
|
||||
'LoadImage',
|
||||
'LoadVideo',
|
||||
'Load3D',
|
||||
'SaveImage',
|
||||
'SaveVideo',
|
||||
'SaveGLB',
|
||||
'PrimitiveStringMultiline',
|
||||
'PreviewImage'
|
||||
],
|
||||
'text generation': ['OpenAIChatNode'],
|
||||
'image generation': [
|
||||
'LoraLoader',
|
||||
'LoraLoaderModelOnly',
|
||||
'ConditioningCombine'
|
||||
],
|
||||
'video generation': [
|
||||
'SubgraphBlueprint.pose_to_video_ltx_2_0',
|
||||
'SubgraphBlueprint.canny_to_video_ltx_2_0',
|
||||
'KlingLipSyncAudioToVideoNode',
|
||||
'KlingOmniProEditVideoNode'
|
||||
],
|
||||
'image tools': [
|
||||
'ImageBatch',
|
||||
'ImageCrop',
|
||||
'ImageCropV2',
|
||||
'ImageScale',
|
||||
'ImageScaleBy',
|
||||
'ImageRotate',
|
||||
'ImageBlur',
|
||||
'ImageBlend',
|
||||
'ImageInvert',
|
||||
'ImageCompare',
|
||||
'Canny',
|
||||
'RecraftRemoveBackgroundNode',
|
||||
'RecraftVectorizeImageNode',
|
||||
'LoadImageMask',
|
||||
'GLSLShader'
|
||||
],
|
||||
'video tools': ['GetVideoComponents', 'CreateVideo', 'Video Slice'],
|
||||
audio: [
|
||||
'LoadAudio',
|
||||
'SaveAudio',
|
||||
'SaveAudioMP3',
|
||||
'StabilityTextToAudio',
|
||||
'EmptyLatentAudio'
|
||||
],
|
||||
'3D': ['TencentTextToModelNode', 'TencentImageToModelNode']
|
||||
}
|
||||
|
||||
/**
|
||||
* Flat map: node name → category (derived from ESSENTIALS_NODES).
|
||||
* Used as mock/fallback when backend doesn't provide essentials_category.
|
||||
*/
|
||||
export const ESSENTIALS_CATEGORY_MAP: Record<string, EssentialsCategory> =
|
||||
Object.fromEntries(
|
||||
Object.entries(ESSENTIALS_NODES).flatMap(([category, nodes]) =>
|
||||
nodes.map((node) => [node, category])
|
||||
)
|
||||
) as Record<string, EssentialsCategory>
|
||||
|
||||
/**
|
||||
* Case-insensitive lookup: lowercase category → canonical category.
|
||||
* Used to normalize backend categories (which may be title-cased) to the
|
||||
* canonical form used in ESSENTIALS_CATEGORIES.
|
||||
*/
|
||||
export const ESSENTIALS_CATEGORY_CANONICAL: ReadonlyMap<
|
||||
string,
|
||||
EssentialsCategory
|
||||
> = new Map(ESSENTIALS_CATEGORIES.map((c) => [c.toLowerCase(), c]))
|
||||
|
||||
/**
|
||||
* "Novel" toolkit nodes for telemetry — basics excluded.
|
||||
* Derived from ESSENTIALS_NODES minus the 'basics' category.
|
||||
*/
|
||||
export const TOOLKIT_NOVEL_NODE_NAMES: ReadonlySet<string> = new Set(
|
||||
Object.entries(ESSENTIALS_NODES)
|
||||
.filter(([cat]) => cat !== 'basics')
|
||||
.flatMap(([, nodes]) => nodes)
|
||||
.filter((n) => !n.startsWith('SubgraphBlueprint.'))
|
||||
)
|
||||
|
||||
/**
|
||||
* python_module values that identify toolkit blueprint nodes.
|
||||
*/
|
||||
export const TOOLKIT_BLUEPRINT_MODULES: ReadonlySet<string> = new Set([
|
||||
'comfy_essentials'
|
||||
])
|
||||
@@ -1,11 +0,0 @@
|
||||
/** Default panel size (%) for sidebar and builder panels */
|
||||
export const SIDE_PANEL_SIZE = 20
|
||||
|
||||
/** Default panel size (%) for the center/main panel */
|
||||
export const CENTER_PANEL_SIZE = 80
|
||||
|
||||
/** Minimum panel size (%) for the sidebar */
|
||||
export const SIDEBAR_MIN_SIZE = 10
|
||||
|
||||
/** Minimum panel size (%) for the builder panel */
|
||||
export const BUILDER_MIN_SIZE = 15
|
||||
@@ -1,10 +1,38 @@
|
||||
/**
|
||||
* Toolkit (Essentials) node detection constants.
|
||||
*
|
||||
* Re-exported from essentialsNodes.ts — the single source of truth.
|
||||
* Used by telemetry to track toolkit node adoption and popularity.
|
||||
* Only novel nodes — basic nodes (LoadImage, SaveImage, etc.) are excluded.
|
||||
*
|
||||
* Source: https://www.notion.so/comfy-org/2fe6d73d365080d0a951d14cdf540778
|
||||
*/
|
||||
export {
|
||||
TOOLKIT_NOVEL_NODE_NAMES as TOOLKIT_NODE_NAMES,
|
||||
TOOLKIT_BLUEPRINT_MODULES
|
||||
} from './essentialsNodes'
|
||||
|
||||
/**
|
||||
* Canonical node type names for individual toolkit nodes.
|
||||
*/
|
||||
export const TOOLKIT_NODE_NAMES: ReadonlySet<string> = new Set([
|
||||
// Image Tools
|
||||
'ImageCrop',
|
||||
'ImageRotate',
|
||||
'ImageBlur',
|
||||
'ImageInvert',
|
||||
'ImageCompare',
|
||||
'Canny',
|
||||
|
||||
// Video Tools
|
||||
'Video Slice',
|
||||
|
||||
// API Nodes
|
||||
'RecraftRemoveBackgroundNode',
|
||||
'RecraftVectorizeImageNode',
|
||||
'KlingOmniProEditVideoNode'
|
||||
])
|
||||
|
||||
/**
|
||||
* python_module values that identify toolkit blueprint nodes.
|
||||
* Essentials blueprints are registered with node_pack 'comfy_essentials',
|
||||
* which maps to python_module on the node def.
|
||||
*/
|
||||
export const TOOLKIT_BLUEPRINT_MODULES: ReadonlySet<string> = new Set([
|
||||
'comfy_essentials'
|
||||
])
|
||||
|
||||
@@ -201,8 +201,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
projected.drawWidget(ctx, {
|
||||
width: widgetWidth,
|
||||
showText: !lowQuality,
|
||||
suppressPromotedOutline: true,
|
||||
previewImages: resolved.node.imgs
|
||||
suppressPromotedOutline: true
|
||||
})
|
||||
|
||||
projected.y = originalY
|
||||
|
||||
@@ -184,17 +184,6 @@ describe('getPromotableWidgets', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('adds virtual canvas preview widget for GLSLShader nodes', () => {
|
||||
const node = new LGraphNode('GLSLShader')
|
||||
node.type = 'GLSLShader'
|
||||
|
||||
const widgets = getPromotableWidgets(node)
|
||||
|
||||
expect(
|
||||
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not add virtual canvas preview widget for non-image nodes', () => {
|
||||
const node = new LGraphNode('TextNode')
|
||||
node.addOutput('TEXT', 'STRING')
|
||||
@@ -243,25 +232,4 @@ describe('promoteRecommendedWidgets', () => {
|
||||
|
||||
expect(updatePreviewsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('eagerly promotes virtual preview widget for CANVAS_IMAGE_PREVIEW nodes', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const glslNode = new LGraphNode('GLSLShader')
|
||||
glslNode.type = 'GLSLShader'
|
||||
subgraph.add(glslNode)
|
||||
|
||||
promoteRecommendedWidgets(subgraphNode)
|
||||
|
||||
const store = usePromotionStore()
|
||||
expect(
|
||||
store.isPromoted(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(glslNode.id),
|
||||
CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
)
|
||||
).toBe(true)
|
||||
expect(updatePreviewsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -227,29 +227,6 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
|
||||
// defer. Core $$ preview widgets are the lazy path that needs updatePreviews.
|
||||
if (hasPreviewWidget()) continue
|
||||
|
||||
// Nodes in CANVAS_IMAGE_PREVIEW_NODE_TYPES support a virtual $$
|
||||
// preview widget. Eagerly promote it so getPseudoWidgetPreviewTargets
|
||||
// includes this node and onDrawBackground can call updatePreviews on it
|
||||
// once execution outputs arrive.
|
||||
if (supportsVirtualCanvasImagePreview(node)) {
|
||||
if (
|
||||
!store.isPromoted(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(node.id),
|
||||
CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
)
|
||||
) {
|
||||
store.promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(node.id),
|
||||
CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Also schedule a deferred check: core $$ widgets are created lazily by
|
||||
// updatePreviews when node outputs are first loaded.
|
||||
requestAnimationFrame(() => updatePreviews(node, promotePreviewWidget))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
import { t } from '@/i18n'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import type { TopbarBadge } from '@/types/comfy'
|
||||
|
||||
@@ -20,7 +21,7 @@ const badges = computed<TopbarBadge[]>(() => {
|
||||
|
||||
// Always add cloud badge last (furthest right)
|
||||
result.push({
|
||||
icon: 'icon-[lucide--cloud]',
|
||||
label: t('g.beta'),
|
||||
text: 'Comfy Cloud'
|
||||
})
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ if (!isCloud) {
|
||||
await import('./nodeTemplates')
|
||||
}
|
||||
import './noteNode'
|
||||
import './painter'
|
||||
import './previewAny'
|
||||
import './rerouteNode'
|
||||
import './saveImageExtraOutput'
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
|
||||
const HIDDEN_WIDGETS = new Set(['width', 'height', 'bg_color'])
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.Painter',
|
||||
|
||||
nodeCreated(node) {
|
||||
if (node.constructor.comfyClass !== 'Painter') return
|
||||
|
||||
const [oldWidth, oldHeight] = node.size
|
||||
node.setSize([Math.max(oldWidth, 450), Math.max(oldHeight, 550)])
|
||||
|
||||
node.hideOutputImages = true
|
||||
|
||||
for (const widget of node.widgets ?? []) {
|
||||
if (HIDDEN_WIDGETS.has(widget.name)) {
|
||||
widget.hidden = true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -43,7 +43,7 @@ async function uploadFile(
|
||||
file: File,
|
||||
updateNode: boolean,
|
||||
pasted: boolean = false
|
||||
): Promise<boolean> {
|
||||
) {
|
||||
try {
|
||||
// Wrap file in formdata so it includes filename
|
||||
const body = new FormData()
|
||||
@@ -76,15 +76,12 @@ async function uploadFile(
|
||||
// Manually trigger the callback to update VueNodes
|
||||
audioWidget.callback?.(path)
|
||||
}
|
||||
return true
|
||||
} else {
|
||||
useToastStore().addAlert(resp.status + ' - ' + resp.statusText)
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
useToastStore().addAlert(error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,17 +232,7 @@ app.registerExtension({
|
||||
|
||||
const handleUpload = async (files: File[]) => {
|
||||
if (files?.length) {
|
||||
const previousValue = audioWidget.value
|
||||
audioWidget.value = files[0].name
|
||||
const success = await uploadFile(
|
||||
audioWidget,
|
||||
audioUIWidget,
|
||||
files[0],
|
||||
true
|
||||
)
|
||||
if (!success) {
|
||||
audioWidget.value = previousValue
|
||||
}
|
||||
uploadFile(audioWidget, audioUIWidget, files[0], true)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
import type { CurvePoint } from '@/components/curve/types'
|
||||
|
||||
import type {
|
||||
CanvasColour,
|
||||
@@ -138,7 +137,6 @@ export type IWidget =
|
||||
| IImageCropWidget
|
||||
| IBoundingBoxWidget
|
||||
| ICurveWidget
|
||||
| IPainterWidget
|
||||
|
||||
export interface IBooleanWidget extends IBaseWidget<boolean, 'toggle'> {
|
||||
type: 'toggle'
|
||||
@@ -331,16 +329,13 @@ export interface IBoundingBoxWidget extends IBaseWidget<Bounds, 'boundingbox'> {
|
||||
value: Bounds
|
||||
}
|
||||
|
||||
export type CurvePoint = [x: number, y: number]
|
||||
|
||||
export interface ICurveWidget extends IBaseWidget<CurvePoint[], 'curve'> {
|
||||
type: 'curve'
|
||||
value: CurvePoint[]
|
||||
}
|
||||
|
||||
export interface IPainterWidget extends IBaseWidget<string, 'painter'> {
|
||||
type: 'painter'
|
||||
value: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Valid widget types. TS cannot provide easily extensible type safety for this at present.
|
||||
* Override linkedWidgets[]
|
||||
@@ -372,6 +367,7 @@ export interface IBaseWidget<
|
||||
/** Widget type (see {@link TWidgetType}) */
|
||||
type: TType
|
||||
value?: TValue
|
||||
vueTrack?: () => void
|
||||
|
||||
/**
|
||||
* Whether the widget value is persisted in the workflow JSON
|
||||
|
||||
@@ -162,38 +162,6 @@ describe('BaseWidget store integration', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('DOM widget value registration', () => {
|
||||
it('registers value from getter when value property is overridden', () => {
|
||||
const defaultValue = 'You are an expert image-generation engine.'
|
||||
const widget = createTestWidget(node, {
|
||||
name: 'system_prompt',
|
||||
value: undefined as unknown as number
|
||||
})
|
||||
|
||||
// Simulate what addDOMWidget does: override value with getter/setter
|
||||
// that falls back to a default (like inputEl.value for textarea widgets)
|
||||
Object.defineProperty(widget, 'value', {
|
||||
get() {
|
||||
const graphId = widget.node.graph?.rootGraph.id
|
||||
if (!graphId) return defaultValue
|
||||
const state = store.getWidget(graphId, node.id, 'system_prompt')
|
||||
return (state?.value as string) ?? defaultValue
|
||||
},
|
||||
set(v: string) {
|
||||
const graphId = widget.node.graph?.rootGraph.id
|
||||
if (!graphId) return
|
||||
const state = store.getWidget(graphId, node.id, 'system_prompt')
|
||||
if (state) state.value = v
|
||||
}
|
||||
})
|
||||
|
||||
widget.setNodeId(node.id)
|
||||
|
||||
const state = store.getWidget(graph.id, node.id, 'system_prompt')
|
||||
expect(state?.value).toBe(defaultValue)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fallback behavior', () => {
|
||||
it('uses internal value before registration', () => {
|
||||
const widget = createTestWidget(node, {
|
||||
|
||||
@@ -27,8 +27,6 @@ export interface DrawWidgetOptions {
|
||||
showText?: boolean
|
||||
/** When true, suppresses the promoted outline color (e.g. for projected copies on SubgraphNode). */
|
||||
suppressPromotedOutline?: boolean
|
||||
/** Transient image source for preview widgets rendered on behalf of another node (e.g. subgraph promotion). */
|
||||
previewImages?: HTMLImageElement[]
|
||||
}
|
||||
|
||||
interface DrawTruncatingTextOptions extends DrawWidgetOptions {
|
||||
@@ -142,10 +140,6 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
|
||||
|
||||
this._state = useWidgetValueStore().registerWidget(graphId, {
|
||||
...this._state,
|
||||
// BaseWidget: this.value getter returns this._state.value. So value: this.value === value: this._state.value.
|
||||
// BaseDOMWidgetImpl: this.value getter returns options.getValue?.() ?? ''. Resolves the correct initial value instead of undefined.
|
||||
// I.e., calls overriden getter -> options.getValue() -> correct value (https://github.com/Comfy-Org/ComfyUI_frontend/issues/9194).
|
||||
value: this.value,
|
||||
nodeId
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { IPainterWidget } from '../types/widgets'
|
||||
import { BaseWidget } from './BaseWidget'
|
||||
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
|
||||
|
||||
/**
|
||||
* Widget for the Painter node canvas drawing tool.
|
||||
* This is a widget that only has a Vue widgets implementation.
|
||||
*/
|
||||
export class PainterWidget
|
||||
extends BaseWidget<IPainterWidget>
|
||||
implements IPainterWidget
|
||||
{
|
||||
override type = 'painter' as const
|
||||
|
||||
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
|
||||
this.drawVueOnlyWarning(ctx, options, 'Painter')
|
||||
}
|
||||
|
||||
onClick(_options: WidgetEventOptions): void {
|
||||
// This is a widget that only has a Vue widgets implementation
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import { FileUploadWidget } from './FileUploadWidget'
|
||||
import { GalleriaWidget } from './GalleriaWidget'
|
||||
import { GradientSliderWidget } from './GradientSliderWidget'
|
||||
import { ImageCompareWidget } from './ImageCompareWidget'
|
||||
import { PainterWidget } from './PainterWidget'
|
||||
import { ImageCropWidget } from './ImageCropWidget'
|
||||
import { KnobWidget } from './KnobWidget'
|
||||
import { LegacyWidget } from './LegacyWidget'
|
||||
@@ -59,7 +58,6 @@ export type WidgetTypeMap = {
|
||||
imagecrop: ImageCropWidget
|
||||
boundingbox: BoundingBoxWidget
|
||||
curve: CurveWidget
|
||||
painter: PainterWidget
|
||||
[key: string]: BaseWidget
|
||||
}
|
||||
|
||||
@@ -138,8 +136,6 @@ export function toConcreteWidget<TWidget extends IWidget | IBaseWidget>(
|
||||
return toClass(BoundingBoxWidget, narrowedWidget, node)
|
||||
case 'curve':
|
||||
return toClass(CurveWidget, narrowedWidget, node)
|
||||
case 'painter':
|
||||
return toClass(PainterWidget, narrowedWidget, node)
|
||||
default: {
|
||||
if (wrapLegacyWidgets) return toClass(LegacyWidget, widget, node)
|
||||
}
|
||||
|
||||