Compare commits

..

1 Commits

Author SHA1 Message Date
Terry Jia
15582f055f fix: sync litegraph node.setSize() to layoutStore 2026-01-11 21:57:42 -05:00
279 changed files with 2136 additions and 33393 deletions

View File

@@ -144,10 +144,9 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v5
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
# Setup pnpm/node to run playwright merge-reports (no browsers needed)
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Download blob reports
uses: actions/download-artifact@v4
@@ -159,10 +158,10 @@ jobs:
- name: Merge into HTML Report
run: |
# Generate HTML report
pnpm dlx @playwright/test merge-reports --reporter=html ./all-blob-reports
pnpm exec playwright merge-reports --reporter=html ./all-blob-reports
# Generate JSON report separately with explicit output path
PLAYWRIGHT_JSON_OUTPUT_NAME=playwright-report/report.json \
pnpm dlx @playwright/test merge-reports --reporter=json ./all-blob-reports
pnpm exec playwright merge-reports --reporter=json ./all-blob-reports
- name: Upload HTML report
uses: actions/upload-artifact@v4

View File

@@ -69,7 +69,7 @@ jobs:
- name: Checkout ComfyUI (sparse)
uses: actions/checkout@v5
with:
repository: Comfy-Org/ComfyUI
repository: comfyanonymous/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/Comfy-Org/ComfyUI.git master; then
if ! git fetch https://github.com/comfyanonymous/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 Comfy-Org/ComfyUI"
echo "Creating PR from ${COMFYUI_FORK} to comfyanonymous/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 Comfy-Org/ComfyUI \
--repo comfyanonymous/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 Comfy-Org/ComfyUI --head "${FORK_OWNER}:${BRANCH}" --json number --jq '.[0].number' 2>&1)
EXISTING_PR=$(gh pr list --repo comfyanonymous/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 Comfy-Org/ComfyUI" >> $GITHUB_STEP_SUMMARY
echo "Draft PR created in comfyanonymous/ComfyUI" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### PR Body:" >> $GITHUB_STEP_SUMMARY
cat pr-body.txt >> $GITHUB_STEP_SUMMARY

View File

@@ -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,7 +55,8 @@
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
# Translations
/src/locales/ @Comfy-Org/comfy_maintainer @Comfy-org/comfy_frontend_devs
/src/locales/ @Yorha4D @KarryCharon @shinshin86 @Comfy-Org/comfy_maintainer @Comfy-org/comfy_frontend_devs
/src/locales/pt-BR/ @JonatanAtila @Yorha4D @KarryCharon @shinshin86
# LLM Instructions (blank on purpose)
.claude/

View File

@@ -3,7 +3,7 @@ import { test as base, expect } from '@playwright/test'
import dotenv from 'dotenv'
import * as fs from 'fs'
import type { LGraphNode, LGraph } from '../../src/lib/litegraph/src/litegraph'
import type { LGraphNode } 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,29 +1591,14 @@ export class ComfyPage {
return window['app'].graph.nodes
})
}
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[]> {
async getNodeRefsByType(type: string): Promise<NodeReference[]> {
return Promise.all(
(
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 }
)
await this.page.evaluate((type) => {
return window['app'].graph.nodes
.filter((n: LGraphNode) => n.type === type)
.map((n: LGraphNode) => n.id)
}, type)
).map((id: NodeId) => this.getNodeRefById(id))
)
}

View File

