Merge branch 'main' into webcam-capture

This commit is contained in:
Johnpaul
2025-11-25 01:53:09 +01:00
214 changed files with 6549 additions and 4582 deletions

View File

@@ -1,5 +1,5 @@
# Description: When upstream electron API is updated, click dispatch to update the TypeScript type definitions in this repo
name: 'Api: Update Electron API Types' name: 'Api: Update Electron API Types'
description: 'When upstream electron API is updated, click dispatch to update the TypeScript type definitions in this repo'
on: on:
workflow_dispatch: workflow_dispatch:

View File

@@ -1,5 +1,5 @@
# Description: When upstream ComfyUI-Manager API is updated, click dispatch to update the TypeScript type definitions in this repo
name: 'Api: Update Manager API Types' name: 'Api: Update Manager API Types'
description: 'When upstream ComfyUI-Manager API is updated, click dispatch to update the TypeScript type definitions in this repo'
on: on:
# Manual trigger # Manual trigger

View File

@@ -1,5 +1,5 @@
# Description: When upstream comfy-api is updated, click dispatch to update the TypeScript type definitions in this repo
name: 'Api: Update Registry API Types' name: 'Api: Update Registry API Types'
description: 'When upstream comfy-api is updated, click dispatch to update the TypeScript type definitions in this repo'
on: on:
# Manual trigger # Manual trigger

View File

@@ -1,5 +1,5 @@
# Description: Validates JSON syntax in all tracked .json files (excluding tsconfig*.json) using jq
name: "CI: JSON Validation" name: "CI: JSON Validation"
description: "Validates JSON syntax in all tracked .json files (excluding tsconfig*.json) using jq"
on: on:
push: push:

View File

@@ -1,5 +1,5 @@
# Description: Linting and code formatting validation for pull requests
name: "CI: Lint Format" name: "CI: Lint Format"
description: "Linting and code formatting validation for pull requests"
on: on:
pull_request: pull_request:

View File

@@ -1,5 +1,5 @@
# Description: Validates Python code in tools/devtools directory
name: "CI: Python Validation" name: "CI: Python Validation"
description: "Validates Python code in tools/devtools directory"
on: on:
pull_request: pull_request:

View File

@@ -1,5 +1,5 @@
# Description: Deploys test results from forked PRs (forks can't access deployment secrets)
name: "CI: Tests E2E (Deploy for Forks)" name: "CI: Tests E2E (Deploy for Forks)"
description: "Deploys test results from forked PRs (forks can't access deployment secrets)"
on: on:
workflow_run: workflow_run:

View File

@@ -1,5 +1,5 @@
# Description: End-to-end testing with Playwright across multiple browsers, deploys test reports to Cloudflare Pages
name: "CI: Tests E2E" name: "CI: Tests E2E"
description: "End-to-end testing with Playwright across multiple browsers, deploys test reports to Cloudflare Pages"
on: on:
push: push:

View File

@@ -1,5 +1,5 @@
# Description: Deploys Storybook previews from forked PRs (forks can't access deployment secrets)
name: "CI: Tests Storybook (Deploy for Forks)" name: "CI: Tests Storybook (Deploy for Forks)"
description: "Deploys Storybook previews from forked PRs (forks can't access deployment secrets)"
on: on:
workflow_run: workflow_run:

View File

@@ -1,10 +1,9 @@
# Description: Builds Storybook and runs visual regression testing via Chromatic, deploys previews to Cloudflare Pages
name: "CI: Tests Storybook" name: "CI: Tests Storybook"
description: "Builds Storybook and runs visual regression testing via Chromatic, deploys previews to Cloudflare Pages"
on: on:
workflow_dispatch: # Allow manual triggering workflow_dispatch: # Allow manual triggering
pull_request: pull_request:
branches: [main]
jobs: jobs:
# Post starting comment for non-forked PRs # Post starting comment for non-forked PRs

View File

@@ -1,5 +1,5 @@
# Description: Unit and component testing with Vitest
name: "CI: Tests Unit" name: "CI: Tests Unit"
description: "Unit and component testing with Vitest"
on: on:
push: push:

View File

@@ -1,5 +1,5 @@
# Description: Validates YAML syntax and style using yamllint with relaxed rules
name: "CI: YAML Validation" name: "CI: YAML Validation"
description: "Validates YAML syntax and style using yamllint with relaxed rules"
on: on:
push: push:

View File

@@ -1,5 +1,5 @@
# Description: Generates and updates translations for core ComfyUI components using OpenAI
name: "i18n: Update Core" name: "i18n: Update Core"
description: "Generates and updates translations for core ComfyUI components using OpenAI"
on: on:
# Manual dispatch for urgent translation updates # Manual dispatch for urgent translation updates

View File

