Merge remote-tracking branch 'origin/main' into pysssss/asset-delete-progress
5
.github/workflows/ci-tests-e2e-forks.yaml
vendored
@@ -1,9 +1,9 @@
|
||||
# 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)'
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["CI: Tests E2E"]
|
||||
workflows: ['CI: Tests E2E']
|
||||
types: [requested, completed]
|
||||
|
||||
env:
|
||||
@@ -81,6 +81,7 @@ jobs:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
run: |
|
||||
# Rename merged report if exists
|
||||
[ -d "reports/playwright-report-chromium-merged" ] && \
|
||||
|
||||
18
.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'
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.8
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.10
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.8
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.10
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -144,9 +144,10 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
# Setup pnpm/node to run playwright merge-reports (no browsers needed)
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Download blob reports
|
||||
uses: actions/download-artifact@v4
|
||||
@@ -158,10 +159,10 @@ jobs:
|
||||
- name: Merge into HTML Report
|
||||
run: |
|
||||
# Generate HTML report
|
||||
pnpm exec playwright merge-reports --reporter=html ./all-blob-reports
|
||||
pnpm dlx @playwright/test merge-reports --reporter=html ./all-blob-reports
|
||||
# Generate JSON report separately with explicit output path
|
||||
PLAYWRIGHT_JSON_OUTPUT_NAME=playwright-report/report.json \
|
||||
pnpm exec playwright merge-reports --reporter=json ./all-blob-reports
|
||||
pnpm dlx @playwright/test merge-reports --reporter=json ./all-blob-reports
|
||||
|
||||
- name: Upload HTML report
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -222,6 +223,7 @@ jobs:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
bash ./scripts/cicd/pr-playwright-deploy-and-comment.sh \
|
||||
"${{ github.event.pull_request.number }}" \
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.8
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.10
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
12
.github/workflows/release-biweekly-comfyui.yaml
vendored
@@ -69,7 +69,7 @@ jobs:
|
||||
- name: Checkout ComfyUI (sparse)
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: comfyanonymous/ComfyUI
|
||||
repository: Comfy-Org/ComfyUI
|
||||
sparse-checkout: |
|
||||
requirements.txt
|
||||
path: comfyui
|
||||
@@ -184,7 +184,7 @@ jobs:
|
||||
# Note: This only affects the local checkout, NOT the fork's master branch
|
||||
# We only push the automation branch, leaving the fork's master untouched
|
||||
echo "Fetching upstream master..."
|
||||
if ! git fetch https://github.com/comfyanonymous/ComfyUI.git master; then
|
||||
if ! git fetch https://github.com/Comfy-Org/ComfyUI.git master; then
|
||||
echo "Failed to fetch upstream master"
|
||||
exit 1
|
||||
fi
|
||||
@@ -257,7 +257,7 @@ jobs:
|
||||
# Extract fork owner from repository name
|
||||
FORK_OWNER=$(echo "$COMFYUI_FORK" | cut -d'/' -f1)
|
||||
|
||||
echo "Creating PR from ${COMFYUI_FORK} to comfyanonymous/ComfyUI"
|
||||
echo "Creating PR from ${COMFYUI_FORK} to Comfy-Org/ComfyUI"
|
||||
|
||||
# Configure git
|
||||
git config user.name "github-actions[bot]"
|
||||
@@ -288,7 +288,7 @@ jobs:
|
||||
|
||||
# Try to create PR, ignore error if it already exists
|
||||
if ! gh pr create \
|
||||
--repo comfyanonymous/ComfyUI \
|
||||
--repo Comfy-Org/ComfyUI \
|
||||
--head "${FORK_OWNER}:${BRANCH}" \
|
||||
--base master \
|
||||
--title "Bump comfyui-frontend-package to ${{ needs.resolve-version.outputs.target_version }}" \
|
||||
@@ -297,7 +297,7 @@ jobs:
|
||||
|
||||
# Check if PR already exists
|
||||
set +e
|
||||
EXISTING_PR=$(gh pr list --repo comfyanonymous/ComfyUI --head "${FORK_OWNER}:${BRANCH}" --json number --jq '.[0].number' 2>&1)
|
||||
EXISTING_PR=$(gh pr list --repo Comfy-Org/ComfyUI --head "${FORK_OWNER}:${BRANCH}" --json number --jq '.[0].number' 2>&1)
|
||||
PR_LIST_EXIT=$?
|
||||
set -e
|
||||
|
||||
@@ -318,7 +318,7 @@ jobs:
|
||||
run: |
|
||||
echo "## ComfyUI PR Created" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Draft PR created in comfyanonymous/ComfyUI" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Draft PR created in Comfy-Org/ComfyUI" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### PR Body:" >> $GITHUB_STEP_SUMMARY
|
||||
cat pr-body.txt >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -10,7 +10,7 @@ module.exports = defineConfig({
|
||||
entry: 'src/locales/en',
|
||||
entryLocale: 'en',
|
||||
output: 'src/locales',
|
||||
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr', 'pt-BR'],
|
||||
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr', 'pt-BR', 'fa'],
|
||||
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream, Civitai, Hugging Face.
|
||||
'latent' is the short form of 'latent space'.
|
||||
'mask' is in the context of image processing.
|
||||
@@ -19,5 +19,11 @@ module.exports = defineConfig({
|
||||
- For 'zh' locale: Use ONLY Simplified Chinese characters (简体中文). Common examples: 节点 (not 節點), 画布 (not 畫布), 图像 (not 圖像), 选择 (not 選擇), 减小 (not 減小).
|
||||
- For 'zh-TW' locale: Use ONLY Traditional Chinese characters (繁體中文) with Taiwan-specific terminology.
|
||||
- NEVER mix Simplified and Traditional Chinese characters within the same locale.
|
||||
|
||||
IMPORTANT Persian Translation Guidelines:
|
||||
- For 'fa' locale: Use formal Persian (فارسی رسمی) for professional tone throughout the UI.
|
||||
- Keep commonly used technical terms in English when they are standard in Persian software (e.g., node, workflow).
|
||||
- Use Arabic-Indic numerals (۰-۹) for numbers where appropriate.
|
||||
- Maintain consistency with terminology used in Persian software and design applications.
|
||||
`
|
||||
});
|
||||
|
||||
@@ -69,9 +69,32 @@ const config: StorybookConfig = {
|
||||
allowedHosts: true
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': process.cwd() + '/src'
|
||||
}
|
||||
alias: [
|
||||
{
|
||||
find: '@/composables/queue/useJobList',
|
||||
replacement: process.cwd() + '/src/storybook/mocks/useJobList.ts'
|
||||
},
|
||||
{
|
||||
find: '@/composables/queue/useJobActions',
|
||||
replacement: process.cwd() + '/src/storybook/mocks/useJobActions.ts'
|
||||
},
|
||||
{
|
||||
find: '@/utils/formatUtil',
|
||||
replacement:
|
||||
process.cwd() +
|
||||
'/packages/shared-frontend-utils/src/formatUtil.ts'
|
||||
},
|
||||
{
|
||||
find: '@/utils/networkUtil',
|
||||
replacement:
|
||||
process.cwd() +
|
||||
'/packages/shared-frontend-utils/src/networkUtil.ts'
|
||||
},
|
||||
{
|
||||
find: '@',
|
||||
replacement: process.cwd() + '/src'
|
||||
}
|
||||
]
|
||||
},
|
||||
esbuild: {
|
||||
// Prevent minification of identifiers to preserve _sfc_main
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
/src/components/graph/selectionToolbox/ @Myestery
|
||||
|
||||
# Minimap
|
||||
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery
|
||||
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery
|
||||
|
||||
# Workflow Templates
|
||||
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki
|
||||
@@ -55,8 +55,7 @@
|
||||
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
|
||||
|
||||
# Translations
|
||||
/src/locales/ @Yorha4D @KarryCharon @shinshin86 @Comfy-Org/comfy_maintainer @Comfy-org/comfy_frontend_devs
|
||||
/src/locales/pt-BR/ @JonatanAtila @Yorha4D @KarryCharon @shinshin86
|
||||
/src/locales/ @Comfy-Org/comfy_maintainer @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# LLM Instructions (blank on purpose)
|
||||
.claude/
|
||||
|
||||
@@ -3,7 +3,7 @@ import { test as base, expect } from '@playwright/test'
|
||||
import dotenv from 'dotenv'
|
||||
import * as fs from 'fs'
|
||||
|
||||
import type { LGraphNode } from '../../src/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode, LGraph } from '../../src/lib/litegraph/src/litegraph'
|
||||
import type { NodeId } from '../../src/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { KeyCombo } from '../../src/schemas/keyBindingSchema'
|
||||
import type { useWorkspaceStore } from '../../src/stores/workspaceStore'
|
||||
@@ -1591,14 +1591,29 @@ export class ComfyPage {
|
||||
return window['app'].graph.nodes
|
||||
})
|
||||
}
|
||||
async getNodeRefsByType(type: string): Promise<NodeReference[]> {
|
||||
async waitForGraphNodes(count: number) {
|
||||
await this.page.waitForFunction((count) => {
|
||||
return window['app']?.canvas.graph?.nodes?.length === count
|
||||
}, count)
|
||||
}
|
||||
async getNodeRefsByType(
|
||||
type: string,
|
||||
includeSubgraph: boolean = false
|
||||
): Promise<NodeReference[]> {
|
||||
return Promise.all(
|
||||
(
|
||||
await this.page.evaluate((type) => {
|
||||
return window['app'].graph.nodes
|
||||
.filter((n: LGraphNode) => n.type === type)
|
||||
.map((n: LGraphNode) => n.id)
|
||||
}, type)
|
||||
await this.page.evaluate(
|
||||
({ type, includeSubgraph }) => {
|
||||
const graph = (
|
||||
includeSubgraph ? window['app'].canvas.graph : window['app'].graph
|
||||
) as LGraph
|
||||
const nodes = graph.nodes
|
||||
return nodes
|
||||
.filter((n: LGraphNode) => n.type === type)
|
||||
.map((n: LGraphNode) => n.id)
|
||||
},
|
||||
{ type, includeSubgraph }
|
||||
)
|
||||
).map((id: NodeId) => this.getNodeRefById(id))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -159,8 +159,18 @@ export class VueNodeHelpers {
|
||||
getInputNumberControls(widget: Locator) {
|
||||
return {
|
||||
input: widget.locator('input'),
|
||||
incrementButton: widget.locator('button').first(),
|
||||
decrementButton: widget.locator('button').nth(1)
|
||||
decrementButton: widget.getByTestId('decrement'),
|
||||
incrementButton: widget.getByTestId('increment')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter the subgraph of a node.
|
||||
* @param nodeId - The ID of the node to enter the subgraph of. If not provided, the first matched subgraph will be entered.
|
||||
*/
|
||||
async enterSubgraph(nodeId?: string): Promise<void> {
|
||||
const locator = nodeId ? this.getNodeLocator(nodeId) : this.page
|
||||
const editButton = locator.getByTestId('subgraph-enter-button')
|
||||
await editButton.click()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,8 +133,11 @@ test.describe('Menu', () => {
|
||||
// Checkmark should be invisible again (panel is hidden)
|
||||
await expect(checkmark).toHaveClass(/invisible/)
|
||||
|
||||
// Click outside to close menu
|
||||
await comfyPage.page.locator('body').click({ position: { x: 10, y: 10 } })
|
||||
// Click in top-right corner to close menu (avoid hamburger menu at top-left)
|
||||
const viewport = comfyPage.page.viewportSize()!
|
||||
await comfyPage.page
|
||||
.locator('body')
|
||||
.click({ position: { x: viewport.width - 10, y: 10 } })
|
||||
|
||||
// Verify menu is now closed
|
||||
await expect(menu).not.toBeVisible()
|
||||
|
||||
@@ -22,8 +22,14 @@ test.describe('Mobile Baseline Snapshots', () => {
|
||||
test('@mobile settings dialog', async ({ comfyPage }) => {
|
||||
await comfyPage.settingDialog.open()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.settingDialog.root).toHaveScreenshot(
|
||||
'mobile-settings-dialog.png'
|
||||
'mobile-settings-dialog.png',
|
||||
{
|
||||
mask: [
|
||||
comfyPage.settingDialog.root.getByTestId('current-user-indicator')
|
||||
]
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 18 KiB |
@@ -8,13 +8,11 @@ test.describe('Properties panel', () => {
|
||||
|
||||
const { propertiesPanel } = comfyPage.menu
|
||||
|
||||
await expect(propertiesPanel.panelTitle).toContainText(
|
||||
'No node(s) selected'
|
||||
)
|
||||
await expect(propertiesPanel.panelTitle).toContainText('Workflow Overview')
|
||||
|
||||
await comfyPage.selectNodes(['KSampler', 'CLIP Text Encode (Prompt)'])
|
||||
|
||||
await expect(propertiesPanel.panelTitle).toContainText('3 nodes selected')
|
||||
await expect(propertiesPanel.panelTitle).toContainText('3 items selected')
|
||||
await expect(propertiesPanel.root.getByText('KSampler')).toHaveCount(1)
|
||||
await expect(
|
||||
propertiesPanel.root.getByText('CLIP Text Encode (Prompt)')
|
||||
|
||||
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 26 KiB |
@@ -102,7 +102,7 @@ test.describe('Vue Node Link Interaction', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setup()
|
||||
// await comfyPage.setup()
|
||||
await comfyPage.loadWorkflow('vueNodes/simple-triple')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await fitToViewInstant(comfyPage)
|
||||
@@ -993,4 +993,51 @@ test.describe('Vue Node Link Interaction', () => {
|
||||
expect(linked).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test('Dragging from subgraph input connects to correct slot', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
// Setup workflow with a KSampler node
|
||||
await comfyPage.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await comfyPage.waitForGraphNodes(0)
|
||||
await comfyPage.executeCommand('Workspace.SearchBox.Toggle')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')
|
||||
await comfyPage.waitForGraphNodes(1)
|
||||
|
||||
// Convert the KSampler node to a subgraph
|
||||
let ksamplerNode = (await comfyPage.getNodeRefsByType('KSampler'))?.[0]
|
||||
await comfyPage.vueNodes.selectNode(String(ksamplerNode.id))
|
||||
await comfyPage.executeCommand('Comfy.Graph.ConvertToSubgraph')
|
||||
|
||||
// Enter the subgraph
|
||||
await comfyPage.vueNodes.enterSubgraph()
|
||||
await fitToViewInstant(comfyPage)
|
||||
|
||||
// Get the KSampler node inside the subgraph
|
||||
ksamplerNode = (await comfyPage.getNodeRefsByType('KSampler', true))?.[0]
|
||||
const positiveInput = await ksamplerNode.getInput(1)
|
||||
const negativeInput = await ksamplerNode.getInput(2)
|
||||
|
||||
const positiveInputPos = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
ksamplerNode.id,
|
||||
1,
|
||||
true
|
||||
)
|
||||
|
||||
const sourceSlot = await comfyPage.getSubgraphInputSlot()
|
||||
const calculatedSourcePos = await sourceSlot.getOpenSlotPosition()
|
||||
|
||||
await comfyMouse.move(calculatedSourcePos)
|
||||
await comfyMouse.drag(positiveInputPos)
|
||||
await comfyMouse.drop()
|
||||
|
||||
// Verify connection went to the correct slot
|
||||
const positiveLinks = await positiveInput.getLinkCount()
|
||||
const negativeLinks = await negativeInput.getLinkCount()
|
||||
expect(positiveLinks).toBe(1)
|
||||
expect(negativeLinks).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 81 KiB |
@@ -194,7 +194,10 @@ test.describe('Image widget', () => {
|
||||
const comboEntry = comfyPage.page.getByRole('menuitem', {
|
||||
name: 'image32x32.webp'
|
||||
})
|
||||
await comboEntry.click({ noWaitAfter: true })
|
||||
await comboEntry.click()
|
||||
|
||||
// Stabilization for the image swap
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Expect the image preview to change automatically
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
|
||||
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 46 KiB |
@@ -8,7 +8,8 @@ const config: KnipConfig = {
|
||||
'src/assets/css/style.css',
|
||||
'src/main.ts',
|
||||
'src/scripts/ui/menu/index.ts',
|
||||
'src/types/index.ts'
|
||||
'src/types/index.ts',
|
||||
'src/storybook/mocks/**/*.ts'
|
||||
],
|
||||
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}']
|
||||
},
|
||||
|
||||
23
lint-staged.config.mjs
Normal file
@@ -0,0 +1,23 @@
|
||||
import path from 'node:path'
|
||||
|
||||
export default {
|
||||
'./**/*.js': (stagedFiles) => formatAndEslint(stagedFiles),
|
||||
|
||||
'./**/*.{ts,tsx,vue,mts}': (stagedFiles) => [
|
||||
...formatAndEslint(stagedFiles),
|
||||
'pnpm typecheck'
|
||||
]
|
||||
}
|
||||
|
||||
function formatAndEslint(fileNames) {
|
||||
// Convert absolute paths to relative paths for better ESLint resolution
|
||||
const relativePaths = fileNames.map((f) => path.relative(process.cwd(), f))
|
||||
const joinedPaths = relativePaths.map((p) => `"${p}"`).join(' ')
|
||||
return [
|
||||
`pnpm exec prettier --cache --write ${joinedPaths}`,
|
||||
`pnpm exec oxlint --fix ${joinedPaths}`,
|
||||
`pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}`
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
"short_name": "ComfyUI",
|
||||
"description": "ComfyUI: AI image generation platform",
|
||||
"start_url": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/assets/images/comfy-logo-single.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml"
|
||||
}
|
||||
],
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#000000"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.37.8",
|
||||
"version": "1.38.2",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -247,6 +247,7 @@
|
||||
--inverted-background-hover: var(--color-charcoal-600);
|
||||
--warning-background: var(--color-gold-400);
|
||||
--warning-background-hover: var(--color-gold-500);
|
||||
--success-background: var(--color-jade-600);
|
||||
--border-default: var(--color-smoke-600);
|
||||
--border-subtle: var(--color-smoke-400);
|
||||
--muted-background: var(--color-smoke-700);
|
||||
@@ -281,7 +282,7 @@
|
||||
--modal-card-border-highlighted: var(--secondary-background-selected);
|
||||
--modal-card-button-surface: var(--color-smoke-300);
|
||||
--modal-card-placeholder-background: var(--color-smoke-600);
|
||||
--modal-card-tag-background: var(--color-smoke-400);
|
||||
--modal-card-tag-background: var(--color-smoke-200);
|
||||
--modal-card-tag-foreground: var(--base-foreground);
|
||||
--modal-panel-background: var(--color-white);
|
||||
}
|
||||
@@ -372,6 +373,7 @@
|
||||
--inverted-background-hover: var(--color-smoke-200);
|
||||
--warning-background: var(--color-gold-600);
|
||||
--warning-background-hover: var(--color-gold-500);
|
||||
--success-background: var(--color-jade-600);
|
||||
--border-default: var(--color-charcoal-200);
|
||||
--border-subtle: var(--color-charcoal-300);
|
||||
--muted-background: var(--color-charcoal-100);
|
||||
@@ -516,6 +518,7 @@
|
||||
--color-inverted-background-hover: var(--inverted-background-hover);
|
||||
--color-warning-background: var(--warning-background);
|
||||
--color-warning-background-hover: var(--warning-background-hover);
|
||||
--color-success-background: var(--success-background);
|
||||
--color-border-default: var(--border-default);
|
||||
--color-border-subtle: var(--border-subtle);
|
||||
--color-muted-background: var(--muted-background);
|
||||
|
||||
@@ -10,37 +10,158 @@ interface TestStats {
|
||||
finished?: number
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
status: string
|
||||
duration?: number
|
||||
error?: {
|
||||
message?: string
|
||||
stack?: string
|
||||
}
|
||||
attachments?: Array<{
|
||||
name: string
|
||||
path?: string
|
||||
contentType: string
|
||||
}>
|
||||
}
|
||||
|
||||
interface TestCase {
|
||||
title: string
|
||||
ok: boolean
|
||||
outcome: string
|
||||
results: TestResult[]
|
||||
}
|
||||
|
||||
interface Suite {
|
||||
title: string
|
||||
file: string
|
||||
suites?: Suite[]
|
||||
tests?: TestCase[]
|
||||
}
|
||||
|
||||
interface FullReportData {
|
||||
stats?: TestStats
|
||||
suites?: Suite[]
|
||||
}
|
||||
|
||||
interface ReportData {
|
||||
stats?: TestStats
|
||||
}
|
||||
|
||||
interface FailedTest {
|
||||
name: string
|
||||
file: string
|
||||
traceUrl?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface TestCounts {
|
||||
passed: number
|
||||
failed: number
|
||||
flaky: number
|
||||
skipped: number
|
||||
total: number
|
||||
failures?: FailedTest[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract failed test details from Playwright report
|
||||
*/
|
||||
function extractFailedTests(
|
||||
reportData: FullReportData,
|
||||
baseUrl?: string
|
||||
): FailedTest[] {
|
||||
const failures: FailedTest[] = []
|
||||
|
||||
function processTest(test: TestCase, file: string, suitePath: string[]) {
|
||||
// Check if test failed or is flaky
|
||||
const hasFailed = test.results.some(
|
||||
(r) => r.status === 'failed' || r.status === 'timedOut'
|
||||
)
|
||||
const isFlaky = test.outcome === 'flaky'
|
||||
|
||||
if (hasFailed || isFlaky) {
|
||||
const fullTestName = [...suitePath, test.title]
|
||||
.filter(Boolean)
|
||||
.join(' › ')
|
||||
const failedResult = test.results.find(
|
||||
(r) => r.status === 'failed' || r.status === 'timedOut'
|
||||
)
|
||||
|
||||
// Find trace attachment
|
||||
let traceUrl: string | undefined
|
||||
if (failedResult?.attachments) {
|
||||
const traceAttachment = failedResult.attachments.find(
|
||||
(a) => a.name === 'trace' && a.contentType === 'application/zip'
|
||||
)
|
||||
if (traceAttachment?.path) {
|
||||
// Convert local path to URL path
|
||||
const tracePath = traceAttachment.path.replace(/\\/g, '/')
|
||||
const traceFile = path.basename(tracePath)
|
||||
if (baseUrl) {
|
||||
// Construct trace viewer URL
|
||||
const traceDataUrl = `${baseUrl}/data/${traceFile}`
|
||||
traceUrl = `${baseUrl}/trace/?trace=${encodeURIComponent(traceDataUrl)}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
failures.push({
|
||||
name: fullTestName,
|
||||
file: file,
|
||||
traceUrl,
|
||||
error: failedResult?.error?.message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function processSuite(suite: Suite, parentPath: string[] = []) {
|
||||
const suitePath = suite.title ? [...parentPath, suite.title] : parentPath
|
||||
|
||||
// Process tests in this suite
|
||||
if (suite.tests) {
|
||||
for (const test of suite.tests) {
|
||||
processTest(test, suite.file, suitePath)
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively process nested suites
|
||||
if (suite.suites) {
|
||||
for (const childSuite of suite.suites) {
|
||||
processSuite(childSuite, suitePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (reportData.suites) {
|
||||
for (const suite of reportData.suites) {
|
||||
processSuite(suite)
|
||||
}
|
||||
}
|
||||
|
||||
return failures
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract test counts from Playwright HTML report
|
||||
* @param reportDir - Path to the playwright-report directory
|
||||
* @returns Test counts { passed, failed, flaky, skipped, total }
|
||||
* @param baseUrl - Base URL of the deployed report (for trace links)
|
||||
* @returns Test counts { passed, failed, flaky, skipped, total, failures }
|
||||
*/
|
||||
function extractTestCounts(reportDir: string): TestCounts {
|
||||
function extractTestCounts(reportDir: string, baseUrl?: string): TestCounts {
|
||||
const counts: TestCounts = {
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
flaky: 0,
|
||||
skipped: 0,
|
||||
total: 0
|
||||
total: 0,
|
||||
failures: []
|
||||
}
|
||||
|
||||
try {
|
||||
// First, try to find report.json which Playwright generates with JSON reporter
|
||||
const jsonReportFile = path.join(reportDir, 'report.json')
|
||||
if (fs.existsSync(jsonReportFile)) {
|
||||
const reportJson: ReportData = JSON.parse(
|
||||
const reportJson: FullReportData = JSON.parse(
|
||||
fs.readFileSync(jsonReportFile, 'utf-8')
|
||||
)
|
||||
if (reportJson.stats) {
|
||||
@@ -54,6 +175,12 @@ function extractTestCounts(reportDir: string): TestCounts {
|
||||
counts.failed = stats.unexpected || 0
|
||||
counts.flaky = stats.flaky || 0
|
||||
counts.skipped = stats.skipped || 0
|
||||
|
||||
// Extract detailed failure information
|
||||
if (counts.failed > 0 || counts.flaky > 0) {
|
||||
counts.failures = extractFailedTests(reportJson, baseUrl)
|
||||
}
|
||||
|
||||
return counts
|
||||
}
|
||||
}
|
||||
@@ -169,15 +296,18 @@ function extractTestCounts(reportDir: string): TestCounts {
|
||||
|
||||
// Main execution
|
||||
const reportDir = process.argv[2]
|
||||
const baseUrl = process.argv[3] // Optional: base URL for trace links
|
||||
|
||||
if (!reportDir) {
|
||||
console.error('Usage: extract-playwright-counts.ts <report-directory>')
|
||||
console.error(
|
||||
'Usage: extract-playwright-counts.ts <report-directory> [base-url]'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const counts = extractTestCounts(reportDir)
|
||||
const counts = extractTestCounts(reportDir, baseUrl)
|
||||
|
||||
// Output as JSON for easy parsing in shell script
|
||||
console.log(JSON.stringify(counts))
|
||||
process.stdout.write(JSON.stringify(counts) + '\n')
|
||||
|
||||
export { extractTestCounts }
|
||||
export { extractTestCounts, extractFailedTests }
|
||||
|
||||
@@ -134,23 +134,22 @@ post_comment() {
|
||||
|
||||
# Main execution
|
||||
if [ "$STATUS" = "starting" ]; then
|
||||
# Post starting comment
|
||||
# Post concise starting comment
|
||||
comment=$(cat <<EOF
|
||||
$COMMENT_MARKER
|
||||
## 🎭 Playwright Test Results
|
||||
## 🎭 Playwright Tests: ⏳ Running...
|
||||
|
||||
<img alt='loading' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px'/> **Tests are starting...**
|
||||
Tests started at $START_TIME UTC
|
||||
|
||||
⏰ Started at: $START_TIME UTC
|
||||
<details>
|
||||
<summary>📊 Browser Tests</summary>
|
||||
|
||||
### 🚀 Running Tests
|
||||
- 🧪 **chromium**: Running tests...
|
||||
- 🧪 **chromium-0.5x**: Running tests...
|
||||
- 🧪 **chromium-2x**: Running tests...
|
||||
- 🧪 **mobile-chrome**: Running tests...
|
||||
- **chromium**: Running...
|
||||
- **chromium-0.5x**: Running...
|
||||
- **chromium-2x**: Running...
|
||||
- **mobile-chrome**: Running...
|
||||
|
||||
---
|
||||
⏱️ Please wait while tests are running...
|
||||
</details>
|
||||
EOF
|
||||
)
|
||||
post_comment "$comment"
|
||||
@@ -189,7 +188,8 @@ else
|
||||
|
||||
if command -v tsx > /dev/null 2>&1 && [ -f "$EXTRACT_SCRIPT" ]; then
|
||||
echo "Extracting counts from $REPORT_DIR using $EXTRACT_SCRIPT" >&2
|
||||
counts=$(tsx "$EXTRACT_SCRIPT" "$REPORT_DIR" 2>&1 || echo '{}')
|
||||
# Pass the base URL so we can generate trace links
|
||||
counts=$(tsx "$EXTRACT_SCRIPT" "$REPORT_DIR" "$url" 2>&1 || echo '{}')
|
||||
echo "Extracted counts for $browser: $counts" >&2
|
||||
echo "$counts" > "$temp_dir/$i.counts"
|
||||
else
|
||||
@@ -286,43 +286,74 @@ else
|
||||
# Determine overall status
|
||||
if [ $total_failed -gt 0 ]; then
|
||||
status_icon="❌"
|
||||
status_text="Some tests failed"
|
||||
status_text="Failed"
|
||||
elif [ $total_flaky -gt 0 ]; then
|
||||
status_icon="⚠️"
|
||||
status_text="Tests passed with flaky tests"
|
||||
status_text="Passed with flaky tests"
|
||||
elif [ $total_tests -gt 0 ]; then
|
||||
status_icon="✅"
|
||||
status_text="All tests passed!"
|
||||
status_text="Passed"
|
||||
else
|
||||
status_icon="🕵🏻"
|
||||
status_text="No test results found"
|
||||
status_text="No test results"
|
||||
fi
|
||||
|
||||
# Generate completion comment
|
||||
# Generate concise completion comment
|
||||
comment="$COMMENT_MARKER
|
||||
## 🎭 Playwright Test Results
|
||||
|
||||
$status_icon **$status_text**
|
||||
|
||||
⏰ Completed at: $(date -u '+%m/%d/%Y, %I:%M:%S %p') UTC"
|
||||
## 🎭 Playwright Tests: $status_icon **$status_text**"
|
||||
|
||||
# Add summary counts if we have test data
|
||||
if [ $total_tests -gt 0 ]; then
|
||||
comment="$comment
|
||||
|
||||
### 📈 Summary
|
||||
- **Total Tests:** $total_tests
|
||||
- **Passed:** $total_passed ✅
|
||||
- **Failed:** $total_failed $([ $total_failed -gt 0 ] && echo '❌' || echo '')
|
||||
- **Flaky:** $total_flaky $([ $total_flaky -gt 0 ] && echo '⚠️' || echo '')
|
||||
- **Skipped:** $total_skipped $([ $total_skipped -gt 0 ] && echo '⏭️' || echo '')"
|
||||
**Results:** $total_passed passed, $total_failed failed, $total_flaky flaky, $total_skipped skipped (Total: $total_tests)"
|
||||
fi
|
||||
|
||||
# Extract and display failed tests from all browsers
|
||||
if [ $total_failed -gt 0 ] || [ $total_flaky -gt 0 ]; then
|
||||
comment="$comment
|
||||
|
||||
### ❌ Failed Tests"
|
||||
|
||||
# Process each browser's failures
|
||||
for counts_json in "${counts_array[@]}"; do
|
||||
[ -z "$counts_json" ] || [ "$counts_json" = "{}" ] && continue
|
||||
|
||||
if command -v jq > /dev/null 2>&1; then
|
||||
# Extract failures array from JSON
|
||||
failures=$(echo "$counts_json" | jq -r '.failures // [] | .[]? | "\(.name)|\(.file)|\(.traceUrl // "")"')
|
||||
|
||||
if [ -n "$failures" ]; then
|
||||
while IFS='|' read -r test_name test_file trace_url; do
|
||||
[ -z "$test_name" ] && continue
|
||||
|
||||
# Convert file path to GitHub URL (relative to repo root)
|
||||
github_file_url="https://github.com/$GITHUB_REPOSITORY/blob/$GITHUB_SHA/$test_file"
|
||||
|
||||
# Build the failed test line
|
||||
test_line="- [$test_name]($github_file_url)"
|
||||
|
||||
if [ -n "$trace_url" ] && [ "$trace_url" != "null" ]; then
|
||||
test_line="$test_line: [View trace]($trace_url)"
|
||||
fi
|
||||
|
||||
comment="$comment
|
||||
$test_line"
|
||||
done <<< "$failures"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Add browser reports in collapsible section
|
||||
comment="$comment
|
||||
|
||||
### 📊 Test Reports by Browser"
|
||||
<details>
|
||||
<summary>📊 Browser Reports</summary>
|
||||
|
||||
"
|
||||
|
||||
# Add browser results with individual counts
|
||||
# Add browser results
|
||||
i=0
|
||||
IFS=' ' read -r -a browser_array <<< "$BROWSERS"
|
||||
IFS=' ' read -r -a url_array <<< "$urls"
|
||||
@@ -349,7 +380,7 @@ $status_icon **$status_text**
|
||||
fi
|
||||
|
||||
if [ -n "$b_total" ] && [ "$b_total" != "0" ]; then
|
||||
counts_str=" • ✅ $b_passed / ❌ $b_failed / ⚠️ $b_flaky / ⏭️ $b_skipped"
|
||||
counts_str=" (✅ $b_passed / ❌ $b_failed / ⚠️ $b_flaky / ⏭️ $b_skipped)"
|
||||
else
|
||||
counts_str=""
|
||||
fi
|
||||
@@ -358,10 +389,10 @@ $status_icon **$status_text**
|
||||
fi
|
||||
|
||||
comment="$comment
|
||||
- ✅ **${browser}**: [View Report](${url})${counts_str}"
|
||||
- **${browser}**: [View Report](${url})${counts_str}"
|
||||
else
|
||||
comment="$comment
|
||||
- ❌ **${browser}**: Deployment failed"
|
||||
- **${browser}**: ❌ Deployment failed"
|
||||
fi
|
||||
i=$((i + 1))
|
||||
done
|
||||
@@ -369,8 +400,7 @@ $status_icon **$status_text**
|
||||
|
||||
comment="$comment
|
||||
|
||||
---
|
||||
🎉 Click on the links above to view detailed test results for each browser configuration."
|
||||
</details>"
|
||||
|
||||
post_comment "$comment"
|
||||
fi
|
||||
|
||||
@@ -1 +1,21 @@
|
||||
@import '@comfyorg/design-system/css/style.css';
|
||||
@import '@comfyorg/design-system/css/style.css';
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
/* List transition animations */
|
||||
.list-scale-move,
|
||||
.list-scale-enter-active,
|
||||
.list-scale-leave-active {
|
||||
transition: opacity 150ms ease, transform 150ms ease;
|
||||
}
|
||||
|
||||
.list-scale-enter-from,
|
||||
.list-scale-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(70%);
|
||||
}
|
||||
|
||||
.list-scale-leave-active {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,8 +59,11 @@
|
||||
{{ queuedCount }}
|
||||
</span>
|
||||
</Button>
|
||||
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
|
||||
<LoginButton v-else-if="isDesktop" />
|
||||
<CurrentUserButton
|
||||
v-if="isLoggedIn && !isIntegratedTabBar"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
|
||||
<Button
|
||||
v-if="!isRightSidePanelOpen"
|
||||
v-tooltip.bottom="rightSidePanelTooltipConfig"
|
||||
@@ -96,6 +99,7 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
@@ -107,6 +111,7 @@ import { useConflictAcknowledgment } from '@/workbench/extensions/manager/compos
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const managerState = useManagerState()
|
||||
@@ -124,6 +129,9 @@ const { shouldShowRedDot: shouldShowConflictRedDot } =
|
||||
useConflictAcknowledgment()
|
||||
const isTopMenuHovered = ref(false)
|
||||
const queuedCount = computed(() => queueStore.pendingTasks.length)
|
||||
const isIntegratedTabBar = computed(
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
|
||||
)
|
||||
const queueHistoryTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="subgraph-breadcrumb w-auto drop-shadow-[var(--interface-panel-drop-shadow)]"
|
||||
class="subgraph-breadcrumb flex w-auto drop-shadow-[var(--interface-panel-drop-shadow)]"
|
||||
:class="{
|
||||
'subgraph-breadcrumb-collapse': collapseTabs,
|
||||
'subgraph-breadcrumb-overflow': overflowingTabs
|
||||
@@ -13,17 +13,37 @@
|
||||
'--p-breadcrumb-icon-width': `${ICON_WIDTH}px`
|
||||
}"
|
||||
>
|
||||
<Button
|
||||
class="context-menu-button pointer-events-auto h-8 w-8 shrink-0 border border-transparent bg-transparent p-0 transition-all hover:rounded-lg hover:border-interface-stroke hover:bg-comfy-menu-bg"
|
||||
icon="pi pi-bars"
|
||||
text
|
||||
severity="secondary"
|
||||
size="small"
|
||||
@click="handleMenuClick"
|
||||
/>
|
||||
<Button
|
||||
v-if="isInSubgraph"
|
||||
class="back-button pointer-events-auto h-8 w-8 shrink-0 border border-transparent bg-transparent p-0 transition-all hover:rounded-lg hover:border-interface-stroke hover:bg-comfy-menu-bg"
|
||||
text
|
||||
severity="secondary"
|
||||
size="small"
|
||||
@click="handleBackClick"
|
||||
>
|
||||
<i class="icon-[lucide--undo-2]" />
|
||||
</Button>
|
||||
<Breadcrumb
|
||||
ref="breadcrumbRef"
|
||||
class="w-fit rounded-lg p-0"
|
||||
:class="{ hidden: !isInSubgraph }"
|
||||
:model="items"
|
||||
:pt="{ item: { class: 'pointer-events-auto' } }"
|
||||
:aria-label="$t('g.graphNavigation')"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<SubgraphBreadcrumbItem
|
||||
:ref="(el) => setItemRef(item, el)"
|
||||
:item="item"
|
||||
:is-active="item === items.at(-1)"
|
||||
:is-active="item.key === activeItemKey"
|
||||
/>
|
||||
</template>
|
||||
<template #separator
|
||||
@@ -35,6 +55,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Breadcrumb from 'primevue/breadcrumb'
|
||||
import Button from 'primevue/button'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { computed, onUpdated, ref, watch } from 'vue'
|
||||
|
||||
@@ -43,6 +64,7 @@ import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
import { forEachSubgraphNode } from '@/utils/graphTraversalUtil'
|
||||
@@ -55,6 +77,12 @@ const ICON_WIDTH = 20
|
||||
const workflowStore = useWorkflowStore()
|
||||
const navigationStore = useSubgraphNavigationStore()
|
||||
const breadcrumbRef = ref<InstanceType<typeof Breadcrumb>>()
|
||||
const rootItemRef = ref<InstanceType<typeof SubgraphBreadcrumbItem>>()
|
||||
const setItemRef = (item: MenuItem, el: unknown) => {
|
||||
if (item.key === 'root') {
|
||||
rootItemRef.value = el as InstanceType<typeof SubgraphBreadcrumbItem>
|
||||
}
|
||||
}
|
||||
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
|
||||
const isBlueprint = computed(() =>
|
||||
useSubgraphStore().isSubgraphBlueprint(workflowStore.activeWorkflow)
|
||||
@@ -62,17 +90,28 @@ const isBlueprint = computed(() =>
|
||||
const collapseTabs = ref(false)
|
||||
const overflowingTabs = ref(false)
|
||||
|
||||
const breadcrumbElement = computed(() => {
|
||||
if (!breadcrumbRef.value) return null
|
||||
const isInSubgraph = computed(() => navigationStore.navigationStack.length > 0)
|
||||
|
||||
const el = (breadcrumbRef.value as unknown as { $el: HTMLElement }).$el
|
||||
const list = el?.querySelector('.p-breadcrumb-list') as HTMLElement
|
||||
return list
|
||||
})
|
||||
const home = computed(() => ({
|
||||
label: workflowName.value,
|
||||
icon: 'pi pi-home',
|
||||
key: 'root',
|
||||
isBlueprint: isBlueprint.value,
|
||||
command: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'breadcrumb_subgraph_root_selected'
|
||||
})
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
|
||||
canvas.setGraph(canvas.graph.rootGraph)
|
||||
}
|
||||
}))
|
||||
|
||||
const items = computed(() => {
|
||||
const items = navigationStore.navigationStack.map<MenuItem>((subgraph) => ({
|
||||
label: subgraph.name,
|
||||
key: `subgraph-${subgraph.id}`,
|
||||
command: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'breadcrumb_subgraph_item_selected'
|
||||
@@ -95,21 +134,26 @@ const items = computed(() => {
|
||||
return [home.value, ...items]
|
||||
})
|
||||
|
||||
const home = computed(() => ({
|
||||
label: workflowName.value,
|
||||
icon: 'pi pi-home',
|
||||
key: 'root',
|
||||
isBlueprint: isBlueprint.value,
|
||||
command: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'breadcrumb_subgraph_root_selected'
|
||||
})
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
const activeItemKey = computed(() => items.value.at(-1)?.key)
|
||||
|
||||
canvas.setGraph(canvas.graph.rootGraph)
|
||||
}
|
||||
}))
|
||||
const handleMenuClick = (event: MouseEvent) => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'breadcrumb_subgraph_menu_selected'
|
||||
})
|
||||
rootItemRef.value?.toggleMenu(event)
|
||||
}
|
||||
|
||||
const handleBackClick = () => {
|
||||
void useCommandStore().execute('Comfy.Graph.ExitSubgraph')
|
||||
}
|
||||
|
||||
const breadcrumbElement = computed(() => {
|
||||
if (!breadcrumbRef.value) return null
|
||||
|
||||
const el = (breadcrumbRef.value as unknown as { $el: HTMLElement }).$el
|
||||
const list = el?.querySelector('.p-breadcrumb-list') as HTMLElement
|
||||
return list
|
||||
})
|
||||
|
||||
// Check for overflow on breadcrumb items and collapse/expand the breadcrumb to fit
|
||||
let overflowObserver: ReturnType<typeof useOverflowObserver> | undefined
|
||||
@@ -189,13 +233,18 @@ onUpdated(() => {
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-item) {
|
||||
@apply flex items-center overflow-hidden;
|
||||
@apply flex items-center overflow-hidden h-8;
|
||||
min-width: calc(var(--p-breadcrumb-item-min-width) + 1rem);
|
||||
border: 1px solid transparent;
|
||||
background-color: transparent;
|
||||
transition: all 0.2s;
|
||||
/* Collapse middle items first */
|
||||
flex-shrink: 10000;
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-separator) {
|
||||
border: 1px solid transparent;
|
||||
background-color: transparent;
|
||||
display: flex;
|
||||
padding: 0 var(--p-breadcrumb-item-margin);
|
||||
}
|
||||
@@ -205,11 +254,9 @@ onUpdated(() => {
|
||||
calc(var(--p-breadcrumb-item-margin) + var(--p-breadcrumb-item-padding));
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-separator),
|
||||
:deep(.p-breadcrumb-item) {
|
||||
@apply h-12;
|
||||
border-top: 1px solid var(--interface-stroke);
|
||||
border-bottom: 1px solid var(--interface-stroke);
|
||||
:deep(.p-breadcrumb-item:hover) {
|
||||
@apply rounded-lg;
|
||||
border-color: var(--interface-stroke);
|
||||
background-color: var(--comfy-menu-bg);
|
||||
}
|
||||
|
||||
@@ -218,10 +265,8 @@ onUpdated(() => {
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-item:first-child) {
|
||||
@apply rounded-l-lg;
|
||||
/* Then collapse the root workflow */
|
||||
flex-shrink: 5000;
|
||||
border-left: 1px solid var(--interface-stroke);
|
||||
|
||||
.p-breadcrumb-item-link {
|
||||
padding-left: var(--p-breadcrumb-item-padding);
|
||||
@@ -229,13 +274,10 @@ onUpdated(() => {
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-item:last-child) {
|
||||
@apply rounded-r-lg;
|
||||
/* Then collapse the active item */
|
||||
flex-shrink: 1;
|
||||
border-right: 1px solid var(--interface-stroke);
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-item-link:hover),
|
||||
:deep(.p-breadcrumb-item-link-menu-visible) {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
}"
|
||||
draggable="false"
|
||||
href="#"
|
||||
class="p-breadcrumb-item-link h-12 cursor-pointer px-2"
|
||||
class="p-breadcrumb-item-link h-8 cursor-pointer px-2"
|
||||
:class="{
|
||||
'flex items-center gap-1': isActive,
|
||||
'p-breadcrumb-item-link-menu-visible': menu?.overlayVisible,
|
||||
@@ -25,7 +25,7 @@
|
||||
<i v-if="isActive" class="pi pi-angle-down text-[10px]"></i>
|
||||
</a>
|
||||
<Menu
|
||||
v-if="isActive"
|
||||
v-if="isActive || isRoot"
|
||||
ref="menu"
|
||||
:model="menuItems"
|
||||
:popup="true"
|
||||
@@ -59,6 +59,7 @@ import Tag from 'primevue/tag'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import {
|
||||
ComfyWorkflow,
|
||||
@@ -135,79 +136,28 @@ const tooltipText = computed(() => {
|
||||
return props.item.label
|
||||
})
|
||||
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
return [
|
||||
{
|
||||
label: t('g.rename'),
|
||||
icon: 'pi pi-pencil',
|
||||
command: startRename
|
||||
},
|
||||
{
|
||||
label: t('breadcrumbsMenu.duplicate'),
|
||||
icon: 'pi pi-copy',
|
||||
command: async () => {
|
||||
await workflowService.duplicateWorkflow(workflowStore.activeWorkflow!)
|
||||
},
|
||||
visible: isRoot && !props.item.isBlueprint
|
||||
},
|
||||
{
|
||||
separator: true,
|
||||
visible: isRoot
|
||||
},
|
||||
{
|
||||
label: t('menuLabels.Save'),
|
||||
icon: 'pi pi-save',
|
||||
command: async () => {
|
||||
await useCommandStore().execute('Comfy.SaveWorkflow')
|
||||
},
|
||||
visible: isRoot
|
||||
},
|
||||
{
|
||||
label: t('menuLabels.Save As'),
|
||||
icon: 'pi pi-save',
|
||||
command: async () => {
|
||||
await useCommandStore().execute('Comfy.SaveWorkflowAs')
|
||||
},
|
||||
visible: isRoot
|
||||
},
|
||||
{
|
||||
separator: true
|
||||
},
|
||||
{
|
||||
label: t('breadcrumbsMenu.clearWorkflow'),
|
||||
icon: 'pi pi-trash',
|
||||
command: async () => {
|
||||
await useCommandStore().execute('Comfy.ClearWorkflow')
|
||||
const startRename = async () => {
|
||||
// Check if element is hidden (collapsed breadcrumb)
|
||||
// When collapsed, root item is hidden via CSS display:none, so use rename command
|
||||
if (isRoot && wrapperRef.value?.offsetParent === null) {
|
||||
await useCommandStore().execute('Comfy.RenameWorkflow')
|
||||
return
|
||||
}
|
||||
|
||||
isEditing.value = true
|
||||
itemLabel.value = props.item.label as string
|
||||
void nextTick(() => {
|
||||
if (itemInputRef.value?.$el) {
|
||||
itemInputRef.value.$el.focus()
|
||||
itemInputRef.value.$el.select()
|
||||
if (wrapperRef.value) {
|
||||
itemInputRef.value.$el.style.width = `${Math.max(200, wrapperRef.value.offsetWidth)}px`
|
||||
}
|
||||
},
|
||||
{
|
||||
separator: true,
|
||||
visible: props.item.key === 'root' && props.item.isBlueprint
|
||||
},
|
||||
{
|
||||
label: t('subgraphStore.publish'),
|
||||
icon: 'pi pi-copy',
|
||||
command: async () => {
|
||||
await workflowService.saveWorkflowAs(workflowStore.activeWorkflow!)
|
||||
},
|
||||
visible: props.item.key === 'root' && props.item.isBlueprint
|
||||
},
|
||||
{
|
||||
separator: true,
|
||||
visible: isRoot
|
||||
},
|
||||
{
|
||||
label: props.item.isBlueprint
|
||||
? t('breadcrumbsMenu.deleteBlueprint')
|
||||
: t('breadcrumbsMenu.deleteWorkflow'),
|
||||
icon: 'pi pi-times',
|
||||
command: async () => {
|
||||
await workflowService.deleteWorkflow(workflowStore.activeWorkflow!)
|
||||
},
|
||||
visible: isRoot
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const { menuItems } = useWorkflowActionsMenu(startRename, { isRoot })
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (isEditing.value) {
|
||||
@@ -228,20 +178,6 @@ const handleClick = (event: MouseEvent) => {
|
||||
}
|
||||
}
|
||||
|
||||
const startRename = () => {
|
||||
isEditing.value = true
|
||||
itemLabel.value = props.item.label as string
|
||||
void nextTick(() => {
|
||||
if (itemInputRef.value?.$el) {
|
||||
itemInputRef.value.$el.focus()
|
||||
itemInputRef.value.$el.select()
|
||||
if (wrapperRef.value) {
|
||||
itemInputRef.value.$el.style.width = `${Math.max(200, wrapperRef.value.offsetWidth)}px`
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const inputBlur = async (doRename: boolean) => {
|
||||
if (doRename) {
|
||||
await rename(itemLabel.value, props.item.label as string)
|
||||
@@ -249,6 +185,14 @@ const inputBlur = async (doRename: boolean) => {
|
||||
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
const toggleMenu = (event: MouseEvent) => {
|
||||
menu.value?.toggle(event)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
toggleMenu
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<template>
|
||||
<div class="relative inline-flex items-center">
|
||||
<Button size="icon" variant="secondary" @click="popover?.toggle">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
v-bind="$attrs"
|
||||
@click="popover?.toggle"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
@@ -60,6 +65,10 @@ import { ref } from 'vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
interface MoreButtonProps {
|
||||
isVertical?: boolean
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="grid place-items-center">
|
||||
<div
|
||||
class="col-start-1 row-start-1 h-12 w-12 animate-spin rounded-full border-4 border-muted-foreground border-t-base-foreground"
|
||||
:class="[
|
||||
'col-start-1 row-start-1 animate-spin rounded-full border-muted-foreground border-t-base-foreground',
|
||||
spinnerSizeClass
|
||||
]"
|
||||
/>
|
||||
<div class="col-start-1 row-start-1">
|
||||
<slot />
|
||||
@@ -22,8 +25,21 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { size = 'md' } = defineProps<{
|
||||
loading: boolean
|
||||
loadingMessage?: string
|
||||
size?: 'sm' | 'md'
|
||||
}>()
|
||||
|
||||
const spinnerSizeClass = computed(() => {
|
||||
switch (size) {
|
||||
case 'sm':
|
||||
return 'h-6 w-6 border-2'
|
||||
case 'md':
|
||||
default:
|
||||
return 'h-12 w-12 border-4'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
65
src/components/common/OverlayIcon.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<span class="relative inline-flex items-center justify-center size-[1em]">
|
||||
<i :class="mainIcon" class="text-[1em]" />
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
subIcon,
|
||||
'absolute leading-none pointer-events-none',
|
||||
positionX === 'left' ? 'left-0' : 'right-0',
|
||||
positionY === 'top' ? 'top-0' : 'bottom-0'
|
||||
)
|
||||
"
|
||||
:style="subIconStyle"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
type Position = 'top' | 'bottom' | 'left' | 'right'
|
||||
|
||||
export interface OverlayIconProps {
|
||||
mainIcon: string
|
||||
subIcon: string
|
||||
positionX?: Position
|
||||
positionY?: Position
|
||||
offsetX?: number
|
||||
offsetY?: number
|
||||
subIconScale?: number
|
||||
}
|
||||
const {
|
||||
mainIcon,
|
||||
subIcon,
|
||||
positionX = 'right',
|
||||
positionY = 'bottom',
|
||||
offsetX = 0,
|
||||
offsetY = 0,
|
||||
subIconScale = 0.6
|
||||
} = defineProps<OverlayIconProps>()
|
||||
|
||||
const textShadow = [
|
||||
`-1px -1px 0 rgba(0, 0, 0, 0.7)`,
|
||||
`1px -1px 0 rgba(0, 0, 0, 0.7)`,
|
||||
`-1px 1px 0 rgba(0, 0, 0, 0.7)`,
|
||||
`1px 1px 0 rgba(0, 0, 0, 0.7)`,
|
||||
`-1px 0 0 rgba(0, 0, 0, 0.7)`,
|
||||
`1px 0 0 rgba(0, 0, 0, 0.7)`,
|
||||
`0 -1px 0 rgba(0, 0, 0, 0.7)`,
|
||||
`0 1px 0 rgba(0, 0, 0, 0.7)`
|
||||
].join(', ')
|
||||
|
||||
const subIconStyle = computed(() => ({
|
||||
fontSize: `${subIconScale}em`,
|
||||
textShadow,
|
||||
...(offsetX !== 0 && {
|
||||
[positionX === 'left' ? 'left' : 'right']: `${offsetX}px`
|
||||
}),
|
||||
...(offsetY !== 0 && {
|
||||
[positionY === 'top' ? 'top' : 'bottom']: `${offsetY}px`
|
||||
})
|
||||
}))
|
||||
</script>
|
||||
@@ -563,7 +563,8 @@ const {
|
||||
availableRunsOn,
|
||||
filteredCount,
|
||||
totalCount,
|
||||
resetFilters
|
||||
resetFilters,
|
||||
loadFuseOptions
|
||||
} = useTemplateFiltering(navigationFilteredTemplates)
|
||||
|
||||
/**
|
||||
@@ -815,10 +816,10 @@ const pageTitle = computed(() => {
|
||||
// Initialize templates loading with useAsyncState
|
||||
const { isLoading } = useAsyncState(
|
||||
async () => {
|
||||
// Run all operations in parallel for better performance
|
||||
await Promise.all([
|
||||
loadTemplates(),
|
||||
workflowTemplatesStore.loadWorkflowTemplates()
|
||||
workflowTemplatesStore.loadWorkflowTemplates(),
|
||||
loadFuseOptions()
|
||||
])
|
||||
return true
|
||||
},
|
||||
|
||||
@@ -158,6 +158,7 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
@@ -175,6 +176,7 @@ const dialogService = useDialogService()
|
||||
const telemetry = useTelemetry()
|
||||
const toast = useToast()
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
const { isSubscriptionEnabled } = useSubscription()
|
||||
|
||||
// Constants
|
||||
const PRESET_AMOUNTS = [10, 25, 50, 100]
|
||||
@@ -252,9 +254,11 @@ async function handleBuy() {
|
||||
telemetry?.trackApiCreditTopupButtonPurchaseClicked(payAmount.value)
|
||||
await authActions.purchaseCredits(payAmount.value)
|
||||
|
||||
// Close top-up dialog (keep tracking) and open subscription panel to show updated credits
|
||||
// Close top-up dialog (keep tracking) and open credits panel to show updated balance
|
||||
handleClose(false)
|
||||
dialogService.showSettingsDialog('subscription')
|
||||
dialogService.showSettingsDialog(
|
||||
isSubscriptionEnabled() ? 'subscription' : 'credits'
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Purchase failed:', error)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
pt:text="w-full"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div data-testid="current-user-indicator">
|
||||
{{ $t('g.currentUser') }}: {{ userStore.currentUser?.username }}
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -220,6 +220,12 @@ function show(event: MouseEvent) {
|
||||
y: screenY / scale - offset[1]
|
||||
}
|
||||
|
||||
// Initialize last* values to current transform to prevent updateMenuPosition
|
||||
// from overwriting PrimeVue's flip-adjusted position on the first RAF tick
|
||||
lastScale = scale
|
||||
lastOffsetX = offset[0]
|
||||
lastOffsetY = offset[1]
|
||||
|
||||
isOpen.value = true
|
||||
contextMenu.value?.show(event)
|
||||
}
|
||||
|
||||
134
src/components/helpcenter/HelpCenterPopups.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<!-- Help Center Popup positioned within canvas area -->
|
||||
<Teleport to="#graph-canvas-container">
|
||||
<div
|
||||
v-if="isHelpCenterVisible"
|
||||
class="help-center-popup"
|
||||
:class="{
|
||||
'sidebar-left':
|
||||
triggerLocation === 'sidebar' && sidebarLocation === 'left',
|
||||
'sidebar-right':
|
||||
triggerLocation === 'sidebar' && sidebarLocation === 'right',
|
||||
'topbar-right': triggerLocation === 'topbar',
|
||||
'small-sidebar': isSmall
|
||||
}"
|
||||
>
|
||||
<HelpCenterMenuContent @close="closeHelpCenter" />
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Release Notification Toast positioned within canvas area -->
|
||||
<Teleport to="#graph-canvas-container">
|
||||
<ReleaseNotificationToast
|
||||
:class="{
|
||||
'sidebar-left': sidebarLocation === 'left',
|
||||
'sidebar-right': sidebarLocation === 'right',
|
||||
'small-sidebar': isSmall
|
||||
}"
|
||||
/>
|
||||
</Teleport>
|
||||
|
||||
<!-- WhatsNew Popup positioned within canvas area -->
|
||||
<Teleport to="#graph-canvas-container">
|
||||
<WhatsNewPopup
|
||||
:class="{
|
||||
'sidebar-left': sidebarLocation === 'left',
|
||||
'sidebar-right': sidebarLocation === 'right',
|
||||
'small-sidebar': isSmall
|
||||
}"
|
||||
@whats-new-dismissed="handleWhatsNewDismissed"
|
||||
/>
|
||||
</Teleport>
|
||||
|
||||
<!-- Backdrop to close popup when clicking outside -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isHelpCenterVisible"
|
||||
class="help-center-backdrop"
|
||||
@click="closeHelpCenter"
|
||||
/>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useHelpCenter } from '@/composables/useHelpCenter'
|
||||
import ReleaseNotificationToast from '@/platform/updates/components/ReleaseNotificationToast.vue'
|
||||
import WhatsNewPopup from '@/platform/updates/components/WhatsNewPopup.vue'
|
||||
|
||||
import HelpCenterMenuContent from './HelpCenterMenuContent.vue'
|
||||
|
||||
const { isSmall = false } = defineProps<{
|
||||
isSmall?: boolean
|
||||
}>()
|
||||
|
||||
const {
|
||||
isHelpCenterVisible,
|
||||
triggerLocation,
|
||||
sidebarLocation,
|
||||
closeHelpCenter,
|
||||
handleWhatsNewDismissed
|
||||
} = useHelpCenter()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.help-center-backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 9999;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.help-center-popup {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
z-index: 10000;
|
||||
animation: slideInUp 0.2s ease-out;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.help-center-popup.sidebar-left {
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.help-center-popup.sidebar-left.small-sidebar {
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.help-center-popup.sidebar-right {
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
.help-center-popup.topbar-right {
|
||||
top: 2rem;
|
||||
right: 1rem;
|
||||
bottom: auto;
|
||||
animation: slideInDown 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideInDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -4,6 +4,7 @@
|
||||
class="maskEditor-dialog-root flex h-full w-full flex-col"
|
||||
@contextmenu.prevent
|
||||
@dragstart="handleDragStart"
|
||||
@keydown.stop
|
||||
>
|
||||
<div
|
||||
id="maskEditorCanvasContainer"
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
|
||||
class="h-6.25 w-6.25 pointer-events-none fill-current"
|
||||
>
|
||||
<path
|
||||
d="M8.77,12.18c-.25,0-.46-.2-.46-.46s.2-.46.46-.46c1.47,0,2.67-1.2,2.67-2.67,0-1.57-1.34-2.67-3.26-2.67h-3.98l1.43,1.43c.18.18.18.47,0,.64-.18.18-.47.18-.64,0l-2.21-2.21c-.18-.18-.18-.47,0-.64l2.21-2.21c.18-.18.47-.18.64,0,.18.18.18.47,0,.64l-1.43,1.43h3.98c2.45,0,4.17,1.47,4.17,3.58,0,1.97-1.61,3.58-3.58,3.58Z"
|
||||
@@ -35,6 +35,74 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="h-5 border-l border-border" />
|
||||
|
||||
<button
|
||||
:class="iconButtonClass"
|
||||
:title="t('maskEditor.rotateLeft')"
|
||||
@click="onRotateLeft"
|
||||
>
|
||||
<svg
|
||||
viewBox="-6 -7 15 15"
|
||||
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
|
||||
>
|
||||
<path
|
||||
d="m2.25-2.625c.3452 0 .625.2798.625.625v5c0 .3452-.2798.625-.625.625h-5c-.3452 0-.625-.2798-.625-.625v-5c0-.3452.2798-.625.625-.625h5zm1.25.625v5c0 .6904-.5596 1.25-1.25 1.25h-5c-.6904 0-1.25-.5596-1.25-1.25v-5c0-.6904.5596-1.25 1.25-1.25h5c.6904 0 1.25.5596 1.25 1.25zm-.1673-2.3757-.4419.4419-1.5246-1.5246 1.5416-1.5417.442.4419-.7871.7872h.9373c1.3807 0 2.5 1.1193 2.5 2.5h-.625c0-1.0355-.8395-1.875-1.875-1.875h-.9375l.7702.7702z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
:class="iconButtonClass"
|
||||
:title="t('maskEditor.rotateRight')"
|
||||
@click="onRotateRight"
|
||||
>
|
||||
<svg
|
||||
viewBox="-9 -7 15 15"
|
||||
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
|
||||
>
|
||||
<g transform="scale(-1, 1)">
|
||||
<path
|
||||
d="m2.25-2.625c.3452 0 .625.2798.625.625v5c0 .3452-.2798.625-.625.625h-5c-.3452 0-.625-.2798-.625-.625v-5c0-.3452.2798-.625.625-.625h5zm1.25.625v5c0 .6904-.5596 1.25-1.25 1.25h-5c-.6904 0-1.25-.5596-1.25-1.25v-5c0-.6904.5596-1.25 1.25-1.25h5c.6904 0 1.25.5596 1.25 1.25zm-.1673-2.3757-.4419.4419-1.5246-1.5246 1.5416-1.5417.442.4419-.7871.7872h.9373c1.3807 0 2.5 1.1193 2.5 2.5h-.625c0-1.0355-.8395-1.875-1.875-1.875h-.9375l.7702.7702z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
:class="iconButtonClass"
|
||||
:title="t('maskEditor.mirrorHorizontal')"
|
||||
@click="onMirrorHorizontal"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
|
||||
>
|
||||
<path
|
||||
d="M7.5,1.5c-.28,0-.5.22-.5.5v11c0,.28.22.5.5.5s.5-.22.5-.5v-11c0-.28-.22-.5-.5-.5Z"
|
||||
/>
|
||||
<path d="M3.5,4.5l-2,3,2,3v-6ZM11.5,4.5v6l2-3-2-3Z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
:class="iconButtonClass"
|
||||
:title="t('maskEditor.mirrorVertical')"
|
||||
@click="onMirrorVertical"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
|
||||
>
|
||||
<path
|
||||
d="M2,7.5c0-.28.22-.5.5-.5h11c.28,0,.5.22.5.5s-.22.5-.5.5h-11c-.28,0-.5-.22-.5-.5Z"
|
||||
/>
|
||||
<path d="M4.5,4.5l3-2,3,2h-6ZM4.5,10.5h6l-3,2-3-2Z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="h-5 w-px bg-[var(--p-form-field-border-color)]" />
|
||||
|
||||
<button :class="textButtonClass" @click="onInvert">
|
||||
{{ t('maskEditor.invert') }}
|
||||
</button>
|
||||
@@ -63,6 +131,7 @@ import { ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCanvasTools } from '@/composables/maskeditor/useCanvasTools'
|
||||
import { useCanvasTransform } from '@/composables/maskeditor/useCanvasTransform'
|
||||
import { useMaskEditorSaver } from '@/composables/maskeditor/useMaskEditorSaver'
|
||||
import { t } from '@/i18n'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
@@ -71,16 +140,17 @@ import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
const store = useMaskEditorStore()
|
||||
const dialogStore = useDialogStore()
|
||||
const canvasTools = useCanvasTools()
|
||||
const canvasTransform = useCanvasTransform()
|
||||
const saver = useMaskEditorSaver()
|
||||
|
||||
const saveButtonText = ref(t('g.save'))
|
||||
const saveEnabled = ref(true)
|
||||
|
||||
const iconButtonClass =
|
||||
'flex h-7.5 w-12.5 items-center justify-center rounded-[10px] border border-[var(--p-form-field-border-color)] pointer-events-auto transition-colors duration-100 bg-[var(--comfy-menu-bg)] hover:bg-secondary-background-hover'
|
||||
'flex h-7.5 w-12.5 items-center justify-center rounded-[10px] border border-border-default pointer-events-auto transition-colors duration-100 bg-comfy-menu-bg hover:bg-secondary-background-hover'
|
||||
|
||||
const textButtonClass =
|
||||
'h-7.5 w-15 rounded-[10px] border border-[var(--p-form-field-border-color)] text-[var(--input-text)] font-sans pointer-events-auto transition-colors duration-100 bg-[var(--comfy-menu-bg)] hover:bg-secondary-background-hover'
|
||||
'h-7.5 w-15 rounded-[10px] border border-border-default text-current font-sans pointer-events-auto transition-colors duration-100 bg-comfy-menu-bg hover:bg-secondary-background-hover'
|
||||
|
||||
const onUndo = () => {
|
||||
store.canvasHistory.undo()
|
||||
@@ -90,6 +160,38 @@ const onRedo = () => {
|
||||
store.canvasHistory.redo()
|
||||
}
|
||||
|
||||
const onRotateLeft = async () => {
|
||||
try {
|
||||
await canvasTransform.rotateCounterclockwise()
|
||||
} catch (error) {
|
||||
console.error('[TopBarHeader] Rotate left failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const onRotateRight = async () => {
|
||||
try {
|
||||
await canvasTransform.rotateClockwise()
|
||||
} catch (error) {
|
||||
console.error('[TopBarHeader] Rotate right failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const onMirrorHorizontal = async () => {
|
||||
try {
|
||||
await canvasTransform.mirrorHorizontal()
|
||||
} catch (error) {
|
||||
console.error('[TopBarHeader] Mirror horizontal failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const onMirrorVertical = async () => {
|
||||
try {
|
||||
await canvasTransform.mirrorVertical()
|
||||
} catch (error) {
|
||||
console.error('[TopBarHeader] Mirror vertical failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const onInvert = () => {
|
||||
canvasTools.invertMask()
|
||||
}
|
||||
|
||||
19
src/components/primevueOverride/SelectPlus.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script>
|
||||
import Select from 'primevue/select'
|
||||
|
||||
export default {
|
||||
name: 'SelectPlus',
|
||||
extends: Select,
|
||||
emits: ['hide'],
|
||||
methods: {
|
||||
onOverlayLeave() {
|
||||
this.unbindOutsideClickListener()
|
||||
this.unbindScrollListener()
|
||||
this.unbindResizeListener()
|
||||
|
||||
this.$emit('hide')
|
||||
this.overlay = null
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -4,6 +4,7 @@ import { defineComponent, nextTick } from 'vue'
|
||||
|
||||
import JobGroupsList from '@/components/queue/job/JobGroupsList.vue'
|
||||
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
|
||||
const QueueJobItemStub = defineComponent({
|
||||
name: 'QueueJobItemStub',
|
||||
@@ -25,20 +26,25 @@ const QueueJobItemStub = defineComponent({
|
||||
template: '<div class="queue-job-item-stub"></div>'
|
||||
})
|
||||
|
||||
const createJobItem = (overrides: Partial<JobListItem> = {}): JobListItem => ({
|
||||
id: 'job-id',
|
||||
title: 'Example job',
|
||||
meta: 'Meta text',
|
||||
state: 'running',
|
||||
iconName: 'icon',
|
||||
iconImageUrl: 'https://example.com/icon.png',
|
||||
showClear: true,
|
||||
taskRef: { workflow: { id: 'workflow-id' } },
|
||||
progressTotalPercent: 60,
|
||||
progressCurrentPercent: 30,
|
||||
runningNodeName: 'Node A',
|
||||
...overrides
|
||||
})
|
||||
const createJobItem = (overrides: Partial<JobListItem> = {}): JobListItem => {
|
||||
const { taskRef, ...rest } = overrides
|
||||
return {
|
||||
id: 'job-id',
|
||||
title: 'Example job',
|
||||
meta: 'Meta text',
|
||||
state: 'running',
|
||||
iconName: 'icon',
|
||||
iconImageUrl: 'https://example.com/icon.png',
|
||||
showClear: true,
|
||||
taskRef: (taskRef ?? {
|
||||
workflow: { id: 'workflow-id' }
|
||||
}) as TaskItemImpl,
|
||||
progressTotalPercent: 60,
|
||||
progressCurrentPercent: 30,
|
||||
runningNodeName: 'Node A',
|
||||
...rest
|
||||
}
|
||||
}
|
||||
|
||||
const mountComponent = (groups: JobGroup[]) =>
|
||||
mount(JobGroupsList, {
|
||||
|
||||
@@ -50,20 +50,22 @@
|
||||
<div
|
||||
v-if="
|
||||
props.state === 'running' &&
|
||||
(props.progressTotalPercent !== undefined ||
|
||||
props.progressCurrentPercent !== undefined)
|
||||
hasAnyProgressPercent(
|
||||
props.progressTotalPercent,
|
||||
props.progressCurrentPercent
|
||||
)
|
||||
"
|
||||
class="absolute inset-0"
|
||||
:class="progressBarContainerClass"
|
||||
>
|
||||
<div
|
||||
v-if="props.progressTotalPercent !== undefined"
|
||||
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-primary transition-[width]"
|
||||
:style="{ width: `${props.progressTotalPercent}%` }"
|
||||
v-if="hasProgressPercent(props.progressTotalPercent)"
|
||||
:class="progressBarPrimaryClass"
|
||||
:style="progressPercentStyle(props.progressTotalPercent)"
|
||||
/>
|
||||
<div
|
||||
v-if="props.progressCurrentPercent !== undefined"
|
||||
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-secondary transition-[width]"
|
||||
:style="{ width: `${props.progressCurrentPercent}%` }"
|
||||
v-if="hasProgressPercent(props.progressCurrentPercent)"
|
||||
:class="progressBarSecondaryClass"
|
||||
:style="progressPercentStyle(props.progressCurrentPercent)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -201,6 +203,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
|
||||
import QueueAssetPreview from '@/components/queue/job/QueueAssetPreview.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useProgressBarBackground } from '@/composables/useProgressBarBackground'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import type { JobState } from '@/types/queue'
|
||||
import { iconForJobState } from '@/utils/queueDisplay'
|
||||
@@ -245,6 +248,14 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
progressBarContainerClass,
|
||||
progressBarPrimaryClass,
|
||||
progressBarSecondaryClass,
|
||||
hasProgressPercent,
|
||||
hasAnyProgressPercent,
|
||||
progressPercentStyle
|
||||
} = useProgressBarBackground()
|
||||
|
||||
const cancelTooltipConfig = computed(() => buildTooltipConfig(t('g.cancel')))
|
||||
const deleteTooltipConfig = computed(() => buildTooltipConfig(t('g.delete')))
|
||||
|
||||
@@ -1,32 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, ref, toValue, watchEffect } from 'vue'
|
||||
import { computed, provide, ref, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import EditableText from '@/components/common/EditableText.vue'
|
||||
import Tab from '@/components/tab/Tab.vue'
|
||||
import TabList from '@/components/tab/TabList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useGraphHierarchy } from '@/composables/graph/useGraphHierarchy'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import TabInfo from './info/TabInfo.vue'
|
||||
import TabParameters from './parameters/TabParameters.vue'
|
||||
import TabGlobalParameters from './parameters/TabGlobalParameters.vue'
|
||||
import TabNodes from './parameters/TabNodes.vue'
|
||||
import TabNormalInputs from './parameters/TabNormalInputs.vue'
|
||||
import TabSubgraphInputs from './parameters/TabSubgraphInputs.vue'
|
||||
import TabGlobalSettings from './settings/TabGlobalSettings.vue'
|
||||
import TabSettings from './settings/TabSettings.vue'
|
||||
import {
|
||||
GetNodeParentGroupKey,
|
||||
useFlatAndCategorizeSelectedItems
|
||||
} from './shared'
|
||||
import SubgraphEditor from './subgraph/SubgraphEditor.vue'
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const settingStore = useSettingStore()
|
||||
const { t } = useI18n()
|
||||
const { findParentGroup } = useGraphHierarchy()
|
||||
|
||||
const { selectedItems } = storeToRefs(canvasStore)
|
||||
const { selectedItems: directlySelectedItems } = storeToRefs(canvasStore)
|
||||
const { activeTab, isEditingSubgraph } = storeToRefs(rightSidePanelStore)
|
||||
|
||||
const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
@@ -40,29 +49,31 @@ const panelIcon = computed(() =>
|
||||
: 'icon-[lucide--panel-right]'
|
||||
)
|
||||
|
||||
const hasSelection = computed(() => selectedItems.value.length > 0)
|
||||
const { flattedItems, selectedNodes, selectedGroups, nodeToParentGroup } =
|
||||
useFlatAndCategorizeSelectedItems(directlySelectedItems)
|
||||
|
||||
const selectedNodes = computed((): LGraphNode[] => {
|
||||
return selectedItems.value.filter(isLGraphNode)
|
||||
const shouldShowGroupNames = computed(() => {
|
||||
return !(
|
||||
directlySelectedItems.value.length === 1 &&
|
||||
(selectedGroups.value.length === 1 || selectedNodes.value.length === 1)
|
||||
)
|
||||
})
|
||||
|
||||
const isSubgraphNode = computed(() => {
|
||||
return selectedNode.value instanceof SubgraphNode
|
||||
provide(GetNodeParentGroupKey, (node: LGraphNode) => {
|
||||
if (!shouldShowGroupNames.value) return null
|
||||
return nodeToParentGroup.value.get(node) ?? findParentGroup(node)
|
||||
})
|
||||
|
||||
const isSingleNodeSelected = computed(() => selectedNodes.value.length === 1)
|
||||
const hasSelection = computed(() => flattedItems.value.length > 0)
|
||||
|
||||
const selectedNode = computed(() => {
|
||||
return isSingleNodeSelected.value ? selectedNodes.value[0] : null
|
||||
const selectedSingleNode = computed(() => {
|
||||
return selectedNodes.value.length === 1 && flattedItems.value.length === 1
|
||||
? selectedNodes.value[0]
|
||||
: null
|
||||
})
|
||||
|
||||
const selectionCount = computed(() => selectedItems.value.length)
|
||||
|
||||
const panelTitle = computed(() => {
|
||||
if (isSingleNodeSelected.value && selectedNode.value) {
|
||||
return selectedNode.value.title || selectedNode.value.type || 'Node'
|
||||
}
|
||||
return t('rightSidePanel.title', { count: selectionCount.value })
|
||||
const isSingleSubgraphNode = computed(() => {
|
||||
return selectedSingleNode.value instanceof SubgraphNode
|
||||
})
|
||||
|
||||
function closePanel() {
|
||||
@@ -75,25 +86,40 @@ type RightSidePanelTabList = Array<{
|
||||
}>
|
||||
|
||||
const tabs = computed<RightSidePanelTabList>(() => {
|
||||
const list: RightSidePanelTabList = [
|
||||
{
|
||||
label: () => t('rightSidePanel.parameters'),
|
||||
value: 'parameters'
|
||||
},
|
||||
{
|
||||
label: () => t('g.settings'),
|
||||
value: 'settings'
|
||||
}
|
||||
]
|
||||
if (
|
||||
!hasSelection.value ||
|
||||
(isSingleNodeSelected.value && !isSubgraphNode.value)
|
||||
) {
|
||||
const list: RightSidePanelTabList = []
|
||||
|
||||
list.push({
|
||||
label: () =>
|
||||
flattedItems.value.length > 1
|
||||
? t('rightSidePanel.nodes')
|
||||
: t('rightSidePanel.parameters'),
|
||||
value: 'parameters'
|
||||
})
|
||||
|
||||
if (!hasSelection.value) {
|
||||
list.push({
|
||||
label: () => t('rightSidePanel.info'),
|
||||
value: 'info'
|
||||
label: () => t('rightSidePanel.nodes'),
|
||||
value: 'nodes'
|
||||
})
|
||||
}
|
||||
|
||||
if (hasSelection.value) {
|
||||
if (selectedSingleNode.value && !isSingleSubgraphNode.value) {
|
||||
list.push({
|
||||
label: () => t('rightSidePanel.info'),
|
||||
value: 'info'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
list.push({
|
||||
label: () =>
|
||||
hasSelection.value
|
||||
? t('g.settings')
|
||||
: t('rightSidePanel.globalSettings.title'),
|
||||
value: 'settings'
|
||||
})
|
||||
|
||||
return list
|
||||
})
|
||||
|
||||
@@ -101,27 +127,59 @@ const tabs = computed<RightSidePanelTabList>(() => {
|
||||
watchEffect(() => {
|
||||
if (
|
||||
!tabs.value.some((tab) => tab.value === activeTab.value) &&
|
||||
!(activeTab.value === 'subgraph' && isSubgraphNode.value)
|
||||
!(activeTab.value === 'subgraph' && isSingleSubgraphNode.value)
|
||||
) {
|
||||
rightSidePanelStore.openPanel(tabs.value[0].value)
|
||||
}
|
||||
})
|
||||
|
||||
function resolveTitle() {
|
||||
const items = flattedItems.value
|
||||
const nodes = selectedNodes.value
|
||||
const groups = selectedGroups.value
|
||||
|
||||
if (items.length === 0) {
|
||||
return t('rightSidePanel.workflowOverview')
|
||||
}
|
||||
if (directlySelectedItems.value.length === 1) {
|
||||
if (groups.length === 1) {
|
||||
return groups[0].title || t('rightSidePanel.fallbackGroupTitle')
|
||||
}
|
||||
if (nodes.length === 1) {
|
||||
return (
|
||||
nodes[0].title || nodes[0].type || t('rightSidePanel.fallbackNodeTitle')
|
||||
)
|
||||
}
|
||||
}
|
||||
return t('rightSidePanel.title', { count: items.length })
|
||||
}
|
||||
|
||||
const panelTitle = ref(resolveTitle())
|
||||
watchEffect(() => (panelTitle.value = resolveTitle()))
|
||||
|
||||
const isEditing = ref(false)
|
||||
|
||||
const allowTitleEdit = computed(() => {
|
||||
return (
|
||||
directlySelectedItems.value.length === 1 &&
|
||||
(selectedGroups.value.length === 1 || selectedNodes.value.length === 1)
|
||||
)
|
||||
})
|
||||
|
||||
function handleTitleEdit(newTitle: string) {
|
||||
isEditing.value = false
|
||||
|
||||
const trimmedTitle = newTitle.trim()
|
||||
if (!trimmedTitle) return
|
||||
|
||||
const node = toValue(selectedNode)
|
||||
const node = selectedGroups.value[0] || selectedNodes.value[0]
|
||||
if (!node) return
|
||||
|
||||
if (trimmedTitle === node.title) return
|
||||
|
||||
node.title = trimmedTitle
|
||||
canvasStore.canvas?.setDirty(true, false)
|
||||
panelTitle.value = trimmedTitle
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
function handleTitleCancel() {
|
||||
@@ -132,21 +190,28 @@ function handleTitleCancel() {
|
||||
<template>
|
||||
<div
|
||||
data-testid="properties-panel"
|
||||
class="flex size-full flex-col bg-interface-panel-surface"
|
||||
class="flex size-full flex-col bg-comfy-menu-bg"
|
||||
>
|
||||
<!-- Panel Header -->
|
||||
<section class="pt-1">
|
||||
<div class="flex items-center justify-between pl-4 pr-3">
|
||||
<h3 class="my-3.5 text-sm font-semibold line-clamp-2">
|
||||
<EditableText
|
||||
v-if="isSingleNodeSelected"
|
||||
:model-value="panelTitle"
|
||||
:is-editing="isEditing"
|
||||
:input-attrs="{ 'data-testid': 'node-title-input' }"
|
||||
@edit="handleTitleEdit"
|
||||
@cancel="handleTitleCancel"
|
||||
@dblclick="isEditing = true"
|
||||
/>
|
||||
<h3 class="my-3.5 text-sm font-semibold line-clamp-2 cursor-default">
|
||||
<template v-if="allowTitleEdit">
|
||||
<EditableText
|
||||
:model-value="panelTitle"
|
||||
:is-editing="isEditing"
|
||||
:input-attrs="{ 'data-testid': 'node-title-input' }"
|
||||
class="cursor-text"
|
||||
@edit="handleTitleEdit"
|
||||
@cancel="handleTitleCancel"
|
||||
@click="isEditing = true"
|
||||
/>
|
||||
<i
|
||||
v-if="!isEditing"
|
||||
class="icon-[lucide--pencil] size-4 text-muted-foreground ml-2 content-center relative top-[2px] hover:text-base-foreground cursor-pointer shrink-0"
|
||||
@click="isEditing = true"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ panelTitle }}
|
||||
</template>
|
||||
@@ -154,7 +219,7 @@ function handleTitleCancel() {
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
v-if="isSubgraphNode"
|
||||
v-if="isSingleSubgraphNode"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:class="cn(isEditingSubgraph && 'bg-secondary-background-selected')"
|
||||
@@ -177,7 +242,7 @@ function handleTitleCancel() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<nav v-if="hasSelection" class="px-4 pb-2 pt-1">
|
||||
<nav class="px-4 pb-2 pt-1 overflow-x-auto">
|
||||
<TabList
|
||||
:model-value="activeTab"
|
||||
@update:model-value="
|
||||
@@ -189,7 +254,7 @@ function handleTitleCancel() {
|
||||
<Tab
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
class="text-sm py-1 px-2 font-inter"
|
||||
class="text-sm py-1 px-2 font-inter transition-all active:scale-95"
|
||||
:value="tab.value"
|
||||
>
|
||||
{{ tab.label() }}
|
||||
@@ -200,25 +265,29 @@ function handleTitleCancel() {
|
||||
|
||||
<!-- Panel Content -->
|
||||
<div class="scrollbar-thin flex-1 overflow-y-auto">
|
||||
<div
|
||||
v-if="!hasSelection"
|
||||
class="flex size-full p-4 items-start justify-start text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('rightSidePanel.noSelection') }}
|
||||
</div>
|
||||
<template v-if="!hasSelection">
|
||||
<TabGlobalParameters v-if="activeTab === 'parameters'" />
|
||||
<TabNodes v-else-if="activeTab === 'nodes'" />
|
||||
<TabGlobalSettings v-else-if="activeTab === 'settings'" />
|
||||
</template>
|
||||
<SubgraphEditor
|
||||
v-else-if="isSubgraphNode && isEditingSubgraph"
|
||||
:node="selectedNode"
|
||||
v-else-if="isSingleSubgraphNode && isEditingSubgraph"
|
||||
:node="selectedSingleNode"
|
||||
/>
|
||||
<template v-else>
|
||||
<TabParameters
|
||||
v-if="activeTab === 'parameters'"
|
||||
<TabSubgraphInputs
|
||||
v-if="activeTab === 'parameters' && isSingleSubgraphNode"
|
||||
:node="selectedSingleNode as SubgraphNode"
|
||||
/>
|
||||
<TabNormalInputs
|
||||
v-else-if="activeTab === 'parameters'"
|
||||
:nodes="selectedNodes"
|
||||
:must-show-node-title="selectedGroups.length > 0"
|
||||
/>
|
||||
<TabInfo v-else-if="activeTab === 'info'" :nodes="selectedNodes" />
|
||||
<TabSettings
|
||||
v-else-if="activeTab === 'settings'"
|
||||
:nodes="selectedNodes"
|
||||
:nodes="flattedItems"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -1,54 +1,70 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineProps<{
|
||||
isEmpty?: boolean
|
||||
import TransitionCollapse from './TransitionCollapse.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
disabled?: boolean
|
||||
label?: string
|
||||
enableEmptyState?: boolean
|
||||
tooltip?: string
|
||||
}>()
|
||||
|
||||
const isCollapse = defineModel<boolean>('collapse', { default: false })
|
||||
|
||||
const isExpanded = computed(() => !isCollapse.value && !props.disabled)
|
||||
|
||||
const tooltipConfig = computed(() => {
|
||||
if (!props.tooltip) return undefined
|
||||
return { value: props.tooltip, showDelay: 1000 }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col bg-interface-panel-surface">
|
||||
<div class="flex flex-col bg-comfy-menu-bg">
|
||||
<div
|
||||
class="sticky top-0 z-10 flex items-center justify-between backdrop-blur-xl bg-inherit"
|
||||
>
|
||||
<button
|
||||
v-tooltip="
|
||||
isEmpty
|
||||
? {
|
||||
value: $t('rightSidePanel.inputsNoneTooltip'),
|
||||
showDelay: 1_000
|
||||
}
|
||||
: undefined
|
||||
"
|
||||
v-tooltip="tooltipConfig"
|
||||
type="button"
|
||||
:class="
|
||||
cn(
|
||||
'group min-h-12 bg-transparent border-0 outline-0 ring-0 w-full text-left flex items-center justify-between pl-4 pr-3',
|
||||
!isEmpty && 'cursor-pointer'
|
||||
!disabled && 'cursor-pointer'
|
||||
)
|
||||
"
|
||||
:disabled="isEmpty"
|
||||
:disabled="disabled"
|
||||
@click="isCollapse = !isCollapse"
|
||||
>
|
||||
<span class="text-sm font-semibold line-clamp-2">
|
||||
<slot name="label" />
|
||||
<span class="text-sm font-semibold line-clamp-2 flex-1">
|
||||
<slot name="label">
|
||||
{{ label }}
|
||||
</slot>
|
||||
</span>
|
||||
|
||||
<i
|
||||
v-if="!isEmpty"
|
||||
:class="
|
||||
cn(
|
||||
'text-muted-foreground group-hover:text-base-foreground group-focus:text-base-foreground icon-[lucide--chevron-up] size-4 transition-all',
|
||||
isCollapse && '-rotate-180'
|
||||
'text-muted-foreground group-hover:text-base-foreground group-has-[.subbutton:hover]:text-muted-foreground group-focus:text-base-foreground icon-[lucide--chevron-up] size-4 transition-all',
|
||||
isCollapse && '-rotate-180',
|
||||
disabled && 'opacity-0'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="!isCollapse && !isEmpty" class="pb-4">
|
||||
<slot />
|
||||
</div>
|
||||
<TransitionCollapse>
|
||||
<div v-if="isExpanded" class="pb-4">
|
||||
<slot />
|
||||
</div>
|
||||
<slot v-else-if="enableEmptyState && disabled" name="empty">
|
||||
<div>
|
||||
{{ $t('g.empty') }}
|
||||
</div>
|
||||
</slot>
|
||||
</TransitionCollapse>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
144
src/components/rightSidePanel/layout/TransitionCollapse.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
// From: https://stackoverflow.com/a/71426342/22392721
|
||||
interface Props {
|
||||
duration?: number
|
||||
easingEnter?: string
|
||||
easingLeave?: string
|
||||
opacityClosed?: number
|
||||
opacityOpened?: number
|
||||
disable?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
duration: 150,
|
||||
easingEnter: 'ease-in-out',
|
||||
easingLeave: 'ease-in-out',
|
||||
opacityClosed: 0,
|
||||
opacityOpened: 1
|
||||
})
|
||||
|
||||
const closed = '0px'
|
||||
|
||||
const isMounted = ref(false)
|
||||
onMounted(() => (isMounted.value = true))
|
||||
|
||||
const duration = computed(() =>
|
||||
isMounted.value && !props.disable ? props.duration : 0
|
||||
)
|
||||
|
||||
interface initialStyle {
|
||||
height: string
|
||||
width: string
|
||||
position: string
|
||||
visibility: string
|
||||
overflow: string
|
||||
paddingTop: string
|
||||
paddingBottom: string
|
||||
borderTopWidth: string
|
||||
borderBottomWidth: string
|
||||
marginTop: string
|
||||
marginBottom: string
|
||||
}
|
||||
|
||||
function getElementStyle(element: HTMLElement) {
|
||||
return {
|
||||
height: element.style.height,
|
||||
width: element.style.width,
|
||||
position: element.style.position,
|
||||
visibility: element.style.visibility,
|
||||
overflow: element.style.overflow,
|
||||
paddingTop: element.style.paddingTop,
|
||||
paddingBottom: element.style.paddingBottom,
|
||||
borderTopWidth: element.style.borderTopWidth,
|
||||
borderBottomWidth: element.style.borderBottomWidth,
|
||||
marginTop: element.style.marginTop,
|
||||
marginBottom: element.style.marginBottom
|
||||
}
|
||||
}
|
||||
|
||||
function prepareElement(element: HTMLElement, initialStyle: initialStyle) {
|
||||
const { width } = getComputedStyle(element)
|
||||
element.style.width = width
|
||||
element.style.position = 'absolute'
|
||||
element.style.visibility = 'hidden'
|
||||
element.style.height = ''
|
||||
const { height } = getComputedStyle(element)
|
||||
element.style.width = initialStyle.width
|
||||
element.style.position = initialStyle.position
|
||||
element.style.visibility = initialStyle.visibility
|
||||
element.style.height = closed
|
||||
element.style.overflow = 'hidden'
|
||||
return initialStyle.height && initialStyle.height !== closed
|
||||
? initialStyle.height
|
||||
: height
|
||||
}
|
||||
|
||||
function animateTransition(
|
||||
element: HTMLElement,
|
||||
initialStyle: initialStyle,
|
||||
done: () => void,
|
||||
keyframes: Keyframe[] | PropertyIndexedKeyframes | null,
|
||||
options?: number | KeyframeAnimationOptions
|
||||
) {
|
||||
const animation = element.animate(keyframes, options)
|
||||
// Set height to 'auto' to restore it after animation
|
||||
element.style.height = initialStyle.height
|
||||
animation.onfinish = () => {
|
||||
element.style.overflow = initialStyle.overflow
|
||||
done()
|
||||
}
|
||||
}
|
||||
|
||||
function getEnterKeyframes(height: string, initialStyle: initialStyle) {
|
||||
return [
|
||||
{
|
||||
height: closed,
|
||||
opacity: props.opacityClosed,
|
||||
paddingTop: closed,
|
||||
paddingBottom: closed,
|
||||
borderTopWidth: closed,
|
||||
borderBottomWidth: closed,
|
||||
marginTop: closed,
|
||||
marginBottom: closed
|
||||
},
|
||||
{
|
||||
height,
|
||||
opacity: props.opacityOpened,
|
||||
paddingTop: initialStyle.paddingTop,
|
||||
paddingBottom: initialStyle.paddingBottom,
|
||||
borderTopWidth: initialStyle.borderTopWidth,
|
||||
borderBottomWidth: initialStyle.borderBottomWidth,
|
||||
marginTop: initialStyle.marginTop,
|
||||
marginBottom: initialStyle.marginBottom
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function enterTransition(element: Element, done: () => void) {
|
||||
const HTMLElement = element as HTMLElement
|
||||
const initialStyle = getElementStyle(HTMLElement)
|
||||
const height = prepareElement(HTMLElement, initialStyle)
|
||||
const keyframes = getEnterKeyframes(height, initialStyle)
|
||||
const options = { duration: duration.value, easing: props.easingEnter }
|
||||
animateTransition(HTMLElement, initialStyle, done, keyframes, options)
|
||||
}
|
||||
|
||||
function leaveTransition(element: Element, done: () => void) {
|
||||
const HTMLElement = element as HTMLElement
|
||||
const initialStyle = getElementStyle(HTMLElement)
|
||||
const { height } = getComputedStyle(HTMLElement)
|
||||
HTMLElement.style.height = height
|
||||
HTMLElement.style.overflow = 'hidden'
|
||||
const keyframes = getEnterKeyframes(height, initialStyle).reverse()
|
||||
const options = { duration: duration.value, easing: props.easingLeave }
|
||||
animateTransition(HTMLElement, initialStyle, done, keyframes, options)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<slot />
|
||||
</Transition>
|
||||
</template>
|
||||
@@ -1,87 +1,185 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, provide } from 'vue'
|
||||
import { computed, inject, provide, ref, shallowRef, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useReactiveWidgetValue } from '@/composables/graph/useGraphNodeManager'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
|
||||
import type {
|
||||
LGraphGroup,
|
||||
LGraphNode,
|
||||
SubgraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
|
||||
import {
|
||||
getComponent,
|
||||
shouldExpand
|
||||
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
import { GetNodeParentGroupKey } from '../shared'
|
||||
import WidgetItem from './WidgetItem.vue'
|
||||
|
||||
const { label, widgets } = defineProps<{
|
||||
const {
|
||||
label,
|
||||
node,
|
||||
widgets: widgetsProp,
|
||||
showLocateButton = false,
|
||||
isDraggable = false,
|
||||
hiddenFavoriteIndicator = false,
|
||||
showNodeName = false,
|
||||
parents = [],
|
||||
enableEmptyState = false,
|
||||
tooltip
|
||||
} = defineProps<{
|
||||
label?: string
|
||||
parents?: SubgraphNode[]
|
||||
node?: LGraphNode
|
||||
widgets: { widget: IBaseWidget; node: LGraphNode }[]
|
||||
showLocateButton?: boolean
|
||||
isDraggable?: boolean
|
||||
hiddenFavoriteIndicator?: boolean
|
||||
showNodeName?: boolean
|
||||
/**
|
||||
* Whether to show the empty state slot when there are no widgets.
|
||||
*/
|
||||
enableEmptyState?: boolean
|
||||
tooltip?: string
|
||||
}>()
|
||||
|
||||
const collapse = defineModel<boolean>('collapse', { default: false })
|
||||
|
||||
const widgetsContainer = ref<HTMLElement>()
|
||||
const rootElement = ref<HTMLElement>()
|
||||
|
||||
const widgets = shallowRef(widgetsProp)
|
||||
watchEffect(() => (widgets.value = widgetsProp))
|
||||
|
||||
provide('hideLayoutField', true)
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
function getWidgetComponent(widget: IBaseWidget) {
|
||||
const component = getComponent(widget.type, widget.name)
|
||||
return component || WidgetLegacy
|
||||
const getNodeParentGroup = inject(GetNodeParentGroupKey, null)
|
||||
|
||||
function isWidgetShownOnParents(
|
||||
widgetNode: LGraphNode,
|
||||
widget: IBaseWidget
|
||||
): boolean {
|
||||
if (!parents.length) return false
|
||||
const proxyWidgets = parseProxyWidgets(parents[0].properties.proxyWidgets)
|
||||
|
||||
// For proxy widgets (already promoted), check using overlay information
|
||||
if (isProxyWidget(widget)) {
|
||||
return proxyWidgets.some(
|
||||
([nodeId, widgetName]) =>
|
||||
widget._overlay.nodeId == nodeId &&
|
||||
widget._overlay.widgetName === widgetName
|
||||
)
|
||||
}
|
||||
|
||||
// For regular widgets (not yet promoted), check using node ID and widget name
|
||||
return proxyWidgets.some(
|
||||
([nodeId, widgetName]) =>
|
||||
widgetNode.id == nodeId && widget.name === widgetName
|
||||
)
|
||||
}
|
||||
|
||||
function onWidgetValueChange(
|
||||
widget: IBaseWidget,
|
||||
value: string | number | boolean | object
|
||||
) {
|
||||
widget.value = value
|
||||
widget.callback?.(value)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
const isEmpty = computed(() => widgets.length === 0)
|
||||
const isEmpty = computed(() => widgets.value.length === 0)
|
||||
|
||||
const displayLabel = computed(
|
||||
() =>
|
||||
label ??
|
||||
(isEmpty.value
|
||||
? t('rightSidePanel.inputsNone')
|
||||
: t('rightSidePanel.inputs'))
|
||||
() => label ?? (node ? node.title : t('rightSidePanel.inputs'))
|
||||
)
|
||||
|
||||
const targetNode = computed<LGraphNode | null>(() => {
|
||||
if (node) return node
|
||||
if (isEmpty.value) return null
|
||||
|
||||
const firstNodeId = widgets.value[0].node.id
|
||||
const allSameNode = widgets.value.every(({ node }) => node.id === firstNodeId)
|
||||
|
||||
return allSameNode ? widgets.value[0].node : null
|
||||
})
|
||||
|
||||
const parentGroup = computed<LGraphGroup | null>(() => {
|
||||
if (!targetNode.value || !getNodeParentGroup) return null
|
||||
return getNodeParentGroup(targetNode.value)
|
||||
})
|
||||
|
||||
const canShowLocateButton = computed(
|
||||
() => showLocateButton && targetNode.value !== null
|
||||
)
|
||||
|
||||
function handleLocateNode() {
|
||||
if (!targetNode.value || !canvasStore.canvas) return
|
||||
|
||||
const graphNode = canvasStore.canvas.graph?.getNodeById(targetNode.value.id)
|
||||
if (graphNode) {
|
||||
canvasStore.canvas.animateToBounds(graphNode.boundingRect)
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
widgetsContainer,
|
||||
rootElement
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PropertiesAccordionItem :is-empty>
|
||||
<template #label>
|
||||
<slot name="label">
|
||||
{{ displayLabel }}
|
||||
</slot>
|
||||
</template>
|
||||
|
||||
<div v-if="!isEmpty" class="space-y-4 rounded-lg bg-interface-surface px-4">
|
||||
<div
|
||||
v-for="({ widget, node }, index) in widgets"
|
||||
:key="`widget-${index}-${widget.name}`"
|
||||
class="widget-item gap-1.5 col-span-full grid grid-cols-subgrid"
|
||||
>
|
||||
<div class="min-h-8">
|
||||
<p v-if="widget.name" class="text-sm leading-8 p-0 m-0 line-clamp-1">
|
||||
{{ widget.label || widget.name }}
|
||||
</p>
|
||||
<div ref="rootElement">
|
||||
<PropertiesAccordionItem
|
||||
v-model:collapse="collapse"
|
||||
:enable-empty-state
|
||||
:disabled="isEmpty"
|
||||
:tooltip
|
||||
>
|
||||
<template #label>
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<span class="flex-1 flex items-center gap-2 min-w-0">
|
||||
<span class="truncate">
|
||||
<slot name="label">
|
||||
{{ displayLabel }}
|
||||
</slot>
|
||||
</span>
|
||||
<span
|
||||
v-if="parentGroup"
|
||||
class="text-xs text-muted-foreground truncate flex-1 text-right min-w-11"
|
||||
:title="parentGroup.title"
|
||||
>
|
||||
{{ parentGroup.title }}
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
v-if="canShowLocateButton"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="subbutton shrink-0 mr-3 size-8 cursor-pointer text-muted-foreground hover:text-base-foreground"
|
||||
:title="t('rightSidePanel.locateNode')"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click.stop="handleLocateNode"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<component
|
||||
:is="getWidgetComponent(widget)"
|
||||
:widget="widget"
|
||||
:model-value="useReactiveWidgetValue(widget)"
|
||||
:node-id="String(node.id)"
|
||||
:node-type="node.type"
|
||||
:class="cn('col-span-1', shouldExpand(widget.type) && 'min-h-36')"
|
||||
@update:model-value="
|
||||
(value: string | number | boolean | object) =>
|
||||
onWidgetValueChange(widget, value)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #empty><slot name="empty" /></template>
|
||||
|
||||
<div
|
||||
ref="widgetsContainer"
|
||||
class="space-y-2 rounded-lg px-4 pt-1 relative"
|
||||
>
|
||||
<TransitionGroup name="list-scale">
|
||||
<WidgetItem
|
||||
v-for="{ widget, node } in widgets"
|
||||
:key="`${node.id}-${widget.name}-${widget.type}`"
|
||||
:widget="widget"
|
||||
:node="node"
|
||||
:is-draggable="isDraggable"
|
||||
:hidden-favorite-indicator="hiddenFavoriteIndicator"
|
||||
:show-node-name="showNodeName"
|
||||
:parents="parents"
|
||||
:is-shown-on-parents="isWidgetShownOnParents(node, widget)"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
</PropertiesAccordionItem>
|
||||
</PropertiesAccordionItem>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
142
src/components/rightSidePanel/parameters/TabGlobalParameters.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import { useMounted, watchDebounced } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
shallowRef
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import { DraggableList } from '@/scripts/ui/draggableList'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import type { ValidFavoritedWidget } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
import { searchWidgets } from '../shared'
|
||||
import SectionWidgets from './SectionWidgets.vue'
|
||||
|
||||
const favoritedWidgetsStore = useFavoritedWidgetsStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { searchQuery } = storeToRefs(rightSidePanelStore)
|
||||
const { t } = useI18n()
|
||||
|
||||
const draggableList = ref<DraggableList | undefined>(undefined)
|
||||
const sectionWidgetsRef = ref<{ widgetsContainer: HTMLElement }>()
|
||||
const isSearching = ref(false)
|
||||
|
||||
const favoritedWidgets = computed(
|
||||
() => favoritedWidgetsStore.validFavoritedWidgets
|
||||
)
|
||||
|
||||
const label = computed(() =>
|
||||
favoritedWidgets.value.length === 0
|
||||
? t('rightSidePanel.favoritesNone')
|
||||
: t('rightSidePanel.favorites')
|
||||
)
|
||||
|
||||
const searchedFavoritedWidgets = shallowRef<ValidFavoritedWidget[]>(
|
||||
favoritedWidgets.value
|
||||
)
|
||||
|
||||
async function searcher(query: string) {
|
||||
isSearching.value = query.trim().length > 0
|
||||
searchedFavoritedWidgets.value = searchWidgets(favoritedWidgets.value, query)
|
||||
}
|
||||
|
||||
const isMounted = useMounted()
|
||||
|
||||
function setDraggableState() {
|
||||
if (!isMounted.value) return
|
||||
draggableList.value?.dispose()
|
||||
const container = sectionWidgetsRef.value?.widgetsContainer
|
||||
if (isSearching.value || !container?.children?.length) return
|
||||
|
||||
draggableList.value = new DraggableList(container, '.draggable-item')
|
||||
|
||||
draggableList.value.applyNewItemsOrder = function () {
|
||||
const reorderedItems: HTMLElement[] = []
|
||||
|
||||
let oldPosition = -1
|
||||
this.getAllItems().forEach((item, index) => {
|
||||
if (item === this.draggableItem) {
|
||||
oldPosition = index
|
||||
return
|
||||
}
|
||||
if (!this.isItemToggled(item)) {
|
||||
reorderedItems[index] = item
|
||||
return
|
||||
}
|
||||
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
|
||||
reorderedItems[newIndex] = item
|
||||
})
|
||||
|
||||
for (let index = 0; index < this.getAllItems().length; index++) {
|
||||
const item = reorderedItems[index]
|
||||
if (typeof item === 'undefined') {
|
||||
reorderedItems[index] = this.draggableItem as HTMLElement
|
||||
}
|
||||
}
|
||||
|
||||
const newPosition = reorderedItems.indexOf(
|
||||
this.draggableItem as HTMLElement
|
||||
)
|
||||
const widgets = [...searchedFavoritedWidgets.value]
|
||||
const [widget] = widgets.splice(oldPosition, 1)
|
||||
widgets.splice(newPosition, 0, widget)
|
||||
searchedFavoritedWidgets.value = widgets
|
||||
favoritedWidgetsStore.reorderFavorites(widgets)
|
||||
}
|
||||
}
|
||||
|
||||
watchDebounced(
|
||||
searchedFavoritedWidgets,
|
||||
() => {
|
||||
setDraggableState()
|
||||
},
|
||||
{ debounce: 100 }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
setDraggableState()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
draggableList.value?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-4 pt-1 pb-4 flex gap-2 border-b border-interface-stroke">
|
||||
<FormSearchInput
|
||||
v-model="searchQuery"
|
||||
:searcher
|
||||
:update-key="favoritedWidgets"
|
||||
/>
|
||||
</div>
|
||||
<SectionWidgets
|
||||
ref="sectionWidgetsRef"
|
||||
:label
|
||||
:widgets="searchedFavoritedWidgets"
|
||||
:is-draggable="!isSearching"
|
||||
hidden-favorite-indicator
|
||||
show-node-name
|
||||
enable-empty-state
|
||||
class="border-b border-interface-stroke"
|
||||
@update:collapse="nextTick(setDraggableState)"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="text-sm text-muted-foreground px-4 text-center py-10">
|
||||
{{
|
||||
isSearching
|
||||
? t('rightSidePanel.noneSearchDesc')
|
||||
: t('rightSidePanel.favoritesNoneDesc')
|
||||
}}
|
||||
</div>
|
||||
</template>
|
||||
</SectionWidgets>
|
||||
</template>
|
||||
82
src/components/rightSidePanel/parameters/TabNodes.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
import { searchWidgetsAndNodes } from '../shared'
|
||||
import type { NodeWidgetsListList } from '../shared'
|
||||
import SectionWidgets from './SectionWidgets.vue'
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const nodes = computed((): LGraphNode[] => {
|
||||
// Depend on activeWorkflow to trigger recomputation when workflow changes
|
||||
void workflowStore.activeWorkflow?.path
|
||||
return (canvasStore.canvas?.graph?.nodes ?? []) as LGraphNode[]
|
||||
})
|
||||
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { searchQuery } = storeToRefs(rightSidePanelStore)
|
||||
|
||||
const widgetsSectionDataList = computed((): NodeWidgetsListList => {
|
||||
return nodes.value.map((node) => {
|
||||
const { widgets = [] } = node
|
||||
const shownWidgets = widgets
|
||||
.filter((w) => !(w.options?.canvasOnly || w.options?.hidden))
|
||||
.map((widget) => ({ node, widget }))
|
||||
return {
|
||||
widgets: shownWidgets,
|
||||
node
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const searchedWidgetsSectionDataList = shallowRef<NodeWidgetsListList>(
|
||||
widgetsSectionDataList.value
|
||||
)
|
||||
const isSearching = ref(false)
|
||||
async function searcher(query: string) {
|
||||
const list = widgetsSectionDataList.value
|
||||
const target = searchedWidgetsSectionDataList
|
||||
isSearching.value = query.trim() !== ''
|
||||
target.value = searchWidgetsAndNodes(list, query)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-4 pt-1 pb-4 flex gap-2 border-b border-interface-stroke">
|
||||
<FormSearchInput
|
||||
v-model="searchQuery"
|
||||
:searcher
|
||||
:update-key="widgetsSectionDataList"
|
||||
/>
|
||||
</div>
|
||||
<TransitionGroup tag="div" name="list-scale" class="relative">
|
||||
<div
|
||||
v-if="isSearching && searchedWidgetsSectionDataList.length === 0"
|
||||
class="text-sm text-muted-foreground px-4 text-center pt-5 pb-15"
|
||||
>
|
||||
{{ $t('rightSidePanel.noneSearchDesc') }}
|
||||
</div>
|
||||
<SectionWidgets
|
||||
v-for="{ node, widgets } in searchedWidgetsSectionDataList"
|
||||
:key="node.id"
|
||||
:node
|
||||
:widgets
|
||||
:collapse="!isSearching"
|
||||
:tooltip="
|
||||
isSearching || widgets.length
|
||||
? ''
|
||||
: $t('rightSidePanel.inputsNoneTooltip')
|
||||
"
|
||||
show-locate-button
|
||||
class="border-b border-interface-stroke"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
96
src/components/rightSidePanel/parameters/TabNormalInputs.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
import { searchWidgetsAndNodes } from '../shared'
|
||||
import type { NodeWidgetsListList } from '../shared'
|
||||
import SectionWidgets from './SectionWidgets.vue'
|
||||
|
||||
const { nodes, mustShowNodeTitle } = defineProps<{
|
||||
mustShowNodeTitle?: boolean
|
||||
nodes: LGraphNode[]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { searchQuery } = storeToRefs(rightSidePanelStore)
|
||||
|
||||
const widgetsSectionDataList = computed((): NodeWidgetsListList => {
|
||||
return nodes.map((node) => {
|
||||
const { widgets = [] } = node
|
||||
const shownWidgets = widgets
|
||||
.filter((w) => !(w.options?.canvasOnly || w.options?.hidden))
|
||||
.map((widget) => ({ node, widget }))
|
||||
|
||||
return { widgets: shownWidgets, node }
|
||||
})
|
||||
})
|
||||
|
||||
const isMultipleNodesSelected = computed(
|
||||
() => widgetsSectionDataList.value.length > 1
|
||||
)
|
||||
|
||||
const searchedWidgetsSectionDataList = shallowRef<NodeWidgetsListList>(
|
||||
widgetsSectionDataList.value
|
||||
)
|
||||
const isSearching = ref(false)
|
||||
|
||||
async function searcher(query: string) {
|
||||
const list = widgetsSectionDataList.value
|
||||
const target = searchedWidgetsSectionDataList
|
||||
isSearching.value = query.trim() !== ''
|
||||
target.value = searchWidgetsAndNodes(list, query)
|
||||
}
|
||||
|
||||
const label = computed(() => {
|
||||
const sections = widgetsSectionDataList.value
|
||||
return !mustShowNodeTitle && sections.length === 1
|
||||
? sections[0].widgets.length !== 0
|
||||
? t('rightSidePanel.inputs')
|
||||
: t('rightSidePanel.inputsNone')
|
||||
: undefined // SectionWidgets display node titles by default
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-4 pt-1 pb-4 flex gap-2 border-b border-interface-stroke">
|
||||
<FormSearchInput
|
||||
v-model="searchQuery"
|
||||
:searcher
|
||||
:update-key="widgetsSectionDataList"
|
||||
/>
|
||||
</div>
|
||||
<TransitionGroup tag="div" name="list-scale" class="relative">
|
||||
<div
|
||||
v-if="searchedWidgetsSectionDataList.length === 0"
|
||||
class="text-sm text-muted-foreground px-4 py-10 text-center"
|
||||
>
|
||||
{{
|
||||
isSearching
|
||||
? t('rightSidePanel.noneSearchDesc')
|
||||
: t('rightSidePanel.nodesNoneDesc')
|
||||
}}
|
||||
</div>
|
||||
<SectionWidgets
|
||||
v-for="{ widgets, node } in searchedWidgetsSectionDataList"
|
||||
:key="node.id"
|
||||
:node
|
||||
:label
|
||||
:widgets
|
||||
:collapse="isMultipleNodesSelected && !isSearching"
|
||||
:show-locate-button="isMultipleNodesSelected"
|
||||
:tooltip="
|
||||
isSearching || widgets.length
|
||||
? ''
|
||||
: t('rightSidePanel.inputsNoneTooltip')
|
||||
"
|
||||
class="border-b border-interface-stroke"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
@@ -1,84 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, shallowRef } from 'vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import SidePanelSearch from '../layout/SidePanelSearch.vue'
|
||||
import SectionWidgets from './SectionWidgets.vue'
|
||||
|
||||
const { nodes } = defineProps<{
|
||||
nodes: LGraphNode[]
|
||||
}>()
|
||||
|
||||
type NodeWidgetsList = Array<{ node: LGraphNode; widget: IBaseWidget }>
|
||||
type NodeWidgetsListList = Array<{
|
||||
node: LGraphNode
|
||||
widgets: NodeWidgetsList
|
||||
}>
|
||||
|
||||
const widgetsSectionDataList = computed((): NodeWidgetsListList => {
|
||||
return nodes.map((node) => {
|
||||
const { widgets = [] } = node
|
||||
const shownWidgets = widgets
|
||||
.filter((w) => !(w.options?.canvasOnly || w.options?.hidden))
|
||||
.map((widget) => ({ node, widget }))
|
||||
return {
|
||||
widgets: shownWidgets,
|
||||
node
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const searchedWidgetsSectionDataList = shallowRef<NodeWidgetsListList>([])
|
||||
|
||||
/**
|
||||
* Searches widgets in all selected nodes and returns search results.
|
||||
* Filters by name, localized label, type, and user-input value.
|
||||
* Performs basic tokenization of the query string.
|
||||
*/
|
||||
async function searcher(query: string) {
|
||||
if (query.trim() === '') {
|
||||
searchedWidgetsSectionDataList.value = widgetsSectionDataList.value
|
||||
return
|
||||
}
|
||||
const words = query.trim().toLowerCase().split(' ')
|
||||
searchedWidgetsSectionDataList.value = widgetsSectionDataList.value
|
||||
.map((item) => {
|
||||
return {
|
||||
...item,
|
||||
widgets: item.widgets.filter(({ widget }) => {
|
||||
const label = widget.label?.toLowerCase()
|
||||
const name = widget.name.toLowerCase()
|
||||
const type = widget.type.toLowerCase()
|
||||
const value = widget.value?.toString().toLowerCase()
|
||||
return words.every(
|
||||
(word) =>
|
||||
name.includes(word) ||
|
||||
label?.includes(word) ||
|
||||
type?.includes(word) ||
|
||||
value?.includes(word)
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
.filter((item) => item.widgets.length > 0)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-4 pb-4 flex gap-2 border-b border-interface-stroke">
|
||||
<SidePanelSearch :searcher :update-key="widgetsSectionDataList" />
|
||||
</div>
|
||||
<SectionWidgets
|
||||
v-for="section in searchedWidgetsSectionDataList"
|
||||
:key="section.node.id"
|
||||
:label="widgetsSectionDataList.length > 1 ? section.node.title : undefined"
|
||||
:widgets="section.widgets"
|
||||
:default-collapse="
|
||||
widgetsSectionDataList.length > 1 &&
|
||||
widgetsSectionDataList === searchedWidgetsSectionDataList
|
||||
"
|
||||
class="border-b border-interface-stroke"
|
||||
/>
|
||||
</template>
|
||||
244
src/components/rightSidePanel/parameters/TabSubgraphInputs.vue
Normal file
@@ -0,0 +1,244 @@
|
||||
<script setup lang="ts">
|
||||
import { useMounted, watchDebounced } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import {
|
||||
computed,
|
||||
customRef,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
shallowRef,
|
||||
triggerRef,
|
||||
useTemplateRef,
|
||||
watch
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
|
||||
import type { ProxyWidgetsProperty } from '@/core/schemas/proxyWidget'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import { DraggableList } from '@/scripts/ui/draggableList'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
import { searchWidgets } from '../shared'
|
||||
import type { NodeWidgetsList } from '../shared'
|
||||
import SectionWidgets from './SectionWidgets.vue'
|
||||
|
||||
const { node } = defineProps<{
|
||||
node: SubgraphNode
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const canvasStore = useCanvasStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { focusedSection, searchQuery } = storeToRefs(rightSidePanelStore)
|
||||
|
||||
const advancedInputsCollapsed = ref(true)
|
||||
const draggableList = ref<DraggableList | undefined>(undefined)
|
||||
const sectionWidgetsRef = useTemplateRef('sectionWidgetsRef')
|
||||
const advancedInputsSectionRef = useTemplateRef('advancedInputsSectionRef')
|
||||
|
||||
// Use customRef to track proxyWidgets changes
|
||||
const proxyWidgets = customRef<ProxyWidgetsProperty>((track, trigger) => ({
|
||||
get() {
|
||||
track()
|
||||
return parseProxyWidgets(node.properties.proxyWidgets)
|
||||
},
|
||||
set(value?: ProxyWidgetsProperty) {
|
||||
trigger()
|
||||
if (!value) return
|
||||
// eslint-disable-next-line vue/no-mutating-props
|
||||
node.properties.proxyWidgets = value
|
||||
}
|
||||
}))
|
||||
|
||||
watch(
|
||||
focusedSection,
|
||||
async (section) => {
|
||||
if (section === 'advanced-inputs') {
|
||||
advancedInputsCollapsed.value = false
|
||||
rightSidePanelStore.clearFocusedSection()
|
||||
|
||||
await nextTick()
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
|
||||
const sectionComponent = advancedInputsSectionRef.value
|
||||
const sectionElement = sectionComponent?.rootElement
|
||||
if (sectionElement) {
|
||||
sectionElement.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const widgetsList = computed((): NodeWidgetsList => {
|
||||
const proxyWidgetsOrder = proxyWidgets.value
|
||||
const { widgets = [] } = node
|
||||
|
||||
// Map proxyWidgets to actual proxy widgets in the correct order
|
||||
const result: NodeWidgetsList = []
|
||||
for (const [nodeId, widgetName] of proxyWidgetsOrder) {
|
||||
// Find the proxy widget that matches this nodeId and widgetName
|
||||
const widget = widgets.find((w) => {
|
||||
// Check if this is a proxy widget with _overlay
|
||||
if (isProxyWidget(w)) {
|
||||
return (
|
||||
String(w._overlay.nodeId) === nodeId &&
|
||||
w._overlay.widgetName === widgetName
|
||||
)
|
||||
}
|
||||
// For non-proxy widgets (like linked widgets), match by name
|
||||
return w.name === widgetName
|
||||
})
|
||||
if (widget) {
|
||||
result.push({ node, widget })
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const advancedInputsWidgets = computed((): NodeWidgetsList => {
|
||||
const interiorNodes = node.subgraph.nodes
|
||||
const proxyWidgetsValue = parseProxyWidgets(node.properties.proxyWidgets)
|
||||
|
||||
// Get all widgets from interior nodes
|
||||
const allInteriorWidgets = interiorNodes.flatMap((interiorNode) => {
|
||||
const { widgets = [] } = interiorNode
|
||||
return widgets
|
||||
.filter((w) => !w.computedDisabled)
|
||||
.map((widget) => ({ node: interiorNode, widget }))
|
||||
})
|
||||
|
||||
// Filter out widgets that are already promoted using tuple matching
|
||||
return allInteriorWidgets.filter(({ node: interiorNode, widget }) => {
|
||||
return !proxyWidgetsValue.some(
|
||||
([nodeId, widgetName]) =>
|
||||
interiorNode.id == nodeId && widget.name === widgetName
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const parents = computed<SubgraphNode[]>(() => [node])
|
||||
|
||||
const searchedWidgetsList = shallowRef<NodeWidgetsList>(widgetsList.value)
|
||||
const isSearching = ref(false)
|
||||
|
||||
async function searcher(query: string) {
|
||||
isSearching.value = query.trim() !== ''
|
||||
searchedWidgetsList.value = searchWidgets(widgetsList.value, query)
|
||||
}
|
||||
|
||||
const isMounted = useMounted()
|
||||
|
||||
function setDraggableState() {
|
||||
if (!isMounted.value) return
|
||||
|
||||
draggableList.value?.dispose()
|
||||
const container = sectionWidgetsRef.value?.widgetsContainer
|
||||
if (isSearching.value || !container?.children?.length) return
|
||||
|
||||
draggableList.value = new DraggableList(container, '.draggable-item')
|
||||
|
||||
draggableList.value.applyNewItemsOrder = function () {
|
||||
const reorderedItems: HTMLElement[] = []
|
||||
|
||||
let oldPosition = -1
|
||||
this.getAllItems().forEach((item, index) => {
|
||||
if (item === this.draggableItem) {
|
||||
oldPosition = index
|
||||
return
|
||||
}
|
||||
if (!this.isItemToggled(item)) {
|
||||
reorderedItems[index] = item
|
||||
return
|
||||
}
|
||||
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
|
||||
reorderedItems[newIndex] = item
|
||||
})
|
||||
|
||||
if (oldPosition === -1) {
|
||||
console.error('[TabSubgraphInputs] draggableItem not found in items')
|
||||
return
|
||||
}
|
||||
|
||||
for (let index = 0; index < this.getAllItems().length; index++) {
|
||||
const item = reorderedItems[index]
|
||||
if (typeof item === 'undefined') {
|
||||
reorderedItems[index] = this.draggableItem as HTMLElement
|
||||
}
|
||||
}
|
||||
|
||||
const newPosition = reorderedItems.indexOf(
|
||||
this.draggableItem as HTMLElement
|
||||
)
|
||||
|
||||
// Update proxyWidgets order
|
||||
const pw = proxyWidgets.value
|
||||
const [w] = pw.splice(oldPosition, 1)
|
||||
pw.splice(newPosition, 0, w)
|
||||
proxyWidgets.value = pw
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
triggerRef(proxyWidgets)
|
||||
}
|
||||
}
|
||||
|
||||
watchDebounced(searchedWidgetsList, () => setDraggableState(), {
|
||||
debounce: 100
|
||||
})
|
||||
onMounted(() => setDraggableState())
|
||||
onBeforeUnmount(() => draggableList.value?.dispose())
|
||||
|
||||
const label = computed(() => {
|
||||
return searchedWidgetsList.value.length !== 0
|
||||
? t('rightSidePanel.inputs')
|
||||
: t('rightSidePanel.inputsNone')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-4 pt-1 pb-4 flex gap-2 border-b border-interface-stroke">
|
||||
<FormSearchInput
|
||||
v-model="searchQuery"
|
||||
:searcher
|
||||
:update-key="widgetsList"
|
||||
/>
|
||||
</div>
|
||||
<SectionWidgets
|
||||
ref="sectionWidgetsRef"
|
||||
:node
|
||||
:label
|
||||
:parents
|
||||
:widgets="searchedWidgetsList"
|
||||
:is-draggable="!isSearching"
|
||||
:enable-empty-state="isSearching"
|
||||
:tooltip="
|
||||
isSearching || searchedWidgetsList.length
|
||||
? ''
|
||||
: t('rightSidePanel.inputsNoneTooltip')
|
||||
"
|
||||
class="border-b border-interface-stroke"
|
||||
@update:collapse="nextTick(setDraggableState)"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="text-sm text-muted-foreground px-4 text-center pt-5 pb-15">
|
||||
{{ t('rightSidePanel.noneSearchDesc') }}
|
||||
</div>
|
||||
</template>
|
||||
</SectionWidgets>
|
||||
<SectionWidgets
|
||||
v-if="advancedInputsWidgets.length > 0"
|
||||
ref="advancedInputsSectionRef"
|
||||
v-model:collapse="advancedInputsCollapsed"
|
||||
:label="t('rightSidePanel.advancedInputs')"
|
||||
:parents="parents"
|
||||
:widgets="advancedInputsWidgets"
|
||||
show-node-name
|
||||
class="border-b border-interface-stroke"
|
||||
/>
|
||||
</template>
|
||||
167
src/components/rightSidePanel/parameters/WidgetActions.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import MoreButton from '@/components/button/MoreButton.vue'
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
import {
|
||||
demoteWidget,
|
||||
promoteWidget
|
||||
} from '@/core/graph/subgraph/proxyWidgetUtils'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
|
||||
const {
|
||||
widget,
|
||||
node,
|
||||
parents = [],
|
||||
isShownOnParents = false
|
||||
} = defineProps<{
|
||||
widget: IBaseWidget
|
||||
node: LGraphNode
|
||||
parents?: SubgraphNode[]
|
||||
isShownOnParents?: boolean
|
||||
}>()
|
||||
|
||||
const label = defineModel<string>('label', { required: true })
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const favoritedWidgetsStore = useFavoritedWidgetsStore()
|
||||
const dialogService = useDialogService()
|
||||
const { t } = useI18n()
|
||||
|
||||
const hasParents = computed(() => parents?.length > 0)
|
||||
const favoriteNode = computed(() =>
|
||||
isShownOnParents && hasParents.value ? parents[0] : node
|
||||
)
|
||||
const isFavorited = computed(() =>
|
||||
favoritedWidgetsStore.isFavorited(favoriteNode.value, widget.name)
|
||||
)
|
||||
|
||||
async function handleRename() {
|
||||
const newLabel = await dialogService.prompt({
|
||||
title: t('g.rename'),
|
||||
message: t('g.enterNewName') + ':',
|
||||
defaultValue: widget.label,
|
||||
placeholder: widget.name
|
||||
})
|
||||
|
||||
if (newLabel === null) return
|
||||
label.value = newLabel
|
||||
}
|
||||
|
||||
function handleHideInput() {
|
||||
if (!parents?.length) return
|
||||
|
||||
// For proxy widgets (already promoted), we need to find the original interior node and widget
|
||||
if (isProxyWidget(widget)) {
|
||||
const subgraph = parents[0].subgraph
|
||||
const interiorNode = subgraph.getNodeById(parseInt(widget._overlay.nodeId))
|
||||
|
||||
if (!interiorNode) {
|
||||
console.error('Could not find interior node for proxy widget')
|
||||
return
|
||||
}
|
||||
|
||||
const originalWidget = interiorNode.widgets?.find(
|
||||
(w) => w.name === widget._overlay.widgetName
|
||||
)
|
||||
|
||||
if (!originalWidget) {
|
||||
console.error('Could not find original widget for proxy widget')
|
||||
return
|
||||
}
|
||||
|
||||
demoteWidget(interiorNode, originalWidget, parents)
|
||||
} else {
|
||||
// For regular widgets (not yet promoted), use them directly
|
||||
demoteWidget(node, widget, parents)
|
||||
}
|
||||
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
function handleShowInput() {
|
||||
if (!parents?.length) return
|
||||
|
||||
promoteWidget(node, widget, parents)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
function handleToggleFavorite() {
|
||||
favoritedWidgetsStore.toggleFavorite(favoriteNode.value, widget.name)
|
||||
}
|
||||
|
||||
const buttonClasses = cn([
|
||||
'border-none bg-transparent',
|
||||
'w-full flex items-center gap-2 rounded px-3 py-2 text-sm',
|
||||
'cursor-pointer transition-all hover:bg-secondary-background-hover active:scale-95'
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MoreButton
|
||||
is-vertical
|
||||
class="text-muted-foreground bg-transparent hover:text-base-foreground hover:bg-secondary-background-hover active:scale-95 transition-all"
|
||||
>
|
||||
<template #default="{ close }">
|
||||
<button
|
||||
:class="buttonClasses"
|
||||
@click="
|
||||
() => {
|
||||
handleRename()
|
||||
close()
|
||||
}
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--edit] size-4" />
|
||||
<span>{{ t('g.rename') }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="hasParents"
|
||||
:class="buttonClasses"
|
||||
@click="
|
||||
() => {
|
||||
if (isShownOnParents) handleHideInput()
|
||||
else handleShowInput()
|
||||
close()
|
||||
}
|
||||
"
|
||||
>
|
||||
<template v-if="isShownOnParents">
|
||||
<i class="icon-[lucide--eye-off] size-4" />
|
||||
<span>{{ t('rightSidePanel.hideInput') }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="icon-[lucide--eye] size-4" />
|
||||
<span>{{ t('rightSidePanel.showInput') }}</span>
|
||||
</template>
|
||||
</button>
|
||||
|
||||
<button
|
||||
:class="buttonClasses"
|
||||
@click="
|
||||
() => {
|
||||
handleToggleFavorite()
|
||||
close()
|
||||
}
|
||||
"
|
||||
>
|
||||
<template v-if="isFavorited">
|
||||
<i class="icon-[lucide--star]" />
|
||||
<span>{{ t('rightSidePanel.removeFavorite') }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="icon-[lucide--star]" />
|
||||
<span>{{ t('rightSidePanel.addFavorite') }}</span>
|
||||
</template>
|
||||
</button>
|
||||
</template>
|
||||
</MoreButton>
|
||||
</template>
|
||||
183
src/components/rightSidePanel/parameters/WidgetItem.vue
Normal file
@@ -0,0 +1,183 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, customRef, ref } from 'vue'
|
||||
|
||||
import EditableText from '@/components/common/EditableText.vue'
|
||||
import { getSharedWidgetEnhancements } from '@/composables/graph/useGraphNodeManager'
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
|
||||
import {
|
||||
getComponent,
|
||||
shouldExpand
|
||||
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { renameWidget } from '../shared'
|
||||
import WidgetActions from './WidgetActions.vue'
|
||||
|
||||
const {
|
||||
widget,
|
||||
node,
|
||||
isDraggable = false,
|
||||
hiddenFavoriteIndicator = false,
|
||||
showNodeName = false,
|
||||
parents = [],
|
||||
isShownOnParents = false
|
||||
} = defineProps<{
|
||||
widget: IBaseWidget
|
||||
node: LGraphNode
|
||||
isDraggable?: boolean
|
||||
hiddenFavoriteIndicator?: boolean
|
||||
showNodeName?: boolean
|
||||
parents?: SubgraphNode[]
|
||||
isShownOnParents?: boolean
|
||||
}>()
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const favoritedWidgetsStore = useFavoritedWidgetsStore()
|
||||
const isEditing = ref(false)
|
||||
|
||||
const widgetComponent = computed(() => {
|
||||
const component = getComponent(widget.type, widget.name)
|
||||
return component || WidgetLegacy
|
||||
})
|
||||
|
||||
const enhancedWidget = computed(() => {
|
||||
// Get shared enhancements (reactive value, controlWidget, spec, nodeType, etc.)
|
||||
const enhancements = getSharedWidgetEnhancements(node, widget)
|
||||
return { ...widget, ...enhancements }
|
||||
})
|
||||
|
||||
const sourceNodeName = computed((): string | null => {
|
||||
let sourceNode: LGraphNode | null = node
|
||||
if (isProxyWidget(widget)) {
|
||||
const { graph, nodeId } = widget._overlay
|
||||
sourceNode = getNodeByExecutionId(graph, nodeId)
|
||||
}
|
||||
return sourceNode ? sourceNode.title || sourceNode.type : null
|
||||
})
|
||||
|
||||
const hasParents = computed(() => parents?.length > 0)
|
||||
const favoriteNode = computed(() =>
|
||||
isShownOnParents && hasParents.value ? parents[0] : node
|
||||
)
|
||||
|
||||
const widgetValue = computed({
|
||||
get: () => {
|
||||
widget.vueTrack?.()
|
||||
return widget.value
|
||||
},
|
||||
set: (newValue: string | number | boolean | object) => {
|
||||
// eslint-disable-next-line vue/no-mutating-props
|
||||
widget.value = newValue
|
||||
widget.callback?.(newValue)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
})
|
||||
|
||||
const displayLabel = customRef((track, trigger) => {
|
||||
return {
|
||||
get() {
|
||||
track()
|
||||
return widget.label || widget.name
|
||||
},
|
||||
set(newValue: string) {
|
||||
isEditing.value = false
|
||||
|
||||
const trimmedLabel = newValue.trim()
|
||||
|
||||
const success = renameWidget(widget, node, trimmedLabel, parents)
|
||||
|
||||
if (success) {
|
||||
canvasStore.canvas?.setDirty(true)
|
||||
trigger()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'widget-item col-span-full grid grid-cols-subgrid rounded-lg group',
|
||||
isDraggable &&
|
||||
'draggable-item !will-change-auto drag-handle cursor-grab bg-comfy-menu-bg [&.is-draggable]:cursor-grabbing outline-comfy-menu-bg [&.is-draggable]:outline-4 [&.is-draggable]:outline-offset-0 [&.is-draggable]:opacity-70'
|
||||
)
|
||||
"
|
||||
>
|
||||
<!-- widget header -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'min-h-8 flex items-center justify-between gap-1 mb-1.5 min-w-0',
|
||||
isDraggable && 'pointer-events-none'
|
||||
)
|
||||
"
|
||||
>
|
||||
<EditableText
|
||||
v-if="widget.name"
|
||||
:model-value="displayLabel"
|
||||
:is-editing="isEditing"
|
||||
:input-attrs="{ placeholder: widget.name }"
|
||||
class="text-sm leading-8 p-0 m-0 truncate pointer-events-auto cursor-text"
|
||||
@edit="displayLabel = $event"
|
||||
@cancel="isEditing = false"
|
||||
@click="isEditing = true"
|
||||
/>
|
||||
|
||||
<span
|
||||
v-if="(showNodeName || hasParents) && sourceNodeName"
|
||||
class="text-xs text-muted-foreground flex-1 p-0 my-0 mx-1 truncate text-right min-w-10"
|
||||
>
|
||||
{{ sourceNodeName }}
|
||||
</span>
|
||||
<div class="flex items-center gap-1 shrink-0 pointer-events-auto">
|
||||
<WidgetActions
|
||||
v-model:label="displayLabel"
|
||||
:widget="widget"
|
||||
:node="node"
|
||||
:parents="parents"
|
||||
:is-shown-on-parents="isShownOnParents"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- favorite indicator -->
|
||||
<div
|
||||
v-if="
|
||||
!hiddenFavoriteIndicator &&
|
||||
favoritedWidgetsStore.isFavorited(favoriteNode, widget.name)
|
||||
"
|
||||
class="relative z-2 pointer-events-none"
|
||||
>
|
||||
<i
|
||||
class="absolute -right-1 -top-1 pi pi-star-fill text-xs text-muted-foreground pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
<!-- widget content -->
|
||||
<component
|
||||
:is="widgetComponent"
|
||||
v-model="widgetValue"
|
||||
:widget="enhancedWidget"
|
||||
:node-id="String(node.id)"
|
||||
:node-type="node.type"
|
||||
:class="cn('col-span-1', shouldExpand(widget.type) && 'min-h-36')"
|
||||
/>
|
||||
<!-- Drag handle -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-none mt-1.5 mx-auto max-w-40 w-1/2 h-1 rounded-lg bg-transparent transition-colors duration-150',
|
||||
'group-hover:bg-interface-stroke group-[.is-draggable]:bg-component-node-widget-background-highlighted',
|
||||
!isDraggable && 'opacity-0'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
21
src/components/rightSidePanel/settings/FieldSwitch.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
|
||||
import LayoutField from './LayoutField.vue'
|
||||
|
||||
defineProps<{
|
||||
label: string
|
||||
tooltip?: string
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<boolean>({ default: false })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LayoutField singleline :label :tooltip>
|
||||
<ToggleSwitch
|
||||
v-model="modelValue"
|
||||
class="transition-transform active:scale-90"
|
||||
/>
|
||||
</LayoutField>
|
||||
</template>
|
||||
38
src/components/rightSidePanel/settings/LayoutField.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
defineProps<{
|
||||
label: string
|
||||
tooltip?: string
|
||||
singleline?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn('flex gap-2', singleline ? 'items-center justify-between' : 'flex-col')
|
||||
"
|
||||
>
|
||||
<span
|
||||
v-tooltip.left="
|
||||
tooltip
|
||||
? {
|
||||
value: tooltip,
|
||||
showDelay: 300
|
||||
}
|
||||
: null
|
||||
"
|
||||
:class="
|
||||
cn(
|
||||
'text-sm text-muted-foreground truncate',
|
||||
tooltip ? 'cursor-help' : '',
|
||||
singleline ? 'flex-1' : ''
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
61
src/components/rightSidePanel/settings/NodeSettings.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div class="space-y-4 text-sm text-muted-foreground">
|
||||
<SetNodeState
|
||||
v-if="isNodes(targetNodes)"
|
||||
:nodes="targetNodes"
|
||||
@changed="handleChanged"
|
||||
/>
|
||||
<SetNodeColor :nodes="targetNodes" @changed="handleChanged" />
|
||||
<SetPinned :nodes="targetNodes" @changed="handleChanged" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { shallowRef, watchEffect } from 'vue'
|
||||
|
||||
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { isLGraphGroup } from '@/utils/litegraphUtil'
|
||||
|
||||
import SetNodeColor from './SetNodeColor.vue'
|
||||
import SetNodeState from './SetNodeState.vue'
|
||||
import SetPinned from './SetPinned.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
/**
|
||||
* - If the item is a Group, Node State cannot be set
|
||||
* as Groups do not have a 'mode' property.
|
||||
*
|
||||
* - The nodes array can contain either all Nodes or all Groups,
|
||||
* but it must not be a mix of both.
|
||||
*/
|
||||
nodes?: LGraphNode[] | LGraphGroup[]
|
||||
}>()
|
||||
|
||||
const targetNodes = shallowRef<LGraphNode[] | LGraphGroup[]>([])
|
||||
watchEffect(() => {
|
||||
if (props.nodes) {
|
||||
targetNodes.value = props.nodes
|
||||
} else {
|
||||
targetNodes.value = []
|
||||
}
|
||||
})
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
function isNodes(nodes: LGraphNode[] | LGraphGroup[]): nodes is LGraphNode[] {
|
||||
return !nodes.some((node) => isLGraphGroup(node))
|
||||
}
|
||||
|
||||
function handleChanged() {
|
||||
/**
|
||||
* This is not a random comment—it's crucial.
|
||||
* Otherwise, the UI cannot update correctly.
|
||||
* There is a bug with triggerRef here, so we can't use triggerRef.
|
||||
* We'll work around it for now and later submit a Vue issue and pull request to fix it.
|
||||
*/
|
||||
targetNodes.value = targetNodes.value.slice()
|
||||
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
</script>
|
||||
144
src/components/rightSidePanel/settings/SetNodeColor.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ColorOption } from '@/lib/litegraph/src/litegraph'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import LayoutField from './LayoutField.vue'
|
||||
|
||||
/**
|
||||
* Good design limits dependencies and simplifies the interface of the abstraction layer.
|
||||
* Here, we only care about the getColorOption and setColorOption methods,
|
||||
* and do not concern ourselves with other methods.
|
||||
*/
|
||||
type PickedNode = Pick<LGraphNode, 'getColorOption' | 'setColorOption'>
|
||||
|
||||
const { nodes } = defineProps<{ nodes: PickedNode[] }>()
|
||||
const emit = defineEmits<{ (e: 'changed'): void }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
|
||||
type NodeColorOption = {
|
||||
name: string
|
||||
localizedName: () => string
|
||||
value: {
|
||||
dark: string
|
||||
light: string
|
||||
ringDark: string
|
||||
ringLight: string
|
||||
}
|
||||
}
|
||||
|
||||
const nodeColorEntries = Object.entries(LGraphCanvas.node_colors)
|
||||
|
||||
function getColorValue(color: string): NodeColorOption['value'] {
|
||||
return {
|
||||
dark: adjustColor(color, { lightness: 0.3 }),
|
||||
light: adjustColor(color, { lightness: 0.4 }),
|
||||
ringDark: adjustColor(color, { lightness: 0.5 }),
|
||||
ringLight: adjustColor(color, { lightness: 0.1 })
|
||||
}
|
||||
}
|
||||
|
||||
const NO_COLOR_OPTION: NodeColorOption = {
|
||||
name: 'noColor',
|
||||
localizedName: () => t('color.noColor'),
|
||||
value: getColorValue(LiteGraph.NODE_DEFAULT_BGCOLOR)
|
||||
}
|
||||
|
||||
const colorOptions: NodeColorOption[] = [
|
||||
NO_COLOR_OPTION,
|
||||
...nodeColorEntries.map(([name, color]) => ({
|
||||
name,
|
||||
localizedName: () => t(`color.${name}`),
|
||||
value: getColorValue(color.bgcolor)
|
||||
}))
|
||||
]
|
||||
|
||||
const isLightTheme = computed(
|
||||
() => colorPaletteStore.completedActivePalette.light_theme
|
||||
)
|
||||
|
||||
const nodeColor = computed<NodeColorOption['name'] | null>({
|
||||
get() {
|
||||
if (nodes.length === 0) return null
|
||||
const theColorOptions = nodes.map((item) => item.getColorOption())
|
||||
|
||||
let colorOption: ColorOption | null | false = theColorOptions[0]
|
||||
if (!theColorOptions.every((option) => option === colorOption)) {
|
||||
colorOption = false
|
||||
}
|
||||
|
||||
if (colorOption === false) return null
|
||||
if (colorOption == null || (!colorOption.bgcolor && !colorOption.color))
|
||||
return NO_COLOR_OPTION.name
|
||||
return (
|
||||
nodeColorEntries.find(
|
||||
([_, color]) =>
|
||||
color.bgcolor === colorOption.bgcolor &&
|
||||
color.color === colorOption.color
|
||||
)?.[0] ?? null
|
||||
)
|
||||
},
|
||||
set(colorName) {
|
||||
if (colorName === null) return
|
||||
|
||||
const canvasColorOption =
|
||||
colorName === NO_COLOR_OPTION.name
|
||||
? null
|
||||
: LGraphCanvas.node_colors[colorName]
|
||||
|
||||
for (const item of nodes) {
|
||||
item.setColorOption(canvasColorOption)
|
||||
}
|
||||
|
||||
emit('changed')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LayoutField :label="t('rightSidePanel.color')">
|
||||
<div
|
||||
class="bg-secondary-background border-none rounded-lg p-1 grid grid-cols-5 gap-1 justify-items-center"
|
||||
>
|
||||
<button
|
||||
v-for="option of colorOptions"
|
||||
:key="option.name"
|
||||
:class="
|
||||
cn(
|
||||
'size-8 rounded-lg bg-transparent border-0 outline-0 ring-0 text-left flex justify-center items-center cursor-pointer',
|
||||
option.name === nodeColor
|
||||
? 'bg-interface-menu-component-surface-selected'
|
||||
: 'hover:bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
"
|
||||
@click="nodeColor = option.name"
|
||||
>
|
||||
<div
|
||||
v-tooltip.top="option.localizedName()"
|
||||
:class="cn('size-4 rounded-full ring-2 ring-gray-500/10')"
|
||||
:style="{
|
||||
backgroundColor: isLightTheme
|
||||
? option.value.light
|
||||
: option.value.dark,
|
||||
'--tw-ring-color':
|
||||
option.name === nodeColor
|
||||
? isLightTheme
|
||||
? option.value.ringLight
|
||||
: option.value.ringDark
|
||||
: undefined
|
||||
}"
|
||||
:data-testid="option.name"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</LayoutField>
|
||||
</template>
|
||||
71
src/components/rightSidePanel/settings/SetNodeState.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
|
||||
import FormSelectButton from '@/renderer/extensions/vueNodes/widgets/components/form/FormSelectButton.vue'
|
||||
|
||||
import LayoutField from './LayoutField.vue'
|
||||
|
||||
/**
|
||||
* Good design limits dependencies and simplifies the interface of the abstraction layer.
|
||||
* Here, we only care about the mode method,
|
||||
* and do not concern ourselves with other methods.
|
||||
*/
|
||||
type PickedNode = Pick<LGraphNode, 'mode'>
|
||||
|
||||
const { nodes } = defineProps<{ nodes: PickedNode[] }>()
|
||||
const emit = defineEmits<{ (e: 'changed'): void }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const nodeState = computed({
|
||||
get() {
|
||||
let mode: LGraphNode['mode'] | null = null
|
||||
|
||||
if (nodes.length === 0) return null
|
||||
|
||||
// For multiple nodes, if all nodes have the same mode, return that mode, otherwise return null
|
||||
if (nodes.length > 1) {
|
||||
mode = nodes[0].mode
|
||||
if (!nodes.every((node) => node.mode === mode)) {
|
||||
mode = null
|
||||
}
|
||||
} else {
|
||||
mode = nodes[0].mode
|
||||
}
|
||||
|
||||
return mode
|
||||
},
|
||||
set(value: LGraphNode['mode']) {
|
||||
nodes.forEach((node) => {
|
||||
node.mode = value
|
||||
})
|
||||
emit('changed')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LayoutField :label="t('rightSidePanel.nodeState')">
|
||||
<FormSelectButton
|
||||
v-model="nodeState"
|
||||
class="w-full"
|
||||
:options="[
|
||||
{
|
||||
label: t('rightSidePanel.normal'),
|
||||
value: LGraphEventMode.ALWAYS
|
||||
},
|
||||
{
|
||||
label: t('rightSidePanel.bypass'),
|
||||
value: LGraphEventMode.BYPASS
|
||||
},
|
||||
{
|
||||
label: t('rightSidePanel.mute'),
|
||||
value: LGraphEventMode.NEVER
|
||||
}
|
||||
]"
|
||||
/>
|
||||
</LayoutField>
|
||||
</template>
|
||||
35
src/components/rightSidePanel/settings/SetPinned.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
import FieldSwitch from './FieldSwitch.vue'
|
||||
|
||||
type PickedNode = Pick<LGraphNode, 'pinned' | 'pin'>
|
||||
|
||||
/**
|
||||
* Good design limits dependencies and simplifies the interface of the abstraction layer.
|
||||
* Here, we only care about the pinned and pin methods,
|
||||
* and do not concern ourselves with other methods.
|
||||
*/
|
||||
const { nodes } = defineProps<{ nodes: PickedNode[] }>()
|
||||
const emit = defineEmits<{ (e: 'changed'): void }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Pinned state
|
||||
const isPinned = computed<boolean>({
|
||||
get() {
|
||||
return nodes.some((node) => node.pinned)
|
||||
},
|
||||
set(value) {
|
||||
nodes.forEach((node) => node.pin(value))
|
||||
emit('changed')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FieldSwitch v-model="isPinned" :label="t('rightSidePanel.pinned')" />
|
||||
</template>
|
||||
205
src/components/rightSidePanel/settings/TabGlobalSettings.vue
Normal file
@@ -0,0 +1,205 @@
|
||||
<script setup lang="ts">
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
import Select from 'primevue/select'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LinkRenderType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { LinkMarkerShape } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { WidgetInputBaseClass } from '@/renderer/extensions/vueNodes/widgets/components/layout'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
import FieldSwitch from './FieldSwitch.vue'
|
||||
import LayoutField from './LayoutField.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const settingStore = useSettingStore()
|
||||
const dialogService = useDialogService()
|
||||
|
||||
// NODES settings
|
||||
const showAdvancedParameters = ref(false) // Placeholder for future implementation
|
||||
|
||||
const showToolbox = computed({
|
||||
get: () => settingStore.get('Comfy.Canvas.SelectionToolbox'),
|
||||
set: (value) => settingStore.set('Comfy.Canvas.SelectionToolbox', value)
|
||||
})
|
||||
|
||||
const nodes2Enabled = computed({
|
||||
get: () => settingStore.get('Comfy.VueNodes.Enabled'),
|
||||
set: (value) => settingStore.set('Comfy.VueNodes.Enabled', value)
|
||||
})
|
||||
|
||||
// CANVAS settings
|
||||
const gridSpacing = computed({
|
||||
get: () => settingStore.get('Comfy.SnapToGrid.GridSize'),
|
||||
set: (value) => settingStore.set('Comfy.SnapToGrid.GridSize', value)
|
||||
})
|
||||
|
||||
const snapToGrid = computed({
|
||||
get: () => settingStore.get('pysssss.SnapToGrid'),
|
||||
set: (value) => settingStore.set('pysssss.SnapToGrid', value)
|
||||
})
|
||||
|
||||
// CONNECTION LINKS settings
|
||||
const linkShape = computed({
|
||||
get: () => settingStore.get('Comfy.Graph.LinkMarkers'),
|
||||
set: (value) => settingStore.set('Comfy.Graph.LinkMarkers', value)
|
||||
})
|
||||
|
||||
const linkShapeOptions = computed(() => [
|
||||
{ value: LinkMarkerShape.None, label: t('g.none') },
|
||||
{ value: LinkMarkerShape.Circle, label: t('shape.circle') },
|
||||
{ value: LinkMarkerShape.Arrow, label: t('shape.arrow') }
|
||||
])
|
||||
|
||||
let theOldLinkRenderMode: LinkRenderType = LiteGraph.SPLINE_LINK
|
||||
const showConnectedLinks = computed({
|
||||
get: () => settingStore.get('Comfy.LinkRenderMode') !== LiteGraph.HIDDEN_LINK,
|
||||
set: (value) => {
|
||||
let oldLinkRenderMode = settingStore.get('Comfy.LinkRenderMode')
|
||||
if (oldLinkRenderMode !== LiteGraph.HIDDEN_LINK) {
|
||||
theOldLinkRenderMode = oldLinkRenderMode
|
||||
}
|
||||
const newMode = value ? theOldLinkRenderMode : LiteGraph.HIDDEN_LINK
|
||||
settingStore.set('Comfy.LinkRenderMode', newMode)
|
||||
}
|
||||
})
|
||||
|
||||
const GRID_SIZE_MIN = 1
|
||||
const GRID_SIZE_MAX = 100
|
||||
const GRID_SIZE_STEP = 1
|
||||
|
||||
function updateGridSpacingFromSlider(values?: number[]) {
|
||||
if (!values?.length) return
|
||||
gridSpacing.value = values[0]
|
||||
}
|
||||
|
||||
function updateGridSpacingFromInput(value: number | null | undefined) {
|
||||
if (typeof value !== 'number') return
|
||||
|
||||
const clampedValue = Math.min(GRID_SIZE_MAX, Math.max(GRID_SIZE_MIN, value))
|
||||
gridSpacing.value = Math.round(clampedValue / GRID_SIZE_STEP) * GRID_SIZE_STEP
|
||||
}
|
||||
|
||||
function openFullSettings() {
|
||||
dialogService.showSettingsDialog()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col border-t border-interface-stroke">
|
||||
<!-- NODES Section -->
|
||||
<PropertiesAccordionItem class="border-b border-interface-stroke">
|
||||
<template #label>
|
||||
{{ t('rightSidePanel.globalSettings.nodes') }}
|
||||
</template>
|
||||
<div class="space-y-4 px-4 py-3">
|
||||
<FieldSwitch
|
||||
v-model="showAdvancedParameters"
|
||||
:label="t('rightSidePanel.globalSettings.showAdvanced')"
|
||||
:tooltip="t('rightSidePanel.globalSettings.showAdvancedTooltip')"
|
||||
/>
|
||||
<FieldSwitch
|
||||
v-model="showToolbox"
|
||||
:label="t('rightSidePanel.globalSettings.showToolbox')"
|
||||
/>
|
||||
<FieldSwitch
|
||||
v-model="nodes2Enabled"
|
||||
:label="t('rightSidePanel.globalSettings.nodes2')"
|
||||
/>
|
||||
</div>
|
||||
</PropertiesAccordionItem>
|
||||
|
||||
<!-- CANVAS Section -->
|
||||
<PropertiesAccordionItem class="border-b border-interface-stroke">
|
||||
<template #label>
|
||||
{{ t('rightSidePanel.globalSettings.canvas') }}
|
||||
</template>
|
||||
<div class="space-y-4 px-4 py-3">
|
||||
<LayoutField :label="t('rightSidePanel.globalSettings.gridSpacing')">
|
||||
<div
|
||||
:class="
|
||||
cn(WidgetInputBaseClass, 'flex items-center gap-2 pl-3 pr-2')
|
||||
"
|
||||
>
|
||||
<Slider
|
||||
:model-value="[gridSpacing]"
|
||||
class="flex-grow text-xs"
|
||||
:min="GRID_SIZE_MIN"
|
||||
:max="GRID_SIZE_MAX"
|
||||
:step="GRID_SIZE_STEP"
|
||||
@update:model-value="updateGridSpacingFromSlider"
|
||||
/>
|
||||
<InputNumber
|
||||
:model-value="gridSpacing"
|
||||
class="w-16"
|
||||
size="small"
|
||||
pt:pc-input-text:root="min-w-[4ch] bg-transparent border-none text-center truncate"
|
||||
:min="GRID_SIZE_MIN"
|
||||
:max="GRID_SIZE_MAX"
|
||||
:step="GRID_SIZE_STEP"
|
||||
:allow-empty="false"
|
||||
@update:model-value="updateGridSpacingFromInput"
|
||||
/>
|
||||
</div>
|
||||
</LayoutField>
|
||||
<FieldSwitch
|
||||
v-model="snapToGrid"
|
||||
:label="t('rightSidePanel.globalSettings.snapNodesToGrid')"
|
||||
/>
|
||||
</div>
|
||||
</PropertiesAccordionItem>
|
||||
|
||||
<!-- CONNECTION LINKS Section -->
|
||||
<PropertiesAccordionItem class="border-b border-interface-stroke">
|
||||
<template #label>
|
||||
{{ t('rightSidePanel.globalSettings.connectionLinks') }}
|
||||
</template>
|
||||
<div class="space-y-4 px-4 py-3">
|
||||
<LayoutField :label="t('rightSidePanel.globalSettings.linkShape')">
|
||||
<Select
|
||||
v-model="linkShape"
|
||||
:options="linkShapeOptions"
|
||||
:aria-label="t('rightSidePanel.globalSettings.linkShape')"
|
||||
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
|
||||
size="small"
|
||||
:pt="{
|
||||
option: 'text-xs',
|
||||
dropdown: 'w-8',
|
||||
label: cn('truncate min-w-[4ch]', $slots.default && 'mr-5'),
|
||||
overlay: 'w-fit min-w-full'
|
||||
}"
|
||||
data-capture-wheel="true"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
/>
|
||||
</LayoutField>
|
||||
<FieldSwitch
|
||||
v-model="showConnectedLinks"
|
||||
:label="t('rightSidePanel.globalSettings.showConnectedLinks')"
|
||||
/>
|
||||
</div>
|
||||
</PropertiesAccordionItem>
|
||||
|
||||
<!-- View all settings button -->
|
||||
<div
|
||||
class="flex items-center justify-center p-4 border-b border-interface-stroke"
|
||||
>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="sm"
|
||||
class="gap-2 text-sm"
|
||||
@click="openFullSettings"
|
||||
>
|
||||
{{ t('rightSidePanel.globalSettings.viewAllSettings') }}
|
||||
<i class="icon-[lucide--settings] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,245 +1,58 @@
|
||||
<template>
|
||||
<div class="space-y-4 p-3 text-sm text-muted-foreground">
|
||||
<!-- Node State -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<span>
|
||||
{{ t('rightSidePanel.nodeState') }}
|
||||
</span>
|
||||
<FormSelectButton
|
||||
v-model="nodeState"
|
||||
class="w-full"
|
||||
:options="[
|
||||
{
|
||||
label: t('rightSidePanel.normal'),
|
||||
value: LGraphEventMode.ALWAYS
|
||||
},
|
||||
{
|
||||
label: t('rightSidePanel.bypass'),
|
||||
value: LGraphEventMode.BYPASS
|
||||
},
|
||||
{
|
||||
label: t('rightSidePanel.mute'),
|
||||
value: LGraphEventMode.NEVER
|
||||
}
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Color Picker -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<span>
|
||||
{{ t('rightSidePanel.color') }}
|
||||
</span>
|
||||
<div
|
||||
class="bg-secondary-background border-none rounded-lg p-1 grid grid-cols-5 gap-1 justify-items-center"
|
||||
>
|
||||
<button
|
||||
v-for="option of colorOptions"
|
||||
:key="option.name"
|
||||
:class="
|
||||
cn(
|
||||
'size-8 rounded-lg bg-transparent border-0 outline-0 ring-0 text-left flex justify-center items-center cursor-pointer',
|
||||
option.name === nodeColor
|
||||
? 'bg-interface-menu-component-surface-selected'
|
||||
: 'hover:bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
"
|
||||
@click="nodeColor = option.name"
|
||||
>
|
||||
<div
|
||||
v-tooltip.top="option.localizedName()"
|
||||
:class="cn('size-4 rounded-full ring-2 ring-gray-500/10')"
|
||||
:style="{
|
||||
backgroundColor: isLightTheme
|
||||
? option.value.light
|
||||
: option.value.dark,
|
||||
'--tw-ring-color':
|
||||
option.name === nodeColor
|
||||
? isLightTheme
|
||||
? option.value.ringLight
|
||||
: option.value.ringDark
|
||||
: undefined
|
||||
}"
|
||||
:data-testid="option.name"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pinned Toggle -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span>
|
||||
{{ t('rightSidePanel.pinned') }}
|
||||
</span>
|
||||
<ToggleSwitch v-model="isPinned" />
|
||||
</div>
|
||||
<div v-if="isOnlyHasNodes" class="p-4">
|
||||
<NodeSettings :nodes="theNodes" />
|
||||
</div>
|
||||
<div v-else class="border-t border-interface-stroke">
|
||||
<PropertiesAccordionItem
|
||||
v-if="hasNodes"
|
||||
class="border-b border-interface-stroke"
|
||||
:label="$t('rightSidePanel.nodes')"
|
||||
>
|
||||
<NodeSettings :nodes="theNodes" class="p-4" />
|
||||
</PropertiesAccordionItem>
|
||||
<PropertiesAccordionItem
|
||||
v-if="hasGroups"
|
||||
class="border-b border-interface-stroke"
|
||||
:label="$t('rightSidePanel.groups')"
|
||||
>
|
||||
<NodeSettings :nodes="theGroups" class="p-4" />
|
||||
</PropertiesAccordionItem>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
import { computed, shallowRef, triggerRef, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
/**
|
||||
* If we only need to show settings for Nodes,
|
||||
* there's no need to wrap them in PropertiesAccordionItem,
|
||||
* making the UI cleaner.
|
||||
* But if there are multiple types of settings,
|
||||
* it's better to wrap them; otherwise,
|
||||
* the UI would look messy.
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import type { Raw } from 'vue'
|
||||
|
||||
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ColorOption, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import FormSelectButton from '@/renderer/extensions/vueNodes/widgets/components/form/FormSelectButton.vue'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { Positionable } from '@/lib/litegraph/src/interfaces'
|
||||
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
|
||||
|
||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
import NodeSettings from './NodeSettings.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
nodes?: LGraphNode[]
|
||||
nodes: Raw<Positionable>[]
|
||||
}>()
|
||||
|
||||
/**
|
||||
* This is not random writing. It is very important.
|
||||
* Otherwise, the UI cannot be updated correctly.
|
||||
*/
|
||||
const targetNodes = shallowRef<LGraphNode[]>([])
|
||||
watchEffect(() => {
|
||||
if (props.nodes) {
|
||||
targetNodes.value = props.nodes
|
||||
} else {
|
||||
targetNodes.value = []
|
||||
}
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const isLightTheme = computed(
|
||||
() => colorPaletteStore.completedActivePalette.light_theme
|
||||
const theNodes = computed<LGraphNode[]>(() =>
|
||||
props.nodes.filter((node) => isLGraphNode(node))
|
||||
)
|
||||
|
||||
const nodeState = computed({
|
||||
get() {
|
||||
let mode: LGraphNode['mode'] | null = null
|
||||
const nodes = targetNodes.value
|
||||
const theGroups = computed<LGraphGroup[]>(() =>
|
||||
props.nodes.filter((node) => isLGraphGroup(node))
|
||||
)
|
||||
|
||||
if (nodes.length === 0) return null
|
||||
|
||||
// For multiple nodes, if all nodes have the same mode, return that mode, otherwise return null
|
||||
if (nodes.length > 1) {
|
||||
mode = nodes[0].mode
|
||||
if (!nodes.every((node) => node.mode === mode)) {
|
||||
mode = null
|
||||
}
|
||||
} else {
|
||||
mode = nodes[0].mode
|
||||
}
|
||||
|
||||
return mode
|
||||
},
|
||||
set(value: LGraphNode['mode']) {
|
||||
targetNodes.value.forEach((node) => {
|
||||
node.mode = value
|
||||
})
|
||||
/*
|
||||
* This is not random writing. It is very important.
|
||||
* Otherwise, the UI cannot be updated correctly.
|
||||
*/
|
||||
triggerRef(targetNodes)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
})
|
||||
|
||||
// Pinned state
|
||||
const isPinned = computed<boolean>({
|
||||
get() {
|
||||
return targetNodes.value.some((node) => node.pinned)
|
||||
},
|
||||
set(value) {
|
||||
targetNodes.value.forEach((node) => node.pin(value))
|
||||
/*
|
||||
* This is not random writing. It is very important.
|
||||
* Otherwise, the UI cannot be updated correctly.
|
||||
*/
|
||||
triggerRef(targetNodes)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
})
|
||||
|
||||
type NodeColorOption = {
|
||||
name: string
|
||||
localizedName: () => string
|
||||
value: {
|
||||
dark: string
|
||||
light: string
|
||||
ringDark: string
|
||||
ringLight: string
|
||||
}
|
||||
}
|
||||
|
||||
function getColorValue(color: string): NodeColorOption['value'] {
|
||||
return {
|
||||
dark: adjustColor(color, { lightness: 0.3 }),
|
||||
light: adjustColor(color, { lightness: 0.4 }),
|
||||
ringDark: adjustColor(color, { lightness: 0.5 }),
|
||||
ringLight: adjustColor(color, { lightness: 0.1 })
|
||||
}
|
||||
}
|
||||
|
||||
const NO_COLOR_OPTION: NodeColorOption = {
|
||||
name: 'noColor',
|
||||
localizedName: () => t('color.noColor'),
|
||||
value: getColorValue(LiteGraph.NODE_DEFAULT_BGCOLOR)
|
||||
}
|
||||
|
||||
const nodeColorEntries = Object.entries(LGraphCanvas.node_colors)
|
||||
|
||||
const colorOptions: NodeColorOption[] = [
|
||||
NO_COLOR_OPTION,
|
||||
...nodeColorEntries.map(([name, color]) => ({
|
||||
name,
|
||||
localizedName: () => t(`color.${name}`),
|
||||
value: getColorValue(color.bgcolor)
|
||||
}))
|
||||
]
|
||||
|
||||
const nodeColor = computed<NodeColorOption['name'] | null>({
|
||||
get() {
|
||||
if (targetNodes.value.length === 0) return null
|
||||
const theColorOptions = targetNodes.value.map((item) =>
|
||||
item.getColorOption()
|
||||
)
|
||||
|
||||
let colorOption: ColorOption | null | false = theColorOptions[0]
|
||||
if (!theColorOptions.every((option) => option === colorOption)) {
|
||||
colorOption = false
|
||||
}
|
||||
|
||||
if (colorOption === false) return null
|
||||
if (colorOption == null || (!colorOption.bgcolor && !colorOption.color))
|
||||
return NO_COLOR_OPTION.name
|
||||
return (
|
||||
nodeColorEntries.find(
|
||||
([_, color]) =>
|
||||
color.bgcolor === colorOption.bgcolor &&
|
||||
color.color === colorOption.color
|
||||
)?.[0] ?? null
|
||||
)
|
||||
},
|
||||
set(colorName) {
|
||||
if (colorName === null) return
|
||||
|
||||
const canvasColorOption =
|
||||
colorName === NO_COLOR_OPTION.name
|
||||
? null
|
||||
: LGraphCanvas.node_colors[colorName]
|
||||
|
||||
for (const item of targetNodes.value) {
|
||||
item.setColorOption(canvasColorOption)
|
||||
}
|
||||
/*
|
||||
* This is not random writing. It is very important.
|
||||
* Otherwise, the UI cannot be updated correctly.
|
||||
*/
|
||||
triggerRef(targetNodes)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
})
|
||||
const hasGroups = computed(() => theGroups.value.length > 0)
|
||||
const hasNodes = computed(() => theNodes.value.length > 0)
|
||||
const isOnlyHasNodes = computed(() => hasNodes.value && !hasGroups.value)
|
||||
</script>
|
||||
|
||||
178
src/components/rightSidePanel/shared.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { Positionable } from '@/lib/litegraph/src/interfaces'
|
||||
import { describe, expect, it, beforeEach } from 'vitest'
|
||||
import { flatAndCategorizeSelectedItems, searchWidgets } from './shared'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
describe('searchWidgets', () => {
|
||||
const createWidget = (
|
||||
name: string,
|
||||
type: string,
|
||||
value?: string,
|
||||
label?: string
|
||||
): { widget: IBaseWidget } => ({
|
||||
widget: {
|
||||
name,
|
||||
type,
|
||||
value,
|
||||
label
|
||||
} as IBaseWidget
|
||||
})
|
||||
|
||||
it('should return all widgets when query is empty', () => {
|
||||
const widgets = [
|
||||
createWidget('width', 'number', '100'),
|
||||
createWidget('height', 'number', '200')
|
||||
]
|
||||
const result = searchWidgets(widgets, '')
|
||||
expect(result).toEqual(widgets)
|
||||
})
|
||||
|
||||
it('should filter widgets by name, label, type, or value', () => {
|
||||
const widgets = [
|
||||
createWidget('width', 'number', '100', 'Size Control'),
|
||||
createWidget('height', 'slider', '200', 'Image Height'),
|
||||
createWidget('quality', 'text', 'high', 'Quality')
|
||||
]
|
||||
|
||||
expect(searchWidgets(widgets, 'width')).toHaveLength(1)
|
||||
expect(searchWidgets(widgets, 'slider')).toHaveLength(1)
|
||||
expect(searchWidgets(widgets, 'high')).toHaveLength(1)
|
||||
expect(searchWidgets(widgets, 'image')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should handle multiple search words', () => {
|
||||
const widgets = [
|
||||
createWidget('width', 'number', '100', 'Image Width'),
|
||||
createWidget('height', 'number', '200', 'Image Height')
|
||||
]
|
||||
const result = searchWidgets(widgets, 'image width')
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].widget.name).toBe('width')
|
||||
})
|
||||
|
||||
it('should be case insensitive', () => {
|
||||
const widgets = [createWidget('Width', 'Number', '100', 'Image Width')]
|
||||
const result = searchWidgets(widgets, 'IMAGE width')
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('flatAndCategorizeSelectedItems', () => {
|
||||
let testGroup1: LGraphGroup
|
||||
let testGroup2: LGraphGroup
|
||||
let testNode1: LGraphNode
|
||||
let testNode2: LGraphNode
|
||||
let testNode3: LGraphNode
|
||||
|
||||
beforeEach(() => {
|
||||
testGroup1 = new LGraphGroup('Group 1', 1)
|
||||
testGroup2 = new LGraphGroup('Group 2', 2)
|
||||
testNode1 = new LGraphNode('Node 1')
|
||||
testNode2 = new LGraphNode('Node 2')
|
||||
testNode3 = new LGraphNode('Node 3')
|
||||
})
|
||||
|
||||
it('should return empty arrays for empty input', () => {
|
||||
const result = flatAndCategorizeSelectedItems([])
|
||||
|
||||
expect(result.all).toEqual([])
|
||||
expect(result.nodes).toEqual([])
|
||||
expect(result.groups).toEqual([])
|
||||
expect(result.others).toEqual([])
|
||||
expect(result.nodeToParentGroup.size).toBe(0)
|
||||
})
|
||||
|
||||
it('should categorize nodes', () => {
|
||||
const result = flatAndCategorizeSelectedItems([testNode1])
|
||||
|
||||
expect(result.all).toEqual([testNode1])
|
||||
expect(result.nodes).toEqual([testNode1])
|
||||
expect(result.groups).toEqual([])
|
||||
expect(result.others).toEqual([])
|
||||
expect(result.nodeToParentGroup.size).toBe(0)
|
||||
})
|
||||
|
||||
it('should categorize single group without children', () => {
|
||||
const result = flatAndCategorizeSelectedItems([testGroup1])
|
||||
|
||||
expect(result.all).toEqual([testGroup1])
|
||||
expect(result.nodes).toEqual([])
|
||||
expect(result.groups).toEqual([testGroup1])
|
||||
expect(result.others).toEqual([])
|
||||
})
|
||||
|
||||
it('should flatten group with child nodes', () => {
|
||||
testGroup1._children.add(testNode1)
|
||||
testGroup1._children.add(testNode2)
|
||||
|
||||
const result = flatAndCategorizeSelectedItems([testGroup1])
|
||||
|
||||
expect(result.all).toEqual([testGroup1, testNode1, testNode2])
|
||||
expect(result.nodes).toEqual([testNode1, testNode2])
|
||||
expect(result.groups).toEqual([testGroup1])
|
||||
expect(result.nodeToParentGroup.get(testNode1)).toBe(testGroup1)
|
||||
expect(result.nodeToParentGroup.get(testNode2)).toBe(testGroup1)
|
||||
})
|
||||
|
||||
it('should handle nested groups', () => {
|
||||
testGroup1._children.add(testGroup2)
|
||||
testGroup2._children.add(testNode1)
|
||||
|
||||
const result = flatAndCategorizeSelectedItems([testGroup1])
|
||||
|
||||
expect(result.all).toEqual([testGroup1, testGroup2, testNode1])
|
||||
expect(result.nodes).toEqual([testNode1])
|
||||
expect(result.groups).toEqual([testGroup1, testGroup2])
|
||||
expect(result.nodeToParentGroup.get(testNode1)).toBe(testGroup2)
|
||||
expect(result.nodeToParentGroup.has(testGroup2 as any)).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle mixed selection of nodes and groups', () => {
|
||||
testGroup1._children.add(testNode2)
|
||||
|
||||
const result = flatAndCategorizeSelectedItems([
|
||||
testNode1,
|
||||
testGroup1,
|
||||
testNode3
|
||||
])
|
||||
|
||||
expect(result.all).toContain(testNode1)
|
||||
expect(result.all).toContain(testNode2)
|
||||
expect(result.all).toContain(testNode3)
|
||||
expect(result.all).toContain(testGroup1)
|
||||
|
||||
expect(result.nodes).toEqual([testNode1, testNode2, testNode3])
|
||||
expect(result.groups).toEqual([testGroup1])
|
||||
expect(result.nodeToParentGroup.get(testNode1)).toBeUndefined()
|
||||
expect(result.nodeToParentGroup.get(testNode2)).toBe(testGroup1)
|
||||
expect(result.nodeToParentGroup.get(testNode3)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should remove duplicate items across group and direct selection', () => {
|
||||
testGroup1._children.add(testNode1)
|
||||
|
||||
const result = flatAndCategorizeSelectedItems([testGroup1, testNode1])
|
||||
|
||||
expect(result.all).toEqual([testGroup1, testNode1])
|
||||
expect(result.nodes).toEqual([testNode1])
|
||||
expect(result.groups).toEqual([testGroup1])
|
||||
expect(result.nodeToParentGroup.get(testNode1)).toBe(testGroup1)
|
||||
})
|
||||
|
||||
it('should handle non-node/non-group items as others', () => {
|
||||
const unknownItem = { pos: [0, 0], size: [100, 100] } as Positionable
|
||||
|
||||
const result = flatAndCategorizeSelectedItems([
|
||||
testNode1,
|
||||
unknownItem,
|
||||
testGroup1
|
||||
])
|
||||
|
||||
expect(result.nodes).toEqual([testNode1])
|
||||
expect(result.groups).toEqual([testGroup1])
|
||||
expect(result.others).toEqual([unknownItem])
|
||||
expect(result.all).not.toContain(unknownItem)
|
||||
})
|
||||
})
|
||||
271
src/components/rightSidePanel/shared.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import type { InjectionKey, MaybeRefOrGetter } from 'vue'
|
||||
import { computed, toValue } from 'vue'
|
||||
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
import type { Positionable } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
|
||||
|
||||
export const GetNodeParentGroupKey: InjectionKey<
|
||||
(node: LGraphNode) => LGraphGroup | null
|
||||
> = Symbol('getNodeParentGroup')
|
||||
|
||||
export type NodeWidgetsList = Array<{ node: LGraphNode; widget: IBaseWidget }>
|
||||
export type NodeWidgetsListList = Array<{
|
||||
node: LGraphNode
|
||||
widgets: NodeWidgetsList
|
||||
}>
|
||||
|
||||
/**
|
||||
* Searches widgets in a list and returns search results.
|
||||
* Filters by name, localized label, type, and user-input value.
|
||||
* Performs basic tokenization of the query string.
|
||||
*/
|
||||
export function searchWidgets<T extends { widget: IBaseWidget }[]>(
|
||||
list: T,
|
||||
query: string
|
||||
): T {
|
||||
if (query.trim() === '') {
|
||||
return list
|
||||
}
|
||||
const words = query.trim().toLowerCase().split(' ')
|
||||
return list.filter(({ widget }) => {
|
||||
const label = widget.label?.toLowerCase()
|
||||
const name = widget.name.toLowerCase()
|
||||
const type = widget.type.toLowerCase()
|
||||
const value = widget.value?.toString().toLowerCase()
|
||||
return words.every(
|
||||
(word) =>
|
||||
name.includes(word) ||
|
||||
label?.includes(word) ||
|
||||
type?.includes(word) ||
|
||||
value?.includes(word)
|
||||
)
|
||||
}) as T
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches widgets and nodes in a list and returns search results.
|
||||
* First checks if the node title matches the query (if so, keeps entire node).
|
||||
* Otherwise, filters widgets using searchWidgets.
|
||||
* Performs basic tokenization of the query string.
|
||||
*/
|
||||
export function searchWidgetsAndNodes(
|
||||
list: NodeWidgetsListList,
|
||||
query: string
|
||||
): NodeWidgetsListList {
|
||||
if (query.trim() === '') {
|
||||
return list
|
||||
}
|
||||
const words = query.trim().toLowerCase().split(' ')
|
||||
return list
|
||||
.map((item) => {
|
||||
const { node } = item
|
||||
const title = node.getTitle().toLowerCase()
|
||||
if (words.every((word) => title.includes(word))) {
|
||||
return { ...item, keep: true }
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
keep: false,
|
||||
widgets: searchWidgets(item.widgets, query)
|
||||
}
|
||||
})
|
||||
.filter((item) => item.keep || item.widgets.length > 0)
|
||||
}
|
||||
|
||||
type MixedSelectionItem = LGraphGroup | LGraphNode
|
||||
type FlatAndCategorizeSelectedItemsResult = {
|
||||
all: MixedSelectionItem[]
|
||||
nodes: LGraphNode[]
|
||||
groups: LGraphGroup[]
|
||||
others: Positionable[]
|
||||
nodeToParentGroup: Map<LGraphNode, LGraphGroup>
|
||||
}
|
||||
|
||||
type FlatItemsContext = {
|
||||
nodeToParentGroup: Map<LGraphNode, LGraphGroup>
|
||||
depth: number
|
||||
parentGroup?: LGraphGroup
|
||||
}
|
||||
|
||||
/**
|
||||
* The selected items may contain "Group" nodes, which can include child nodes.
|
||||
* This function flattens such structures and categorizes items into:
|
||||
* - all: all categorizable nodes (does not include nodes in "others")
|
||||
* - nodes: node items
|
||||
* - groups: group items
|
||||
* - others: items not currently supported
|
||||
* - nodeToParentGroup: a map from each node to its direct parent group (if any)
|
||||
* @param items The selected items to flatten and categorize
|
||||
* @returns An object containing arrays: all, nodes, groups, others, and nodeToParentGroup map
|
||||
*/
|
||||
export function flatAndCategorizeSelectedItems(
|
||||
items: Positionable[]
|
||||
): FlatAndCategorizeSelectedItemsResult {
|
||||
const ctx: FlatItemsContext = {
|
||||
nodeToParentGroup: new Map<LGraphNode, LGraphGroup>(),
|
||||
depth: 0
|
||||
}
|
||||
const { all, nodes, groups, others } = flatItems(items, ctx)
|
||||
return {
|
||||
all: repeatItems(all),
|
||||
nodes: repeatItems(nodes),
|
||||
groups: repeatItems(groups),
|
||||
others: repeatItems(others),
|
||||
nodeToParentGroup: ctx.nodeToParentGroup
|
||||
}
|
||||
}
|
||||
|
||||
export function useFlatAndCategorizeSelectedItems(
|
||||
items: MaybeRefOrGetter<Positionable[]>
|
||||
) {
|
||||
const result = computed(() => flatAndCategorizeSelectedItems(toValue(items)))
|
||||
|
||||
return {
|
||||
flattedItems: computed(() => result.value.all),
|
||||
selectedNodes: computed(() => result.value.nodes),
|
||||
selectedGroups: computed(() => result.value.groups),
|
||||
selectedOthers: computed(() => result.value.others),
|
||||
nodeToParentGroup: computed(() => result.value.nodeToParentGroup)
|
||||
}
|
||||
}
|
||||
|
||||
function flatItems(
|
||||
items: Positionable[],
|
||||
ctx: FlatItemsContext
|
||||
): Omit<FlatAndCategorizeSelectedItemsResult, 'nodeToParentGroup'> {
|
||||
const result: MixedSelectionItem[] = []
|
||||
const nodes: LGraphNode[] = []
|
||||
const groups: LGraphGroup[] = []
|
||||
const others: Positionable[] = []
|
||||
|
||||
if (ctx.depth > 1000) {
|
||||
return {
|
||||
all: [],
|
||||
nodes: [],
|
||||
groups: [],
|
||||
others: []
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i] as Positionable
|
||||
|
||||
if (isLGraphGroup(item)) {
|
||||
result.push(item)
|
||||
groups.push(item)
|
||||
|
||||
const children = Array.from(item.children)
|
||||
const childCtx: FlatItemsContext = {
|
||||
nodeToParentGroup: ctx.nodeToParentGroup,
|
||||
depth: ctx.depth + 1,
|
||||
parentGroup: item
|
||||
}
|
||||
const {
|
||||
all: childAll,
|
||||
nodes: childNodes,
|
||||
groups: childGroups,
|
||||
others: childOthers
|
||||
} = flatItems(children, childCtx)
|
||||
result.push(...childAll)
|
||||
nodes.push(...childNodes)
|
||||
groups.push(...childGroups)
|
||||
others.push(...childOthers)
|
||||
} else if (isLGraphNode(item)) {
|
||||
result.push(item)
|
||||
nodes.push(item)
|
||||
if (ctx.parentGroup) {
|
||||
ctx.nodeToParentGroup.set(item, ctx.parentGroup)
|
||||
}
|
||||
} else {
|
||||
// Other types of items are not supported yet
|
||||
// Do not add to all
|
||||
others.push(item)
|
||||
}
|
||||
}
|
||||
return {
|
||||
all: result,
|
||||
nodes,
|
||||
groups,
|
||||
others
|
||||
}
|
||||
}
|
||||
|
||||
function repeatItems<T>(items: T[]): T[] {
|
||||
const itemSet = new Set<T>()
|
||||
const result: T[] = []
|
||||
for (const item of items) {
|
||||
if (itemSet.has(item)) continue
|
||||
itemSet.add(item)
|
||||
result.push(item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames a widget and its corresponding input.
|
||||
* Handles both regular widgets and proxy widgets in subgraphs.
|
||||
*
|
||||
* @param widget The widget to rename
|
||||
* @param node The node containing the widget
|
||||
* @param newLabel The new label for the widget (empty string or undefined to clear)
|
||||
* @param parents Optional array of parent SubgraphNodes (for proxy widgets)
|
||||
* @returns true if the rename was successful, false otherwise
|
||||
*/
|
||||
export function renameWidget(
|
||||
widget: IBaseWidget,
|
||||
node: LGraphNode,
|
||||
newLabel: string,
|
||||
parents?: SubgraphNode[]
|
||||
): boolean {
|
||||
// For proxy widgets in subgraphs, we need to rename the original interior widget
|
||||
if (isProxyWidget(widget) && parents?.length) {
|
||||
const subgraph = parents[0].subgraph
|
||||
if (!subgraph) {
|
||||
console.error('Could not find subgraph for proxy widget')
|
||||
return false
|
||||
}
|
||||
const interiorNode = subgraph.getNodeById(parseInt(widget._overlay.nodeId))
|
||||
|
||||
if (!interiorNode) {
|
||||
console.error('Could not find interior node for proxy widget')
|
||||
return false
|
||||
}
|
||||
|
||||
const originalWidget = interiorNode.widgets?.find(
|
||||
(w) => w.name === widget._overlay.widgetName
|
||||
)
|
||||
|
||||
if (!originalWidget) {
|
||||
console.error('Could not find original widget for proxy widget')
|
||||
return false
|
||||
}
|
||||
|
||||
// Rename the original widget
|
||||
originalWidget.label = newLabel || undefined
|
||||
|
||||
// Also rename the corresponding input on the interior node
|
||||
const interiorInput = interiorNode.inputs?.find(
|
||||
(inp) => inp.widget?.name === widget._overlay.widgetName
|
||||
)
|
||||
if (interiorInput) {
|
||||
interiorInput.label = newLabel || undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Always rename the widget on the current node (either regular widget or proxy widget)
|
||||
const input = node.inputs?.find((inp) => inp.widget?.name === widget.name)
|
||||
|
||||
// Intentionally mutate the widget object here as it's a reference
|
||||
// to the actual widget in the graph
|
||||
widget.label = newLabel || undefined
|
||||
if (input) {
|
||||
input.label = newLabel || undefined
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import {
|
||||
computed,
|
||||
customRef,
|
||||
@@ -26,17 +27,19 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import { DraggableList } from '@/scripts/ui/draggableList'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
import SidePanelSearch from '../layout/SidePanelSearch.vue'
|
||||
import SubgraphNodeWidget from './SubgraphNodeWidget.vue'
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { searchQuery } = storeToRefs(rightSidePanelStore)
|
||||
|
||||
const draggableList = ref<DraggableList | undefined>(undefined)
|
||||
const draggableItems = ref()
|
||||
const searchQuery = ref<string>('')
|
||||
const proxyWidgets = customRef<ProxyWidgetsProperty>((track, trigger) => ({
|
||||
get() {
|
||||
track()
|
||||
@@ -56,10 +59,6 @@ const proxyWidgets = customRef<ProxyWidgetsProperty>((track, trigger) => ({
|
||||
}
|
||||
}))
|
||||
|
||||
async function searcher(query: string) {
|
||||
searchQuery.value = query
|
||||
}
|
||||
|
||||
const activeNode = computed(() => {
|
||||
const node = canvasStore.selectedItems[0]
|
||||
if (node instanceof SubgraphNode) return node
|
||||
@@ -244,14 +243,25 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<div v-if="activeNode" class="subgraph-edit-section flex h-full flex-col">
|
||||
<div class="p-4 flex gap-2">
|
||||
<SidePanelSearch :searcher />
|
||||
<div class="px-4 pb-4 pt-1 flex gap-2 border-b border-interface-stroke">
|
||||
<FormSearchInput v-model="searchQuery" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<div
|
||||
v-if="
|
||||
searchQuery &&
|
||||
filteredActive.length === 0 &&
|
||||
filteredCandidates.length === 0
|
||||
"
|
||||
class="text-sm text-muted-foreground px-4 py-10 text-center"
|
||||
>
|
||||
{{ $t('rightSidePanel.noneSearchDesc') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="filteredActive.length"
|
||||
class="flex flex-col border-t border-interface-stroke"
|
||||
class="flex flex-col border-b border-interface-stroke"
|
||||
>
|
||||
<div
|
||||
class="sticky top-0 z-10 flex items-center justify-between backdrop-blur-xl min-h-12 px-4"
|
||||
@@ -270,7 +280,7 @@ onBeforeUnmount(() => {
|
||||
<SubgraphNodeWidget
|
||||
v-for="[node, widget] in filteredActive"
|
||||
:key="toKey([node, widget])"
|
||||
class="bg-interface-panel-surface"
|
||||
class="bg-comfy-menu-bg"
|
||||
:node-title="node.title"
|
||||
:widget-name="widget.name"
|
||||
:is-shown="true"
|
||||
@@ -283,7 +293,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div
|
||||
v-if="filteredCandidates.length"
|
||||
class="flex flex-col border-t border-interface-stroke"
|
||||
class="flex flex-col border-b border-interface-stroke"
|
||||
>
|
||||
<div
|
||||
class="sticky top-0 z-10 flex items-center justify-between backdrop-blur-xl min-h-12 px-4"
|
||||
@@ -302,7 +312,7 @@ onBeforeUnmount(() => {
|
||||
<SubgraphNodeWidget
|
||||
v-for="[node, widget] in filteredCandidates"
|
||||
:key="toKey([node, widget])"
|
||||
class="bg-interface-panel-surface"
|
||||
class="bg-comfy-menu-bg"
|
||||
:node-title="node.title"
|
||||
:widget-name="widget.name"
|
||||
@toggle-visibility="promote([node, widget])"
|
||||
@@ -312,7 +322,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div
|
||||
v-if="recommendedWidgets.length"
|
||||
class="flex justify-center border-t border-interface-stroke py-4"
|
||||
class="flex justify-center border-b border-interface-stroke py-4"
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
28
src/components/sidebar/ModeToggle.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { t } from '@/i18n'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
</script>
|
||||
<template>
|
||||
<div class="p-1 bg-secondary-background rounded-lg w-10">
|
||||
<Button
|
||||
size="icon"
|
||||
:title="t('linearMode.linearMode')"
|
||||
:variant="canvasStore.linearMode ? 'inverted' : 'secondary'"
|
||||
@click="useCommandStore().execute('Comfy.ToggleLinear')"
|
||||
>
|
||||
<i class="icon-[lucide--panels-top-left]" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
:title="t('linearMode.graphMode')"
|
||||
:variant="canvasStore.linearMode ? 'secondary' : 'inverted'"
|
||||
@click="useCommandStore().execute('Comfy.ToggleLinear')"
|
||||
>
|
||||
<i class="icon-[comfy--workflow]" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<nav
|
||||
ref="sideToolbarRef"
|
||||
class="side-tool-bar-container flex h-full flex-col items-center bg-transparent [.floating-sidebar]:-mr-2 pointer-events-auto"
|
||||
class="side-tool-bar-container flex h-full flex-col items-center bg-transparent [.floating-sidebar]:-mr-2"
|
||||
:class="{
|
||||
'small-sidebar': isSmall,
|
||||
'connected-sidebar': isConnected,
|
||||
'connected-sidebar pointer-events-auto': isConnected,
|
||||
'floating-sidebar': !isConnected,
|
||||
'overflowing-sidebar': isOverflowing,
|
||||
'border-r border-[var(--interface-stroke)] shadow-interface': isConnected
|
||||
@@ -40,12 +40,16 @@
|
||||
v-if="userStore.isMultiUserServer"
|
||||
:is-small="isSmall"
|
||||
/>
|
||||
<SidebarHelpCenterIcon :is-small="isSmall" />
|
||||
<SidebarHelpCenterIcon v-if="!isIntegratedTabBar" :is-small="isSmall" />
|
||||
<SidebarBottomPanelToggleButton :is-small="isSmall" />
|
||||
<SidebarShortcutsToggleButton :is-small="isSmall" />
|
||||
<SidebarSettingsButton :is-small="isSmall" />
|
||||
<ModeToggle
|
||||
v-if="menuItemStore.hasSeenLinear || flags.linearToggleEnabled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<HelpCenterPopups :is-small="isSmall" />
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
@@ -54,15 +58,19 @@ import { useResizeObserver } from '@vueuse/core'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import HelpCenterPopups from '@/components/helpcenter/HelpCenterPopups.vue'
|
||||
import ComfyMenuButton from '@/components/sidebar/ComfyMenuButton.vue'
|
||||
import ModeToggle from '@/components/sidebar/ModeToggle.vue'
|
||||
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
|
||||
import SidebarSettingsButton from '@/components/sidebar/SidebarSettingsButton.vue'
|
||||
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useKeybindingStore } from '@/stores/keybindingStore'
|
||||
import { useMenuItemStore } from '@/stores/menuItemStore'
|
||||
import { useUserStore } from '@/stores/userStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import type { SidebarTabExtension } from '@/types/extensionTypes'
|
||||
@@ -78,9 +86,11 @@ const settingStore = useSettingStore()
|
||||
const userStore = useUserStore()
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const menuItemStore = useMenuItemStore()
|
||||
const sideToolbarRef = ref<HTMLElement>()
|
||||
const topToolbarRef = ref<HTMLElement>()
|
||||
const bottomToolbarRef = ref<HTMLElement>()
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
const isSmall = computed(
|
||||
() => settingStore.get('Comfy.Sidebar.Size') === 'small'
|
||||
@@ -89,6 +99,9 @@ const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
settingStore.get('Comfy.Sidebar.Location')
|
||||
)
|
||||
const sidebarStyle = computed(() => settingStore.get('Comfy.Sidebar.Style'))
|
||||
const isIntegratedTabBar = computed(
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
|
||||
)
|
||||
const isConnected = computed(
|
||||
() =>
|
||||
selectedTab.value ||
|
||||
@@ -145,8 +158,8 @@ const getTabTooltipSuffix = (tab: SidebarTabExtension) => {
|
||||
const isOverflowing = ref(false)
|
||||
const groupClasses = computed(() =>
|
||||
cn(
|
||||
'sidebar-item-group flex flex-col items-center overflow-hidden flex-shrink-0' +
|
||||
(isConnected.value ? '' : ' rounded-lg shadow-interface')
|
||||
'sidebar-item-group flex flex-col items-center overflow-hidden flex-shrink-0',
|
||||
!isConnected.value && 'rounded-lg shadow-interface pointer-events-auto'
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -1,204 +1,28 @@
|
||||
<template>
|
||||
<div>
|
||||
<SidebarIcon
|
||||
icon="pi pi-question-circle"
|
||||
class="comfy-help-center-btn"
|
||||
:label="$t('menu.help')"
|
||||
:tooltip="$t('sideToolbar.helpCenter')"
|
||||
:icon-badge="shouldShowRedDot ? '•' : ''"
|
||||
:is-small="isSmall"
|
||||
@click="toggleHelpCenter"
|
||||
/>
|
||||
|
||||
<!-- Help Center Popup positioned within canvas area -->
|
||||
<Teleport to="#graph-canvas-container">
|
||||
<div
|
||||
v-if="isHelpCenterVisible"
|
||||
class="help-center-popup"
|
||||
:class="{
|
||||
'sidebar-left': sidebarLocation === 'left',
|
||||
'sidebar-right': sidebarLocation === 'right',
|
||||
'small-sidebar': isSmall
|
||||
}"
|
||||
>
|
||||
<HelpCenterMenuContent @close="closeHelpCenter" />
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Release Notification Toast positioned within canvas area -->
|
||||
<Teleport to="#graph-canvas-container">
|
||||
<ReleaseNotificationToast
|
||||
:class="{
|
||||
'sidebar-left': sidebarLocation === 'left',
|
||||
'sidebar-right': sidebarLocation === 'right',
|
||||
'small-sidebar': isSmall
|
||||
}"
|
||||
/>
|
||||
</Teleport>
|
||||
|
||||
<!-- WhatsNew Popup positioned within canvas area -->
|
||||
<Teleport to="#graph-canvas-container">
|
||||
<WhatsNewPopup
|
||||
:class="{
|
||||
'sidebar-left': sidebarLocation === 'left',
|
||||
'sidebar-right': sidebarLocation === 'right',
|
||||
'small-sidebar': isSmall
|
||||
}"
|
||||
@whats-new-dismissed="handleWhatsNewDismissed"
|
||||
/>
|
||||
</Teleport>
|
||||
|
||||
<!-- Backdrop to close popup when clicking outside -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isHelpCenterVisible"
|
||||
class="help-center-backdrop"
|
||||
@click="closeHelpCenter"
|
||||
/>
|
||||
</Teleport>
|
||||
</div>
|
||||
<SidebarIcon
|
||||
icon="pi pi-question-circle"
|
||||
class="comfy-help-center-btn"
|
||||
:label="$t('menu.help')"
|
||||
:tooltip="$t('sideToolbar.helpCenter')"
|
||||
:icon-badge="shouldShowRedDot ? '•' : ''"
|
||||
:is-small="isSmall"
|
||||
@click="toggleHelpCenter"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, toRefs } from 'vue'
|
||||
|
||||
import HelpCenterMenuContent from '@/components/helpcenter/HelpCenterMenuContent.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||
import ReleaseNotificationToast from '@/platform/updates/components/ReleaseNotificationToast.vue'
|
||||
import WhatsNewPopup from '@/platform/updates/components/WhatsNewPopup.vue'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useHelpCenterStore } from '@/stores/helpCenterStore'
|
||||
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
||||
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
|
||||
import { useHelpCenter } from '@/composables/useHelpCenter'
|
||||
|
||||
import SidebarIcon from './SidebarIcon.vue'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const releaseStore = useReleaseStore()
|
||||
const helpCenterStore = useHelpCenterStore()
|
||||
const { isVisible: isHelpCenterVisible } = storeToRefs(helpCenterStore)
|
||||
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
|
||||
|
||||
const conflictDetection = useConflictDetection()
|
||||
|
||||
const { showNodeConflictDialog } = useDialogService()
|
||||
|
||||
// Use conflict acknowledgment state from composable - call only once
|
||||
const { shouldShowRedDot: shouldShowConflictRedDot, markConflictsAsSeen } =
|
||||
useConflictAcknowledgment()
|
||||
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
isSmall: boolean
|
||||
}>()
|
||||
const { isSmall } = toRefs(props)
|
||||
|
||||
// Use either release red dot or conflict red dot
|
||||
const shouldShowRedDot = computed((): boolean => {
|
||||
const releaseRedDot = showReleaseRedDot.value
|
||||
return releaseRedDot || shouldShowConflictRedDot.value
|
||||
})
|
||||
|
||||
const sidebarLocation = computed(() =>
|
||||
settingStore.get('Comfy.Sidebar.Location')
|
||||
)
|
||||
|
||||
/**
|
||||
* Toggle Help Center and track UI button click.
|
||||
*/
|
||||
const toggleHelpCenter = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'sidebar_help_center_toggled'
|
||||
})
|
||||
helpCenterStore.toggle()
|
||||
}
|
||||
|
||||
const closeHelpCenter = () => {
|
||||
helpCenterStore.hide()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle What's New popup dismissal
|
||||
* Check if conflict modal should be shown after ComfyUI update
|
||||
*/
|
||||
const handleWhatsNewDismissed = async () => {
|
||||
try {
|
||||
// Check if conflict modal should be shown after update
|
||||
const shouldShow =
|
||||
await conflictDetection.shouldShowConflictModalAfterUpdate()
|
||||
if (shouldShow) {
|
||||
showConflictModal()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[HelpCenter] Error checking conflict modal:', error)
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Show the node conflict dialog with current conflict data
|
||||
*/
|
||||
const showConflictModal = () => {
|
||||
showNodeConflictDialog({
|
||||
showAfterWhatsNew: true,
|
||||
dialogComponentProps: {
|
||||
onClose: () => {
|
||||
markConflictsAsSeen()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Initialize release store on mount
|
||||
onMounted(async () => {
|
||||
// Initialize release store to fetch releases for toast and popup
|
||||
await releaseStore.initialize()
|
||||
})
|
||||
const { shouldShowRedDot, toggleHelpCenter } = useHelpCenter()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.help-center-backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 9999;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.help-center-popup {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
z-index: 10000;
|
||||
animation: slideInUp 0.2s ease-out;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.help-center-popup.sidebar-left {
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.help-center-popup.sidebar-left.small-sidebar {
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.help-center-popup.sidebar-right {
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
@keyframes slideInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.p-badge) {
|
||||
background: #ff3b30;
|
||||
color: #ff3b30;
|
||||
|
||||
154
src/components/sidebar/tabs/AssetsSidebarListView.stories.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import type { JobAction } from '@/composables/queue/useJobActions'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { setMockJobActions } from '@/storybook/mocks/useJobActions'
|
||||
import { setMockJobItems } from '@/storybook/mocks/useJobList'
|
||||
import { iconForJobState } from '@/utils/queueDisplay'
|
||||
|
||||
import AssetsSidebarListView from './AssetsSidebarListView.vue'
|
||||
|
||||
type StoryArgs = {
|
||||
assets: AssetItem[]
|
||||
jobs: JobListItem[]
|
||||
selectedAssetIds?: string[]
|
||||
actionsByJobId?: Record<string, JobAction[]>
|
||||
}
|
||||
|
||||
function baseDecorator() {
|
||||
return {
|
||||
template: `
|
||||
<div class="bg-base-background p-6">
|
||||
<story />
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
const meta: Meta<StoryArgs> = {
|
||||
title: 'Components/Sidebar/AssetsSidebarListView',
|
||||
component: AssetsSidebarListView,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
decorators: [baseDecorator]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const baseTimestamp = '2024-01-15T10:00:00Z'
|
||||
|
||||
const sampleJobs: JobListItem[] = [
|
||||
{
|
||||
id: 'job-pending-1',
|
||||
title: 'In queue',
|
||||
meta: '8:59:30pm',
|
||||
state: 'pending',
|
||||
iconName: iconForJobState('pending'),
|
||||
showClear: true
|
||||
},
|
||||
{
|
||||
id: 'job-init-1',
|
||||
title: 'Initializing...',
|
||||
meta: '8:59:35pm',
|
||||
state: 'initialization',
|
||||
iconName: iconForJobState('initialization'),
|
||||
showClear: true
|
||||
},
|
||||
{
|
||||
id: 'job-running-1',
|
||||
title: 'Total: 30%',
|
||||
meta: 'KSampler: 70%',
|
||||
state: 'running',
|
||||
iconName: iconForJobState('running'),
|
||||
showClear: true,
|
||||
progressTotalPercent: 30,
|
||||
progressCurrentPercent: 70
|
||||
}
|
||||
]
|
||||
|
||||
const sampleAssets: AssetItem[] = [
|
||||
{
|
||||
id: 'asset-image-1',
|
||||
name: 'image-032.png',
|
||||
created_at: baseTimestamp,
|
||||
preview_url: '/assets/images/comfy-logo-single.svg',
|
||||
size: 1887437,
|
||||
tags: [],
|
||||
user_metadata: {
|
||||
promptId: 'job-running-1',
|
||||
nodeId: 12,
|
||||
executionTimeInSeconds: 1.84
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'asset-video-1',
|
||||
name: 'clip-01.mp4',
|
||||
created_at: baseTimestamp,
|
||||
preview_url: '/assets/images/default-template.png',
|
||||
size: 8394820,
|
||||
tags: [],
|
||||
user_metadata: {
|
||||
duration: 132000
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'asset-audio-1',
|
||||
name: 'soundtrack-01.mp3',
|
||||
created_at: baseTimestamp,
|
||||
size: 5242880,
|
||||
tags: [],
|
||||
user_metadata: {
|
||||
duration: 200000
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'asset-3d-1',
|
||||
name: 'scene-01.glb',
|
||||
created_at: baseTimestamp,
|
||||
size: 134217728,
|
||||
tags: []
|
||||
}
|
||||
]
|
||||
|
||||
const cancelAction: JobAction = {
|
||||
icon: 'icon-[lucide--x]',
|
||||
label: 'Cancel',
|
||||
variant: 'destructive'
|
||||
}
|
||||
|
||||
export const RunningAndGenerated: Story = {
|
||||
args: {
|
||||
assets: sampleAssets,
|
||||
jobs: sampleJobs,
|
||||
actionsByJobId: {
|
||||
'job-pending-1': [cancelAction],
|
||||
'job-init-1': [cancelAction],
|
||||
'job-running-1': [cancelAction]
|
||||
}
|
||||
},
|
||||
render: renderAssetsSidebarListView
|
||||
}
|
||||
|
||||
function renderAssetsSidebarListView(args: StoryArgs) {
|
||||
return {
|
||||
components: { AssetsSidebarListView },
|
||||
setup() {
|
||||
setMockJobItems(args.jobs)
|
||||
setMockJobActions(args.actionsByJobId ?? {})
|
||||
const selectedIds = new Set(args.selectedAssetIds ?? [])
|
||||
function isSelected(assetId: string) {
|
||||
return selectedIds.has(assetId)
|
||||
}
|
||||
|
||||
return { args, isSelected }
|
||||
},
|
||||
template: `
|
||||
<div class="h-[520px] w-[320px] overflow-hidden rounded-lg border border-panel-border">
|
||||
<AssetsSidebarListView :assets="args.assets" :is-selected="isSelected" />
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
241
src/components/sidebar/tabs/AssetsSidebarListView.vue
Normal file
@@ -0,0 +1,241 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<div
|
||||
v-if="activeJobItems.length"
|
||||
class="flex max-h-[50%] flex-col gap-2 overflow-y-auto px-2"
|
||||
>
|
||||
<AssetsListItem
|
||||
v-for="job in activeJobItems"
|
||||
:key="job.id"
|
||||
:class="
|
||||
cn(
|
||||
'w-full shrink-0 text-text-primary transition-colors hover:bg-secondary-background-hover',
|
||||
'cursor-default'
|
||||
)
|
||||
"
|
||||
:preview-url="job.iconImageUrl"
|
||||
:preview-alt="job.title"
|
||||
:icon-name="job.iconName"
|
||||
:icon-class="getJobIconClass(job)"
|
||||
:primary-text="job.title"
|
||||
:secondary-text="job.meta"
|
||||
:progress-total-percent="job.progressTotalPercent"
|
||||
:progress-current-percent="job.progressCurrentPercent"
|
||||
@mouseenter="onJobEnter(job.id)"
|
||||
@mouseleave="onJobLeave(job.id)"
|
||||
@click.stop
|
||||
>
|
||||
<template v-if="hoveredJobId === job.id" #actions>
|
||||
<Button
|
||||
v-if="canCancelJob"
|
||||
:variant="cancelAction.variant"
|
||||
size="icon"
|
||||
:aria-label="cancelAction.label"
|
||||
@click.stop="runCancelJob()"
|
||||
>
|
||||
<i :class="cancelAction.icon" class="size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
</AssetsListItem>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="assets.length"
|
||||
:class="cn('px-2', activeJobItems.length && 'mt-2')"
|
||||
>
|
||||
<div
|
||||
class="flex items-center py-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
|
||||
>
|
||||
{{ t('sideToolbar.generatedAssetsHeader') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VirtualGrid
|
||||
class="flex-1"
|
||||
:items="assetItems"
|
||||
:grid-style="listGridStyle"
|
||||
@approach-end="emit('approach-end')"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<div class="relative">
|
||||
<LoadingOverlay
|
||||
:loading="assetsStore.isAssetDeleting(item.asset.id)"
|
||||
size="sm"
|
||||
>
|
||||
<i class="pi pi-trash text-xs" />
|
||||
</LoadingOverlay>
|
||||
<AssetsListItem
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-label="
|
||||
t('assetBrowser.ariaLabel.assetCard', {
|
||||
name: item.asset.name,
|
||||
type: getMediaTypeFromFilename(item.asset.name)
|
||||
})
|
||||
"
|
||||
:class="getAssetCardClass(isSelected(item.asset.id))"
|
||||
:preview-url="item.asset.preview_url"
|
||||
:preview-alt="item.asset.name"
|
||||
:icon-name="
|
||||
iconForMediaType(getMediaTypeFromFilename(item.asset.name))
|
||||
"
|
||||
:primary-text="getAssetPrimaryText(item.asset)"
|
||||
:secondary-text="getAssetSecondaryText(item.asset)"
|
||||
@mouseenter="onAssetEnter(item.asset.id)"
|
||||
@mouseleave="onAssetLeave(item.asset.id)"
|
||||
@contextmenu.prevent.stop="emit('context-menu', $event, item.asset)"
|
||||
@click.stop="emit('select-asset', item.asset)"
|
||||
>
|
||||
<template v-if="hoveredAssetId === item.asset.id" #actions>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('mediaAsset.actions.moreOptions')"
|
||||
@click.stop="emit('context-menu', $event, item.asset)"
|
||||
>
|
||||
<i class="icon-[lucide--ellipsis] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
</AssetsListItem>
|
||||
</div>
|
||||
</template>
|
||||
</VirtualGrid>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useJobActions } from '@/composables/queue/useJobActions'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import { useJobList } from '@/composables/queue/useJobList'
|
||||
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import type { JobState } from '@/types/queue'
|
||||
import {
|
||||
formatDuration,
|
||||
formatSize,
|
||||
getMediaTypeFromFilename,
|
||||
truncateFilename
|
||||
} from '@/utils/formatUtil'
|
||||
import { iconForJobState } from '@/utils/queueDisplay'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { assets, isSelected } = defineProps<{
|
||||
assets: AssetItem[]
|
||||
isSelected: (assetId: string) => boolean
|
||||
}>()
|
||||
|
||||
const assetsStore = useAssetsStore()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select-asset', asset: AssetItem): void
|
||||
(e: 'context-menu', event: MouseEvent, asset: AssetItem): void
|
||||
(e: 'approach-end'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { jobItems } = useJobList()
|
||||
const hoveredJobId = ref<string | null>(null)
|
||||
const hoveredAssetId = ref<string | null>(null)
|
||||
|
||||
type AssetListItem = { key: string; asset: AssetItem }
|
||||
|
||||
const activeJobItems = computed(() =>
|
||||
jobItems.value.filter((item) => isActiveJobState(item.state))
|
||||
)
|
||||
const hoveredJob = computed(() =>
|
||||
hoveredJobId.value
|
||||
? (activeJobItems.value.find((job) => job.id === hoveredJobId.value) ??
|
||||
null)
|
||||
: null
|
||||
)
|
||||
const { cancelAction, canCancelJob, runCancelJob } = useJobActions(hoveredJob)
|
||||
|
||||
const assetItems = computed<AssetListItem[]>(() =>
|
||||
assets.map((asset) => ({
|
||||
key: `asset-${asset.id}`,
|
||||
asset
|
||||
}))
|
||||
)
|
||||
|
||||
const listGridStyle = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0, 1fr)',
|
||||
padding: '0 0.5rem',
|
||||
gap: '0.5rem'
|
||||
}
|
||||
|
||||
function isActiveJobState(state: JobState): boolean {
|
||||
return (
|
||||
state === 'pending' || state === 'initialization' || state === 'running'
|
||||
)
|
||||
}
|
||||
|
||||
function getAssetPrimaryText(asset: AssetItem): string {
|
||||
return truncateFilename(asset.name)
|
||||
}
|
||||
|
||||
function getAssetSecondaryText(asset: AssetItem): string {
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
if (typeof metadata?.executionTimeInSeconds === 'number') {
|
||||
return `${metadata.executionTimeInSeconds.toFixed(2)}s`
|
||||
}
|
||||
|
||||
const duration = asset.user_metadata?.duration
|
||||
if (typeof duration === 'number') {
|
||||
return formatDuration(duration)
|
||||
}
|
||||
|
||||
if (typeof asset.size === 'number') {
|
||||
return formatSize(asset.size)
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function getAssetCardClass(selected: boolean): string {
|
||||
return cn(
|
||||
'w-full text-text-primary transition-colors hover:bg-secondary-background-hover',
|
||||
'cursor-pointer',
|
||||
selected &&
|
||||
'bg-secondary-background-hover ring-1 ring-inset ring-modal-card-border-highlighted'
|
||||
)
|
||||
}
|
||||
|
||||
function onJobEnter(jobId: string) {
|
||||
hoveredJobId.value = jobId
|
||||
}
|
||||
|
||||
function onJobLeave(jobId: string) {
|
||||
if (hoveredJobId.value === jobId) {
|
||||
hoveredJobId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function onAssetEnter(assetId: string) {
|
||||
hoveredAssetId.value = assetId
|
||||
}
|
||||
|
||||
function onAssetLeave(assetId: string) {
|
||||
if (hoveredAssetId.value === assetId) {
|
||||
hoveredAssetId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function getJobIconClass(job: JobListItem): string | undefined {
|
||||
const classes = []
|
||||
const iconName = job.iconName ?? iconForJobState(job.state)
|
||||
if (!job.iconImageUrl && iconName === iconForJobState('pending')) {
|
||||
classes.push('animate-spin')
|
||||
}
|
||||
return classes.length ? classes.join(' ') : undefined
|
||||
}
|
||||
</script>
|
||||
@@ -79,10 +79,10 @@
|
||||
<Divider v-else type="dashed" class="my-2" />
|
||||
</template>
|
||||
<template #body>
|
||||
<div v-if="loading && !displayAssets.length">
|
||||
<div v-if="showLoadingState">
|
||||
<ProgressSpinner class="absolute left-1/2 w-[50px] -translate-x-1/2" />
|
||||
</div>
|
||||
<div v-else-if="!loading && !displayAssets.length">
|
||||
<div v-else-if="showEmptyState">
|
||||
<NoResultsPlaceholder
|
||||
icon="pi pi-info-circle"
|
||||
:title="
|
||||
@@ -96,7 +96,16 @@
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="relative size-full" @click="handleEmptySpaceClick">
|
||||
<AssetsSidebarListView
|
||||
v-if="isListView"
|
||||
:assets="displayAssets"
|
||||
:is-selected="isSelected"
|
||||
@select-asset="handleAssetSelect"
|
||||
@context-menu="handleAssetContextMenu"
|
||||
@approach-end="handleApproachEnd"
|
||||
/>
|
||||
<VirtualGrid
|
||||
v-else
|
||||
:items="mediaAssetsWithKey"
|
||||
:grid-style="{
|
||||
display: 'grid',
|
||||
@@ -112,17 +121,10 @@
|
||||
:selected="isSelected(item.id)"
|
||||
:show-output-count="shouldShowOutputCount(item)"
|
||||
:output-count="getOutputCount(item)"
|
||||
:show-delete-button="shouldShowDeleteButton"
|
||||
:open-context-menu-id="openContextMenuId"
|
||||
:selected-assets="getSelectedAssets(displayAssets)"
|
||||
:has-selection="hasSelection"
|
||||
:is-deleting="deletingAssetIds.has(item.id)"
|
||||
@click="handleAssetSelect(item)"
|
||||
@context-menu="handleAssetContextMenu"
|
||||
@zoom="handleZoomClick(item)"
|
||||
@output-count-click="enterFolderView(item)"
|
||||
@context-menu-opened="openContextMenuId = item.id"
|
||||
@bulk-download="handleBulkDownload"
|
||||
@bulk-delete="handleBulkDelete"
|
||||
/>
|
||||
</template>
|
||||
</VirtualGrid>
|
||||
@@ -188,6 +190,24 @@
|
||||
v-model:active-index="galleryActiveIndex"
|
||||
:all-gallery-items="galleryItems"
|
||||
/>
|
||||
<MediaAssetContextMenu
|
||||
v-if="contextMenuAsset"
|
||||
ref="contextMenuRef"
|
||||
:asset="contextMenuAsset"
|
||||
:asset-type="contextMenuAssetType"
|
||||
:file-kind="contextMenuFileKind"
|
||||
:show-delete-button="shouldShowDeleteButton"
|
||||
:selected-assets="selectedAssets"
|
||||
:is-bulk-mode="isBulkMode"
|
||||
@zoom="handleZoomClick(contextMenuAsset)"
|
||||
@hide="handleContextMenuHide"
|
||||
@asset-deleted="refreshAssets"
|
||||
@bulk-download="handleBulkDownload"
|
||||
@bulk-delete="handleBulkDelete"
|
||||
@bulk-add-to-workflow="handleBulkAddToWorkflow"
|
||||
@bulk-open-workflow="handleBulkOpenWorkflow"
|
||||
@bulk-export-workflow="handleBulkExportWorkflow"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -195,31 +215,29 @@ import { useDebounceFn, useElementHover, useResizeObserver } from '@vueuse/core'
|
||||
import Divider from 'primevue/divider'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import {
|
||||
computed,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
watch
|
||||
} from 'vue'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.vue'
|
||||
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
|
||||
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
||||
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||
import Tab from '@/components/tab/Tab.vue'
|
||||
import TabList from '@/components/tab/TabList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
|
||||
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
|
||||
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
|
||||
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
|
||||
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
|
||||
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
|
||||
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
||||
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
@@ -241,12 +259,12 @@ const viewMode = ref<'list' | 'grid'>('grid')
|
||||
const isQueuePanelV2Enabled = computed(() =>
|
||||
settingStore.get('Comfy.Queue.QPOV2')
|
||||
)
|
||||
const isListView = computed(
|
||||
() => isQueuePanelV2Enabled.value && viewMode.value === 'list'
|
||||
)
|
||||
|
||||
// Track which asset's context menu is open (for single-instance context menu management)
|
||||
const openContextMenuId = ref<string | null>(null)
|
||||
|
||||
// Track which assets are currently being deleted (for showing loading state)
|
||||
const deletingAssetIds = ref(new Set<string>())
|
||||
const contextMenuRef = ref<InstanceType<typeof MediaAssetContextMenu>>()
|
||||
const contextMenuAsset = ref<AssetItem | null>(null)
|
||||
|
||||
// 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)
|
||||
@@ -255,6 +273,14 @@ const shouldShowDeleteButton = computed(() => {
|
||||
return true
|
||||
})
|
||||
|
||||
const contextMenuAssetType = computed(() =>
|
||||
contextMenuAsset.value ? getAssetType(contextMenuAsset.value.tags) : 'input'
|
||||
)
|
||||
|
||||
const contextMenuFileKind = computed<MediaKind>(() =>
|
||||
getMediaTypeFromFilename(contextMenuAsset.value?.name ?? '')
|
||||
)
|
||||
|
||||
const shouldShowOutputCount = (item: AssetItem): boolean => {
|
||||
if (activeTab.value !== 'output' || isInFolderView.value) {
|
||||
return false
|
||||
@@ -298,7 +324,13 @@ const {
|
||||
deactivate: deactivateSelection
|
||||
} = useAssetSelection()
|
||||
|
||||
const { downloadMultipleAssets, deleteMultipleAssets } = useMediaAssetActions()
|
||||
const {
|
||||
downloadMultipleAssets,
|
||||
deleteAssets,
|
||||
addMultipleToWorkflow,
|
||||
openMultipleWorkflows,
|
||||
exportMultipleWorkflows
|
||||
} = useMediaAssetActions()
|
||||
|
||||
// Footer responsive behavior
|
||||
const footerRef = ref<HTMLElement | null>(null)
|
||||
@@ -324,8 +356,7 @@ const isHoveringSelectionCount = useElementHover(selectionCountButtonRef)
|
||||
|
||||
// Total output count for all selected assets
|
||||
const totalOutputCount = computed(() => {
|
||||
const selectedAssets = getSelectedAssets(displayAssets.value)
|
||||
return getTotalOutputCount(selectedAssets)
|
||||
return getTotalOutputCount(selectedAssets.value)
|
||||
})
|
||||
|
||||
const currentAssets = computed(() =>
|
||||
@@ -356,6 +387,26 @@ const displayAssets = computed(() => {
|
||||
return filteredAssets.value
|
||||
})
|
||||
|
||||
const selectedAssets = computed(() => getSelectedAssets(displayAssets.value))
|
||||
|
||||
const isBulkMode = computed(
|
||||
() => hasSelection.value && selectedAssets.value.length > 1
|
||||
)
|
||||
|
||||
const showLoadingState = computed(
|
||||
() =>
|
||||
loading.value &&
|
||||
displayAssets.value.length === 0 &&
|
||||
(!isListView.value || activeJobsCount.value === 0)
|
||||
)
|
||||
|
||||
const showEmptyState = computed(
|
||||
() =>
|
||||
!loading.value &&
|
||||
displayAssets.value.length === 0 &&
|
||||
(!isListView.value || activeJobsCount.value === 0)
|
||||
)
|
||||
|
||||
watch(displayAssets, (newAssets) => {
|
||||
if (currentGalleryAssetId.value && galleryActiveIndex.value !== -1) {
|
||||
const newIndex = newAssets.findIndex(
|
||||
@@ -427,6 +478,20 @@ const handleAssetSelect = (asset: AssetItem) => {
|
||||
handleAssetClick(asset, index, displayAssets.value)
|
||||
}
|
||||
|
||||
function handleAssetContextMenu(event: MouseEvent, asset: AssetItem) {
|
||||
contextMenuAsset.value = asset
|
||||
void nextTick(() => {
|
||||
contextMenuRef.value?.show(event)
|
||||
})
|
||||
}
|
||||
|
||||
function handleContextMenuHide() {
|
||||
// Delay clearing to allow command callbacks to emit before component unmounts
|
||||
requestAnimationFrame(() => {
|
||||
contextMenuAsset.value = null
|
||||
})
|
||||
}
|
||||
|
||||
const handleZoomClick = (asset: AssetItem) => {
|
||||
const mediaType = getMediaTypeFromFilename(asset.name)
|
||||
|
||||
@@ -535,19 +600,13 @@ const copyJobId = async () => {
|
||||
}
|
||||
|
||||
const handleDownloadSelected = () => {
|
||||
const selectedAssets = getSelectedAssets(displayAssets.value)
|
||||
downloadMultipleAssets(selectedAssets)
|
||||
downloadMultipleAssets(selectedAssets.value)
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
const setAssetsDeletingState = (assetIds: string[], isDeleting: boolean) => {
|
||||
assetIds.forEach((id) => {
|
||||
if (isDeleting) {
|
||||
deletingAssetIds.value.add(id)
|
||||
} else {
|
||||
deletingAssetIds.value.delete(id)
|
||||
}
|
||||
})
|
||||
const handleDeleteSelected = async () => {
|
||||
await deleteAssets(selectedAssets.value)
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
const handleBulkDownload = (assets: AssetItem[]) => {
|
||||
@@ -555,15 +614,23 @@ const handleBulkDownload = (assets: AssetItem[]) => {
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
const handleDeleteSelected = () =>
|
||||
handleBulkDelete(getSelectedAssets(displayAssets.value))
|
||||
|
||||
const handleBulkDelete = async (assets: AssetItem[]) => {
|
||||
const assetIds = assets.map((a) => a.id)
|
||||
await deleteAssets(assets)
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
await deleteMultipleAssets(assets, (isDeleting) =>
|
||||
setAssetsDeletingState(assetIds, isDeleting)
|
||||
)
|
||||
const handleBulkAddToWorkflow = async (assets: AssetItem[]) => {
|
||||
await addMultipleToWorkflow(assets)
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
const handleBulkOpenWorkflow = async (assets: AssetItem[]) => {
|
||||
await openMultipleWorkflows(assets)
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
const handleBulkExportWorkflow = async (assets: AssetItem[]) => {
|
||||
await exportMultipleWorkflows(assets)
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<SidebarTabTemplate
|
||||
:title="$t('sideToolbar.workflows')"
|
||||
v-bind="$attrs"
|
||||
class="workflows-sidebar-tab"
|
||||
>
|
||||
<template #tool-buttons>
|
||||
|
||||
@@ -9,11 +9,16 @@
|
||||
@click="popover?.toggle($event)"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-1 rounded-full hover:bg-interface-button-hover-surface"
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-1 rounded-full hover:bg-interface-button-hover-surface justify-center',
|
||||
compact && 'size-full aspect-square'
|
||||
)
|
||||
"
|
||||
>
|
||||
<UserAvatar :photo-url="photoURL" />
|
||||
<UserAvatar :photo-url="photoURL" :class="compact && 'size-full'" />
|
||||
|
||||
<i class="icon-[lucide--chevron-down] size-3 px-1" />
|
||||
<i v-if="showArrow" class="icon-[lucide--chevron-down] size-3 px-1" />
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
@@ -38,9 +43,15 @@ import { computed, ref } from 'vue'
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import CurrentUserPopover from './CurrentUserPopover.vue'
|
||||
|
||||
const { showArrow = true, compact = false } = defineProps<{
|
||||
showArrow?: boolean
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const { isLoggedIn, userPhotoUrl } = useCurrentUser()
|
||||
|
||||
const popover = ref<InstanceType<typeof Popover> | null>(null)
|
||||
|
||||