@@ -159,18 +159,8 @@ export class VueNodeHelpers {
getInputNumberControls(widget: Locator) {
return {
input: widget.locator('input'),
decrementButton: widget.getByTestId('decrement'),
incrementButton: widget.getByTestId('increment')
incrementButton: widget.locator('button').first(),
decrementButton: widget.locator('button').nth(1)
}
}
/**
* 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()
}
}

View File

@@ -133,11 +133,8 @@ test.describe('Menu', () => {
// Checkmark should be invisible again (panel is hidden)
await expect(checkmark).toHaveClass(/invisible/)
// 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 } })
// Click outside to close menu
await comfyPage.page.locator('body').click({ position: { x: 10, y: 10 } })
// Verify menu is now closed
await expect(menu).not.toBeVisible()

View File

@@ -22,14 +22,8 @@ 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',
{
mask: [
comfyPage.settingDialog.root.getByTestId('current-user-indicator')
]
}
'mobile-settings-dialog.png'
)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -8,11 +8,13 @@ test.describe('Properties panel', () => {
const { propertiesPanel } = comfyPage.menu
await expect(propertiesPanel.panelTitle).toContainText('Workflow Overview')
await expect(propertiesPanel.panelTitle).toContainText(
'No node(s) selected'
)
await comfyPage.selectNodes(['KSampler', 'CLIP Text Encode (Prompt)'])
await expect(propertiesPanel.panelTitle).toContainText('3 items selected')
await expect(propertiesPanel.panelTitle).toContainText('3 nodes selected')
await expect(propertiesPanel.root.getByText('KSampler')).toHaveCount(1)
await expect(
propertiesPanel.root.getByText('CLIP Text Encode (Prompt)')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -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,51 +993,4 @@ 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)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -194,10 +194,7 @@ test.describe('Image widget', () => {
const comboEntry = comfyPage.page.getByRole('menuitem', {
name: 'image32x32.webp'
})
await comboEntry.click()
// Stabilization for the image swap
await comfyPage.nextFrame()
await comboEntry.click({ noWaitAfter: true })
// Expect the image preview to change automatically
await expect(comfyPage.canvas).toHaveScreenshot(

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 49 KiB

View File

@@ -1,23 +0,0 @@
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}`
]
}

View File

@@ -3,13 +3,6 @@
"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"

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.38.3",
"version": "1.37.9",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",

View File

@@ -247,7 +247,6 @@
--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);
@@ -282,7 +281,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-200);
--modal-card-tag-background: var(--color-smoke-400);
--modal-card-tag-foreground: var(--base-foreground);
--modal-panel-background: var(--color-white);
}
@@ -373,7 +372,6 @@
--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);
@@ -518,7 +516,6 @@
--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);

View File

@@ -1,21 +1 @@
@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%;
}
}
@import '@comfyorg/design-system/css/style.css';

View File

@@ -1,6 +1,6 @@
<template>
<div
class="subgraph-breadcrumb flex w-auto drop-shadow-[var(--interface-panel-drop-shadow)]"
class="subgraph-breadcrumb w-auto drop-shadow-[var(--interface-panel-drop-shadow)]"
:class="{
'subgraph-breadcrumb-collapse': collapseTabs,
'subgraph-breadcrumb-overflow': overflowingTabs
@@ -13,37 +13,17 @@
'--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.key === activeItemKey"
:is-active="item === items.at(-1)"
/>
</template>
<template #separator
@@ -55,7 +35,6 @@
<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'
@@ -64,7 +43,6 @@ 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'
@@ -77,12 +55,6 @@ 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)
@@ -90,28 +62,17 @@ const isBlueprint = computed(() =>
const collapseTabs = ref(false)
const overflowingTabs = ref(false)
const isInSubgraph = computed(() => navigationStore.navigationStack.length > 0)
const breadcrumbElement = computed(() => {
if (!breadcrumbRef.value) return null
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 el = (breadcrumbRef.value as unknown as { $el: HTMLElement }).$el
const list = el?.querySelector('.p-breadcrumb-list') as HTMLElement
return list
})
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'
@@ -134,26 +95,21 @@ const items = computed(() => {
return [home.value, ...items]
})
const activeItemKey = computed(() => items.value.at(-1)?.key)
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 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
})
canvas.setGraph(canvas.graph.rootGraph)
}
}))
// Check for overflow on breadcrumb items and collapse/expand the breadcrumb to fit
let overflowObserver: ReturnType<typeof useOverflowObserver> | undefined
@@ -233,18 +189,13 @@ onUpdated(() => {
}
:deep(.p-breadcrumb-item) {
@apply flex items-center overflow-hidden h-8;
@apply flex items-center overflow-hidden;
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);
}
@@ -254,9 +205,11 @@ onUpdated(() => {
calc(var(--p-breadcrumb-item-margin) + var(--p-breadcrumb-item-padding));
}
:deep(.p-breadcrumb-item:hover) {
@apply rounded-lg;
border-color: var(--interface-stroke);
: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);
background-color: var(--comfy-menu-bg);
}
@@ -265,8 +218,10 @@ 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);
@@ -274,10 +229,13 @@ 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,

View File

@@ -7,7 +7,7 @@
}"
draggable="false"
href="#"
class="p-breadcrumb-item-link h-8 cursor-pointer px-2"
class="p-breadcrumb-item-link h-12 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 || isRoot"
v-if="isActive"
ref="menu"
:model="menuItems"
:popup="true"
@@ -59,7 +59,6 @@ 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,
@@ -136,28 +135,79 @@ const tooltipText = computed(() => {
return props.item.label
})
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`
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')
}
},
{
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) {
@@ -178,6 +228,20 @@ 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)
@@ -185,14 +249,6 @@ const inputBlur = async (doRename: boolean) => {
isEditing.value = false
}
const toggleMenu = (event: MouseEvent) => {
menu.value?.toggle(event)
}
defineExpose({
toggleMenu
})
</script>
<style scoped>

View File

@@ -1,11 +1,6 @@
<template>
<div class="relative inline-flex items-center">
<Button
size="icon"
variant="secondary"
v-bind="$attrs"
@click="popover?.toggle"
>
<Button size="icon" variant="secondary" @click="popover?.toggle">
<i
:class="
cn(
@@ -65,10 +60,6 @@ import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
defineOptions({
inheritAttrs: false
})
interface MoreButtonProps {
isVertical?: boolean
}

View File

@@ -1,65 +0,0 @@
<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>

View File

@@ -158,7 +158,6 @@ 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'
@@ -176,7 +175,6 @@ const dialogService = useDialogService()
const telemetry = useTelemetry()
const toast = useToast()
const { buildDocsUrl, docsPaths } = useExternalLink()
const { isSubscriptionEnabled } = useSubscription()
// Constants
const PRESET_AMOUNTS = [10, 25, 50, 100]
@@ -254,11 +252,9 @@ async function handleBuy() {
telemetry?.trackApiCreditTopupButtonPurchaseClicked(payAmount.value)
await authActions.purchaseCredits(payAmount.value)
// Close top-up dialog (keep tracking) and open credits panel to show updated balance
// Close top-up dialog (keep tracking) and open subscription panel to show updated credits
handleClose(false)
dialogService.showSettingsDialog(
isSubscriptionEnabled() ? 'subscription' : 'credits'
)
dialogService.showSettingsDialog('subscription')
} catch (error) {
console.error('Purchase failed:', error)

View File

@@ -7,7 +7,7 @@
pt:text="w-full"
>
<div class="flex items-center justify-between">
<div data-testid="current-user-indicator">
<div>
{{ $t('g.currentUser') }}: {{ userStore.currentUser?.username }}
</div>
<Button

View File

@@ -1,19 +0,0 @@
<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>

View File

@@ -4,7 +4,6 @@ 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',
@@ -26,25 +25,20 @@ const QueueJobItemStub = defineComponent({
template: '<div class="queue-job-item-stub"></div>'
})
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 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 mountComponent = (groups: JobGroup[]) =>
mount(JobGroupsList, {

View File

@@ -1,41 +1,32 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, provide, ref, watchEffect } from 'vue'
import { computed, ref, toValue, 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 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 TabParameters from './parameters/TabParameters.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: directlySelectedItems } = storeToRefs(canvasStore)
const { selectedItems } = storeToRefs(canvasStore)
const { activeTab, isEditingSubgraph } = storeToRefs(rightSidePanelStore)
const sidebarLocation = computed<'left' | 'right'>(() =>
@@ -49,31 +40,29 @@ const panelIcon = computed(() =>
: 'icon-[lucide--panel-right]'
)
const { flattedItems, selectedNodes, selectedGroups, nodeToParentGroup } =
useFlatAndCategorizeSelectedItems(directlySelectedItems)
const hasSelection = computed(() => selectedItems.value.length > 0)
const shouldShowGroupNames = computed(() => {
return !(
directlySelectedItems.value.length === 1 &&
(selectedGroups.value.length === 1 || selectedNodes.value.length === 1)
)
const selectedNodes = computed((): LGraphNode[] => {
return selectedItems.value.filter(isLGraphNode)
})
provide(GetNodeParentGroupKey, (node: LGraphNode) => {
if (!shouldShowGroupNames.value) return null
return nodeToParentGroup.value.get(node) ?? findParentGroup(node)
const isSubgraphNode = computed(() => {
return selectedNode.value instanceof SubgraphNode
})
const hasSelection = computed(() => flattedItems.value.length > 0)
const isSingleNodeSelected = computed(() => selectedNodes.value.length === 1)
const selectedSingleNode = computed(() => {
return selectedNodes.value.length === 1 && flattedItems.value.length === 1
? selectedNodes.value[0]
: null
const selectedNode = computed(() => {
return isSingleNodeSelected.value ? selectedNodes.value[0] : null
})
const isSingleSubgraphNode = computed(() => {
return selectedSingleNode.value instanceof SubgraphNode
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 })
})
function closePanel() {
@@ -86,40 +75,25 @@ type RightSidePanelTabList = Array<{
}>
const tabs = computed<RightSidePanelTabList>(() => {
const list: RightSidePanelTabList = []
list.push({
label: () =>
flattedItems.value.length > 1
? t('rightSidePanel.nodes')
: t('rightSidePanel.parameters'),
value: 'parameters'
})
if (!hasSelection.value) {
const list: RightSidePanelTabList = [
{
label: () => t('rightSidePanel.parameters'),
value: 'parameters'
},
{
label: () => t('g.settings'),
value: 'settings'
}
]
if (
!hasSelection.value ||
(isSingleNodeSelected.value && !isSubgraphNode.value)
) {
list.push({
label: () => t('rightSidePanel.nodes'),
value: 'nodes'
label: () => t('rightSidePanel.info'),
value: 'info'
})
}
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
})
@@ -127,59 +101,27 @@ const tabs = computed<RightSidePanelTabList>(() => {
watchEffect(() => {
if (
!tabs.value.some((tab) => tab.value === activeTab.value) &&
!(activeTab.value === 'subgraph' && isSingleSubgraphNode.value)
!(activeTab.value === 'subgraph' && isSubgraphNode.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 = selectedGroups.value[0] || selectedNodes.value[0]
const node = toValue(selectedNode)
if (!node) return
if (trimmedTitle === node.title) return
node.title = trimmedTitle
panelTitle.value = trimmedTitle
canvasStore.canvas?.setDirty(true, true)
canvasStore.canvas?.setDirty(true, false)
}
function handleTitleCancel() {
@@ -190,28 +132,21 @@ function handleTitleCancel() {
<template>
<div
data-testid="properties-panel"
class="flex size-full flex-col bg-comfy-menu-bg"
class="flex size-full flex-col bg-interface-panel-surface"
>
<!-- 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 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>
<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"
/>
<template v-else>
{{ panelTitle }}
</template>
@@ -219,7 +154,7 @@ function handleTitleCancel() {
<div class="flex gap-2">
<Button
v-if="isSingleSubgraphNode"
v-if="isSubgraphNode"
variant="secondary"
size="icon"
:class="cn(isEditingSubgraph && 'bg-secondary-background-selected')"
@@ -242,7 +177,7 @@ function handleTitleCancel() {
</Button>
</div>
</div>
<nav class="px-4 pb-2 pt-1 overflow-x-auto">
<nav v-if="hasSelection" class="px-4 pb-2 pt-1">
<TabList
:model-value="activeTab"
@update:model-value="
@@ -254,7 +189,7 @@ function handleTitleCancel() {
<Tab
v-for="tab in tabs"
:key="tab.value"
class="text-sm py-1 px-2 font-inter transition-all active:scale-95"
class="text-sm py-1 px-2 font-inter"
:value="tab.value"
>
{{ tab.label() }}
@@ -265,29 +200,25 @@ function handleTitleCancel() {
<!-- Panel Content -->
<div class="scrollbar-thin flex-1 overflow-y-auto">
<template v-if="!hasSelection">
<TabGlobalParameters v-if="activeTab === 'parameters'" />
<TabNodes v-else-if="activeTab === 'nodes'" />
<TabGlobalSettings v-else-if="activeTab === 'settings'" />
</template>
<div
v-if="!hasSelection"
class="flex size-full p-4 items-start justify-start text-sm text-muted-foreground"
>
{{ $t('rightSidePanel.noSelection') }}
</div>
<SubgraphEditor
v-else-if="isSingleSubgraphNode && isEditingSubgraph"
:node="selectedSingleNode"
v-else-if="isSubgraphNode && isEditingSubgraph"
:node="selectedNode"
/>
<template v-else>
<TabSubgraphInputs
v-if="activeTab === 'parameters' && isSingleSubgraphNode"
:node="selectedSingleNode as SubgraphNode"
/>
<TabNormalInputs
v-else-if="activeTab === 'parameters'"
<TabParameters
v-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="flattedItems"
:nodes="selectedNodes"
/>
</template>
</div>

View File

@@ -1,70 +1,54 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import TransitionCollapse from './TransitionCollapse.vue'
const props = defineProps<{
disabled?: boolean
label?: string
enableEmptyState?: boolean
tooltip?: string
defineProps<{
isEmpty?: boolean
}>()
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-comfy-menu-bg">
<div class="flex flex-col bg-interface-panel-surface">
<div
class="sticky top-0 z-10 flex items-center justify-between backdrop-blur-xl bg-inherit"
>
<button
v-tooltip="tooltipConfig"
v-tooltip="
isEmpty
? {
value: $t('rightSidePanel.inputsNoneTooltip'),
showDelay: 1_000
}
: undefined
"
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',
!disabled && 'cursor-pointer'
!isEmpty && 'cursor-pointer'
)
"
:disabled="disabled"
:disabled="isEmpty"
@click="isCollapse = !isCollapse"
>
<span class="text-sm font-semibold line-clamp-2 flex-1">
<slot name="label">
{{ label }}
</slot>
<span class="text-sm font-semibold line-clamp-2">
<slot name="label" />
</span>
<i
v-if="!isEmpty"
:class="
cn(
'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'
'text-muted-foreground group-hover:text-base-foreground group-focus:text-base-foreground icon-[lucide--chevron-up] size-4 transition-all',
isCollapse && '-rotate-180'
)
"
/>
</button>
</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 v-if="!isCollapse && !isEmpty" class="pb-4">
<slot />
</div>
</div>
</template>

View File

@@ -1,30 +1,23 @@
<script setup lang="ts">
import { refDebounced } from '@vueuse/core'
import { ref, toRef, toValue, watch } from 'vue'
import type { HTMLAttributes, MaybeRefOrGetter } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const {
searcher = async () => {},
updateKey,
autofocus = false,
class: customClass
} = defineProps<{
const { searcher = async () => {}, updateKey } = defineProps<{
searcher?: (
query: string,
onCleanup: (cleanupFn: () => void) => void
) => Promise<void>
updateKey?: MaybeRefOrGetter<unknown>
autofocus?: boolean
class?: HTMLAttributes['class']
}>()
const searchQuery = defineModel<string>({ default: '' })
const isQuerying = ref(false)
const debouncedSearchQuery = refDebounced(searchQuery, 250, {
maxWait: 1000
const debouncedSearchQuery = refDebounced(searchQuery, 100, {
maxWait: 100
})
watch(searchQuery, (value) => {
isQuerying.value = value !== debouncedSearchQuery.value
@@ -51,54 +44,34 @@ watch(
},
{ immediate: true }
)
function handleFocus(event: FocusEvent) {
const target = event.target as HTMLInputElement
target.select()
}
</script>
<template>
<label
:class="
cn(
'group',
'bg-component-node-widget-background rounded-lg transition-all duration-150',
'flex-1 flex items-center',
'mt-1 py-1.5 bg-secondary-background rounded-lg transition-all duration-150',
'flex-1 flex gap-2 px-2 items-center',
'text-base-foreground border-0',
'focus-within:ring focus-within:ring-component-node-widget-background-highlighted/80',
customClass
'focus-within:ring focus-within:ring-component-node-widget-background-highlighted/80'
)
"
>
<i
:class="
cn(
'size-4 ml-2 shrink-0 transition-colors duration-150',
'size-4 text-muted-foreground',
isQuerying
? 'icon-[lucide--loader-circle] animate-spin'
: 'icon-[lucide--search]',
searchQuery?.trim() !== ''
? 'text-base-foreground'
: 'text-muted-foreground group-hover:text-base-foreground group-focus-within:text-base-foreground'
: 'icon-[lucide--search]'
)
"
/>
<input
v-model="searchQuery"
type="text"
class="bg-transparent border-0 outline-0 ring-0 h-5 w-full my-1.5 mx-2"
class="bg-transparent border-0 outline-0 ring-0 h-5"
:placeholder="$t('g.searchPlaceholder')"
:autofocus
@focus="handleFocus"
/>
<button
v-if="searchQuery.trim().length > 0"
class="text-muted-foreground hover:text-base-foreground bg-transparent shrink-0 border-0 outline-0 ring-0 p-0 m-0 pr-3 pl-1 flex items-center justify-center transition-all duration-150 hover:scale-108"
:aria-label="$t('g.clear')"
@click="searchQuery = ''"
>
<i :class="cn('icon-[lucide--delete] size-4 cursor-pointer')" />
</button>
</label>
</template>

View File

@@ -1,144 +0,0 @@
<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>

View File

@@ -1,185 +1,87 @@
<script setup lang="ts">
import { computed, inject, provide, ref, shallowRef, watchEffect } from 'vue'
import { computed, provide } from 'vue'
import { useI18n } from 'vue-i18n'
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 { useReactiveWidgetValue } from '@/composables/graph/useGraphNodeManager'
import type { LGraphNode } 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,
node,
widgets: widgetsProp,
showLocateButton = false,
isDraggable = false,
hiddenFavoriteIndicator = false,
showNodeName = false,
parents = [],
enableEmptyState = false,
tooltip
} = defineProps<{
const { label, widgets } = 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()
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 getWidgetComponent(widget: IBaseWidget) {
const component = getComponent(widget.type, widget.name)
return component || WidgetLegacy
}
const isEmpty = computed(() => widgets.value.length === 0)
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 displayLabel = computed(
() => label ?? (node ? node.title : t('rightSidePanel.inputs'))
() =>
label ??
(isEmpty.value
? t('rightSidePanel.inputsNone')
: 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>
<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>
</template>
<template #empty><slot name="empty" /></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
ref="widgetsContainer"
class="space-y-2 rounded-lg px-4 pt-1 relative"
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"
>
<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 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>
<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)
"
/>
</div>
</PropertiesAccordionItem>
</div>
</div>
</PropertiesAccordionItem>
</template>

View File

@@ -1,142 +0,0 @@
<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>

View File

@@ -1,82 +0,0 @@
<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>

View File

@@ -1,96 +0,0 @@
<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>

View File

@@ -0,0 +1,84 @@
<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>

View File

@@ -1,244 +0,0 @@
<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>

View File

@@ -1,167 +0,0 @@
<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>

View File

@@ -1,183 +0,0 @@
<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>

View File

@@ -1,21 +0,0 @@
<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>

View File

@@ -1,38 +0,0 @@
<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>

View File

@@ -1,61 +0,0 @@
<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>

View File

@@ -1,144 +0,0 @@
<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>

View File

@@ -1,71 +0,0 @@
<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>

View File

@@ -1,35 +0,0 @@
<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>

View File

@@ -1,205 +0,0 @@
<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>

View File

@@ -1,58 +1,245 @@
<template>
<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 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>
</template>
<script setup lang="ts">
/**
* 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 ToggleSwitch from 'primevue/toggleswitch'
import { computed, shallowRef, triggerRef, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
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'
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'
const props = defineProps<{
nodes: Raw<Positionable>[]
nodes?: LGraphNode[]
}>()
const theNodes = computed<LGraphNode[]>(() =>
props.nodes.filter((node) => isLGraphNode(node))
/**
* 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 theGroups = computed<LGraphGroup[]>(() =>
props.nodes.filter((node) => isLGraphGroup(node))
)
const nodeState = computed({
get() {
let mode: LGraphNode['mode'] | null = null
const nodes = targetNodes.value
const hasGroups = computed(() => theGroups.value.length > 0)
const hasNodes = computed(() => theNodes.value.length > 0)
const isOnlyHasNodes = computed(() => hasNodes.value && !hasGroups.value)
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)
}
})
</script>

View File

@@ -1,178 +0,0 @@
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)
})
})

View File

@@ -1,271 +0,0 @@
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
}

View File

@@ -1,6 +1,5 @@
<script setup lang="ts">
import { watchDebounced } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import {
computed,
customRef,
@@ -27,19 +26,17 @@ 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()
@@ -59,6 +56,10 @@ 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
@@ -243,25 +244,14 @@ onBeforeUnmount(() => {
<template>
<div v-if="activeNode" class="subgraph-edit-section flex h-full flex-col">
<div class="px-4 pb-4 pt-1 flex gap-2 border-b border-interface-stroke">
<FormSearchInput v-model="searchQuery" />
<div class="p-4 flex gap-2">
<SidePanelSearch :searcher />
</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-b border-interface-stroke"
class="flex flex-col border-t border-interface-stroke"
>
<div
class="sticky top-0 z-10 flex items-center justify-between backdrop-blur-xl min-h-12 px-4"
@@ -280,7 +270,7 @@ onBeforeUnmount(() => {
<SubgraphNodeWidget
v-for="[node, widget] in filteredActive"
:key="toKey([node, widget])"
class="bg-comfy-menu-bg"
class="bg-interface-panel-surface"
:node-title="node.title"
:widget-name="widget.name"
:is-shown="true"
@@ -293,7 +283,7 @@ onBeforeUnmount(() => {
<div
v-if="filteredCandidates.length"
class="flex flex-col border-b border-interface-stroke"
class="flex flex-col border-t border-interface-stroke"
>
<div
class="sticky top-0 z-10 flex items-center justify-between backdrop-blur-xl min-h-12 px-4"
@@ -312,7 +302,7 @@ onBeforeUnmount(() => {
<SubgraphNodeWidget
v-for="[node, widget] in filteredCandidates"
:key="toKey([node, widget])"
class="bg-comfy-menu-bg"
class="bg-interface-panel-surface"
:node-title="node.title"
:widget-name="widget.name"
@toggle-visibility="promote([node, widget])"
@@ -322,7 +312,7 @@ onBeforeUnmount(() => {
<div
v-if="recommendedWidgets.length"
class="flex justify-center border-b border-interface-stroke py-4"
class="flex justify-center border-t border-interface-stroke py-4"
>
<Button
size="sm"

View File

@@ -1,28 +0,0 @@
<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>

View File

@@ -44,9 +44,6 @@
<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" />
@@ -60,17 +57,14 @@ 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'
@@ -86,11 +80,9 @@ 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'

View File

@@ -74,22 +74,8 @@
"
: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>
/>
</template>
</VirtualGrid>
</div>
@@ -125,14 +111,12 @@ const { assets, isSelected } = defineProps<{
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 }
@@ -208,16 +192,6 @@ function onJobLeave(jobId: string) {
}
}
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)

View File

@@ -101,7 +101,6 @@
:assets="displayAssets"
:is-selected="isSelected"
@select-asset="handleAssetSelect"
@context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd"
/>
<VirtualGrid
@@ -121,10 +120,17 @@
: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"
@click="handleAssetSelect(item)"
@context-menu="handleAssetContextMenu"
@zoom="handleZoomClick(item)"
@output-count-click="enterFolderView(item)"
@asset-deleted="refreshAssets"
@context-menu-opened="openContextMenuId = item.id"
@bulk-download="handleBulkDownload"
@bulk-delete="handleBulkDelete"
/>
</template>
</VirtualGrid>
@@ -190,24 +196,6 @@
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">
@@ -215,7 +203,7 @@ import { useDebounceFn, useElementHover, useResizeObserver } from '@vueuse/core'
import Divider from 'primevue/divider'
import ProgressSpinner from 'primevue/progressspinner'
import { useToast } from 'primevue/usetoast'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
@@ -228,16 +216,13 @@ 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'
@@ -263,8 +248,8 @@ const isListView = computed(
() => isQueuePanelV2Enabled.value && viewMode.value === 'list'
)
const contextMenuRef = ref<InstanceType<typeof MediaAssetContextMenu>>()
const contextMenuAsset = ref<AssetItem | null>(null)
// Track which asset's context menu is open (for single-instance context menu management)
const openContextMenuId = ref<string | 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)
@@ -273,14 +258,6 @@ 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
@@ -324,13 +301,7 @@ const {
deactivate: deactivateSelection
} = useAssetSelection()
const {
downloadMultipleAssets,
deleteMultipleAssets,
addMultipleToWorkflow,
openMultipleWorkflows,
exportMultipleWorkflows
} = useMediaAssetActions()
const { downloadMultipleAssets, deleteMultipleAssets } = useMediaAssetActions()
// Footer responsive behavior
const footerRef = ref<HTMLElement | null>(null)
@@ -356,7 +327,8 @@ const isHoveringSelectionCount = useElementHover(selectionCountButtonRef)
// Total output count for all selected assets
const totalOutputCount = computed(() => {
return getTotalOutputCount(selectedAssets.value)
const selectedAssets = getSelectedAssets(displayAssets.value)
return getTotalOutputCount(selectedAssets)
})
const currentAssets = computed(() =>
@@ -387,12 +359,6 @@ 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 &&
@@ -478,20 +444,6 @@ 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)
@@ -600,12 +552,14 @@ const copyJobId = async () => {
}
const handleDownloadSelected = () => {
downloadMultipleAssets(selectedAssets.value)
const selectedAssets = getSelectedAssets(displayAssets.value)
downloadMultipleAssets(selectedAssets)
clearSelection()
}
const handleDeleteSelected = async () => {
await deleteMultipleAssets(selectedAssets.value)
const selectedAssets = getSelectedAssets(displayAssets.value)
await deleteMultipleAssets(selectedAssets)
clearSelection()
}
@@ -619,21 +573,6 @@ const handleBulkDelete = async (assets: AssetItem[]) => {
clearSelection()
}
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()
}
const handleClearQueue = async () => {
await commandStore.execute('Comfy.ClearPendingTasks')
}

View File

@@ -1,7 +1,6 @@
<template>
<SidebarTabTemplate
:title="$t('sideToolbar.workflows')"
v-bind="$attrs"
class="workflows-sidebar-tab"
>
<template #tool-buttons>

View File

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

View File

@@ -4,7 +4,7 @@
variant="textonly"
@click="toggleHelpCenter"
>
<div class="not-md:hidden">{{ $t('menu.helpAndFeedback') }}</div>
{{ $t('menu.helpAndFeedback') }}
<i class="icon-[lucide--circle-help] ml-0.5" />
<span
v-if="shouldShowRedDot"

View File

@@ -7,10 +7,6 @@
@mouseleave="handleMouseLeave"
@click="handleClick"
>
<i
v-if="workflowOption.workflow.activeState?.extra?.linearMode"
class="icon-[lucide--panels-top-left] bg-primary-background"
/>
<span class="workflow-label inline-block max-w-[150px] truncate text-sm">
{{ workflowOption.workflow.filename }}
</span>

View File

@@ -80,12 +80,7 @@
/>
<LoginButton v-else-if="isDesktop" class="p-1" />
</div>
<ContextMenu ref="menu" :model="contextMenuItems">
<template #itemicon="{ item }">
<OverlayIcon v-if="item.overlayIcon" v-bind="item.overlayIcon" />
<i v-else-if="item.icon" :class="item.icon" />
</template>
</ContextMenu>
<ContextMenu ref="menu" :model="contextMenuItems" />
<div v-if="isDesktop" class="window-actions-spacer app-drag shrink-0" />
</div>
</template>
@@ -99,8 +94,6 @@ import { computed, nextTick, onUpdated, ref, watch } from 'vue'
import type { WatchStopHandle } from 'vue'
import { useI18n } from 'vue-i18n'
import OverlayIcon from '@/components/common/OverlayIcon.vue'
import type { OverlayIconProps } from '@/components/common/OverlayIcon.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
import TopMenuHelpButton from '@/components/topbar/TopMenuHelpButton.vue'
@@ -108,11 +101,13 @@ import WorkflowTab from '@/components/topbar/WorkflowTab.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import {
useWorkflowBookmarkStore,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isElectron } from '@/utils/envUtil'
@@ -133,8 +128,8 @@ const { t } = useI18n()
const settingStore = useSettingStore()
const workspaceStore = useWorkspaceStore()
const workflowStore = useWorkflowStore()
const workflowBookmarkStore = useWorkflowBookmarkStore()
const workflowService = useWorkflowService()
const commandStore = useCommandStore()
const { isLoggedIn } = useCurrentUser()
const isIntegratedTabBar = computed(
@@ -198,73 +193,54 @@ const showContextMenu = (event: MouseEvent, option: WorkflowOption) => {
rightClickedTab.value = option
menu.value.show(event)
}
const rightClickedWorkflow = computed(
() => rightClickedTab.value?.workflow ?? null
)
const { menuItems: baseMenuItems } = useWorkflowActionsMenu(
() => commandStore.execute('Comfy.RenameWorkflow'),
{
includeDelete: false,
workflow: rightClickedWorkflow
}
)
const contextMenuItems = computed(() => {
const tab = rightClickedTab.value
const tab = rightClickedTab.value as WorkflowOption
if (!tab) return []
const index = options.value.findIndex((v) => v.workflow === tab.workflow)
return [
...baseMenuItems.value,
{
label: t('tabMenu.duplicateTab'),
command: async () => {
await workflowService.duplicateWorkflow(tab.workflow)
}
},
{
separator: true
},
{
label: t('tabMenu.closeTab'),
icon: 'pi pi-times',
command: () => onCloseWorkflow(tab)
},
{
label: t('tabMenu.closeTabsToLeft'),
overlayIcon: {
mainIcon: 'pi pi-times',
subIcon: 'pi pi-arrow-left',
positionX: 'right',
positionY: 'bottom',
subIconScale: 0.5
} as OverlayIconProps,
command: () => closeWorkflows(options.value.slice(0, index)),
disabled: index <= 0
},
{
label: t('tabMenu.closeTabsToRight'),
overlayIcon: {
mainIcon: 'pi pi-times',
subIcon: 'pi pi-arrow-right',
positionX: 'right',
positionY: 'bottom',
subIconScale: 0.5
} as OverlayIconProps,
command: () => closeWorkflows(options.value.slice(index + 1)),
disabled: index === options.value.length - 1
},
{
label: t('tabMenu.closeOtherTabs'),
overlayIcon: {
mainIcon: 'pi pi-times',
subIcon: 'pi pi-arrows-h',
positionX: 'right',
positionY: 'bottom',
subIconScale: 0.5
} as OverlayIconProps,
command: () =>
closeWorkflows([
...options.value.slice(index + 1),
...options.value.slice(0, index)
]),
disabled: options.value.length <= 1
},
{
label: workflowBookmarkStore.isBookmarked(tab.workflow.path)
? t('tabMenu.removeFromBookmarks')
: t('tabMenu.addToBookmarks'),
command: () => workflowBookmarkStore.toggleBookmarked(tab.workflow.path),
disabled: tab.workflow.isTemporary
}
]
})
const commandStore = useCommandStore()
// Horizontal scroll on wheel
const handleWheel = (event: WheelEvent) => {

View File

@@ -1,76 +0,0 @@
<script setup lang="ts">
import {
PopoverArrow,
PopoverContent,
PopoverPortal,
PopoverRoot,
PopoverTrigger
} from 'reka-ui'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
defineOptions({
inheritAttrs: false
})
defineProps<{
entries?: { label: string; action?: () => void; icon?: string }[][]
icon?: string
to?: string | HTMLElement
}>()
</script>
<template>
<PopoverRoot v-slot="{ close }">
<PopoverTrigger as-child>
<slot name="button">
<Button size="icon">
<i :class="icon ?? 'icon-[lucide--ellipsis]'" />
</Button>
</slot>
</PopoverTrigger>
<PopoverPortal :to>
<PopoverContent
side="bottom"
:side-offset="5"
:collision-padding="10"
v-bind="$attrs"
class="rounded-lg p-2 bg-base-background shadow-sm border border-border-subtle will-change-[transform,opacity] data-[state=open]:data-[side=top]:animate-slideDownAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade"
>
<slot>
<div class="flex flex-col p-1">
<section
v-for="(entryGroup, index) in entries ?? []"
:key="index"
class="flex flex-col border-b-2 last:border-none border-border-subtle"
>
<div
v-for="{ label, action, icon } in entryGroup"
:key="label"
:class="
cn(
'flex flex-row gap-4 p-2 rounded-sm my-1',
action &&
'cursor-pointer hover:bg-secondary-background-hover'
)
"
@click="
() => {
if (!action) return
action()
close()
}
"
>
<i v-if="icon" :class="icon" />
{{ label }}
</div>
</section>
</div>
</slot>
<PopoverArrow class="fill-base-background stroke-border-subtle" />
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
</template>

View File

@@ -1,29 +0,0 @@
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import { useTemplateRef } from 'vue'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
defineProps<{
dataTfWidget: string
}>()
const feedbackRef = useTemplateRef('feedbackRef')
whenever(feedbackRef, () => {
const scriptEl = document.createElement('script')
scriptEl.src = '//embed.typeform.com/next/embed.js'
feedbackRef.value?.appendChild(scriptEl)
})
</script>
<template>
<Popover>
<template #button>
<Button variant="inverted" class="rounded-full size-12">
<i class="icon-[lucide--circle-question-mark] size-6" />
</Button>
</template>
<div ref="feedbackRef" data-tf-auto-resize :data-tf-widget />
</Popover>
</template>

View File

@@ -1,59 +0,0 @@
<script setup lang="ts">
import { computed, ref, useTemplateRef } from 'vue'
const zoomPane = useTemplateRef('zoomPane')
const zoom = ref(1.0)
const panX = ref(0.0)
const panY = ref(0.0)
function handleWheel(e: WheelEvent) {
const zoomPaneEl = zoomPane.value
if (!zoomPaneEl) return
zoom.value -= e.deltaY
const { x, y, width, height } = zoomPaneEl.getBoundingClientRect()
const offsetX = e.clientX - x - width / 2
const offsetY = e.clientY - y - height / 2
const scaler = 1.1 ** (e.deltaY / -30)
panY.value = panY.value * scaler - offsetY * (scaler - 1)
panX.value = panX.value * scaler - offsetX * (scaler - 1)
}
let dragging = false
function handleDown(e: PointerEvent) {
if (e.button !== 0) return
const zoomPaneEl = zoomPane.value
if (!zoomPaneEl) return
zoomPaneEl.parentElement?.focus()
zoomPaneEl.setPointerCapture(e.pointerId)
dragging = true
}
function handleMove(e: PointerEvent) {
if (!dragging) return
panX.value += e.movementX
panY.value += e.movementY
}
const transform = computed(() => {
const scale = 1.1 ** (zoom.value / 30)
const matrix = [scale, 0, 0, scale, panX.value, panY.value]
return `matrix(${matrix.join(',')})`
})
</script>
<template>
<div
ref="zoomPane"
class="contain-size flex place-content-center"
@wheel="handleWheel"
@pointerdown.prevent="handleDown"
@pointermove="handleMove"
@pointerup="dragging = false"
@pointercancel="dragging = false"
>
<slot :style="{ transform }" />
</div>
</template>

View File

@@ -1,182 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { ComponentExposed } from 'vue-component-type-helpers'
import { ref } from 'vue'
import TagsInput from './TagsInput.vue'
import TagsInputInput from './TagsInputInput.vue'
import TagsInputItem from './TagsInputItem.vue'
import TagsInputItemDelete from './TagsInputItemDelete.vue'
import TagsInputItemText from './TagsInputItemText.vue'
interface GenericMeta<C> extends Omit<Meta<C>, 'component'> {
component: ComponentExposed<C>
}
const meta: GenericMeta<typeof TagsInput> = {
title: 'Components/TagsInput',
component: TagsInput,
tags: ['autodocs'],
argTypes: {
modelValue: {
control: 'object',
description: 'Array of tag values'
},
disabled: {
control: 'boolean',
description:
'When true, completely disables the component. When false (default), shows read-only state with edit icon until clicked.'
},
'onUpdate:modelValue': { action: 'update:modelValue' }
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: (args) => ({
components: {
TagsInput,
TagsInputInput,
TagsInputItem,
TagsInputItemDelete,
TagsInputItemText
},
setup() {
const tags = ref(args.modelValue || ['tag1', 'tag2'])
return { tags, args }
},
template: `
<TagsInput v-model="tags" :disabled="args.disabled" class="w-80" v-slot="{ isEmpty }">
<TagsInputItem v-for="tag in tags" :key="tag" :value="tag">
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput :is-empty="isEmpty" placeholder="Add tag..." />
</TagsInput>
<div class="mt-4 text-sm text-muted-foreground">
Tags: {{ tags.join(', ') }}
</div>
`
}),
args: {
modelValue: ['Vue', 'TypeScript'],
disabled: false
}
}
export const Empty: Story = {
args: {
disabled: false
},
render: (args) => ({
components: {
TagsInput,
TagsInputInput,
TagsInputItem,
TagsInputItemDelete,
TagsInputItemText
},
setup() {
const tags = ref<string[]>([])
return { tags, args }
},
template: `
<TagsInput v-model="tags" :disabled="args.disabled" class="w-80" v-slot="{ isEmpty }">
<TagsInputItem v-for="tag in tags" :key="tag" :value="tag">
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput :is-empty="isEmpty" placeholder="Start typing to add tags..." />
</TagsInput>
`
})
}
export const ManyTags: Story = {
render: (args) => ({
components: {
TagsInput,
TagsInputInput,
TagsInputItem,
TagsInputItemDelete,
TagsInputItemText
},
setup() {
const tags = ref([
'JavaScript',
'TypeScript',
'Vue',
'React',
'Svelte',
'Node.js',
'Python',
'Rust'
])
return { tags, args }
},
template: `
<TagsInput v-model="tags" :disabled="args.disabled" class="w-96" v-slot="{ isEmpty }">
<TagsInputItem v-for="tag in tags" :key="tag" :value="tag">
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput :is-empty="isEmpty" placeholder="Add technology..." />
</TagsInput>
`
})
}
export const Disabled: Story = {
args: {
disabled: true
},
render: (args) => ({
components: {
TagsInput,
TagsInputInput,
TagsInputItem,
TagsInputItemDelete,
TagsInputItemText
},
setup() {
const tags = ref(['Read', 'Only', 'Tags'])
return { tags, args }
},
template: `
<TagsInput v-model="tags" :disabled="args.disabled" class="w-80" v-slot="{ isEmpty }">
<TagsInputItem v-for="tag in tags" :key="tag" :value="tag">
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput :is-empty="isEmpty" placeholder="Cannot add tags..." />
</TagsInput>
`
})
}
export const CustomWidth: Story = {
render: (args) => ({
components: {
TagsInput,
TagsInputInput,
TagsInputItem,
TagsInputItemDelete,
TagsInputItemText
},
setup() {
const tags = ref(['Full', 'Width'])
return { tags, args }
},
template: `
<TagsInput v-model="tags" :disabled="args.disabled" class="w-full" v-slot="{ isEmpty }">
<TagsInputItem v-for="tag in tags" :key="tag" :value="tag">
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput :is-empty="isEmpty" placeholder="Add tag..." />
</TagsInput>
`
})
}

View File

@@ -1,161 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { h, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import TagsInput from './TagsInput.vue'
import TagsInputInput from './TagsInputInput.vue'
import TagsInputItem from './TagsInputItem.vue'
import TagsInputItemDelete from './TagsInputItemDelete.vue'
import TagsInputItemText from './TagsInputItemText.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { g: { removeTag: 'Remove tag' } } }
})
describe('TagsInput', () => {
function mountTagsInput(props = {}, slots = {}) {
return mount(TagsInput, {
props: {
modelValue: [],
...props
},
slots
})
}
it('renders slot content', () => {
const wrapper = mountTagsInput({}, { default: '<span>Slot Content</span>' })
expect(wrapper.text()).toContain('Slot Content')
})
})
describe('TagsInput with child components', () => {
function mountFullTagsInput(tags: string[] = ['tag1', 'tag2']) {
return mount(TagsInput, {
global: { plugins: [i18n] },
props: {
modelValue: tags
},
slots: {
default: () => [
...tags.map((tag) =>
h(TagsInputItem, { key: tag, value: tag }, () => [
h(TagsInputItemText),
h(TagsInputItemDelete)
])
),
h(TagsInputInput, { placeholder: 'Add tag...' })
]
}
})
}
it('renders tags structure and content', () => {
const tags = ['tag1', 'tag2']
const wrapper = mountFullTagsInput(tags)
const items = wrapper.findAllComponents(TagsInputItem)
const textElements = wrapper.findAllComponents(TagsInputItemText)
const deleteButtons = wrapper.findAllComponents(TagsInputItemDelete)
expect(items).toHaveLength(tags.length)
expect(textElements).toHaveLength(tags.length)
expect(deleteButtons).toHaveLength(tags.length)
textElements.forEach((el, i) => {
expect(el.text()).toBe(tags[i])
})
expect(wrapper.findComponent(TagsInputInput).exists()).toBe(true)
})
it('updates model value when adding a tag', async () => {
let currentTags = ['existing']
const wrapper = mount<typeof TagsInput<string>>(TagsInput, {
props: {
modelValue: currentTags,
'onUpdate:modelValue': (payload) => {
currentTags = payload
}
},
slots: {
default: () => h(TagsInputInput, { placeholder: 'Add tag...' })
}
})
await wrapper.trigger('click')
await nextTick()
const input = wrapper.find('input')
await input.setValue('newTag')
await input.trigger('keydown', { key: 'Enter' })
await nextTick()
expect(currentTags).toContain('newTag')
})
it('does not enter edit mode when disabled', async () => {
const wrapper = mount<typeof TagsInput<string>>(TagsInput, {
props: {
modelValue: ['tag1'],
disabled: true
},
slots: {
default: () => h(TagsInputInput, { placeholder: 'Add tag...' })
}
})
expect(wrapper.find('input').exists()).toBe(false)
await wrapper.trigger('click')
await nextTick()
expect(wrapper.find('input').exists()).toBe(false)
})
it('exits edit mode when clicking outside', async () => {
const wrapper = mount<typeof TagsInput<string>>(TagsInput, {
props: {
modelValue: ['tag1']
},
slots: {
default: () => h(TagsInputInput, { placeholder: 'Add tag...' })
},
attachTo: document.body
})
await wrapper.trigger('click')
await nextTick()
expect(wrapper.find('input').exists()).toBe(true)
document.body.click()
await nextTick()
expect(wrapper.find('input').exists()).toBe(false)
wrapper.unmount()
})
it('shows placeholder when modelValue is empty', async () => {
const wrapper = mount<typeof TagsInput<string>>(TagsInput, {
props: {
modelValue: []
},
slots: {
default: ({ isEmpty }: { isEmpty: boolean }) =>
h(TagsInputInput, { placeholder: 'Add tag...', isEmpty })
}
})
await nextTick()
const input = wrapper.find('input')
expect(input.exists()).toBe(true)
expect(input.attributes('placeholder')).toBe('Add tag...')
})
})

View File

@@ -1,77 +0,0 @@
<script setup lang="ts" generic="T extends AcceptableInputValue = string">
import { onClickOutside, useCurrentElement } from '@vueuse/core'
import type {
AcceptableInputValue,
TagsInputRootEmits,
TagsInputRootProps
} from 'reka-ui'
import { TagsInputRoot, useForwardPropsEmits } from 'reka-ui'
import { computed, nextTick, provide, ref } from 'vue'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { tagsInputFocusKey, tagsInputIsEditingKey } from './tagsInputContext'
import type { FocusCallback } from './tagsInputContext'
const {
disabled = false,
class: className,
...restProps
} = defineProps<TagsInputRootProps<T> & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<TagsInputRootEmits<T>>()
const isEditing = ref(false)
const rootEl = useCurrentElement<HTMLElement>()
const focusInput = ref<FocusCallback>()
provide(tagsInputFocusKey, (callback: FocusCallback) => {
focusInput.value = callback
})
provide(tagsInputIsEditingKey, isEditing)
const internalDisabled = computed(() => disabled || !isEditing.value)
const delegatedProps = computed(() => ({
...restProps,
disabled: internalDisabled.value
}))
const forwarded = useForwardPropsEmits(delegatedProps, emits)
async function enableEditing() {
if (!disabled && !isEditing.value) {
isEditing.value = true
await nextTick()
focusInput.value?.()
}
}
onClickOutside(rootEl, () => {
isEditing.value = false
})
</script>
<template>
<TagsInputRoot
v-slot="{ modelValue }"
v-bind="forwarded"
:class="
cn(
'group relative flex flex-wrap items-center gap-2 rounded-lg bg-transparent p-2 text-xs text-base-foreground',
!internalDisabled &&
'hover:bg-modal-card-background-hovered focus-within:bg-modal-card-background-hovered',
!disabled && !isEditing && 'cursor-pointer',
className
)
"
@click="enableEditing"
>
<slot :is-empty="modelValue.length === 0" />
<i
v-if="!disabled && !isEditing"
aria-hidden="true"
class="icon-[lucide--square-pen] absolute bottom-2 right-2 size-4 text-muted-foreground"
/>
</TagsInputRoot>
</template>

View File

@@ -1,48 +0,0 @@
<script setup lang="ts">
import type { TagsInputInputProps } from 'reka-ui'
import { TagsInputInput, useForwardExpose, useForwardProps } from 'reka-ui'
import { computed, inject, onMounted, onUnmounted, ref } from 'vue'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { tagsInputFocusKey, tagsInputIsEditingKey } from './tagsInputContext'
const {
isEmpty = false,
class: className,
...restProps
} = defineProps<
TagsInputInputProps & { class?: HTMLAttributes['class']; isEmpty?: boolean }
>()
const forwardedProps = useForwardProps(restProps)
const isEditing = inject(tagsInputIsEditingKey, ref(true))
const showInput = computed(() => isEditing.value || isEmpty)
const { forwardRef, currentElement } = useForwardExpose()
const registerFocus = inject(tagsInputFocusKey, undefined)
onMounted(() => {
registerFocus?.(() => currentElement.value?.focus())
})
onUnmounted(() => {
registerFocus?.(undefined)
})
</script>
<template>
<TagsInputInput
v-if="showInput"
:ref="forwardRef"
v-bind="forwardedProps"
:class="
cn(
'min-h-6 flex-1 bg-transparent text-xs text-muted-foreground placeholder:text-muted-foreground focus:outline-none appearance-none border-none',
!isEditing && 'pointer-events-none',
className
)
"
/>
</template>

View File

@@ -1,27 +0,0 @@
<script setup lang="ts">
import type { TagsInputItemProps } from 'reka-ui'
import { TagsInputItem, useForwardProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
TagsInputItemProps & { class?: HTMLAttributes['class'] }
>()
const forwardedProps = useForwardProps(restProps)
</script>
<template>
<TagsInputItem
v-bind="forwardedProps"
:class="
cn(
'flex h-6 items-center gap-1 rounded-sm bg-modal-card-tag-background py-1 pl-2 pr-1 text-modal-card-tag-foreground backdrop-blur-sm ring-offset-base-background data-[state=active]:ring-2 data-[state=active]:ring-base-foreground data-[state=active]:ring-offset-1',
className
)
"
>
<slot />
</TagsInputItem>
</template>

View File

@@ -1,36 +0,0 @@
<script setup lang="ts">
import type { TagsInputItemDeleteProps } from 'reka-ui'
import { TagsInputItemDelete, useForwardProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
TagsInputItemDeleteProps & { class?: HTMLAttributes['class'] }
>()
const forwardedProps = useForwardProps(restProps)
const { t } = useI18n()
</script>
<template>
<TagsInputItemDelete
v-bind="forwardedProps"
:as="Button"
variant="textonly"
size="icon-sm"
:aria-label="t('g.removeTag')"
:class="
cn(
'opacity-60 hover:bg-transparent hover:opacity-100 transition-[opacity,width] duration-150 w-4 data-[disabled]:w-0 data-[disabled]:opacity-0 data-[disabled]:pointer-events-none overflow-hidden',
className
)
"
>
<slot>
<i class="icon-[lucide--x] size-4" />
</slot>
</TagsInputItemDelete>
</template>

View File

@@ -1,20 +0,0 @@
<script setup lang="ts">
import type { TagsInputItemTextProps } from 'reka-ui'
import { TagsInputItemText, useForwardProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
TagsInputItemTextProps & { class?: HTMLAttributes['class'] }
>()
const forwardedProps = useForwardProps(restProps)
</script>
<template>
<TagsInputItemText
v-bind="forwardedProps"
:class="cn('bg-transparent text-xs', className)"
/>
</template>

View File

@@ -1,10 +0,0 @@
import type { InjectionKey, Ref } from 'vue'
export type FocusCallback = (() => void) | undefined
export const tagsInputFocusKey: InjectionKey<
(callback: FocusCallback) => void
> = Symbol('tagsInputFocus')
export const tagsInputIsEditingKey: InjectionKey<Ref<boolean>> =
Symbol('tagsInputIsEditing')

View File

@@ -1,10 +1,5 @@
<template>
<div
v-tooltip.right="{
value: tooltipText,
disabled: !isOverflowing,
pt: { text: { class: 'whitespace-nowrap' } }
}"
class="flex cursor-pointer items-start gap-2 rounded-md px-4 py-3 text-sm transition-colors text-base-foreground"
:class="
active
@@ -12,22 +7,19 @@
: 'hover:bg-interface-menu-component-surface-hovered'
"
role="button"
@mouseenter="checkOverflow"
@click="onClick"
>
<div v-if="icon" class="pt-0.5">
<NavIcon :icon="icon" />
</div>
<i v-else class="text-neutral icon-[lucide--folder] text-xs shrink-0" />
<span ref="textRef" class="min-w-0 truncate">
<span class="flex items-center break-all">
<slot></slot>
</span>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { NavItemData } from '@/types/navTypes'
import NavIcon from './NavIcon.vue'
@@ -37,15 +29,4 @@ const { icon, active, onClick } = defineProps<{
active?: boolean
onClick: () => void
}>()
const textRef = ref<HTMLElement | null>(null)
const isOverflowing = ref(false)
const checkOverflow = () => {
if (!textRef.value) return
isOverflowing.value =
textRef.value.scrollWidth > textRef.value.clientWidth + 1
}
const tooltipText = computed(() => textRef.value?.textContent ?? '')
</script>

View File

@@ -41,8 +41,8 @@ export const useCurrentUser = () => {
whenever(() => authStore.tokenRefreshTrigger, callback)
const onUserLogout = (callback: () => void) => {
watch(resolvedUserInfo, (user, prevUser) => {
if (prevUser && !user) callback()
watch(resolvedUserInfo, (user) => {
if (!user) callback()
})
}

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