@@ -78,8 +78,7 @@ jobs:
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
LABELS=$(gh pr view ${{ inputs.pr_number }} --json labels | jq -r '.labels[].name') LABELS=$(gh pr view ${{ inputs.pr_number }} --json labels | jq -r '.labels[].name')
else else
LABELS='${{ toJSON(github.event.pull_request.labels) }}' LABELS=$(jq -r '.pull_request.labels[].name' "$GITHUB_EVENT_PATH")
LABELS=$(echo "$LABELS" | jq -r '.[].name')
fi fi
add_target() { add_target() {

View File

@@ -1,5 +1,5 @@
# Description: AI-powered code review triggered by adding the 'claude-review' label to a PR
name: "PR: Claude Review" name: "PR: Claude Review"
description: "AI-powered code review triggered by adding the 'claude-review' label to a PR"
permissions: permissions:
contents: read contents: read

View File

@@ -148,10 +148,10 @@ jobs:
done done
{ {
echo "results<<'EOF'" echo "results<<EOF"
cat "$RESULTS_FILE" cat "$RESULTS_FILE"
echo "EOF" echo "EOF"
} >> $GITHUB_OUTPUT } >> "$GITHUB_OUTPUT"
- name: Ensure release labels - name: Ensure release labels
if: steps.check_version.outputs.is_minor_bump == 'true' if: steps.check_version.outputs.is_minor_bump == 'true'

View File

@@ -1,5 +1,5 @@
# Description: Manual workflow to increment package version with semantic versioning support
name: "Release: Version Bump" name: "Release: Version Bump"
description: "Manual workflow to increment package version with semantic versioning support"
on: on:
workflow_dispatch: workflow_dispatch:

View File

@@ -1,5 +1,5 @@
# Description: Automated weekly documentation accuracy check and update via Claude
name: "Weekly Documentation Check" name: "Weekly Documentation Check"
description: "Automated weekly documentation accuracy check and update via Claude"
permissions: permissions:
contents: write contents: write

View File

@@ -91,7 +91,7 @@
"build-storybook": "storybook build -o dist/storybook" "build-storybook": "storybook build -o dist/storybook"
}, },
"dependencies": { "dependencies": {
"@comfyorg/comfyui-electron-types": "0.4.73-0", "@comfyorg/comfyui-electron-types": "catalog:",
"@comfyorg/shared-frontend-utils": "workspace:*", "@comfyorg/shared-frontend-utils": "workspace:*",
"@primevue/core": "catalog:", "@primevue/core": "catalog:",
"@primevue/themes": "catalog:", "@primevue/themes": "catalog:",

View File

@@ -22,7 +22,11 @@
<h1 v-if="title" class="font-inter font-bold text-3xl text-neutral-300"> <h1 v-if="title" class="font-inter font-bold text-3xl text-neutral-300">
{{ title }} {{ title }}
</h1> </h1>
<p v-if="statusText" class="text-lg text-neutral-400"> <p
v-if="statusText"
class="text-lg text-neutral-400"
data-testid="startup-status-text"
>
{{ statusText }} {{ statusText }}
</p> </p>
</div> </div>

View File

@@ -115,19 +115,18 @@ import Button from 'primevue/button'
import Divider from 'primevue/divider' import Divider from 'primevue/divider'
import InputText from 'primevue/inputtext' import InputText from 'primevue/inputtext'
import Message from 'primevue/message' import Message from 'primevue/message'
import { type ModelRef, computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import type { ModelRef } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import MigrationPicker from '@/components/install/MigrationPicker.vue' import { PYPI_MIRROR, PYTHON_MIRROR } from '@/constants/uvMirrors'
import MirrorItem from '@/components/install/mirror/MirrorItem.vue' import type { UVMirror } from '@/constants/uvMirrors'
import {
PYPI_MIRROR,
PYTHON_MIRROR,
type UVMirror
} from '@/constants/uvMirrors'
import { electronAPI } from '@/utils/envUtil' import { electronAPI } from '@/utils/envUtil'
import { ValidationState } from '@/utils/validationUtil' import { ValidationState } from '@/utils/validationUtil'
import MigrationPicker from './MigrationPicker.vue'
import MirrorItem from './mirror/MirrorItem.vue'
const { t } = useI18n() const { t } = useI18n()
const installPath = defineModel<string>('installPath', { required: true }) const installPath = defineModel<string>('installPath', { required: true })
@@ -229,6 +228,10 @@ const validatePath = async (path: string | undefined) => {
} }
if (validation.parentMissing) errors.push(t('install.parentMissing')) if (validation.parentMissing) errors.push(t('install.parentMissing'))
if (validation.isOneDrive) errors.push(t('install.isOneDrive')) if (validation.isOneDrive) errors.push(t('install.isOneDrive'))
if (validation.isInsideAppInstallDir)
errors.push(t('install.insideAppInstallDir'))
if (validation.isInsideUpdaterCache)
errors.push(t('install.insideUpdaterCache'))
if (validation.error) if (validation.error)
errors.push(`${t('install.unhandledError')}: ${validation.error}`) errors.push(`${t('install.unhandledError')}: ${validation.error}`)

View File

@@ -16,7 +16,8 @@ export const DESKTOP_MAINTENANCE_TASKS: Readonly<MaintenanceTask>[] = [
execute: async () => await electron.setBasePath(), execute: async () => await electron.setBasePath(),
name: 'Base path', name: 'Base path',
shortDescription: 'Change the application base path.', shortDescription: 'Change the application base path.',
errorDescription: 'Unable to open the base path. Please select a new one.', errorDescription:
'The current base path is invalid or unsafe. Please select a new location.',
description: description:
'The base path is the default location where ComfyUI stores data. It is the location for the python environment, and may also contain models, custom nodes, and other extensions.', 'The base path is the default location where ComfyUI stores data. It is the location for the python environment, and may also contain models, custom nodes, and other extensions.',
isInstallationFix: true, isInstallationFix: true,

View File

@@ -85,6 +85,7 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
const electron = electronAPI() const electron = electronAPI()
// Reactive state // Reactive state
const lastUpdate = ref<InstallValidation | null>(null)
const isRefreshing = ref(false) const isRefreshing = ref(false)
const isRunningTerminalCommand = computed(() => const isRunningTerminalCommand = computed(() =>
tasks.value tasks.value
@@ -97,6 +98,13 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
.some((task) => getRunner(task)?.executing) .some((task) => getRunner(task)?.executing)
) )
const unsafeBasePath = computed(
() => lastUpdate.value?.unsafeBasePath === true
)
const unsafeBasePathReason = computed(
() => lastUpdate.value?.unsafeBasePathReason
)
// Task list // Task list
const tasks = ref(DESKTOP_MAINTENANCE_TASKS) const tasks = ref(DESKTOP_MAINTENANCE_TASKS)
@@ -123,6 +131,7 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
* @param validationUpdate Update details passed in by electron * @param validationUpdate Update details passed in by electron
*/ */
const processUpdate = (validationUpdate: InstallValidation) => { const processUpdate = (validationUpdate: InstallValidation) => {
lastUpdate.value = validationUpdate
const update = validationUpdate as IndexedUpdate const update = validationUpdate as IndexedUpdate
isRefreshing.value = true isRefreshing.value = true
@@ -155,7 +164,11 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
} }
const execute = async (task: MaintenanceTask) => { const execute = async (task: MaintenanceTask) => {
return getRunner(task).execute(task) const success = await getRunner(task).execute(task)
if (success && task.isInstallationFix) {
await refreshDesktopTasks()
}
return success
} }
return { return {
@@ -163,6 +176,8 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
isRefreshing, isRefreshing,
isRunningTerminalCommand, isRunningTerminalCommand,
isRunningInstallationFix, isRunningInstallationFix,
unsafeBasePath,
unsafeBasePathReason,
execute, execute,
getRunner, getRunner,
processUpdate, processUpdate,

View File

@@ -0,0 +1,159 @@
// eslint-disable-next-line storybook/no-renderer-packages
import type { Meta, StoryObj } from '@storybook/vue3'
import { defineAsyncComponent } from 'vue'
type UnsafeReason = 'appInstallDir' | 'updaterCache' | 'oneDrive' | null
type ValidationIssueState = 'OK' | 'warning' | 'error' | 'skipped'
type ValidationState = {
inProgress: boolean
installState: string
basePath?: ValidationIssueState
unsafeBasePath: boolean
unsafeBasePathReason: UnsafeReason
venvDirectory?: ValidationIssueState
pythonInterpreter?: ValidationIssueState
pythonPackages?: ValidationIssueState
uv?: ValidationIssueState
git?: ValidationIssueState
vcRedist?: ValidationIssueState
upgradePackages?: ValidationIssueState
}
const validationState: ValidationState = {
inProgress: false,
installState: 'installed',
basePath: 'OK',
unsafeBasePath: false,
unsafeBasePathReason: null,
venvDirectory: 'OK',
pythonInterpreter: 'OK',
pythonPackages: 'OK',
uv: 'OK',
git: 'OK',
vcRedist: 'OK',
upgradePackages: 'OK'
}
const createMockElectronAPI = () => {
const logListeners: Array<(message: string) => void> = []
const getValidationUpdate = () => ({
...validationState
})
return {
getPlatform: () => 'darwin',
changeTheme: (_theme: unknown) => {},
onLogMessage: (listener: (message: string) => void) => {
logListeners.push(listener)
},
showContextMenu: (_options: unknown) => {},
Events: {
trackEvent: (_eventName: string, _data?: unknown) => {}
},
Validation: {
onUpdate: (_callback: (update: unknown) => void) => {},
async getStatus() {
return getValidationUpdate()
},
async validateInstallation(callback: (update: unknown) => void) {
callback(getValidationUpdate())
},
async complete() {
// Only allow completion when the base path is safe
return !validationState.unsafeBasePath
},
dispose: () => {}
},
setBasePath: () => Promise.resolve(true),
reinstall: () => Promise.resolve(),
uv: {
installRequirements: () => Promise.resolve(),
clearCache: () => Promise.resolve(),
resetVenv: () => Promise.resolve()
}
}
}
const ensureElectronAPI = () => {
const globalWindow = window as unknown as { electronAPI?: unknown }
if (!globalWindow.electronAPI) {
globalWindow.electronAPI = createMockElectronAPI()
}
return globalWindow.electronAPI
}
const MaintenanceView = defineAsyncComponent(async () => {
ensureElectronAPI()
const module = await import('./MaintenanceView.vue')
return module.default
})
const meta: Meta<typeof MaintenanceView> = {
title: 'Desktop/Views/MaintenanceView',
component: MaintenanceView,
parameters: {
layout: 'fullscreen',
backgrounds: {
default: 'dark',
values: [
{ name: 'dark', value: '#0a0a0a' },
{ name: 'neutral-900', value: '#171717' },
{ name: 'neutral-950', value: '#0a0a0a' }
]
}
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
name: 'All tasks OK',
render: () => ({
components: { MaintenanceView },
setup() {
validationState.inProgress = false
validationState.installState = 'installed'
validationState.basePath = 'OK'
validationState.unsafeBasePath = false
validationState.unsafeBasePathReason = null
validationState.venvDirectory = 'OK'
validationState.pythonInterpreter = 'OK'
validationState.pythonPackages = 'OK'
validationState.uv = 'OK'
validationState.git = 'OK'
validationState.vcRedist = 'OK'
validationState.upgradePackages = 'OK'
ensureElectronAPI()
return {}
},
template: '<MaintenanceView />'
})
}
export const UnsafeBasePathOneDrive: Story = {
name: 'Unsafe base path (OneDrive)',
render: () => ({
components: { MaintenanceView },
setup() {
validationState.inProgress = false
validationState.installState = 'installed'
validationState.basePath = 'error'
validationState.unsafeBasePath = true
validationState.unsafeBasePathReason = 'oneDrive'
validationState.venvDirectory = 'OK'
validationState.pythonInterpreter = 'OK'
validationState.pythonPackages = 'OK'
validationState.uv = 'OK'
validationState.git = 'OK'
validationState.vcRedist = 'OK'
validationState.upgradePackages = 'OK'
ensureElectronAPI()
return {}
},
template: '<MaintenanceView />'
})
}

View File

@@ -47,6 +47,28 @@
</div> </div>
</div> </div>
<!-- Unsafe migration warning -->
<div v-if="taskStore.unsafeBasePath" class="my-4">
<p class="flex items-start gap-3 text-neutral-300">
<Tag
icon="pi pi-exclamation-triangle"
severity="warn"
:value="t('icon.exclamation-triangle')"
/>
<span>
<strong class="block mb-1">
{{ t('maintenance.unsafeMigration.title') }}
</strong>
<span class="block mb-1">
{{ unsafeReasonText }}
</span>
<span class="block text-sm text-neutral-400">
{{ t('maintenance.unsafeMigration.action') }}
</span>
</span>
</p>
</div>
<!-- Tasks --> <!-- Tasks -->
<TaskListPanel <TaskListPanel
class="border-neutral-700 border-solid border-x-0 border-y" class="border-neutral-700 border-solid border-x-0 border-y"
@@ -89,10 +111,10 @@
import { PrimeIcons } from '@primevue/core/api' import { PrimeIcons } from '@primevue/core/api'
import Button from 'primevue/button' import Button from 'primevue/button'
import SelectButton from 'primevue/selectbutton' import SelectButton from 'primevue/selectbutton'
import Tag from 'primevue/tag'
import Toast from 'primevue/toast' import Toast from 'primevue/toast'
import { useToast } from 'primevue/usetoast' import { useToast } from 'primevue/usetoast'
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { watch } from 'vue'
import RefreshButton from '@/components/common/RefreshButton.vue' import RefreshButton from '@/components/common/RefreshButton.vue'
import StatusTag from '@/components/maintenance/StatusTag.vue' import StatusTag from '@/components/maintenance/StatusTag.vue'
@@ -139,6 +161,27 @@ const filterOptions = ref([
/** Filter binding; can be set to show all tasks, or only errors. */ /** Filter binding; can be set to show all tasks, or only errors. */
const filter = ref<MaintenanceFilter>(filterOptions.value[0]) const filter = ref<MaintenanceFilter>(filterOptions.value[0])
const unsafeReasonText = computed(() => {
const reason = taskStore.unsafeBasePathReason
if (!reason) {
return t('maintenance.unsafeMigration.generic')
}
if (reason === 'appInstallDir') {
return t('maintenance.unsafeMigration.appInstallDir')
}
if (reason === 'updaterCache') {
return t('maintenance.unsafeMigration.updaterCache')
}
if (reason === 'oneDrive') {
return t('maintenance.unsafeMigration.oneDrive')
}
return t('maintenance.unsafeMigration.generic')
})
/** If valid, leave the validation window. */ /** If valid, leave the validation window. */
const completeValidation = async () => { const completeValidation = async () => {
const isValid = await electron.Validation.complete() const isValid = await electron.Validation.complete()

View File

@@ -16,7 +16,6 @@ import { ComfyNodeSearchBox } from './components/ComfyNodeSearchBox'
import { SettingDialog } from './components/SettingDialog' import { SettingDialog } from './components/SettingDialog'
import { import {
NodeLibrarySidebarTab, NodeLibrarySidebarTab,
QueueSidebarTab,
WorkflowsSidebarTab WorkflowsSidebarTab
} from './components/SidebarTab' } from './components/SidebarTab'
import { Topbar } from './components/Topbar' import { Topbar } from './components/Topbar'
@@ -31,7 +30,6 @@ type WorkspaceStore = ReturnType<typeof useWorkspaceStore>
class ComfyMenu { class ComfyMenu {
private _nodeLibraryTab: NodeLibrarySidebarTab | null = null private _nodeLibraryTab: NodeLibrarySidebarTab | null = null
private _workflowsTab: WorkflowsSidebarTab | null = null private _workflowsTab: WorkflowsSidebarTab | null = null
private _queueTab: QueueSidebarTab | null = null
private _topbar: Topbar | null = null private _topbar: Topbar | null = null
public readonly sideToolbar: Locator public readonly sideToolbar: Locator
@@ -60,11 +58,6 @@ class ComfyMenu {
return this._workflowsTab return this._workflowsTab
} }
get queueTab() {
this._queueTab ??= new QueueSidebarTab(this.page)
return this._queueTab
}
get topbar() { get topbar() {
this._topbar ??= new Topbar(this.page) this._topbar ??= new Topbar(this.page)
return this._topbar return this._topbar
@@ -564,7 +557,7 @@ export class ComfyPage {
async dragAndDrop(source: Position, target: Position) { async dragAndDrop(source: Position, target: Position) {
await this.page.mouse.move(source.x, source.y) await this.page.mouse.move(source.x, source.y)
await this.page.mouse.down() await this.page.mouse.down()
await this.page.mouse.move(target.x, target.y) await this.page.mouse.move(target.x, target.y, { steps: 100 })
await this.page.mouse.up() await this.page.mouse.up()
await this.nextFrame() await this.nextFrame()
} }

View File

@@ -65,7 +65,9 @@ export class VueNodeHelpers {
* Select a specific Vue node by ID * Select a specific Vue node by ID
*/ */
async selectNode(nodeId: string): Promise<void> { async selectNode(nodeId: string): Promise<void> {
await this.page.locator(`[data-node-id="${nodeId}"]`).click() await this.page
.locator(`[data-node-id="${nodeId}"] .lg-node-header`)
.click()
} }
/** /**
@@ -77,11 +79,13 @@ export class VueNodeHelpers {
// Select first node normally // Select first node normally
await this.selectNode(nodeIds[0]) await this.selectNode(nodeIds[0])
// Add additional nodes with Ctrl+click // Add additional nodes with Ctrl+click on header
for (let i = 1; i < nodeIds.length; i++) { for (let i = 1; i < nodeIds.length; i++) {
await this.page.locator(`[data-node-id="${nodeIds[i]}"]`).click({ await this.page
modifiers: ['Control'] .locator(`[data-node-id="${nodeIds[i]}"] .lg-node-header`)
}) .click({
modifiers: ['Control']
})
} }
} }

View File

@@ -148,124 +148,3 @@ export class WorkflowsSidebarTab extends SidebarTab {
.click() .click()
} }
} }
export class QueueSidebarTab extends SidebarTab {
constructor(public readonly page: Page) {
super(page, 'queue')
}
get root() {
return this.page.locator('.sidebar-content-container', { hasText: 'Queue' })
}
get tasks() {
return this.root.locator('[data-virtual-grid-item]')
}
get visibleTasks() {
return this.tasks.locator('visible=true')
}
get clearButton() {
return this.root.locator('.clear-all-button')
}
get collapseTasksButton() {
return this.getToggleExpandButton(false)
}
get expandTasksButton() {
return this.getToggleExpandButton(true)
}
get noResultsPlaceholder() {
return this.root.locator('.no-results-placeholder')
}
get galleryImage() {
return this.page.locator('.galleria-image')
}
private getToggleExpandButton(isExpanded: boolean) {
const iconSelector = isExpanded ? '.pi-image' : '.pi-images'
return this.root.locator(`.toggle-expanded-button ${iconSelector}`)
}
async open() {
await super.open()
return this.root.waitFor({ state: 'visible' })
}
async close() {
await super.close()
await this.root.waitFor({ state: 'hidden' })
}
async expandTasks() {
await this.expandTasksButton.click()
await this.collapseTasksButton.waitFor({ state: 'visible' })
}
async collapseTasks() {
await this.collapseTasksButton.click()
await this.expandTasksButton.waitFor({ state: 'visible' })
}
async waitForTasks() {
return Promise.all([
this.tasks.first().waitFor({ state: 'visible' }),
this.tasks.last().waitFor({ state: 'visible' })
])
}
async scrollTasks(direction: 'up' | 'down') {
const scrollToEl =
direction === 'up' ? this.tasks.last() : this.tasks.first()
await scrollToEl.scrollIntoViewIfNeeded()
await this.waitForTasks()
}
async clearTasks() {
await this.clearButton.click()
const confirmButton = this.page.getByLabel('Delete')
await confirmButton.click()
await this.noResultsPlaceholder.waitFor({ state: 'visible' })
}
/** Set the width of the tab (out of 100). Must call before opening the tab */
async setTabWidth(width: number) {
if (width < 0 || width > 100) {
throw new Error('Width must be between 0 and 100')
}
return this.page.evaluate((width) => {
localStorage.setItem('queue', JSON.stringify([width, 100 - width]))
}, width)
}
getTaskPreviewButton(taskIndex: number) {
return this.tasks.nth(taskIndex).getByRole('button')
}
async openTaskPreview(taskIndex: number) {
const previewButton = this.getTaskPreviewButton(taskIndex)
await previewButton.click()
return this.galleryImage.waitFor({ state: 'visible' })
}
getGalleryImage(imageFilename: string) {
return this.galleryImage.and(this.page.getByAltText(imageFilename))
}
getTaskImage(imageFilename: string) {
return this.tasks.getByAltText(imageFilename)
}
/** Trigger the queue store and tasks to update */
async triggerTasksUpdate() {
await this.page.evaluate(() => {
window['app']['api'].dispatchCustomEvent('status', {
exec_info: { queue_remaining: 0 }
})
})
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 97 KiB

View File

@@ -1,210 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
test.describe.skip('Queue sidebar', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test('can display tasks', async ({ comfyPage }) => {
await comfyPage.setupHistory().withTask(['example.webp']).setupRoutes()
await comfyPage.menu.queueTab.open()
await comfyPage.menu.queueTab.waitForTasks()
expect(await comfyPage.menu.queueTab.visibleTasks.count()).toBe(1)
})
test('can display tasks after closing then opening', async ({
comfyPage
}) => {
await comfyPage.setupHistory().withTask(['example.webp']).setupRoutes()
await comfyPage.menu.queueTab.open()
await comfyPage.menu.queueTab.close()
await comfyPage.menu.queueTab.open()
await comfyPage.menu.queueTab.waitForTasks()
expect(await comfyPage.menu.queueTab.visibleTasks.count()).toBe(1)
})
test.describe('Virtual scroll', () => {
const layouts = [
{ description: 'Five columns layout', width: 95, rows: 3, cols: 5 },
{ description: 'Three columns layout', width: 55, rows: 3, cols: 3 },
{ description: 'Two columns layout', width: 40, rows: 3, cols: 2 }
]
test.beforeEach(async ({ comfyPage }) => {
await comfyPage
.setupHistory()
.withTask(['example.webp'])
.repeat(50)
.setupRoutes()
})
layouts.forEach(({ description, width, rows, cols }) => {
const preRenderedRows = 1
const preRenderedTasks = preRenderedRows * cols * 2
const visibleTasks = rows * cols
const expectRenderLimit = visibleTasks + preRenderedTasks
test.describe(description, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.menu.queueTab.setTabWidth(width)
await comfyPage.menu.queueTab.open()
await comfyPage.menu.queueTab.waitForTasks()
})
test('should not render items outside of view', async ({
comfyPage
}) => {
const renderedCount =
await comfyPage.menu.queueTab.visibleTasks.count()
expect(renderedCount).toBeLessThanOrEqual(expectRenderLimit)
})
test('should teardown items after scrolling away', async ({
comfyPage
}) => {
await comfyPage.menu.queueTab.scrollTasks('down')
const renderedCount =
await comfyPage.menu.queueTab.visibleTasks.count()
expect(renderedCount).toBeLessThanOrEqual(expectRenderLimit)
})
test('should re-render items after scrolling away then back', async ({
comfyPage
}) => {
await comfyPage.menu.queueTab.scrollTasks('down')
await comfyPage.menu.queueTab.scrollTasks('up')
const renderedCount =
await comfyPage.menu.queueTab.visibleTasks.count()
expect(renderedCount).toBeLessThanOrEqual(expectRenderLimit)
})
})
})
})
test.describe('Expand tasks', () => {
test.beforeEach(async ({ comfyPage }) => {
// 2-item batch and 3-item batch -> 3 additional items when expanded
await comfyPage
.setupHistory()
.withTask(['example.webp', 'example.webp', 'example.webp'])
.withTask(['example.webp', 'example.webp'])
.setupRoutes()
await comfyPage.menu.queueTab.open()
await comfyPage.menu.queueTab.waitForTasks()
})
test('can expand tasks with multiple outputs', async ({ comfyPage }) => {
const initialCount = await comfyPage.menu.queueTab.visibleTasks.count()
await comfyPage.menu.queueTab.expandTasks()
expect(await comfyPage.menu.queueTab.visibleTasks.count()).toBe(
initialCount + 3
)
})
test('can collapse flat tasks', async ({ comfyPage }) => {
const initialCount = await comfyPage.menu.queueTab.visibleTasks.count()
await comfyPage.menu.queueTab.expandTasks()
await comfyPage.menu.queueTab.collapseTasks()
expect(await comfyPage.menu.queueTab.visibleTasks.count()).toBe(
initialCount
)
})
})
test.describe('Clear tasks', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage
.setupHistory()
.withTask(['example.webp'])
.repeat(6)
.setupRoutes()
await comfyPage.menu.queueTab.open()
})
test('can clear all tasks', async ({ comfyPage }) => {
await comfyPage.menu.queueTab.clearTasks()
expect(await comfyPage.menu.queueTab.visibleTasks.count()).toBe(0)
expect(
await comfyPage.menu.queueTab.noResultsPlaceholder.isVisible()
).toBe(true)
})
test('can load new tasks after clearing all', async ({ comfyPage }) => {
await comfyPage.menu.queueTab.clearTasks()
await comfyPage.menu.queueTab.close()
await comfyPage.setupHistory().withTask(['example.webp']).setupRoutes()
await comfyPage.menu.queueTab.open()
await comfyPage.menu.queueTab.waitForTasks()
expect(await comfyPage.menu.queueTab.visibleTasks.count()).toBe(1)
})
})
test.describe('Gallery', () => {
const firstImage = 'example.webp'
const secondImage = 'image32x32.webp'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage
.setupHistory()
.withTask([secondImage])
.withTask([firstImage])
.setupRoutes()
await comfyPage.menu.queueTab.open()
await comfyPage.menu.queueTab.waitForTasks()
await comfyPage.menu.queueTab.openTaskPreview(0)
})
test('displays gallery image after opening task preview', async ({
comfyPage
}) => {
await comfyPage.nextFrame()
await expect(
comfyPage.menu.queueTab.getGalleryImage(firstImage)
).toBeVisible()
})
test('maintains active gallery item when new tasks are added', async ({
comfyPage
}) => {
// Add a new task while the gallery is still open
const newImage = 'image64x64.webp'
comfyPage.setupHistory().withTask([newImage])
await comfyPage.menu.queueTab.triggerTasksUpdate()
await comfyPage.page.waitForTimeout(500)
const newTask = comfyPage.menu.queueTab.tasks.getByAltText(newImage)
await newTask.waitFor({ state: 'visible' })
// The active gallery item should still be the initial image
await expect(
comfyPage.menu.queueTab.getGalleryImage(firstImage)
).toBeVisible()
})
test.describe('Gallery navigation', () => {
const paths: {
description: string
path: ('Right' | 'Left')[]
end: string
}[] = [
{ description: 'Right', path: ['Right'], end: secondImage },
{ description: 'Left', path: ['Right', 'Left'], end: firstImage },
{ description: 'Left wrap', path: ['Left'], end: secondImage },
{ description: 'Right wrap', path: ['Right', 'Right'], end: firstImage }
]
paths.forEach(({ description, path, end }) => {
test(`can navigate gallery ${description}`, async ({ comfyPage }) => {
for (const direction of path)
await comfyPage.page.keyboard.press(`Arrow${direction}`, {
delay: 256
})
await comfyPage.nextFrame()
await expect(
comfyPage.menu.queueTab.getGalleryImage(end)
).toBeVisible()
})
})
})
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -6,6 +6,7 @@ import {
test.describe('Vue Nodes Zoom', () => { test.describe('Vue Nodes Zoom', () => {
test.beforeEach(async ({ comfyPage }) => { test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true) await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setSetting('LiteGraph.Canvas.MinFontSizeForLOD', 8)
await comfyPage.vueNodes.waitForNodes() await comfyPage.vueNodes.waitForNodes()
}) })

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 143 KiB

View File

@@ -11,6 +11,7 @@ test.describe('Vue Nodes - LOD', () => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true) await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup() await comfyPage.setup()
await comfyPage.loadWorkflow('default') await comfyPage.loadWorkflow('default')
await comfyPage.setSetting('LiteGraph.Canvas.MinFontSizeForLOD', 8)
}) })
test('should toggle LOD based on zoom threshold', async ({ comfyPage }) => { test('should toggle LOD based on zoom threshold', async ({ comfyPage }) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -1,7 +1,7 @@
{ {
"name": "@comfyorg/comfyui-frontend", "name": "@comfyorg/comfyui-frontend",
"private": true, "private": true,
"version": "1.32.6", "version": "1.33.8",
"type": "module", "type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend", "repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org", "homepage": "https://comfy.org",
@@ -75,6 +75,7 @@
"@vitest/coverage-v8": "catalog:", "@vitest/coverage-v8": "catalog:",
"@vitest/ui": "catalog:", "@vitest/ui": "catalog:",
"@vue/test-utils": "catalog:", "@vue/test-utils": "catalog:",
"@webgpu/types": "catalog:",
"cross-env": "catalog:", "cross-env": "catalog:",
"eslint": "catalog:", "eslint": "catalog:",
"eslint-config-prettier": "catalog:", "eslint-config-prettier": "catalog:",
@@ -112,6 +113,7 @@
"typescript": "catalog:", "typescript": "catalog:",
"typescript-eslint": "catalog:", "typescript-eslint": "catalog:",
"unplugin-icons": "catalog:", "unplugin-icons": "catalog:",
"unplugin-typegpu": "catalog:",
"unplugin-vue-components": "catalog:", "unplugin-vue-components": "catalog:",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"vite": "catalog:", "vite": "catalog:",
@@ -128,7 +130,7 @@
"dependencies": { "dependencies": {
"@alloc/quick-lru": "catalog:", "@alloc/quick-lru": "catalog:",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1", "@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "0.4.73-0", "@comfyorg/comfyui-electron-types": "catalog:",
"@comfyorg/design-system": "workspace:*", "@comfyorg/design-system": "workspace:*",
"@comfyorg/registry-types": "workspace:*", "@comfyorg/registry-types": "workspace:*",
"@comfyorg/shared-frontend-utils": "workspace:*", "@comfyorg/shared-frontend-utils": "workspace:*",
@@ -176,6 +178,7 @@
"semver": "^7.7.2", "semver": "^7.7.2",
"three": "^0.170.0", "three": "^0.170.0",
"tiptap-markdown": "^0.8.10", "tiptap-markdown": "^0.8.10",
"typegpu": "catalog:",
"vue": "catalog:", "vue": "catalog:",
"vue-i18n": "catalog:", "vue-i18n": "catalog:",
"vue-router": "catalog:", "vue-router": "catalog:",

141
pnpm-lock.yaml generated
View File

@@ -9,6 +9,9 @@ catalogs:
'@alloc/quick-lru': '@alloc/quick-lru':
specifier: ^5.2.0 specifier: ^5.2.0
version: 5.2.0 version: 5.2.0
'@comfyorg/comfyui-electron-types':
specifier: 0.5.5
version: 0.5.5
'@eslint/js': '@eslint/js':
specifier: ^9.35.0 specifier: ^9.35.0
version: 9.35.0 version: 9.35.0
@@ -123,6 +126,9 @@ catalogs:
'@vueuse/integrations': '@vueuse/integrations':
specifier: ^13.9.0 specifier: ^13.9.0
version: 13.9.0 version: 13.9.0
'@webgpu/types':
specifier: ^0.1.66
version: 0.1.66
algoliasearch: algoliasearch:
specifier: ^5.21.0 specifier: ^5.21.0
version: 5.21.0 version: 5.21.0
@@ -243,6 +249,9 @@ catalogs:
tw-animate-css: tw-animate-css:
specifier: ^1.3.8 specifier: ^1.3.8
version: 1.3.8 version: 1.3.8
typegpu:
specifier: ^0.8.2
version: 0.8.2
typescript: typescript:
specifier: ^5.9.2 specifier: ^5.9.2
version: 5.9.2 version: 5.9.2
@@ -252,6 +261,9 @@ catalogs:
unplugin-icons: unplugin-icons:
specifier: ^0.22.0 specifier: ^0.22.0
version: 0.22.0 version: 0.22.0
unplugin-typegpu:
specifier: 0.8.0
version: 0.8.0
unplugin-vue-components: unplugin-vue-components:
specifier: ^0.28.0 specifier: ^0.28.0
version: 0.28.0 version: 0.28.0
@@ -318,8 +330,8 @@ importers:
specifier: ^1.3.1 specifier: ^1.3.1
version: 1.3.1 version: 1.3.1
'@comfyorg/comfyui-electron-types': '@comfyorg/comfyui-electron-types':
specifier: 0.4.73-0 specifier: 'catalog:'
version: 0.4.73-0 version: 0.5.5
'@comfyorg/design-system': '@comfyorg/design-system':
specifier: workspace:* specifier: workspace:*
version: link:packages/design-system version: link:packages/design-system
@@ -461,6 +473,9 @@ importers:
tiptap-markdown: tiptap-markdown:
specifier: ^0.8.10 specifier: ^0.8.10
version: 0.8.10(@tiptap/core@2.10.4(@tiptap/pm@2.10.4)) version: 0.8.10(@tiptap/core@2.10.4(@tiptap/pm@2.10.4))
typegpu:
specifier: 'catalog:'
version: 0.8.2
vue: vue:
specifier: 'catalog:' specifier: 'catalog:'
version: 3.5.13(typescript@5.9.2) version: 3.5.13(typescript@5.9.2)
@@ -558,6 +573,9 @@ importers:
'@vue/test-utils': '@vue/test-utils':
specifier: 'catalog:' specifier: 'catalog:'
version: 2.4.6 version: 2.4.6
'@webgpu/types':
specifier: 'catalog:'
version: 0.1.66
cross-env: cross-env:
specifier: 'catalog:' specifier: 'catalog:'
version: 10.1.0 version: 10.1.0
@@ -669,6 +687,9 @@ importers:
unplugin-icons: unplugin-icons:
specifier: 'catalog:' specifier: 'catalog:'
version: 0.22.0(@vue/compiler-sfc@3.5.13) version: 0.22.0(@vue/compiler-sfc@3.5.13)
unplugin-typegpu:
specifier: 'catalog:'
version: 0.8.0(typegpu@0.8.2)
unplugin-vue-components: unplugin-vue-components:
specifier: 'catalog:' specifier: 'catalog:'
version: 0.28.0(@babel/parser@7.28.4)(rollup@4.22.4)(vue@3.5.13(typescript@5.9.2)) version: 0.28.0(@babel/parser@7.28.4)(rollup@4.22.4)(vue@3.5.13(typescript@5.9.2))
@@ -709,8 +730,8 @@ importers:
apps/desktop-ui: apps/desktop-ui:
dependencies: dependencies:
'@comfyorg/comfyui-electron-types': '@comfyorg/comfyui-electron-types':
specifier: 0.4.73-0 specifier: 'catalog:'
version: 0.4.73-0 version: 0.5.5
'@comfyorg/shared-frontend-utils': '@comfyorg/shared-frontend-utils':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/shared-frontend-utils version: link:../../packages/shared-frontend-utils
@@ -1428,6 +1449,10 @@ packages:
resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@babel/standalone@7.28.5':
resolution: {integrity: sha512-1DViPYJpRU50irpGMfLBQ9B4kyfQuL6X7SS7pwTeWeZX0mNkjzPi0XFqxCjSdddZXUQy4AhnQnnesA/ZHnvAdw==}
engines: {node: '>=6.9.0'}
'@babel/template@7.27.2': '@babel/template@7.27.2':
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@@ -1453,8 +1478,8 @@ packages:
'@cacheable/utils@2.0.3': '@cacheable/utils@2.0.3':
resolution: {integrity: sha512-m7Rce68cMHlAUjvWBy9Ru1Nmw5gU0SjGGtQDdhpe6E0xnbcvrIY0Epy//JU1VYYBUTzrG9jvgmTauULGKzOkWA==} resolution: {integrity: sha512-m7Rce68cMHlAUjvWBy9Ru1Nmw5gU0SjGGtQDdhpe6E0xnbcvrIY0Epy//JU1VYYBUTzrG9jvgmTauULGKzOkWA==}
'@comfyorg/comfyui-electron-types@0.4.73-0': '@comfyorg/comfyui-electron-types@0.5.5':
resolution: {integrity: sha512-WlItGJQx9ZWShNG9wypx3kq+19pSig/U+s5sD2SAeEcMph4u8A/TS+lnRgdKhT58VT1uD7cMcj2SJpfdBPNWvw==} resolution: {integrity: sha512-f3XOXpMsALIwHakz7FekVPm4/Fh2pvJPEi8tRe8jYGBt8edsd4Mkkq31Yjs2Weem3BP7yNwbdNuSiQdP/pxJyg==}
'@csstools/color-helpers@5.1.0': '@csstools/color-helpers@5.1.0':
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
@@ -3787,8 +3812,8 @@ packages:
peerDependencies: peerDependencies:
vue: ^3.5.0 vue: ^3.5.0
'@webgpu/types@0.1.51': '@webgpu/types@0.1.66':
resolution: {integrity: sha512-ktR3u64NPjwIViNCck+z9QeyN0iPkQCUOQ07ZCV1RzlkfP+olLTeEZ95O1QHS+v4w9vJeY9xj/uJuSphsHy5rQ==} resolution: {integrity: sha512-YA2hLrwLpDsRueNDXIMqN9NTzD6bCDkuXbOSe0heS+f8YE8usA6Gbv1prj81pzVHrbaAma7zObnIC+I6/sXJgA==}
'@xstate/fsm@1.6.5': '@xstate/fsm@1.6.5':
resolution: {integrity: sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==} resolution: {integrity: sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==}
@@ -4413,6 +4438,9 @@ packages:
csstype@3.1.3: csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
data-urls@5.0.0: data-urls@5.0.0:
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -6032,6 +6060,10 @@ packages:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true hasBin: true
magic-string-ast@1.0.3:
resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==}
engines: {node: '>=20.19.0'}
magic-string@0.30.19: magic-string@0.30.19:
resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==}
@@ -7000,6 +7032,11 @@ packages:
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
hasBin: true hasBin: true
resolve@1.22.11:
resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
engines: {node: '>= 0.4'}
hasBin: true
restore-cursor@3.1.0: restore-cursor@3.1.0:
resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -7095,6 +7132,11 @@ packages:
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true hasBin: true
semver@7.7.3:
resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
engines: {node: '>=10'}
hasBin: true
set-function-length@1.2.2: set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -7395,6 +7437,14 @@ packages:
tinybench@2.9.0: tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinyest-for-wgsl@0.1.3:
resolution: {integrity: sha512-Wm5ADG1UyDxykf42S1gLYP4U9e1QP/TdtJeovQi6y68zttpiFLKqQGioHmPs9Mjysh7YMSAr/Lpuk0cD2MVdGA==}
engines: {node: '>=12.20.0'}
tinyest@0.1.2:
resolution: {integrity: sha512-aHRmouyowIq1P5jrTF+YK6pGX+WuvFtSCLbqk91yHnU3SWQRIcNIamZLM5XF6lLqB13AWz0PGPXRff2QGDsxIg==}
engines: {node: '>=12.20.0'}
tinyexec@0.3.2: tinyexec@0.3.2:
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
@@ -7521,6 +7571,13 @@ packages:
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
typed-binary@4.3.2:
resolution: {integrity: sha512-HT3pIBM2njCZUmeczDaQUUErGiM6GXFCqMsHegE12HCoBtvHCkfR10JJni0TeGOTnLilTd6YFyj+YhflqQDrDQ==}
typegpu@0.8.2:
resolution: {integrity: sha512-wkMJWhJE0pSkw2G/FesjqjbtHkREyOKu1Zmyj19xfmaX5+65YFwgfQNKSK8CxqN4kJkP7JFelLDJTSYY536TYg==}
engines: {node: '>=12.20.0'}
typescript-eslint@8.44.0: typescript-eslint@8.44.0:
resolution: {integrity: sha512-ib7mCkYuIzYonCq9XWF5XNw+fkj2zg629PSa9KNIQ47RXFF763S5BIX4wqz1+FLPogTZoiw8KmCiRPRa8bL3qw==} resolution: {integrity: sha512-ib7mCkYuIzYonCq9XWF5XNw+fkj2zg629PSa9KNIQ47RXFF763S5BIX4wqz1+FLPogTZoiw8KmCiRPRa8bL3qw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -7625,6 +7682,11 @@ packages:
vue-template-es2015-compiler: vue-template-es2015-compiler:
optional: true optional: true
unplugin-typegpu@0.8.0:
resolution: {integrity: sha512-VJHdXSXGOkAx0WhwFczhVUjAI6HyDkrQXk20HnwyuzIE3FdqE5l9sJTCYZzoVGo3z8i/IA5TMHCDzzP0Bc97Cw==}
peerDependencies:
typegpu: ^0.8.0
unplugin-vue-components@0.28.0: unplugin-vue-components@0.28.0:
resolution: {integrity: sha512-jiTGtJ3JsRFBjgvyilfrX7yUoGKScFgbdNw+6p6kEXU+Spf/rhxzgvdfuMcvhCcLmflB/dY3pGQshYBVGOUx7Q==} resolution: {integrity: sha512-jiTGtJ3JsRFBjgvyilfrX7yUoGKScFgbdNw+6p6kEXU+Spf/rhxzgvdfuMcvhCcLmflB/dY3pGQshYBVGOUx7Q==}
engines: {node: '>=14'} engines: {node: '>=14'}
@@ -7815,8 +7877,8 @@ packages:
vue-component-type-helpers@3.1.1: vue-component-type-helpers@3.1.1:
resolution: {integrity: sha512-B0kHv7qX6E7+kdc5nsaqjdGZ1KwNKSUQDWGy7XkTYT7wFsOpkEyaJ1Vq79TjwrrtuLRgizrTV7PPuC4rRQo+vw==} resolution: {integrity: sha512-B0kHv7qX6E7+kdc5nsaqjdGZ1KwNKSUQDWGy7XkTYT7wFsOpkEyaJ1Vq79TjwrrtuLRgizrTV7PPuC4rRQo+vw==}
vue-component-type-helpers@3.1.3: vue-component-type-helpers@3.1.4:
resolution: {integrity: sha512-V1dOD8XYfstOKCnXbWyEJIrhTBMwSyNjv271L1Jlx9ExpNlCSuqOs3OdWrGJ0V544zXufKbcYabi/o+gK8lyfQ==} resolution: {integrity: sha512-Uws7Ew1OzTTqHW8ZVl/qLl/HB+jf08M0NdFONbVWAx0N4gMLK8yfZDgeB77hDnBmaigWWEn5qP8T9BG59jIeyQ==}
vue-demi@0.14.10: vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@@ -8953,6 +9015,8 @@ snapshots:
'@babel/runtime@7.28.4': {} '@babel/runtime@7.28.4': {}
'@babel/standalone@7.28.5': {}
'@babel/template@7.27.2': '@babel/template@7.27.2':
dependencies: dependencies:
'@babel/code-frame': 7.27.1 '@babel/code-frame': 7.27.1
@@ -8992,7 +9056,7 @@ snapshots:
'@cacheable/utils@2.0.3': {} '@cacheable/utils@2.0.3': {}
'@comfyorg/comfyui-electron-types@0.4.73-0': {} '@comfyorg/comfyui-electron-types@0.5.5': {}
'@csstools/color-helpers@5.1.0': {} '@csstools/color-helpers@5.1.0': {}
@@ -10617,7 +10681,7 @@ snapshots:
storybook: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)) storybook: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
type-fest: 2.19.0 type-fest: 2.19.0
vue: 3.5.13(typescript@5.9.2) vue: 3.5.13(typescript@5.9.2)
vue-component-type-helpers: 3.1.3 vue-component-type-helpers: 3.1.4
'@swc/helpers@0.5.17': '@swc/helpers@0.5.17':
dependencies: dependencies:
@@ -10989,7 +11053,7 @@ snapshots:
'@types/react@19.1.9': '@types/react@19.1.9':
dependencies: dependencies:
csstype: 3.1.3 csstype: 3.2.3
'@types/semver@7.7.0': {} '@types/semver@7.7.0': {}
@@ -11000,7 +11064,7 @@ snapshots:
'@tweenjs/tween.js': 23.1.3 '@tweenjs/tween.js': 23.1.3
'@types/stats.js': 0.17.3 '@types/stats.js': 0.17.3
'@types/webxr': 0.5.20 '@types/webxr': 0.5.20
'@webgpu/types': 0.1.51 '@webgpu/types': 0.1.66
fflate: 0.8.2 fflate: 0.8.2
meshoptimizer: 0.18.1 meshoptimizer: 0.18.1
@@ -11503,7 +11567,7 @@ snapshots:
dependencies: dependencies:
vue: 3.5.13(typescript@5.9.2) vue: 3.5.13(typescript@5.9.2)
'@webgpu/types@0.1.51': {} '@webgpu/types@0.1.66': {}
'@xstate/fsm@1.6.5': {} '@xstate/fsm@1.6.5': {}
@@ -12168,6 +12232,8 @@ snapshots:
csstype@3.1.3: {} csstype@3.1.3: {}
csstype@3.2.3: {}
data-urls@5.0.0: data-urls@5.0.0:
dependencies: dependencies:
whatwg-mimetype: 4.0.0 whatwg-mimetype: 4.0.0
@@ -12594,7 +12660,7 @@ snapshots:
dependencies: dependencies:
debug: 3.2.7 debug: 3.2.7
is-core-module: 2.16.1 is-core-module: 2.16.1
resolve: 1.22.10 resolve: 1.22.11
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
optional: true optional: true
@@ -13740,7 +13806,7 @@ snapshots:
acorn: 8.15.0 acorn: 8.15.0
eslint-visitor-keys: 3.4.3 eslint-visitor-keys: 3.4.3
espree: 9.6.1 espree: 9.6.1
semver: 7.7.2 semver: 7.7.3
jsonc-parser@3.2.0: {} jsonc-parser@3.2.0: {}
@@ -13982,6 +14048,10 @@ snapshots:
lz-string@1.5.0: {} lz-string@1.5.0: {}
magic-string-ast@1.0.3:
dependencies:
magic-string: 0.30.19
magic-string@0.30.19: magic-string@0.30.19:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
@@ -15345,6 +15415,13 @@ snapshots:
path-parse: 1.0.7 path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0 supports-preserve-symlinks-flag: 1.0.0
resolve@1.22.11:
dependencies:
is-core-module: 2.16.1
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
optional: true
restore-cursor@3.1.0: restore-cursor@3.1.0:
dependencies: dependencies:
onetime: 5.1.2 onetime: 5.1.2
@@ -15449,6 +15526,8 @@ snapshots:
semver@7.7.2: {} semver@7.7.2: {}
semver@7.7.3: {}
set-function-length@1.2.2: set-function-length@1.2.2:
dependencies: dependencies:
define-data-property: 1.1.4 define-data-property: 1.1.4
@@ -15837,6 +15916,12 @@ snapshots:
tinybench@2.9.0: {} tinybench@2.9.0: {}
tinyest-for-wgsl@0.1.3:
dependencies:
tinyest: 0.1.2
tinyest@0.1.2: {}
tinyexec@0.3.2: {} tinyexec@0.3.2: {}
tinyexec@1.0.1: {} tinyexec@1.0.1: {}
@@ -15968,6 +16053,13 @@ snapshots:
reflect.getprototypeof: 1.0.10 reflect.getprototypeof: 1.0.10
optional: true optional: true
typed-binary@4.3.2: {}
typegpu@0.8.2:
dependencies:
tinyest: 0.1.2
typed-binary: 4.3.2
typescript-eslint@8.44.0(eslint@9.35.0(jiti@2.4.2))(typescript@5.9.2): typescript-eslint@8.44.0(eslint@9.35.0(jiti@2.4.2))(typescript@5.9.2):
dependencies: dependencies:
'@typescript-eslint/eslint-plugin': 8.44.0(@typescript-eslint/parser@8.44.0(eslint@9.35.0(jiti@2.4.2))(typescript@5.9.2))(eslint@9.35.0(jiti@2.4.2))(typescript@5.9.2) '@typescript-eslint/eslint-plugin': 8.44.0(@typescript-eslint/parser@8.44.0(eslint@9.35.0(jiti@2.4.2))(typescript@5.9.2))(eslint@9.35.0(jiti@2.4.2))(typescript@5.9.2)
@@ -16063,6 +16155,19 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
unplugin-typegpu@0.8.0(typegpu@0.8.2):
dependencies:
'@babel/standalone': 7.28.5
defu: 6.1.4
estree-walker: 3.0.3
magic-string-ast: 1.0.3
pathe: 2.0.3
picomatch: 4.0.3
tinyest: 0.1.2
tinyest-for-wgsl: 0.1.3
typegpu: 0.8.2
unplugin: 2.3.5
unplugin-vue-components@0.28.0(@babel/parser@7.28.4)(rollup@4.22.4)(vue@3.5.13(typescript@5.9.2)): unplugin-vue-components@0.28.0(@babel/parser@7.28.4)(rollup@4.22.4)(vue@3.5.13(typescript@5.9.2)):
dependencies: dependencies:
'@antfu/utils': 0.7.10 '@antfu/utils': 0.7.10
@@ -16343,7 +16448,7 @@ snapshots:
vue-component-type-helpers@3.1.1: {} vue-component-type-helpers@3.1.1: {}
vue-component-type-helpers@3.1.3: {} vue-component-type-helpers@3.1.4: {}
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.2)): vue-demi@0.14.10(vue@3.5.13(typescript@5.9.2)):
dependencies: dependencies:

View File

@@ -4,6 +4,7 @@ packages:
catalog: catalog:
'@alloc/quick-lru': ^5.2.0 '@alloc/quick-lru': ^5.2.0
'@comfyorg/comfyui-electron-types': 0.5.5
'@eslint/js': ^9.35.0 '@eslint/js': ^9.35.0
'@iconify-json/lucide': ^1.1.178 '@iconify-json/lucide': ^1.1.178
'@iconify/json': ^2.2.380 '@iconify/json': ^2.2.380
@@ -42,6 +43,7 @@ catalog:
'@vue/test-utils': ^2.4.6 '@vue/test-utils': ^2.4.6
'@vueuse/core': ^11.0.0 '@vueuse/core': ^11.0.0
'@vueuse/integrations': ^13.9.0 '@vueuse/integrations': ^13.9.0
'@webgpu/types': ^0.1.66
algoliasearch: ^5.21.0 algoliasearch: ^5.21.0
axios: ^1.8.2 axios: ^1.8.2
cross-env: ^10.1.0 cross-env: ^10.1.0
@@ -82,9 +84,11 @@ catalog:
tailwindcss-primeui: ^0.6.1 tailwindcss-primeui: ^0.6.1
tsx: ^4.15.6 tsx: ^4.15.6
tw-animate-css: ^1.3.8 tw-animate-css: ^1.3.8
typegpu: ^0.8.2
typescript: ^5.9.2 typescript: ^5.9.2
typescript-eslint: ^8.44.0 typescript-eslint: ^8.44.0
unplugin-icons: ^0.22.0 unplugin-icons: ^0.22.0
unplugin-typegpu: 0.8.0
unplugin-vue-components: ^0.28.0 unplugin-vue-components: ^0.28.0
vite: ^5.4.19 vite: ^5.4.19
vite-plugin-dts: ^4.5.4 vite-plugin-dts: ^4.5.4

View File

@@ -10,7 +10,6 @@
severity="primary" severity="primary"
size="small" size="small"
:model="queueModeMenuItems" :model="queueModeMenuItems"
:disabled="hasMissingNodes"
data-testid="queue-button" data-testid="queue-button"
@click="queuePrompt" @click="queuePrompt"
> >
@@ -32,46 +31,12 @@
</template> </template>
</SplitButton> </SplitButton>
<BatchCountEdit /> <BatchCountEdit />
<ButtonGroup class="execution-actions flex flex-nowrap">
<Button
v-tooltip.bottom="{
value: $t('menu.interrupt'),
showDelay: 600
}"
icon="pi pi-times"
:severity="executingPrompt ? 'danger' : 'secondary'"
:disabled="!executingPrompt"
text
:aria-label="$t('menu.interrupt')"
@click="() => commandStore.execute('Comfy.Interrupt')"
/>
<Button
v-tooltip.bottom="{
value: $t('sideToolbar.queueTab.clearPendingTasks'),
showDelay: 600
}"
icon="pi pi-stop"
:severity="hasPendingTasks ? 'danger' : 'secondary'"
:disabled="!hasPendingTasks"
text
:aria-label="$t('sideToolbar.queueTab.clearPendingTasks')"
@click="
() => {
if (queueCountStore.count.value > 1) {
commandStore.execute('Comfy.ClearPendingTasks')
}
queueMode = 'disabled'
}
"
/>
</ButtonGroup>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import Button from 'primevue/button' import Button from 'primevue/button'
import ButtonGroup from 'primevue/buttongroup'
import type { MenuItem } from 'primevue/menuitem' import type { MenuItem } from 'primevue/menuitem'
import SplitButton from 'primevue/splitbutton' import SplitButton from 'primevue/splitbutton'
import { computed } from 'vue' import { computed } from 'vue'
@@ -80,17 +45,13 @@ import { useI18n } from 'vue-i18n'
import { isCloud } from '@/platform/distribution/types' import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry' import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore' import { useCommandStore } from '@/stores/commandStore'
import { import { useQueueSettingsStore } from '@/stores/queueStore'
useQueuePendingTaskCountStore,
useQueueSettingsStore
} from '@/stores/queueStore'
import { useWorkspaceStore } from '@/stores/workspaceStore' import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes' import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import BatchCountEdit from '../BatchCountEdit.vue' import BatchCountEdit from '../BatchCountEdit.vue'
const workspaceStore = useWorkspaceStore() const workspaceStore = useWorkspaceStore()
const queueCountStore = storeToRefs(useQueuePendingTaskCountStore())
const { mode: queueMode, batchCount } = storeToRefs(useQueueSettingsStore()) const { mode: queueMode, batchCount } = storeToRefs(useQueueSettingsStore())
const { hasMissingNodes } = useMissingNodes() const { hasMissingNodes } = useMissingNodes()
@@ -145,11 +106,6 @@ const queueModeMenuItems = computed(() =>
Object.values(queueModeMenuItemLookup.value) Object.values(queueModeMenuItemLookup.value)
) )
const executingPrompt = computed(() => !!queueCountStore.count.value)
const hasPendingTasks = computed(
() => queueCountStore.count.value > 1 || queueMode.value !== 'disabled'
)
const iconClass = computed(() => { const iconClass = computed(() => {
if (hasMissingNodes.value) { if (hasMissingNodes.value) {
return 'icon-[lucide--triangle-alert]' return 'icon-[lucide--triangle-alert]'

View File

@@ -24,7 +24,7 @@ import {
import { cn } from '@/utils/tailwindUtil' import { cn } from '@/utils/tailwindUtil'
interface IconButtonProps extends BaseButtonProps { interface IconButtonProps extends BaseButtonProps {
onClick: (event: Event) => void onClick?: (event: MouseEvent) => void
} }
defineOptions({ defineOptions({

View File

@@ -47,7 +47,7 @@ const {
} = defineProps<IconTextButtonProps>() } = defineProps<IconTextButtonProps>()
const buttonStyle = computed(() => { const buttonStyle = computed(() => {
const baseClasses = `${getBaseButtonClasses()} justify-start! gap-2` const baseClasses = `${getBaseButtonClasses()} justify-start gap-2`
const sizeClasses = getButtonSizeClasses(size) const sizeClasses = getButtonSizeClasses(size)
const typeClasses = border const typeClasses = border
? getBorderButtonTypeClasses(type) ? getBorderButtonTypeClasses(type)

View File

@@ -68,4 +68,8 @@ const toggle = (event: Event) => {
const hide = () => { const hide = () => {
popover.value?.hide() popover.value?.hide()
} }
defineExpose({
hide
})
</script> </script>

View File

@@ -92,7 +92,7 @@
class="w-62.5" class="w-62.5"
> >
<template #icon> <template #icon>
<i class="icon-[lucide--arrow-up-down]" /> <i class="icon-[lucide--arrow-up-down] text-muted-foreground" />
</template> </template>
</SingleSelect> </SingleSelect>
</div> </div>

View File

@@ -1,6 +1,7 @@
<template> <template>
<div <div
class="flex w-[490px] flex-col border-t-1 border-b-1 border-border-default" class="flex w-[490px] flex-col border-t-1 border-border-default"
:class="isCloud ? 'border-b-1' : ''"
> >
<div class="flex h-full w-full flex-col gap-4 p-4"> <div class="flex h-full w-full flex-col gap-4 p-4">
<!-- Description --> <!-- Description -->

View File

@@ -4,6 +4,7 @@
<InputText <InputText
ref="inputRef" ref="inputRef"
v-model="inputValue" v-model="inputValue"
:placeholder
autofocus autofocus
@keyup.enter="onConfirm" @keyup.enter="onConfirm"
@focus="selectAllText" @focus="selectAllText"
@@ -28,6 +29,7 @@ const props = defineProps<{
message: string message: string
defaultValue: string defaultValue: string
onConfirm: (value: string) => void onConfirm: (value: string) => void
placeholder?: string
}>() }>()
const inputValue = ref<string>(props.defaultValue) const inputValue = ref<string>(props.defaultValue)

View File

@@ -17,7 +17,7 @@
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({ root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
class: cn( class: cn(
'h-10 relative inline-flex cursor-pointer select-none', 'h-10 relative inline-flex cursor-pointer select-none',
'rounded-lg bg-base-background text-base-foreground', 'rounded-lg bg-secondary-background text-base-foreground',
'transition-all duration-200 ease-in-out', 'transition-all duration-200 ease-in-out',
'border-[2.5px] border-solid', 'border-[2.5px] border-solid',
selectedCount > 0 selectedCount > 0
@@ -83,7 +83,7 @@
role="combobox" role="combobox"
:aria-expanded="false" :aria-expanded="false"
aria-haspopup="listbox" aria-haspopup="listbox"
tabindex="0" :tabindex="0"
> >
<template <template
v-if="showSearchBox || showSelectedCount || showClearButton" v-if="showSearchBox || showSelectedCount || showClearButton"
@@ -127,7 +127,7 @@
<!-- Trigger value (keep text scale identical) --> <!-- Trigger value (keep text scale identical) -->
<template #value> <template #value>
<span class="text-sm text-muted-foreground"> <span class="text-sm">
{{ label }} {{ label }}
</span> </span>
<span <span
@@ -140,7 +140,7 @@
<!-- Chevron size identical to current --> <!-- Chevron size identical to current -->
<template #dropdownicon> <template #dropdownicon>
<i class="icon-[lucide--chevron-down] text-lg text-neutral-400" /> <i class="icon-[lucide--chevron-down] text-muted-foreground" />
</template> </template>
<!-- Custom option row: square checkbox + label (unchanged layout/colors) --> <!-- Custom option row: square checkbox + label (unchanged layout/colors) -->

View File

@@ -1,6 +1,6 @@
<template> <template>
<div :class="wrapperStyle" @click="focusInput"> <div :class="wrapperStyle" @click="focusInput">
<i class="icon-[lucide--search] text-muted" /> <i class="icon-[lucide--search] text-muted-foreground" />
<InputText <InputText
ref="input" ref="input"
v-model="internalSearchQuery" v-model="internalSearchQuery"
@@ -73,7 +73,7 @@ onMounted(() => autofocus && focusInput())
const wrapperStyle = computed(() => { const wrapperStyle = computed(() => {
const baseClasses = const baseClasses =
'relative flex w-full items-center gap-2 bg-base-background cursor-text' 'relative flex w-full items-center gap-2 bg-secondary-background cursor-text'
if (showBorder) { if (showBorder) {
return cn( return cn(

View File

@@ -20,7 +20,7 @@
'h-10 relative inline-flex cursor-pointer select-none items-center', 'h-10 relative inline-flex cursor-pointer select-none items-center',
// trigger surface // trigger surface
'rounded-lg', 'rounded-lg',
'bg-base-background text-base-foreground', 'bg-secondary-background text-base-foreground',
'border-[2.5px] border-solid border-transparent', 'border-[2.5px] border-solid border-transparent',
'transition-all duration-200 ease-in-out', 'transition-all duration-200 ease-in-out',
'focus-within:border-node-component-border', 'focus-within:border-node-component-border',
@@ -84,7 +84,7 @@
> >
<!-- Trigger value --> <!-- Trigger value -->
<template #value="slotProps"> <template #value="slotProps">
<div class="flex items-center gap-2 text-sm text-neutral-500"> <div class="flex items-center gap-2 text-sm">
<slot name="icon" /> <slot name="icon" />
<span <span
v-if="slotProps.value !== null && slotProps.value !== undefined" v-if="slotProps.value !== null && slotProps.value !== undefined"
@@ -100,7 +100,7 @@
<!-- Trigger caret --> <!-- Trigger caret -->
<template #dropdownicon> <template #dropdownicon>
<i class="icon-[lucide--chevron-down] text-base text-neutral-500" /> <i class="icon-[lucide--chevron-down] text-muted-foreground" />
</template> </template>
<!-- Option row --> <!-- Option row -->

View File

@@ -26,6 +26,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import {
getEffectiveBrushSize,
getEffectiveHardness
} from '@/composables/maskeditor/brushUtils'
import { BrushShape } from '@/extensions/core/maskeditor/types' import { BrushShape } from '@/extensions/core/maskeditor/types'
import { useMaskEditorStore } from '@/stores/maskEditorStore' import { useMaskEditorStore } from '@/stores/maskEditorStore'
@@ -36,11 +40,14 @@ const { containerRef } = defineProps<{
const store = useMaskEditorStore() const store = useMaskEditorStore()
const brushOpacity = computed(() => { const brushOpacity = computed(() => {
return store.brushVisible ? '1' : '0' return store.brushVisible ? 1 : 0
}) })
const brushRadius = computed(() => { const brushRadius = computed(() => {
return store.brushSettings.size * store.zoomRatio const size = store.brushSettings.size
const hardness = store.brushSettings.hardness
const effectiveSize = getEffectiveBrushSize(size, hardness)
return effectiveSize * store.zoomRatio
}) })
const brushSize = computed(() => { const brushSize = computed(() => {
@@ -78,19 +85,26 @@ const gradientVisible = computed(() => {
}) })
const gradientBackground = computed(() => { const gradientBackground = computed(() => {
const size = store.brushSettings.size
const hardness = store.brushSettings.hardness const hardness = store.brushSettings.hardness
const effectiveSize = getEffectiveBrushSize(size, hardness)
const effectiveHardness = getEffectiveHardness(size, hardness, effectiveSize)
if (hardness === 1) { if (effectiveHardness === 1) {
return 'rgba(255, 0, 0, 0.5)' return 'rgba(255, 0, 0, 0.5)'
} }
const midStop = hardness * 100 const midStop = effectiveHardness * 100
const outerStop = 100 const outerStop = 100
// Add an intermediate stop to approximate the squared falloff
// At 50% of the fade region, squared falloff is 0.25 (relative to max)
const fadeMidStop = midStop + (outerStop - midStop) * 0.5
return `radial-gradient( return `radial-gradient(
circle, circle,
rgba(255, 0, 0, 0.5) 0%, rgba(255, 0, 0, 0.5) 0%,
rgba(255, 0, 0, 0.25) ${midStop}%, rgba(255, 0, 0, 0.5) ${midStop}%,
rgba(255, 0, 0, 0.125) ${fadeMidStop}%,
rgba(255, 0, 0, 0) ${outerStop}% rgba(255, 0, 0, 0) ${outerStop}%
)` )`
}) })

View File

@@ -55,7 +55,7 @@
<SliderControl <SliderControl
:label="t('maskEditor.thickness')" :label="t('maskEditor.thickness')"
:min="1" :min="1"
:max="100" :max="500"
:step="1" :step="1"
:model-value="store.brushSettings.size" :model-value="store.brushSettings.size"
@update:model-value="onThicknessChange" @update:model-value="onThicknessChange"
@@ -80,12 +80,12 @@
/> />
<SliderControl <SliderControl
:label="t('maskEditor.smoothingPrecision')" label="Stepsize"
:min="1" :min="1"
:max="100" :max="100"
:step="1" :step="1"
:model-value="store.brushSettings.smoothingPrecision" :model-value="store.brushSettings.stepSize"
@update:model-value="onSmoothingPrecisionChange" @update:model-value="onStepSizeChange"
/> />
</div> </div>
</template> </template>
@@ -119,8 +119,8 @@ const onHardnessChange = (value: number) => {
store.setBrushHardness(value) store.setBrushHardness(value)
} }
const onSmoothingPrecisionChange = (value: number) => { const onStepSizeChange = (value: number) => {
store.setBrushSmoothingPrecision(value) store.setBrushStepSize(value)
} }
const resetToDefault = () => { const resetToDefault = () => {

View File

@@ -12,19 +12,28 @@
> >
<canvas <canvas
ref="imgCanvasRef" ref="imgCanvasRef"
class="absolute top-0 left-0 w-full h-full" class="absolute top-0 left-0 w-full h-full z-0"
@contextmenu.prevent @contextmenu.prevent
/> />
<canvas <canvas
ref="rgbCanvasRef" ref="rgbCanvasRef"
class="absolute top-0 left-0 w-full h-full" class="absolute top-0 left-0 w-full h-full z-10"
@contextmenu.prevent @contextmenu.prevent
/> />
<canvas <canvas
ref="maskCanvasRef" ref="maskCanvasRef"
class="absolute top-0 left-0 w-full h-full" class="absolute top-0 left-0 w-full h-full z-30"
@contextmenu.prevent @contextmenu.prevent
/> />
<!-- GPU Preview Canvas -->
<canvas
ref="gpuCanvasRef"
class="absolute top-0 left-0 w-full h-full pointer-events-none"
:class="{
'z-20': store.activeLayer === 'rgb',
'z-40': store.activeLayer === 'mask'
}"
/>
<div ref="canvasBackgroundRef" class="bg-white w-full h-full" /> <div ref="canvasBackgroundRef" class="bg-white w-full h-full" />
</div> </div>
@@ -87,6 +96,7 @@ const canvasContainerRef = ref<HTMLDivElement>()
const imgCanvasRef = ref<HTMLCanvasElement>() const imgCanvasRef = ref<HTMLCanvasElement>()
const maskCanvasRef = ref<HTMLCanvasElement>() const maskCanvasRef = ref<HTMLCanvasElement>()
const rgbCanvasRef = ref<HTMLCanvasElement>() const rgbCanvasRef = ref<HTMLCanvasElement>()
const gpuCanvasRef = ref<HTMLCanvasElement>()
const canvasBackgroundRef = ref<HTMLDivElement>() const canvasBackgroundRef = ref<HTMLDivElement>()
const toolPanelRef = ref<InstanceType<typeof ToolPanel>>() const toolPanelRef = ref<InstanceType<typeof ToolPanel>>()
@@ -97,7 +107,7 @@ const initialized = ref(false)
const keyboard = useKeyboard() const keyboard = useKeyboard()
const panZoom = usePanAndZoom() const panZoom = usePanAndZoom()
let toolManager: ReturnType<typeof useToolManager> | null = null const toolManager = useToolManager(keyboard, panZoom)
let resizeObserver: ResizeObserver | null = null let resizeObserver: ResizeObserver | null = null
@@ -135,8 +145,6 @@ const initUI = async () => {
try { try {
await loader.loadFromNode(node) await loader.loadFromNode(node)
toolManager = useToolManager(keyboard, panZoom)
const imageLoader = useImageLoader() const imageLoader = useImageLoader()
const image = await imageLoader.loadImages() const image = await imageLoader.loadImages()
@@ -149,6 +157,18 @@ const initUI = async () => {
store.canvasHistory.saveInitialState() store.canvasHistory.saveInitialState()
// Initialize GPU resources
if (toolManager.brushDrawing) {
await toolManager.brushDrawing.initGPUResources()
if (gpuCanvasRef.value && toolManager.brushDrawing.initPreviewCanvas) {
// Match preview canvas resolution to mask canvas
gpuCanvasRef.value.width = maskCanvasRef.value.width
gpuCanvasRef.value.height = maskCanvasRef.value.height
toolManager.brushDrawing.initPreviewCanvas(gpuCanvasRef.value)
}
}
initialized.value = true initialized.value = true
} catch (error) { } catch (error) {
console.error('[MaskEditorContent] Initialization failed:', error) console.error('[MaskEditorContent] Initialization failed:', error)
@@ -172,7 +192,7 @@ onMounted(() => {
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
toolManager?.brushDrawing.saveBrushSettings() toolManager.brushDrawing.saveBrushSettings()
keyboard?.removeListeners() keyboard?.removeListeners()

View File

@@ -102,6 +102,7 @@ const onInvert = () => {
const onClear = () => { const onClear = () => {
canvasTools.clearMask() canvasTools.clearMask()
store.triggerClear()
} }
const handleSave = async () => { const handleSave = async () => {

View File

@@ -3,7 +3,7 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
--> -->
<template> <template>
<LGraphNodePreview v-if="shouldRenderVueNodes" :node-def="nodeDef" /> <LGraphNodePreview v-if="shouldRenderVueNodes" :node-def="nodeDef" />
<div v-else class="_sb_node_preview"> <div v-else class="_sb_node_preview bg-component-node-background">
<div class="_sb_table"> <div class="_sb_table">
<div <div
class="node_header mr-4 text-ellipsis" class="node_header mr-4 text-ellipsis"
@@ -200,7 +200,6 @@ const truncateDefaultValue = (value: any, charLimit: number = 32): string => {
} }
._sb_node_preview { ._sb_node_preview {
background-color: var(--comfy-menu-bg);
font-family: 'Open Sans', sans-serif; font-family: 'Open Sans', sans-serif;
color: var(--descrip-text); color: var(--descrip-text);
border: 1px solid var(--descrip-text); border: 1px solid var(--descrip-text);

View File

@@ -1,8 +1,10 @@
<template> <template>
<button <IconButton
type="button" type="secondary"
class="group flex w-full items-center justify-between gap-3 rounded-lg border-0 bg-secondary-background p-1 text-left transition-colors duration-200 ease-in-out hover:cursor-pointer hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background" size="fit-content"
class="group w-full justify-between gap-3 rounded-lg p-1 text-left font-normal hover:cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:aria-label="props.ariaLabel" :aria-label="props.ariaLabel"
@click="emit('click', $event)"
> >
<span class="inline-flex items-center gap-2"> <span class="inline-flex items-center gap-2">
<span v-if="props.mode === 'allFailed'" class="inline-flex items-center"> <span v-if="props.mode === 'allFailed'" class="inline-flex items-center">
@@ -76,10 +78,11 @@
> >
<i class="icon-[lucide--chevron-down] block size-4 leading-none" /> <i class="icon-[lucide--chevron-down] block size-4 leading-none" />
</span> </span>
</button> </IconButton>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import IconButton from '@/components/button/IconButton.vue'
import type { import type {
CompletionSummary, CompletionSummary,
CompletionSummaryMode CompletionSummaryMode
@@ -96,4 +99,8 @@ type Props = {
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
thumbnailUrls: () => [] thumbnailUrls: () => []
}) })
const emit = defineEmits<{
(e: 'click', event: MouseEvent): void
}>()
</script> </script>

View File

@@ -42,17 +42,19 @@
t('sideToolbar.queueProgressOverlay.running') t('sideToolbar.queueProgressOverlay.running')
}}</span> }}</span>
</span> </span>
<button <IconButton
v-if="runningCount > 0" v-if="runningCount > 0"
v-tooltip.top="cancelJobTooltip" v-tooltip.top="cancelJobTooltip"
class="inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background p-0 transition-colors hover:bg-destructive-background" type="secondary"
size="sm"
class="size-6 bg-secondary-background hover:bg-destructive-background"
:aria-label="t('sideToolbar.queueProgressOverlay.interruptAll')" :aria-label="t('sideToolbar.queueProgressOverlay.interruptAll')"
@click="$emit('interruptAll')" @click="$emit('interruptAll')"
> >
<i <i
class="icon-[lucide--x] block size-4 leading-none text-text-primary" class="icon-[lucide--x] block size-4 leading-none text-text-primary"
/> />
</button> </IconButton>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -62,26 +64,28 @@
t('sideToolbar.queueProgressOverlay.queuedSuffix') t('sideToolbar.queueProgressOverlay.queuedSuffix')
}}</span> }}</span>
</span> </span>
<button <IconButton
v-if="queuedCount > 0" v-if="queuedCount > 0"
v-tooltip.top="clearQueueTooltip" v-tooltip.top="clearQueueTooltip"
class="inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background p-0 transition-colors hover:bg-destructive-background" type="secondary"
size="sm"
class="size-6 bg-secondary-background hover:bg-destructive-background"
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')" :aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
@click="$emit('clearQueued')" @click="$emit('clearQueued')"
> >
<i <i
class="icon-[lucide--list-x] block size-4 leading-none text-text-primary" class="icon-[lucide--list-x] block size-4 leading-none text-text-primary"
/> />
</button> </IconButton>
</div> </div>
</div> </div>
<button <TextButton
class="inline-flex h-6 min-w-[120px] flex-1 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background px-2 py-0 text-[12px] text-text-primary hover:bg-secondary-background-hover hover:opacity-90" class="h-6 min-w-[120px] flex-1 px-2 py-0 text-[12px]"
type="secondary"
:label="t('sideToolbar.queueProgressOverlay.viewAllJobs')"
@click="$emit('viewAllJobs')" @click="$emit('viewAllJobs')"
> />
{{ t('sideToolbar.queueProgressOverlay.viewAllJobs') }}
</button>
</div> </div>
</div> </div>
</template> </template>
@@ -90,6 +94,8 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import TextButton from '@/components/button/TextButton.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig' import { buildTooltipConfig } from '@/composables/useTooltipConfig'
defineProps<{ defineProps<{

View File

@@ -8,17 +8,20 @@
/> />
<div class="flex items-center justify-between px-3"> <div class="flex items-center justify-between px-3">
<button <IconTextButton
class="inline-flex grow cursor-pointer items-center justify-center gap-1 rounded border-0 bg-secondary-background p-2 text-center font-inter text-[12px] leading-none text-text-primary hover:bg-secondary-background-hover hover:opacity-90" class="grow gap-1 p-2 text-center font-inter text-[12px] leading-none hover:opacity-90 justify-center"
type="secondary"
:label="t('sideToolbar.queueProgressOverlay.showAssets')"
:aria-label="t('sideToolbar.queueProgressOverlay.showAssets')" :aria-label="t('sideToolbar.queueProgressOverlay.showAssets')"
@click="$emit('showAssets')" @click="$emit('showAssets')"
> >
<div <template #icon>
class="pointer-events-none block size-4 shrink-0 leading-none icon-[comfy--image-ai-edit]" <div
aria-hidden="true" class="pointer-events-none block size-4 shrink-0 leading-none icon-[comfy--image-ai-edit]"
/> aria-hidden="true"
<span>{{ t('sideToolbar.queueProgressOverlay.showAssets') }}</span> />
</button> </template>
</IconTextButton>
<div class="ml-4 inline-flex items-center"> <div class="ml-4 inline-flex items-center">
<div <div
class="inline-flex h-6 items-center text-[12px] leading-none text-text-primary opacity-90" class="inline-flex h-6 items-center text-[12px] leading-none text-text-primary opacity-90"
@@ -28,16 +31,18 @@
t('sideToolbar.queueProgressOverlay.queuedSuffix') t('sideToolbar.queueProgressOverlay.queuedSuffix')
}}</span> }}</span>
</div> </div>
<button <IconButton
v-if="queuedCount > 0" v-if="queuedCount > 0"
class="group ml-2 inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background p-0 transition-colors hover:bg-destructive-background" class="group ml-2 size-6 bg-secondary-background hover:bg-destructive-background"
type="secondary"
size="sm"
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')" :aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
@click="$emit('clearQueued')" @click="$emit('clearQueued')"
> >
<i <i
class="pointer-events-none icon-[lucide--list-x] block size-4 leading-none text-text-primary transition-colors group-hover:text-base-background" class="pointer-events-none icon-[lucide--list-x] block size-4 leading-none text-text-primary transition-colors group-hover:text-base-background"
/> />
</button> </IconButton>
</div> </div>
</div> </div>
@@ -75,6 +80,8 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import type { import type {
JobGroup, JobGroup,
JobListItem, JobListItem,

View File

@@ -18,16 +18,18 @@
</span> </span>
</div> </div>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<button <IconButton
v-tooltip.top="moreTooltipConfig" v-tooltip.top="moreTooltipConfig"
class="inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-transparent p-0 hover:bg-secondary-background hover:opacity-100" type="transparent"
size="sm"
class="size-6 bg-transparent hover:bg-secondary-background hover:opacity-100"
:aria-label="t('sideToolbar.queueProgressOverlay.moreOptions')" :aria-label="t('sideToolbar.queueProgressOverlay.moreOptions')"
@click="onMoreClick" @click="onMoreClick"
> >
<i <i
class="icon-[lucide--more-horizontal] block size-4 leading-none text-text-secondary" class="icon-[lucide--more-horizontal] block size-4 leading-none text-text-secondary"
/> />
</button> </IconButton>
<Popover <Popover
ref="morePopoverRef" ref="morePopoverRef"
:dismissable="true" :dismissable="true"
@@ -45,18 +47,19 @@
<div <div
class="flex flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3 font-inter" class="flex flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3 font-inter"
> >
<button <IconTextButton
class="inline-flex w-full cursor-pointer items-center justify-start gap-2 rounded-lg border-0 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90" class="w-full justify-start gap-2 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
type="transparent"
:label="t('sideToolbar.queueProgressOverlay.clearHistory')"
:aria-label="t('sideToolbar.queueProgressOverlay.clearHistory')" :aria-label="t('sideToolbar.queueProgressOverlay.clearHistory')"
@click="onClearHistoryFromMenu" @click="onClearHistoryFromMenu"
> >
<i <template #icon>
class="icon-[lucide--file-x-2] block size-4 leading-none text-text-secondary" <i
/> class="icon-[lucide--file-x-2] block size-4 leading-none text-text-secondary"
<span>{{ />
t('sideToolbar.queueProgressOverlay.clearHistory') </template>
}}</span> </IconTextButton>
</button>
</div> </div>
</Popover> </Popover>
</div> </div>
@@ -69,6 +72,8 @@ import type { PopoverMethods } from 'primevue/popover'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig' import { buildTooltipConfig } from '@/composables/useTooltipConfig'
defineProps<{ defineProps<{

View File

@@ -8,13 +8,15 @@
<p class="m-0 text-[14px] font-normal leading-none"> <p class="m-0 text-[14px] font-normal leading-none">
{{ t('sideToolbar.queueProgressOverlay.clearHistoryDialogTitle') }} {{ t('sideToolbar.queueProgressOverlay.clearHistoryDialogTitle') }}
</p> </p>
<button <IconButton
class="inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-transparent p-0 text-text-secondary transition hover:bg-secondary-background hover:opacity-100" type="transparent"
size="sm"
class="size-6 bg-transparent text-text-secondary hover:bg-secondary-background hover:opacity-100"
:aria-label="t('g.close')" :aria-label="t('g.close')"
@click="onCancel" @click="onCancel"
> >
<i class="icon-[lucide--x] block size-4 leading-none" /> <i class="icon-[lucide--x] block size-4 leading-none" />
</button> </IconButton>
</header> </header>
<div class="flex flex-col gap-4 px-4 py-4 text-[14px] text-text-secondary"> <div class="flex flex-col gap-4 px-4 py-4 text-[14px] text-text-secondary">
@@ -30,21 +32,19 @@
<footer class="flex items-center justify-end px-4 py-4"> <footer class="flex items-center justify-end px-4 py-4">
<div class="flex items-center gap-4 text-[14px] leading-none"> <div class="flex items-center gap-4 text-[14px] leading-none">
<button <TextButton
class="inline-flex min-h-[24px] cursor-pointer items-center rounded-md border-0 bg-transparent px-1 py-1 text-[14px] leading-[1] text-text-secondary transition hover:text-text-primary" class="min-h-[24px] px-1 py-1 text-[14px] leading-[1] text-text-secondary hover:text-text-primary"
:aria-label="t('g.cancel')" type="transparent"
:label="t('g.cancel')"
@click="onCancel" @click="onCancel"
> />
{{ t('g.cancel') }} <TextButton
</button> class="min-h-[32px] px-4 py-2 text-[12px] font-normal leading-[1]"
<button type="secondary"
class="inline-flex min-h-[32px] items-center rounded-lg border-0 bg-secondary-background px-4 py-2 text-[12px] font-normal leading-[1] text-text-primary transition hover:bg-secondary-background-hover hover:text-text-primary disabled:cursor-not-allowed disabled:opacity-60" :label="t('g.clear')"
:aria-label="t('g.clear')"
:disabled="isClearing" :disabled="isClearing"
@click="onConfirm" @click="onConfirm"
> />
{{ t('g.clear') }}
</button>
</div> </div>
</footer> </footer>
</section> </section>
@@ -54,6 +54,8 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import TextButton from '@/components/button/TextButton.vue'
import { useErrorHandling } from '@/composables/useErrorHandling' import { useErrorHandling } from '@/composables/useErrorHandling'
import { useDialogStore } from '@/stores/dialogStore' import { useDialogStore } from '@/stores/dialogStore'
import { useQueueStore } from '@/stores/queueStore' import { useQueueStore } from '@/stores/queueStore'

View File

@@ -20,21 +20,24 @@
<div v-if="entry.kind === 'divider'" class="px-2 py-1"> <div v-if="entry.kind === 'divider'" class="px-2 py-1">
<div class="h-px bg-interface-stroke" /> <div class="h-px bg-interface-stroke" />
</div> </div>
<button <IconTextButton
v-else v-else
class="inline-flex w-full cursor-pointer items-center justify-start gap-2 rounded-lg border-0 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary transition-colors duration-150 hover:bg-interface-panel-hover-surface" class="w-full justify-start gap-2 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-interface-panel-hover-surface"
type="transparent"
:label="entry.label"
:aria-label="entry.label" :aria-label="entry.label"
@click="onEntry(entry)" @click="onEntry(entry)"
> >
<i <template #icon>
v-if="entry.icon" <i
:class="[ v-if="entry.icon"
entry.icon, :class="[
'block size-4 shrink-0 leading-none text-text-secondary' entry.icon,
]" 'block size-4 shrink-0 leading-none text-text-secondary'
/> ]"
<span>{{ entry.label }}</span> />
</button> </template>
</IconTextButton>
</template> </template>
</div> </div>
</Popover> </Popover>
@@ -44,6 +47,7 @@
import Popover from 'primevue/popover' import Popover from 'primevue/popover'
import { ref } from 'vue' import { ref } from 'vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import type { MenuEntry } from '@/composables/queue/useJobMenu' import type { MenuEntry } from '@/composables/queue/useJobMenu'
defineProps<{ entries: MenuEntry[] }>() defineProps<{ entries: MenuEntry[] }>()

View File

@@ -20,17 +20,18 @@
class="flex min-w-0 items-center text-[0.75rem] leading-normal font-normal text-text-secondary" class="flex min-w-0 items-center text-[0.75rem] leading-normal font-normal text-text-secondary"
> >
<span class="block min-w-0 truncate">{{ row.value }}</span> <span class="block min-w-0 truncate">{{ row.value }}</span>
<button <IconButton
v-if="row.canCopy" v-if="row.canCopy"
type="button" type="transparent"
class="ml-2 inline-flex size-6 items-center justify-center rounded border-0 bg-transparent p-0 hover:opacity-90" size="sm"
class="ml-2 size-6 bg-transparent hover:opacity-90"
:aria-label="copyAriaLabel" :aria-label="copyAriaLabel"
@click.stop="copyJobId" @click.stop="copyJobId"
> >
<i <i
class="icon-[lucide--copy] block size-4 leading-none text-text-secondary" class="icon-[lucide--copy] block size-4 leading-none text-text-secondary"
/> />
</button> </IconButton>
</div> </div>
</template> </template>
</div> </div>
@@ -60,25 +61,31 @@
{{ t('queue.jobDetails.errorMessage') }} {{ t('queue.jobDetails.errorMessage') }}
</div> </div>
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<button <IconTextButton
type="button" class="h-6 justify-start gap-2 bg-transparent px-0 text-[0.75rem] leading-none text-text-secondary hover:opacity-90"
class="inline-flex h-6 items-center justify-center gap-2 rounded border-none bg-transparent px-0 text-[0.75rem] leading-none text-text-secondary hover:opacity-90" type="transparent"
:label="copyAriaLabel"
:aria-label="copyAriaLabel" :aria-label="copyAriaLabel"
icon-position="right"
@click.stop="copyErrorMessage" @click.stop="copyErrorMessage"
> >
<span>{{ copyAriaLabel }}</span> <template #icon>
<i class="icon-[lucide--copy] block size-3.5 leading-none" /> <i class="icon-[lucide--copy] block size-3.5 leading-none" />
</button> </template>
<button </IconTextButton>
type="button" <IconTextButton
class="inline-flex h-6 items-center justify-center gap-2 rounded border-none bg-transparent px-0 text-[0.75rem] leading-none text-text-secondary hover:opacity-90" class="h-6 justify-start gap-2 bg-transparent px-0 text-[0.75rem] leading-none text-text-secondary hover:opacity-90"
type="transparent"
:label="t('queue.jobDetails.report')"
icon-position="right"
@click.stop="reportJobError" @click.stop="reportJobError"
> >
<span>{{ t('queue.jobDetails.report') }}</span> <template #icon>
<i <i
class="icon-[lucide--message-circle-warning] block size-3.5 leading-none" class="icon-[lucide--message-circle-warning] block size-3.5 leading-none"
/> />
</button> </template>
</IconTextButton>
</div> </div>
<div <div
class="col-span-2 mt-2 rounded bg-interface-panel-hover-surface px-4 py-2 text-[0.75rem] leading-normal text-text-secondary" class="col-span-2 mt-2 rounded bg-interface-panel-hover-surface px-4 py-2 text-[0.75rem] leading-normal text-text-secondary"
@@ -94,6 +101,8 @@
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard' import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { t } from '@/i18n' import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types' import { isCloud } from '@/platform/distribution/types'

View File

@@ -2,26 +2,26 @@
<div class="flex items-center justify-between gap-2 px-3"> <div class="flex items-center justify-between gap-2 px-3">
<div class="min-w-0 flex-1 overflow-x-auto"> <div class="min-w-0 flex-1 overflow-x-auto">
<div class="inline-flex items-center gap-1 whitespace-nowrap"> <div class="inline-flex items-center gap-1 whitespace-nowrap">
<button <TextButton
v-for="tab in visibleJobTabs" v-for="tab in visibleJobTabs"
:key="tab" :key="tab"
class="h-6 cursor-pointer rounded border-0 px-3 py-1 text-[12px] leading-none hover:opacity-90" class="h-6 px-3 py-1 text-[12px] leading-none hover:opacity-90"
:type="selectedJobTab === tab ? 'secondary' : 'transparent'"
:class="[ :class="[
selectedJobTab === tab selectedJobTab === tab ? 'text-text-primary' : 'text-text-secondary'
? 'bg-secondary-background text-text-primary'
: 'bg-transparent text-text-secondary'
]" ]"
:label="tabLabel(tab)"
@click="$emit('update:selectedJobTab', tab)" @click="$emit('update:selectedJobTab', tab)"
> />
{{ tabLabel(tab) }}
</button>
</div> </div>
</div> </div>
<div class="ml-2 flex shrink-0 items-center gap-2"> <div class="ml-2 flex shrink-0 items-center gap-2">
<button <IconButton
v-if="showWorkflowFilter" v-if="showWorkflowFilter"
v-tooltip.top="filterTooltipConfig" v-tooltip.top="filterTooltipConfig"
class="relative inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background p-0 hover:bg-secondary-background-hover hover:opacity-90" type="secondary"
size="sm"
class="relative size-6 bg-secondary-background hover:bg-secondary-background-hover hover:opacity-90"
:aria-label="t('sideToolbar.queueProgressOverlay.filterJobs')" :aria-label="t('sideToolbar.queueProgressOverlay.filterJobs')"
@click="onFilterClick" @click="onFilterClick"
> >
@@ -32,7 +32,7 @@
v-if="selectedWorkflowFilter !== 'all'" v-if="selectedWorkflowFilter !== 'all'"
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground" class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
/> />
</button> </IconButton>
<Popover <Popover
v-if="showWorkflowFilter" v-if="showWorkflowFilter"
ref="filterPopoverRef" ref="filterPopoverRef"
@@ -51,46 +51,48 @@
<div <div
class="flex min-w-[12rem] flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3" class="flex min-w-[12rem] flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3"
> >
<button <IconTextButton
class="inline-flex w-full cursor-pointer items-center justify-start gap-1 rounded-lg border-0 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90" class="w-full justify-between gap-1 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
type="transparent"
icon-position="right"
:label="t('sideToolbar.queueProgressOverlay.filterAllWorkflows')"
:aria-label=" :aria-label="
t('sideToolbar.queueProgressOverlay.filterAllWorkflows') t('sideToolbar.queueProgressOverlay.filterAllWorkflows')
" "
@click="selectWorkflowFilter('all')" @click="selectWorkflowFilter('all')"
> >
<span>{{ <template #icon>
t('sideToolbar.queueProgressOverlay.filterAllWorkflows')
}}</span>
<span class="ml-auto inline-flex items-center">
<i <i
v-if="selectedWorkflowFilter === 'all'" v-if="selectedWorkflowFilter === 'all'"
class="icon-[lucide--check] block size-4 leading-none text-text-secondary" class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
/> />
</span> </template>
</button> </IconTextButton>
<div class="mx-2 mt-1 h-px" /> <div class="mx-2 mt-1 h-px" />
<button <IconTextButton
class="inline-flex w-full cursor-pointer items-center justify-start gap-1 rounded-lg border-0 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90" class="w-full justify-between gap-1 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
type="transparent"
icon-position="right"
:label="t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')"
:aria-label=" :aria-label="
t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow') t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')
" "
@click="selectWorkflowFilter('current')" @click="selectWorkflowFilter('current')"
> >
<span>{{ <template #icon>
t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')
}}</span>
<span class="ml-auto inline-flex items-center">
<i <i
v-if="selectedWorkflowFilter === 'current'" v-if="selectedWorkflowFilter === 'current'"
class="icon-[lucide--check] block size-4 leading-none text-text-secondary" class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
/> />
</span> </template>
</button> </IconTextButton>
</div> </div>
</Popover> </Popover>
<button <IconButton
v-tooltip.top="sortTooltipConfig" v-tooltip.top="sortTooltipConfig"
class="relative inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background p-0 hover:bg-secondary-background-hover hover:opacity-90" type="secondary"
size="sm"
class="relative size-6 bg-secondary-background hover:bg-secondary-background-hover hover:opacity-90"
:aria-label="t('sideToolbar.queueProgressOverlay.sortJobs')" :aria-label="t('sideToolbar.queueProgressOverlay.sortJobs')"
@click="onSortClick" @click="onSortClick"
> >
@@ -101,7 +103,7 @@
v-if="selectedSortMode !== 'mostRecent'" v-if="selectedSortMode !== 'mostRecent'"
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground" class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
/> />
</button> </IconButton>
<Popover <Popover
ref="sortPopoverRef" ref="sortPopoverRef"
:dismissable="true" :dismissable="true"
@@ -120,19 +122,21 @@
class="flex min-w-[12rem] flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3" class="flex min-w-[12rem] flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3"
> >
<template v-for="(mode, index) in jobSortModes" :key="mode"> <template v-for="(mode, index) in jobSortModes" :key="mode">
<button <IconTextButton
class="inline-flex w-full cursor-pointer items-center justify-start gap-1 rounded-lg border-0 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90" class="w-full justify-between gap-1 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
type="transparent"
icon-position="right"
:label="sortLabel(mode)"
:aria-label="sortLabel(mode)" :aria-label="sortLabel(mode)"
@click="selectSortMode(mode)" @click="selectSortMode(mode)"
> >
<span>{{ sortLabel(mode) }}</span> <template #icon>
<span class="ml-auto inline-flex items-center">
<i <i
v-if="selectedSortMode === mode" v-if="selectedSortMode === mode"
class="icon-[lucide--check] block size-4 leading-none text-text-secondary" class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
/> />
</span> </template>
</button> </IconTextButton>
<div <div
v-if="index < jobSortModes.length - 1" v-if="index < jobSortModes.length - 1"
class="mx-2 mt-1 h-px" class="mx-2 mt-1 h-px"
@@ -149,6 +153,9 @@ import Popover from 'primevue/popover'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import TextButton from '@/components/button/TextButton.vue'
import { jobSortModes, jobTabs } from '@/composables/queue/useJobList' import { jobSortModes, jobTabs } from '@/composables/queue/useJobList'
import type { JobSortMode, JobTab } from '@/composables/queue/useJobList' import type { JobSortMode, JobTab } from '@/composables/queue/useJobList'
import { buildTooltipConfig } from '@/composables/useTooltipConfig' import { buildTooltipConfig } from '@/composables/useTooltipConfig'

View File

@@ -108,45 +108,47 @@
key="actions" key="actions"
class="inline-flex items-center gap-2 pr-1" class="inline-flex items-center gap-2 pr-1"
> >
<button <IconButton
v-if="props.state === 'failed' && computedShowClear" v-if="props.state === 'failed' && computedShowClear"
v-tooltip.top="deleteTooltipConfig" v-tooltip.top="deleteTooltipConfig"
type="button" type="transparent"
class="inline-flex h-6 transform cursor-pointer items-center gap-1 rounded border-0 bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background hover:opacity-95" size="sm"
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background hover:opacity-95"
:aria-label="t('g.delete')" :aria-label="t('g.delete')"
@click.stop="emit('delete')" @click.stop="emit('delete')"
> >
<i class="icon-[lucide--trash-2] size-4" /> <i class="icon-[lucide--trash-2] size-4" />
</button> </IconButton>
<button <IconButton
v-else-if="props.state !== 'completed' && computedShowClear" v-else-if="props.state !== 'completed' && computedShowClear"
v-tooltip.top="cancelTooltipConfig" v-tooltip.top="cancelTooltipConfig"
type="button" type="transparent"
class="inline-flex h-6 transform cursor-pointer items-center gap-1 rounded border-0 bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background hover:opacity-95" size="sm"
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background hover:opacity-95"
:aria-label="t('g.cancel')" :aria-label="t('g.cancel')"
@click.stop="emit('cancel')" @click.stop="emit('cancel')"
> >
<i class="icon-[lucide--x] size-4" /> <i class="icon-[lucide--x] size-4" />
</button> </IconButton>
<button <TextButton
v-else-if="props.state === 'completed'" v-else-if="props.state === 'completed'"
type="button" class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-2 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
class="inline-flex h-6 transform cursor-pointer items-center gap-1 rounded border-0 bg-modal-card-button-surface px-2 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95" type="transparent"
:label="t('menuLabels.View')"
:aria-label="t('menuLabels.View')" :aria-label="t('menuLabels.View')"
@click.stop="emit('view')" @click.stop="emit('view')"
> />
<span>{{ t('menuLabels.View') }}</span> <IconButton
</button>
<button
v-if="props.showMenu !== undefined ? props.showMenu : true" v-if="props.showMenu !== undefined ? props.showMenu : true"
v-tooltip.top="moreTooltipConfig" v-tooltip.top="moreTooltipConfig"
type="button" type="transparent"
class="inline-flex h-6 transform cursor-pointer items-center gap-1 rounded border-0 bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95" size="sm"
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
:aria-label="t('g.more')" :aria-label="t('g.more')"
@click.stop="emit('menu', $event)" @click.stop="emit('menu', $event)"
> >
<i class="icon-[lucide--more-horizontal] size-4" /> <i class="icon-[lucide--more-horizontal] size-4" />
</button> </IconButton>
</div> </div>
<div v-else key="secondary" class="pr-2"> <div v-else key="secondary" class="pr-2">
<slot name="secondary">{{ props.rightText }}</slot> <slot name="secondary">{{ props.rightText }}</slot>
@@ -161,6 +163,8 @@
import { computed, nextTick, ref, watch } from 'vue' import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import TextButton from '@/components/button/TextButton.vue'
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue' import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
import QueueAssetPreview from '@/components/queue/job/QueueAssetPreview.vue' import QueueAssetPreview from '@/components/queue/job/QueueAssetPreview.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig' import { buildTooltipConfig } from '@/composables/useTooltipConfig'

View File

@@ -44,6 +44,7 @@
<SidebarHelpCenterIcon :is-small="isSmall" /> <SidebarHelpCenterIcon :is-small="isSmall" />
<SidebarBottomPanelToggleButton :is-small="isSmall" /> <SidebarBottomPanelToggleButton :is-small="isSmall" />
<SidebarShortcutsToggleButton :is-small="isSmall" /> <SidebarShortcutsToggleButton :is-small="isSmall" />
<SidebarSettingsButton :is-small="isSmall" />
</div> </div>
</div> </div>
</nav> </nav>
@@ -56,6 +57,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ComfyMenuButton from '@/components/sidebar/ComfyMenuButton.vue' import ComfyMenuButton from '@/components/sidebar/ComfyMenuButton.vue'
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue' import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
import SidebarSettingsButton from '@/components/sidebar/SidebarSettingsButton.vue'
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue' import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
import { useSettingStore } from '@/platform/settings/settingStore' import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry' import { useTelemetry } from '@/platform/telemetry'

View File

@@ -0,0 +1,39 @@
<template>
<SidebarIcon
:label="$t('g.settings')"
:tooltip="tooltipText"
@click="showSettingsDialog"
>
<template #icon>
<i class="icon-[lucide--settings]" />
</template>
</SidebarIcon>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
import SidebarIcon from './SidebarIcon.vue'
const { t } = useI18n()
const { getCommand, formatKeySequence } = useCommandStore()
const command = getCommand('Comfy.ShowSettingsDialog')
const tooltipText = computed(
() => `${t('g.settings')} (${formatKeySequence(command)})`
)
/**
* Toggle keyboard shortcuts panel and track UI button click.
*/
const showSettingsDialog = () => {
command.function()
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_settings_button_clicked'
})
}
</script>

View File

@@ -85,10 +85,13 @@
:show-output-count="shouldShowOutputCount(item)" :show-output-count="shouldShowOutputCount(item)"
:output-count="getOutputCount(item)" :output-count="getOutputCount(item)"
:show-delete-button="shouldShowDeleteButton" :show-delete-button="shouldShowDeleteButton"
:open-popover-id="openPopoverId"
@click="handleAssetSelect(item)" @click="handleAssetSelect(item)"
@zoom="handleZoomClick(item)" @zoom="handleZoomClick(item)"
@output-count-click="enterFolderView(item)" @output-count-click="enterFolderView(item)"
@asset-deleted="refreshAssets" @asset-deleted="refreshAssets"
@popover-opened="openPopoverId = item.id"
@popover-closed="openPopoverId = null"
/> />
</template> </template>
</VirtualGrid> </VirtualGrid>
@@ -199,6 +202,9 @@ const folderPromptId = ref<string | null>(null)
const folderExecutionTime = ref<number | undefined>(undefined) const folderExecutionTime = ref<number | undefined>(undefined)
const isInFolderView = computed(() => folderPromptId.value !== null) const isInFolderView = computed(() => folderPromptId.value !== null)
// Track which asset's popover is open (for single-instance popover management)
const openPopoverId = ref<string | null>(null)
// Determine if delete button should be shown // Determine if delete button should be shown
// Hide delete button when in input tab and not in cloud (OSS mode - files are from local folders) // Hide delete button when in input tab and not in cloud (OSS mode - files are from local folders)
const shouldShowDeleteButton = computed(() => { const shouldShowDeleteButton = computed(() => {
@@ -208,7 +214,7 @@ const shouldShowDeleteButton = computed(() => {
const getOutputCount = (item: AssetItem): number => { const getOutputCount = (item: AssetItem): number => {
const count = item.user_metadata?.outputCount const count = item.user_metadata?.outputCount
return typeof count === 'number' && count > 0 ? count : 0 return typeof count === 'number' && count > 0 ? count : 1
} }
const shouldShowOutputCount = (item: AssetItem): boolean => { const shouldShowOutputCount = (item: AssetItem): boolean => {

View File

@@ -1,289 +0,0 @@
<template>
<SidebarTabTemplate :title="$t('sideToolbar.queue')">
<template #tool-buttons>
<Button
v-tooltip.bottom="$t(`sideToolbar.queueTab.${imageFit}ImagePreview`)"
:icon="
imageFit === 'cover'
? 'pi pi-arrow-down-left-and-arrow-up-right-to-center'
: 'pi pi-arrow-up-right-and-arrow-down-left-from-center'
"
text
severity="secondary"
class="toggle-expanded-button"
@click="toggleImageFit"
/>
<Button
v-if="isInFolderView"
v-tooltip.bottom="$t('sideToolbar.queueTab.backToAllTasks')"
icon="pi pi-arrow-left"
text
severity="secondary"
class="back-button"
@click="exitFolderView"
/>
<template v-else>
<Button
v-tooltip="$t('sideToolbar.queueTab.showFlatList')"
:icon="isExpanded ? 'pi pi-images' : 'pi pi-image'"
text
severity="secondary"
class="toggle-expanded-button"
@click="toggleExpanded"
/>
<Button
v-if="queueStore.hasPendingTasks"
v-tooltip.bottom="$t('sideToolbar.queueTab.clearPendingTasks')"
icon="pi pi-stop"
severity="danger"
text
@click="() => commandStore.execute('Comfy.ClearPendingTasks')"
/>
<Button
icon="pi pi-trash"
text
severity="primary"
class="clear-all-button"
@click="confirmRemoveAll($event)"
/>
</template>
</template>
<template #body>
<VirtualGrid
v-if="allTasks?.length"
:items="allTasks"
:grid-style="{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
padding: '0.5rem',
gap: '0.5rem'
}"
>
<template #item="{ item }">
<TaskItem
:task="item"
:is-flat-task="isExpanded || isInFolderView"
@contextmenu="handleContextMenu"
@preview="handlePreview"
@task-output-length-clicked="enterFolderView($event)"
/>
</template>
</VirtualGrid>
<div v-else-if="queueStore.isLoading">
<ProgressSpinner
style="width: 50px; left: 50%; transform: translateX(-50%)"
/>
</div>
<div v-else>
<NoResultsPlaceholder
icon="pi pi-info-circle"
:title="$t('g.noTasksFound')"
:message="$t('g.noTasksFoundMessage')"
/>
</div>
</template>
</SidebarTabTemplate>
<ConfirmPopup />
<ContextMenu ref="menu" :model="menuItems" />
<ResultGallery
v-model:active-index="galleryActiveIndex"
:all-gallery-items="allGalleryItems"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import ConfirmPopup from 'primevue/confirmpopup'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
import ProgressSpinner from 'primevue/progressspinner'
import { useConfirm } from 'primevue/useconfirm'
import { useToast } from 'primevue/usetoast'
import { computed, ref, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { ComfyNode } from '@/platform/workflow/validation/schemas/workflowSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useLitegraphService } from '@/services/litegraphService'
import { useCommandStore } from '@/stores/commandStore'
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
import { useQueueStore } from '@/stores/queueStore'
import SidebarTabTemplate from './SidebarTabTemplate.vue'
import ResultGallery from './queue/ResultGallery.vue'
import TaskItem from './queue/TaskItem.vue'
const IMAGE_FIT = 'Comfy.Queue.ImageFit'
const confirm = useConfirm()
const toast = useToast()
const queueStore = useQueueStore()
const settingStore = useSettingStore()
const commandStore = useCommandStore()
const { t } = useI18n()
// Expanded view: show all outputs in a flat list.
const isExpanded = ref(false)
const galleryActiveIndex = ref(-1)
const allGalleryItems = shallowRef<ResultItemImpl[]>([])
// Folder view: only show outputs from a single selected task.
const folderTask = ref<TaskItemImpl | null>(null)
const isInFolderView = computed(() => folderTask.value !== null)
const imageFit = computed<string>(() => settingStore.get(IMAGE_FIT))
const allTasks = computed(() =>
isInFolderView.value
? folderTask.value
? folderTask.value.flatten()
: []
: isExpanded.value
? queueStore.flatTasks
: queueStore.tasks
)
const updateGalleryItems = () => {
allGalleryItems.value = allTasks.value.flatMap((task: TaskItemImpl) => {
const previewOutput = task.previewOutput
return previewOutput ? [previewOutput] : []
})
}
const toggleExpanded = () => {
isExpanded.value = !isExpanded.value
}
const removeTask = async (task: TaskItemImpl) => {
if (task.isRunning) {
await api.interrupt(task.promptId)
}
await queueStore.delete(task)
}
const removeAllTasks = async () => {
await queueStore.clear()
}
const confirmRemoveAll = (event: Event) => {
confirm.require({
target: event.currentTarget as HTMLElement,
message: 'Do you want to delete all tasks?',
icon: 'pi pi-info-circle',
rejectProps: {
label: 'Cancel',
severity: 'secondary',
outlined: true
},
acceptProps: {
label: 'Delete',
severity: 'danger'
},
accept: async () => {
await removeAllTasks()
toast.add({
severity: 'info',
summary: 'Confirmed',
detail: 'Tasks deleted',
life: 3000
})
}
})
}
const menu = ref<InstanceType<typeof ContextMenu> | null>(null)
const menuTargetTask = ref<TaskItemImpl | null>(null)
const menuTargetNode = ref<ComfyNode | null>(null)
const menuItems = computed<MenuItem[]>(() => {
const items: MenuItem[] = [
{
label: t('g.delete'),
icon: 'pi pi-trash',
command: () => menuTargetTask.value && removeTask(menuTargetTask.value),
disabled: isExpanded.value || isInFolderView.value
},
{
label: t('g.loadWorkflow'),
icon: 'pi pi-file-export',
command: () => menuTargetTask.value?.loadWorkflow(app),
disabled: isCloud
? !menuTargetTask.value?.isHistory
: !menuTargetTask.value?.workflow
},
{
label: t('g.goToNode'),
icon: 'pi pi-arrow-circle-right',
command: () => {
if (!menuTargetNode.value) return
useLitegraphService().goToNode(menuTargetNode.value.id)
},
visible: !!menuTargetNode.value
}
]
if (menuTargetTask.value?.previewOutput?.mediaType === 'images') {
items.push({
label: t('g.setAsBackground'),
icon: 'pi pi-image',
command: () => {
const url = menuTargetTask.value?.previewOutput?.url
if (url) {
void settingStore.set('Comfy.Canvas.BackgroundImage', url)
}
}
})
}
return items
})
const handleContextMenu = ({
task,
event,
node
}: {
task: TaskItemImpl
event: Event
node: ComfyNode | null
}) => {
menuTargetTask.value = task
menuTargetNode.value = node
menu.value?.show(event)
}
const handlePreview = (task: TaskItemImpl) => {
updateGalleryItems()
galleryActiveIndex.value = allGalleryItems.value.findIndex(
(item) => item.url === task.previewOutput?.url
)
}
const enterFolderView = (task: TaskItemImpl) => {
folderTask.value = task
}
const exitFolderView = () => {
folderTask.value = null
}
const toggleImageFit = async () => {
await settingStore.set(
IMAGE_FIT,
imageFit.value === 'cover' ? 'contain' : 'cover'
)
}
watch(allTasks, () => {
const isGalleryOpen = galleryActiveIndex.value !== -1
if (!isGalleryOpen) return
const prevLength = allGalleryItems.value.length
updateGalleryItems()
const lengthChange = allGalleryItems.value.length - prevLength
if (!lengthChange) return
const newIndex = galleryActiveIndex.value + lengthChange
galleryActiveIndex.value = Math.max(0, newIndex)
})
</script>

View File

@@ -1,79 +0,0 @@
<template>
<div
ref="resultContainer"
class="result-container"
@click="handlePreviewClick"
>
<ComfyImage
v-if="result.isImage"
:src="result.url"
class="task-output-image"
:contain="imageFit === 'contain'"
:alt="result.filename"
/>
<ResultVideo v-else-if="result.isVideo" :result="result" />
<ResultAudio v-else-if="result.isAudio" :result="result" />
<div v-else class="task-result-preview">
<i class="pi pi-file" />
<span>{{ result.mediaType }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import ComfyImage from '@/components/common/ComfyImage.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { ResultItemImpl } from '@/stores/queueStore'
import ResultAudio from './ResultAudio.vue'
import ResultVideo from './ResultVideo.vue'
const props = defineProps<{
result: ResultItemImpl
}>()
const emit = defineEmits<{
(e: 'preview', result: ResultItemImpl): void
}>()
const resultContainer = ref<HTMLElement | null>(null)
const settingStore = useSettingStore()
const imageFit = computed<string>(() =>
settingStore.get('Comfy.Queue.ImageFit')
)
const handlePreviewClick = () => {
if (props.result.supportsPreview) {
emit('preview', props.result)
}
}
onMounted(() => {
if (props.result.mediaType === 'images') {
resultContainer.value?.querySelectorAll('img').forEach((img) => {
img.draggable = true
})
}
})
</script>
<style scoped>
.result-container {
width: 100%;
height: 100%;
aspect-ratio: 1 / 1;
overflow: hidden;
position: relative;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
transition: transform 0.2s ease;
}
.result-container:hover {
transform: scale(1.02);
}
</style>

View File

@@ -1,271 +0,0 @@
<template>
<div class="task-item" @contextmenu="handleContextMenu">
<div class="task-result-preview">
<template
v-if="
task.displayStatus === TaskItemDisplayStatus.Completed ||
cancelledWithResults
"
>
<ResultItem
v-if="flatOutputs.length && coverResult"
:result="coverResult"
@preview="handlePreview"
/>
</template>
<template v-if="task.displayStatus === TaskItemDisplayStatus.Running">
<i v-if="!progressPreviewBlobUrl" class="pi pi-spin pi-spinner" />
<img
v-else
:src="progressPreviewBlobUrl"
class="progress-preview-img"
/>
</template>
<span v-else-if="task.displayStatus === TaskItemDisplayStatus.Pending"
>...</span
>
<i
v-else-if="cancelledWithoutResults"
class="pi pi-exclamation-triangle"
/>
<i
v-else-if="task.displayStatus === TaskItemDisplayStatus.Failed"
class="pi pi-exclamation-circle"
/>
</div>
<div class="task-item-details">
<div class="tag-wrapper status-tag-group">
<Tag v-if="isFlatTask && task.isHistory" class="node-name-tag">
<Button
class="task-node-link"
:label="`${node?.type} (#${node?.id})`"
link
size="small"
@click="
() => {
if (!node) return
litegraphService.goToNode(node.id)
}
"
/>
</Tag>
<Tag :severity="taskTagSeverity(task.displayStatus)">
<span v-html="taskStatusText(task.displayStatus)" />
<span v-if="task.isHistory" class="task-time">
{{ formatTime(task.executionTimeInSeconds) }}
</span>
<span v-if="isFlatTask" class="task-prompt-id">
{{ task.promptId.split('-')[0] }}
</span>
</Tag>
</div>
<div class="tag-wrapper">
<Button
v-if="task.isHistory && flatOutputs.length > 1"
outlined
@click="handleOutputLengthClick"
>
<span style="font-weight: 700">{{ flatOutputs.length }}</span>
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Tag from 'primevue/tag'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import type { ComfyNode } from '@/platform/workflow/validation/schemas/workflowSchema'
import { api } from '@/scripts/api'
import { useLitegraphService } from '@/services/litegraphService'
import { TaskItemDisplayStatus } from '@/stores/queueStore'
import type { TaskItemImpl } from '@/stores/queueStore'
import ResultItem from './ResultItem.vue'
const props = defineProps<{
task: TaskItemImpl
isFlatTask: boolean
}>()
const litegraphService = useLitegraphService()
const flatOutputs = props.task.flatOutputs
const coverResult = flatOutputs.length
? props.task.previewOutput || flatOutputs[0]
: null
// Using `==` instead of `===` because NodeId can be a string or a number
const node: ComfyNode | null =
flatOutputs.length && props.task.workflow
? (props.task.workflow.nodes.find(
(n: ComfyNode) => n.id == coverResult?.nodeId
) ?? null)
: null
const progressPreviewBlobUrl = ref('')
const emit = defineEmits<{
(
e: 'contextmenu',
value: { task: TaskItemImpl; event: MouseEvent; node: ComfyNode | null }
): void
(e: 'preview', value: TaskItemImpl): void
(e: 'task-output-length-clicked', value: TaskItemImpl): void
}>()
onMounted(() => {
api.addEventListener('b_preview', onProgressPreviewReceived)
})
onUnmounted(() => {
if (progressPreviewBlobUrl.value) {
URL.revokeObjectURL(progressPreviewBlobUrl.value)
}
api.removeEventListener('b_preview', onProgressPreviewReceived)
})
const handleContextMenu = (e: MouseEvent) => {
emit('contextmenu', { task: props.task, event: e, node })
}
const handlePreview = () => {
emit('preview', props.task)
}
const handleOutputLengthClick = () => {
emit('task-output-length-clicked', props.task)
}
const taskTagSeverity = (status: TaskItemDisplayStatus) => {
switch (status) {
case TaskItemDisplayStatus.Pending:
return 'secondary'
case TaskItemDisplayStatus.Running:
return 'info'
case TaskItemDisplayStatus.Completed:
return 'success'
case TaskItemDisplayStatus.Failed:
return 'danger'
case TaskItemDisplayStatus.Cancelled:
return 'warn'
}
}
const taskStatusText = (status: TaskItemDisplayStatus) => {
switch (status) {
case TaskItemDisplayStatus.Pending:
return 'Pending'
case TaskItemDisplayStatus.Running:
return '<i class="pi pi-spin pi-spinner" style="font-weight: bold"></i> Running'
case TaskItemDisplayStatus.Completed:
return '<i class="pi pi-check" style="font-weight: bold"></i>'
case TaskItemDisplayStatus.Failed:
return 'Failed'
case TaskItemDisplayStatus.Cancelled:
return 'Cancelled'
}
}
const formatTime = (time?: number) => {
if (time === undefined) {
return ''
}
return `${time.toFixed(2)}s`
}
const onProgressPreviewReceived = async ({ detail }: CustomEvent) => {
if (props.task.displayStatus === TaskItemDisplayStatus.Running) {
if (progressPreviewBlobUrl.value) {
URL.revokeObjectURL(progressPreviewBlobUrl.value)
}
progressPreviewBlobUrl.value = URL.createObjectURL(detail)
}
}
const cancelledWithResults = computed(() => {
return (
props.task.displayStatus === TaskItemDisplayStatus.Cancelled &&
flatOutputs.length
)
})
const cancelledWithoutResults = computed(() => {
return (
props.task.displayStatus === TaskItemDisplayStatus.Cancelled &&
flatOutputs.length === 0
)
})
</script>
<style scoped>
.task-result-preview {
aspect-ratio: 1 / 1;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
.task-result-preview i,
.task-result-preview span {
font-size: 2rem;
}
.task-item {
display: flex;
flex-direction: column;
border-radius: 4px;
overflow: hidden;
position: relative;
}
.task-item-details {
position: absolute;
top: 0.5rem;
padding: 0.6rem;
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
z-index: 1;
pointer-events: none; /* Allow clicks to pass through this div */
}
/* Make individual controls clickable again by restoring pointer events */
.task-item-details .tag-wrapper,
.task-item-details button {
pointer-events: auto;
}
.task-node-link {
padding: 2px;
}
/* In dark mode, transparent background color for tags is not ideal for tags that
are floating on top of images. */
.tag-wrapper {
background-color: var(--p-primary-contrast-color);
border-radius: 6px;
display: inline-flex;
}
.node-name-tag {
word-break: break-all;
}
.status-tag-group {
display: flex;
flex-direction: column;
}
.progress-preview-img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
</style>

View File

@@ -181,7 +181,6 @@ Composables for sidebar functionality:
|------------|-------------| |------------|-------------|
| `useModelLibrarySidebarTab` | Manages the model library sidebar tab | | `useModelLibrarySidebarTab` | Manages the model library sidebar tab |
| `useNodeLibrarySidebarTab` | Manages the node library sidebar tab | | `useNodeLibrarySidebarTab` | Manages the node library sidebar tab |
| `useQueueSidebarTab` | Manages the queue sidebar tab |
| `useWorkflowsSidebarTab` | Manages the workflows sidebar tab | | `useWorkflowsSidebarTab` | Manages the workflows sidebar tab |
### Tree ### Tree

View File

@@ -13,6 +13,7 @@ import type {
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations' import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types' import { LayoutSource } from '@/renderer/core/layout/types'
import type { NodeId } from '@/renderer/core/layout/types'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { isDOMWidget } from '@/scripts/domWidget' import { isDOMWidget } from '@/scripts/domWidget'
import { useNodeDefStore } from '@/stores/nodeDefStore' import { useNodeDefStore } from '@/stores/nodeDefStore'
@@ -46,7 +47,7 @@ export interface SafeWidgetData {
} }
export interface VueNodeData { export interface VueNodeData {
id: string id: NodeId
title: string title: string
type: string type: string
mode: number mode: number
@@ -78,10 +79,64 @@ export interface GraphNodeManager {
cleanup(): void cleanup(): void
} }
export function safeWidgetMapper(
node: LGraphNode,
slotMetadata: Map<string, WidgetSlotMetadata>
): (widget: IBaseWidget) => SafeWidgetData {
const nodeDefStore = useNodeDefStore()
return function (widget) {
try {
// TODO: Use widget.getReactiveData() once TypeScript types are updated
let value = widget.value
// For combo widgets, if value is undefined, use the first option as default
if (
value === undefined &&
widget.type === 'combo' &&
widget.options?.values &&
Array.isArray(widget.options.values) &&
widget.options.values.length > 0
) {
value = widget.options.values[0]
}
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
const slotInfo = slotMetadata.get(widget.name)
return {
name: widget.name,
type: widget.type,
value: value,
label: widget.label,
options: widget.options ? { ...widget.options } : undefined,
callback: widget.callback,
spec,
slotMetadata: slotInfo,
isDOMWidget: isDOMWidget(widget)
}
} catch (error) {
return {
name: widget.name || 'unknown',
type: widget.type || 'text',
value: undefined
}
}
}
}
export function isValidWidgetValue(value: unknown): value is WidgetValue {
return (
value === null ||
value === undefined ||
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean' ||
typeof value === 'object'
)
}
export function useGraphNodeManager(graph: LGraph): GraphNodeManager { export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Get layout mutations composable // Get layout mutations composable
const { createNode, deleteNode, setSource } = useLayoutMutations() const { createNode, deleteNode, setSource } = useLayoutMutations()
const nodeDefStore = useNodeDefStore()
// Safe reactive data extracted from LiteGraph nodes // Safe reactive data extracted from LiteGraph nodes
const vueNodeData = reactive(new Map<string, VueNodeData>()) const vueNodeData = reactive(new Map<string, VueNodeData>())
@@ -147,45 +202,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
linked: input.link != null linked: input.link != null
}) })
}) })
return ( return node.widgets?.map(safeWidgetMapper(node, slotMetadata)) ?? []
node.widgets?.map((widget) => {
try {
// TODO: Use widget.getReactiveData() once TypeScript types are updated
let value = widget.value
// For combo widgets, if value is undefined, use the first option as default
if (
value === undefined &&
widget.type === 'combo' &&
widget.options?.values &&
Array.isArray(widget.options.values) &&
widget.options.values.length > 0
) {
value = widget.options.values[0]
}
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
const slotInfo = slotMetadata.get(widget.name)
return {
name: widget.name,
type: widget.type,
value: value,
label: widget.label,
options: widget.options ? { ...widget.options } : undefined,
callback: widget.callback,
spec,
slotMetadata: slotInfo,
isDOMWidget: isDOMWidget(widget)
}
} catch (error) {
return {
name: widget.name || 'unknown',
type: widget.type || 'text',
value: undefined
}
}
}) ?? []
)
}) })
const nodeType = const nodeType =

View File

@@ -3,6 +3,7 @@ import { shallowRef, watch } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager' import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import type { GraphNodeManager } from '@/composables/graph/useGraphNodeManager' import type { GraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { useRenderModeSetting } from '@/composables/settings/useRenderModeSetting'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags' import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { useVueNodesMigrationDismissed } from '@/composables/useVueNodesMigrationDismissed' import { useVueNodesMigrationDismissed } from '@/composables/useVueNodesMigrationDismissed'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -18,15 +19,18 @@ function useVueNodeLifecycleIndividual() {
const canvasStore = useCanvasStore() const canvasStore = useCanvasStore()
const layoutMutations = useLayoutMutations() const layoutMutations = useLayoutMutations()
const { shouldRenderVueNodes } = useVueFeatureFlags() const { shouldRenderVueNodes } = useVueFeatureFlags()
const nodeManager = shallowRef<GraphNodeManager | null>(null) const nodeManager = shallowRef<GraphNodeManager | null>(null)
const { startSync } = useLayoutSync() const { startSync } = useLayoutSync()
const isVueNodeToastDismissed = useVueNodesMigrationDismissed() const isVueNodeToastDismissed = useVueNodesMigrationDismissed()
let hasShownMigrationToast = false let hasShownMigrationToast = false
useRenderModeSetting(
{ setting: 'LiteGraph.Canvas.MinFontSizeForLOD', vue: 0, litegraph: 8 },
shouldRenderVueNodes
)
const initializeNodeManager = () => { const initializeNodeManager = () => {
// Use canvas graph if available (handles subgraph contexts), fallback to app graph // Use canvas graph if available (handles subgraph contexts), fallback to app graph
const activeGraph = comfyApp.canvas?.graph const activeGraph = comfyApp.canvas?.graph

View File

@@ -0,0 +1,84 @@
import { describe, it, expect } from 'vitest'
import { resampleSegment } from './splineUtils'
import type { Point } from '@/extensions/core/maskeditor/types'
describe('Shift+Click Drawing Logic', () => {
it('should generate equidistant points across connected segments', () => {
const spacing = 4
let remainder = spacing // Simulate start point already painted
const outputPoints: Point[] = []
// Define points: A -> B -> C
// A(0,0) -> B(10,0) -> C(20,0)
// Total length 20. Spacing 4.
// Expected points at x = 4, 8, 12, 16, 20
const pA = { x: 0, y: 0 }
const pB = { x: 10, y: 0 }
const pC = { x: 20, y: 0 }
// Segment 1: A -> B
const result1 = resampleSegment([pA, pB], spacing, remainder)
outputPoints.push(...result1.points)
remainder = result1.remainder
// Verify intermediate state
// Length 10. Spacing 4. Start offset 4.
// Points at 4, 8. Next at 12.
// Remainder = 12 - 10 = 2.
expect(result1.points.length).toBe(2)
expect(result1.points[0].x).toBeCloseTo(4)
expect(result1.points[1].x).toBeCloseTo(8)
expect(remainder).toBeCloseTo(2)
// Segment 2: B -> C
const result2 = resampleSegment([pB, pC], spacing, remainder)
outputPoints.push(...result2.points)
remainder = result2.remainder
// Verify final state
// Start offset 2. Points at 2, 6, 10 (relative to B).
// Absolute x: 12, 16, 20.
expect(result2.points.length).toBe(3)
expect(result2.points[0].x).toBeCloseTo(12)
expect(result2.points[1].x).toBeCloseTo(16)
expect(result2.points[2].x).toBeCloseTo(20)
// Verify all distances
// Note: The first point is at distance `spacing` from start (0,0)
// Subsequent points are `spacing` apart.
let prevX = 0
for (const p of outputPoints) {
const dist = p.x - prevX
expect(dist).toBeCloseTo(spacing)
prevX = p.x
}
})
it('should handle segments shorter than spacing', () => {
const spacing = 10
let remainder = spacing // Simulate start point already painted
// A(0,0) -> B(5,0) -> C(15,0)
const pA = { x: 0, y: 0 }
const pB = { x: 5, y: 0 }
const pC = { x: 15, y: 0 }
// Segment 1: A -> B (Length 5)
// Spacing 10. No points should be generated.
// Remainder should be 5 (next point needs 5 more units).
const result1 = resampleSegment([pA, pB], spacing, remainder)
expect(result1.points.length).toBe(0)
expect(result1.remainder).toBeCloseTo(5)
remainder = result1.remainder
// Segment 2: B -> C (Length 10)
// Start offset 5. First point at 5 (relative to B).
// Absolute x = 10.
// Next point at 15 (relative to B). Segment ends at 10.
// Remainder = 15 - 10 = 5.
const result2 = resampleSegment([pB, pC], spacing, remainder)
expect(result2.points.length).toBe(1)
expect(result2.points[0].x).toBeCloseTo(10)
expect(result2.remainder).toBeCloseTo(5)
})
})

View File

@@ -0,0 +1,108 @@
import { describe, it, expect } from 'vitest'
import { StrokeProcessor } from './StrokeProcessor'
import type { Point } from '@/extensions/core/maskeditor/types'
describe('StrokeProcessor', () => {
it('should generate equidistant points from irregular input', () => {
const spacing = 10
const processor = new StrokeProcessor(spacing)
const outputPoints: Point[] = []
// Simulate a horizontal line drawn with irregular speed
// Points: (0,0) -> (5,0) -> (25,0) -> (30,0) -> (100,0)
const inputPoints: Point[] = [
{ x: 0, y: 0 },
{ x: 5, y: 0 }, // dist 5
{ x: 25, y: 0 }, // dist 20
{ x: 30, y: 0 }, // dist 5
{ x: 100, y: 0 } // dist 70
]
for (const p of inputPoints) {
outputPoints.push(...processor.addPoint(p))
}
outputPoints.push(...processor.endStroke())
// Verify we have points
expect(outputPoints.length).toBeGreaterThan(0)
// Verify spacing
// Note: The first few points might be affected by the start condition,
// but the middle section should be perfectly spaced.
// Also, Catmull-Rom splines don't necessarily pass through control points in a straight line
// if the points are collinear, they should be straight.
// Let's check distances between consecutive points
const distances: number[] = []
for (let i = 1; i < outputPoints.length; i++) {
const dx = outputPoints[i].x - outputPoints[i - 1].x
const dy = outputPoints[i].y - outputPoints[i - 1].y
distances.push(Math.hypot(dx, dy))
}
// Check that distances are close to spacing
// We allow a small epsilon because of floating point and spline approximation
// Filter out the very last segment which might be shorter (remainder)
// But wait, our logic doesn't output the last point if it's not a full spacing step?
// resampleSegment outputs points at [start + spacing, start + 2*spacing, ...]
// It does NOT output the end point of the segment.
// So all distances between output points should be exactly `spacing`.
// EXCEPT possibly if the spline curvature makes the straight-line distance slightly different
// from the arc length. But for a straight line input, it should be exact.
// However, catmull-rom with collinear points IS a straight line.
// Let's log the distances for debugging if test fails
// console.log('Distances:', distances)
// All distances should be approximately equal to spacing
// We might have a gap between segments if the logic isn't perfect,
// but within a segment it's guaranteed by resampleSegment.
// The critical part is the transition between segments.
for (let i = 0; i < distances.length; i++) {
const d = distances[i]
if (Math.abs(d - spacing) > 0.5) {
console.log(
`Distance mismatch at index ${i}: ${d} (expected ${spacing})`
)
console.log(`Point ${i}:`, outputPoints[i])
console.log(`Point ${i + 1}:`, outputPoints[i + 1])
}
expect(d).toBeCloseTo(spacing, 1)
}
})
it('should handle a simple 3-point stroke', () => {
const spacing = 5
const processor = new StrokeProcessor(spacing)
const points: Point[] = []
points.push(...processor.addPoint({ x: 0, y: 0 }))
points.push(...processor.addPoint({ x: 10, y: 0 }))
points.push(...processor.addPoint({ x: 20, y: 0 }))
points.push(...processor.endStroke())
expect(points.length).toBeGreaterThan(0)
// Check distances
for (let i = 1; i < points.length; i++) {
const dx = points[i].x - points[i - 1].x
const dy = points[i].y - points[i - 1].y
const d = Math.hypot(dx, dy)
expect(d).toBeCloseTo(spacing, 1)
}
})
it('should handle a single point click', () => {
const spacing = 5
const processor = new StrokeProcessor(spacing)
const points: Point[] = []
points.push(...processor.addPoint({ x: 100, y: 100 }))
points.push(...processor.endStroke())
expect(points.length).toBe(1)
expect(points[0]).toEqual({ x: 100, y: 100 })
})
})

View File

@@ -0,0 +1,115 @@
import type { Point } from '@/extensions/core/maskeditor/types'
import { catmullRomSpline, resampleSegment } from './splineUtils'
export class StrokeProcessor {
private controlPoints: Point[] = []
private remainder: number = 0
private spacing: number
private isFirstPoint: boolean = true
private hasProcessedSegment: boolean = false
constructor(spacing: number) {
this.spacing = spacing
}
/**
* Adds a point to the stroke and returns any new equidistant points generated.
* Maintain a sliding window of 4 control points for spline generation
*/
public addPoint(point: Point): Point[] {
// Initialize buffer with the first point
if (this.isFirstPoint) {
this.controlPoints.push(point) // p0: phantom start point
this.controlPoints.push(point) // p1: actual start point
this.isFirstPoint = false
return [] // Wait for more points to form a segment
}
this.controlPoints.push(point)
// Require 4 points for a spline segment
if (this.controlPoints.length < 4) {
return []
}
// Generate segment p1->p2
const p0 = this.controlPoints[0]
const p1 = this.controlPoints[1]
const p2 = this.controlPoints[2]
const p3 = this.controlPoints[3]
const newPoints = this.processSegment(p0, p1, p2, p3)
// Slide window
this.controlPoints.shift()
return newPoints
}
/**
* End stroke and flush remaining segments
*/
public endStroke(): Point[] {
if (this.controlPoints.length < 2) {
// Insufficient points for a segment
return []
}
// Process remaining segments by duplicating the last point
const newPoints: Point[] = []
// Flush the buffer by processing the final segment
while (this.controlPoints.length >= 3) {
const p0 = this.controlPoints[0]
const p1 = this.controlPoints[1]
const p2 = this.controlPoints[2]
const p3 = p2 // Duplicate last point as phantom end
const points = this.processSegment(p0, p1, p2, p3)
newPoints.push(...points)
this.controlPoints.shift()
}
// Handle single point click
if (!this.hasProcessedSegment && this.controlPoints.length >= 2) {
// Process zero-length segment for single point
const p = this.controlPoints[1]
const points = this.processSegment(p, p, p, p)
newPoints.push(...points)
}
return newPoints
}
private processSegment(p0: Point, p1: Point, p2: Point, p3: Point): Point[] {
this.hasProcessedSegment = true
// Generate dense points for the segment
const densePoints: Point[] = []
// Adaptive sampling based on segment length
const dist = Math.hypot(p2.x - p1.x, p2.y - p1.y)
// Use 1 sample per pixel, but at least 5 samples to ensure smoothness for short segments
// and cap at a reasonable maximum if needed (though not strictly necessary with density)
const samples = Math.max(5, Math.ceil(dist))
for (let i = 0; i < samples; i++) {
const t = i / samples
densePoints.push(catmullRomSpline(p0, p1, p2, p3, t))
}
// Add segment end point
densePoints.push(p2)
// Resample points with carried-over remainder
const { points, remainder } = resampleSegment(
densePoints,
this.spacing,
this.remainder
)
this.remainder = remainder
return points
}
}

View File

@@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest'
import { getEffectiveBrushSize, getEffectiveHardness } from './brushUtils'
describe('brushUtils', () => {
describe('getEffectiveBrushSize', () => {
it('should return original size when hardness is 1.0', () => {
const size = 100
const hardness = 1.0
expect(getEffectiveBrushSize(size, hardness)).toBe(100)
})
it('should return 1.5x size when hardness is 0.0', () => {
const size = 100
const hardness = 0.0
expect(getEffectiveBrushSize(size, hardness)).toBe(150)
})
it('should interpolate linearly', () => {
const size = 100
const hardness = 0.5
// Scale should be 1.0 + 0.5 * 0.5 = 1.25
expect(getEffectiveBrushSize(size, hardness)).toBe(125)
})
})
describe('getEffectiveHardness', () => {
it('should return same hardness if effective size matches size', () => {
const size = 100
const hardness = 0.8
const effectiveSize = 100
expect(getEffectiveHardness(size, hardness, effectiveSize)).toBe(0.8)
})
it('should scale hardness down as effective size increases', () => {
const size = 100
const hardness = 0.5
// Effective size at 0.5 hardness is 125
const effectiveSize = 125
// Hard core radius = 50. New hardness = 50 / 125 = 0.4
expect(getEffectiveHardness(size, hardness, effectiveSize)).toBe(0.4)
})
it('should return 0 if effective size is 0', () => {
expect(getEffectiveHardness(100, 0.5, 0)).toBe(0)
})
})
})

View File

@@ -0,0 +1,34 @@
/**
* Calculates the effective brush size based on the base size and hardness.
* As hardness decreases, the effective size increases to allow for a softer falloff.
*
* @param size - The base radius of the brush
* @param hardness - The hardness of the brush (0.0 to 1.0)
* @returns The effective radius of the brush
*/
export function getEffectiveBrushSize(size: number, hardness: number): number {
// Scale factor for maximum softness
const MAX_SCALE = 1.5
const scale = 1.0 + (1.0 - hardness) * (MAX_SCALE - 1.0)
return size * scale
}
/**
* Calculates the effective hardness to maintain the visual "hard core" of the brush.
* Since the effective size is larger, we need to adjust the hardness value so that
* the inner hard circle remains at the same physical radius as the original size * hardness.
*
* @param size - The base radius of the brush
* @param hardness - The base hardness of the brush
* @param effectiveSize - The effective radius (calculated by getEffectiveBrushSize)
* @returns The adjusted hardness value (0.0 to 1.0)
*/
export function getEffectiveHardness(
size: number,
hardness: number,
effectiveSize: number
): number {
if (effectiveSize <= 0) return 0
// Adjust hardness to maintain the physical radius of the hard core
return (size * hardness) / effectiveSize
}

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