Merge branch 'main' into webcam-capture
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
2
.github/workflows/ci-json-validation.yaml
vendored
@@ -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:
|
||||||
|
|||||||
2
.github/workflows/ci-lint-format.yaml
vendored
@@ -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:
|
||||||
|
|||||||
2
.github/workflows/ci-python-validation.yaml
vendored
@@ -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:
|
||||||
|
|||||||
2
.github/workflows/ci-tests-e2e-forks.yaml
vendored
@@ -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:
|
||||||
|
|||||||
2
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
3
.github/workflows/ci-tests-storybook.yaml
vendored
@@ -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
|
||||||
|
|||||||
2
.github/workflows/ci-tests-unit.yaml
vendored
@@ -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:
|
||||||
|
|||||||
2
.github/workflows/ci-yaml-validation.yaml
vendored
@@ -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:
|
||||||
|
|||||||
2
.github/workflows/i18n-update-core.yaml
vendored
@@ -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
|
||||||
|
|||||||
3
.github/workflows/pr-backport.yaml
vendored
@@ -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() {
|
||||||
|
|||||||
2
.github/workflows/pr-claude-review.yaml
vendored
@@ -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
|
||||||
|
|||||||
4
.github/workflows/release-branch-create.yaml
vendored
@@ -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'
|
||||||
|
|||||||
2
.github/workflows/release-version-bump.yaml
vendored
@@ -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:
|
||||||
|
|||||||
2
.github/workflows/weekly-docs-check.yaml
vendored
@@ -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
|
||||||
|
|||||||
@@ -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:",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}`)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
159
apps/desktop-ui/src/views/MaintenanceView.stories.ts
Normal 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 />'
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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']
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 97 KiB |
@@ -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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 36 KiB |
@@ -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()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 143 KiB |
@@ -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 }) => {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 81 KiB |
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]'
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -68,4 +68,8 @@ const toggle = (event: Event) => {
|
|||||||
const hide = () => {
|
const hide = () => {
|
||||||
popover.value?.hide()
|
popover.value?.hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
hide
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) -->
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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}%
|
||||||
)`
|
)`
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ const onInvert = () => {
|
|||||||
|
|
||||||
const onClear = () => {
|
const onClear = () => {
|
||||||
canvasTools.clearMask()
|
canvasTools.clearMask()
|
||||||
|
store.triggerClear()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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<{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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<{
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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[] }>()
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
39
src/components/sidebar/SidebarSettingsButton.vue
Normal 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>
|
||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
84
src/composables/maskeditor/ShiftClick.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
108
src/composables/maskeditor/StrokeProcessor.test.ts
Normal 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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
115
src/composables/maskeditor/StrokeProcessor.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/composables/maskeditor/brushUtils.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
34
src/composables/maskeditor/brushUtils.ts
Normal 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
|
||||||
|
}
|
||||||