Merge branch 'main' into austin/fix-linked-widget-promotion
12
.github/workflows/release-biweekly-comfyui.yaml
vendored
@@ -69,7 +69,7 @@ jobs:
|
||||
- name: Checkout ComfyUI (sparse)
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: comfyanonymous/ComfyUI
|
||||
repository: Comfy-Org/ComfyUI
|
||||
sparse-checkout: |
|
||||
requirements.txt
|
||||
path: comfyui
|
||||
@@ -184,7 +184,7 @@ jobs:
|
||||
# Note: This only affects the local checkout, NOT the fork's master branch
|
||||
# We only push the automation branch, leaving the fork's master untouched
|
||||
echo "Fetching upstream master..."
|
||||
if ! git fetch https://github.com/comfyanonymous/ComfyUI.git master; then
|
||||
if ! git fetch https://github.com/Comfy-Org/ComfyUI.git master; then
|
||||
echo "Failed to fetch upstream master"
|
||||
exit 1
|
||||
fi
|
||||
@@ -257,7 +257,7 @@ jobs:
|
||||
# Extract fork owner from repository name
|
||||
FORK_OWNER=$(echo "$COMFYUI_FORK" | cut -d'/' -f1)
|
||||
|
||||
echo "Creating PR from ${COMFYUI_FORK} to comfyanonymous/ComfyUI"
|
||||
echo "Creating PR from ${COMFYUI_FORK} to Comfy-Org/ComfyUI"
|
||||
|
||||
# Configure git
|
||||
git config user.name "github-actions[bot]"
|
||||
@@ -288,7 +288,7 @@ jobs:
|
||||
|
||||
# Try to create PR, ignore error if it already exists
|
||||
if ! gh pr create \
|
||||
--repo comfyanonymous/ComfyUI \
|
||||
--repo Comfy-Org/ComfyUI \
|
||||
--head "${FORK_OWNER}:${BRANCH}" \
|
||||
--base master \
|
||||
--title "Bump comfyui-frontend-package to ${{ needs.resolve-version.outputs.target_version }}" \
|
||||
@@ -297,7 +297,7 @@ jobs:
|
||||
|
||||
# Check if PR already exists
|
||||
set +e
|
||||
EXISTING_PR=$(gh pr list --repo comfyanonymous/ComfyUI --head "${FORK_OWNER}:${BRANCH}" --json number --jq '.[0].number' 2>&1)
|
||||
EXISTING_PR=$(gh pr list --repo Comfy-Org/ComfyUI --head "${FORK_OWNER}:${BRANCH}" --json number --jq '.[0].number' 2>&1)
|
||||
PR_LIST_EXIT=$?
|
||||
set -e
|
||||
|
||||
@@ -318,7 +318,7 @@ jobs:
|
||||
run: |
|
||||
echo "## ComfyUI PR Created" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Draft PR created in comfyanonymous/ComfyUI" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Draft PR created in Comfy-Org/ComfyUI" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### PR Body:" >> $GITHUB_STEP_SUMMARY
|
||||
cat pr-body.txt >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -3,7 +3,7 @@ import { test as base, expect } from '@playwright/test'
|
||||
import dotenv from 'dotenv'
|
||||
import * as fs from 'fs'
|
||||
|
||||
import type { LGraphNode } from '../../src/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode, LGraph } from '../../src/lib/litegraph/src/litegraph'
|
||||
import type { NodeId } from '../../src/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { KeyCombo } from '../../src/schemas/keyBindingSchema'
|
||||
import type { useWorkspaceStore } from '../../src/stores/workspaceStore'
|
||||
@@ -1591,14 +1591,29 @@ export class ComfyPage {
|
||||
return window['app'].graph.nodes
|
||||
})
|
||||
}
|
||||
async getNodeRefsByType(type: string): Promise<NodeReference[]> {
|
||||
async waitForGraphNodes(count: number) {
|
||||
await this.page.waitForFunction((count) => {
|
||||
return window['app']?.canvas.graph?.nodes?.length === count
|
||||
}, count)
|
||||
}
|
||||
async getNodeRefsByType(
|
||||
type: string,
|
||||
includeSubgraph: boolean = false
|
||||
): Promise<NodeReference[]> {
|
||||
return Promise.all(
|
||||
(
|
||||
await this.page.evaluate((type) => {
|
||||
return window['app'].graph.nodes
|
||||
.filter((n: LGraphNode) => n.type === type)
|
||||
.map((n: LGraphNode) => n.id)
|
||||
}, type)
|
||||
await this.page.evaluate(
|
||||
({ type, includeSubgraph }) => {
|
||||
const graph = (
|
||||
includeSubgraph ? window['app'].canvas.graph : window['app'].graph
|
||||
) as LGraph
|
||||
const nodes = graph.nodes
|
||||
return nodes
|
||||
.filter((n: LGraphNode) => n.type === type)
|
||||
.map((n: LGraphNode) => n.id)
|
||||
},
|
||||
{ type, includeSubgraph }
|
||||
)
|
||||
).map((id: NodeId) => this.getNodeRefById(id))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -163,4 +163,14 @@ export class VueNodeHelpers {
|
||||
incrementButton: widget.getByTestId('increment')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter the subgraph of a node.
|
||||
* @param nodeId - The ID of the node to enter the subgraph of. If not provided, the first matched subgraph will be entered.
|
||||
*/
|
||||
async enterSubgraph(nodeId?: string): Promise<void> {
|
||||
const locator = nodeId ? this.getNodeLocator(nodeId) : this.page
|
||||
const editButton = locator.getByTestId('subgraph-enter-button')
|
||||
await editButton.click()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,11 @@ test.describe('Properties panel', () => {
|
||||
|
||||
const { propertiesPanel } = comfyPage.menu
|
||||
|
||||
await expect(propertiesPanel.panelTitle).toContainText(
|
||||
'No node(s) selected'
|
||||
)
|
||||
await expect(propertiesPanel.panelTitle).toContainText('Workflow Overview')
|
||||
|
||||
await comfyPage.selectNodes(['KSampler', 'CLIP Text Encode (Prompt)'])
|
||||
|
||||
await expect(propertiesPanel.panelTitle).toContainText('3 nodes selected')
|
||||
await expect(propertiesPanel.panelTitle).toContainText('3 items selected')
|
||||
await expect(propertiesPanel.root.getByText('KSampler')).toHaveCount(1)
|
||||
await expect(
|
||||
propertiesPanel.root.getByText('CLIP Text Encode (Prompt)')
|
||||
|
||||
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 103 KiB |
@@ -102,7 +102,7 @@ test.describe('Vue Node Link Interaction', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setup()
|
||||
// await comfyPage.setup()
|
||||
await comfyPage.loadWorkflow('vueNodes/simple-triple')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await fitToViewInstant(comfyPage)
|
||||
@@ -993,4 +993,51 @@ test.describe('Vue Node Link Interaction', () => {
|
||||
expect(linked).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test('Dragging from subgraph input connects to correct slot', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
// Setup workflow with a KSampler node
|
||||
await comfyPage.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await comfyPage.waitForGraphNodes(0)
|
||||
await comfyPage.executeCommand('Workspace.SearchBox.Toggle')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')
|
||||
await comfyPage.waitForGraphNodes(1)
|
||||
|
||||
// Convert the KSampler node to a subgraph
|
||||
let ksamplerNode = (await comfyPage.getNodeRefsByType('KSampler'))?.[0]
|
||||
await comfyPage.vueNodes.selectNode(String(ksamplerNode.id))
|
||||
await comfyPage.executeCommand('Comfy.Graph.ConvertToSubgraph')
|
||||
|
||||
// Enter the subgraph
|
||||
await comfyPage.vueNodes.enterSubgraph()
|
||||
await fitToViewInstant(comfyPage)
|
||||
|
||||
// Get the KSampler node inside the subgraph
|
||||
ksamplerNode = (await comfyPage.getNodeRefsByType('KSampler', true))?.[0]
|
||||
const positiveInput = await ksamplerNode.getInput(1)
|
||||
const negativeInput = await ksamplerNode.getInput(2)
|
||||
|
||||
const positiveInputPos = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
ksamplerNode.id,
|
||||
1,
|
||||
true
|
||||
)
|
||||
|
||||
const sourceSlot = await comfyPage.getSubgraphInputSlot()
|
||||
const calculatedSourcePos = await sourceSlot.getOpenSlotPosition()
|
||||
|
||||
await comfyMouse.move(calculatedSourcePos)
|
||||
await comfyMouse.drag(positiveInputPos)
|
||||
await comfyMouse.drop()
|
||||
|
||||
// Verify connection went to the correct slot
|
||||
const positiveLinks = await positiveInput.getLinkCount()
|
||||
const negativeLinks = await negativeInput.getLinkCount()
|
||||
expect(positiveLinks).toBe(1)
|
||||
expect(negativeLinks).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.38.0",
|
||||
"version": "1.38.1",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -247,6 +247,7 @@
|
||||
--inverted-background-hover: var(--color-charcoal-600);
|
||||
--warning-background: var(--color-gold-400);
|
||||
--warning-background-hover: var(--color-gold-500);
|
||||
--success-background: var(--color-jade-600);
|
||||
--border-default: var(--color-smoke-600);
|
||||
--border-subtle: var(--color-smoke-400);
|
||||
--muted-background: var(--color-smoke-700);
|
||||
@@ -372,6 +373,7 @@
|
||||
--inverted-background-hover: var(--color-smoke-200);
|
||||
--warning-background: var(--color-gold-600);
|
||||
--warning-background-hover: var(--color-gold-500);
|
||||
--success-background: var(--color-jade-600);
|
||||
--border-default: var(--color-charcoal-200);
|
||||
--border-subtle: var(--color-charcoal-300);
|
||||
--muted-background: var(--color-charcoal-100);
|
||||
@@ -516,6 +518,7 @@
|
||||
--color-inverted-background-hover: var(--inverted-background-hover);
|
||||
--color-warning-background: var(--warning-background);
|
||||
--color-warning-background-hover: var(--warning-background-hover);
|
||||
--color-success-background: var(--success-background);
|
||||
--color-border-default: var(--border-default);
|
||||
--color-border-subtle: var(--border-subtle);
|
||||
--color-muted-background: var(--muted-background);
|
||||
|
||||
@@ -1 +1,21 @@
|
||||
@import '@comfyorg/design-system/css/style.css';
|
||||
@import '@comfyorg/design-system/css/style.css';
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
/* List transition animations */
|
||||
.list-scale-move,
|
||||
.list-scale-enter-active,
|
||||
.list-scale-leave-active {
|
||||
transition: opacity 150ms ease, transform 150ms ease;
|
||||
}
|
||||
|
||||
.list-scale-enter-from,
|
||||
.list-scale-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(70%);
|
||||
}
|
||||
|
||||
.list-scale-leave-active {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<template>
|
||||
<div class="relative inline-flex items-center">
|
||||
<Button size="icon" variant="secondary" @click="popover?.toggle">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
v-bind="$attrs"
|
||||
@click="popover?.toggle"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
@@ -60,6 +65,10 @@ import { ref } from 'vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
interface MoreButtonProps {
|
||||
isVertical?: boolean
|
||||
}
|
||||
|
||||
@@ -1,32 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, ref, toValue, watchEffect } from 'vue'
|
||||
import { computed, provide, ref, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import EditableText from '@/components/common/EditableText.vue'
|
||||
import Tab from '@/components/tab/Tab.vue'
|
||||
import TabList from '@/components/tab/TabList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useGraphHierarchy } from '@/composables/graph/useGraphHierarchy'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import TabInfo from './info/TabInfo.vue'
|
||||
import TabParameters from './parameters/TabParameters.vue'
|
||||
import TabGlobalParameters from './parameters/TabGlobalParameters.vue'
|
||||
import TabNodes from './parameters/TabNodes.vue'
|
||||
import TabNormalInputs from './parameters/TabNormalInputs.vue'
|
||||
import TabSubgraphInputs from './parameters/TabSubgraphInputs.vue'
|
||||
import TabGlobalSettings from './settings/TabGlobalSettings.vue'
|
||||
import TabSettings from './settings/TabSettings.vue'
|
||||
import {
|
||||
GetNodeParentGroupKey,
|
||||
useFlatAndCategorizeSelectedItems
|
||||
} from './shared'
|
||||
import SubgraphEditor from './subgraph/SubgraphEditor.vue'
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const settingStore = useSettingStore()
|
||||
const { t } = useI18n()
|
||||
const { findParentGroup } = useGraphHierarchy()
|
||||
|
||||
const { selectedItems } = storeToRefs(canvasStore)
|
||||
const { selectedItems: directlySelectedItems } = storeToRefs(canvasStore)
|
||||
const { activeTab, isEditingSubgraph } = storeToRefs(rightSidePanelStore)
|
||||
|
||||
const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
@@ -40,29 +49,31 @@ const panelIcon = computed(() =>
|
||||
: 'icon-[lucide--panel-right]'
|
||||
)
|
||||
|
||||
const hasSelection = computed(() => selectedItems.value.length > 0)
|
||||
const { flattedItems, selectedNodes, selectedGroups, nodeToParentGroup } =
|
||||
useFlatAndCategorizeSelectedItems(directlySelectedItems)
|
||||
|
||||
const selectedNodes = computed((): LGraphNode[] => {
|
||||
return selectedItems.value.filter(isLGraphNode)
|
||||
const shouldShowGroupNames = computed(() => {
|
||||
return !(
|
||||
directlySelectedItems.value.length === 1 &&
|
||||
(selectedGroups.value.length === 1 || selectedNodes.value.length === 1)
|
||||
)
|
||||
})
|
||||
|
||||
const isSubgraphNode = computed(() => {
|
||||
return selectedNode.value instanceof SubgraphNode
|
||||
provide(GetNodeParentGroupKey, (node: LGraphNode) => {
|
||||
if (!shouldShowGroupNames.value) return null
|
||||
return nodeToParentGroup.value.get(node) ?? findParentGroup(node)
|
||||
})
|
||||
|
||||
const isSingleNodeSelected = computed(() => selectedNodes.value.length === 1)
|
||||
const hasSelection = computed(() => flattedItems.value.length > 0)
|
||||
|
||||
const selectedNode = computed(() => {
|
||||
return isSingleNodeSelected.value ? selectedNodes.value[0] : null
|
||||
const selectedSingleNode = computed(() => {
|
||||
return selectedNodes.value.length === 1 && flattedItems.value.length === 1
|
||||
? selectedNodes.value[0]
|
||||
: null
|
||||
})
|
||||
|
||||
const selectionCount = computed(() => selectedItems.value.length)
|
||||
|
||||
const panelTitle = computed(() => {
|
||||
if (isSingleNodeSelected.value && selectedNode.value) {
|
||||
return selectedNode.value.title || selectedNode.value.type || 'Node'
|
||||
}
|
||||
return t('rightSidePanel.title', { count: selectionCount.value })
|
||||
const isSingleSubgraphNode = computed(() => {
|
||||
return selectedSingleNode.value instanceof SubgraphNode
|
||||
})
|
||||
|
||||
function closePanel() {
|
||||
@@ -75,25 +86,40 @@ type RightSidePanelTabList = Array<{
|
||||
}>
|
||||
|
||||
const tabs = computed<RightSidePanelTabList>(() => {
|
||||
const list: RightSidePanelTabList = [
|
||||
{
|
||||
label: () => t('rightSidePanel.parameters'),
|
||||
value: 'parameters'
|
||||
},
|
||||
{
|
||||
label: () => t('g.settings'),
|
||||
value: 'settings'
|
||||
}
|
||||
]
|
||||
if (
|
||||
!hasSelection.value ||
|
||||
(isSingleNodeSelected.value && !isSubgraphNode.value)
|
||||
) {
|
||||
const list: RightSidePanelTabList = []
|
||||
|
||||
list.push({
|
||||
label: () =>
|
||||
flattedItems.value.length > 1
|
||||
? t('rightSidePanel.nodes')
|
||||
: t('rightSidePanel.parameters'),
|
||||
value: 'parameters'
|
||||
})
|
||||
|
||||
if (!hasSelection.value) {
|
||||
list.push({
|
||||
label: () => t('rightSidePanel.info'),
|
||||
value: 'info'
|
||||
label: () => t('rightSidePanel.nodes'),
|
||||
value: 'nodes'
|
||||
})
|
||||
}
|
||||
|
||||
if (hasSelection.value) {
|
||||
if (selectedSingleNode.value && !isSingleSubgraphNode.value) {
|
||||
list.push({
|
||||
label: () => t('rightSidePanel.info'),
|
||||
value: 'info'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
list.push({
|
||||
label: () =>
|
||||
hasSelection.value
|
||||
? t('g.settings')
|
||||
: t('rightSidePanel.globalSettings.title'),
|
||||
value: 'settings'
|
||||
})
|
||||
|
||||
return list
|
||||
})
|
||||
|
||||
@@ -101,27 +127,59 @@ const tabs = computed<RightSidePanelTabList>(() => {
|
||||
watchEffect(() => {
|
||||
if (
|
||||
!tabs.value.some((tab) => tab.value === activeTab.value) &&
|
||||
!(activeTab.value === 'subgraph' && isSubgraphNode.value)
|
||||
!(activeTab.value === 'subgraph' && isSingleSubgraphNode.value)
|
||||
) {
|
||||
rightSidePanelStore.openPanel(tabs.value[0].value)
|
||||
}
|
||||
})
|
||||
|
||||
function resolveTitle() {
|
||||
const items = flattedItems.value
|
||||
const nodes = selectedNodes.value
|
||||
const groups = selectedGroups.value
|
||||
|
||||
if (items.length === 0) {
|
||||
return t('rightSidePanel.workflowOverview')
|
||||
}
|
||||
if (directlySelectedItems.value.length === 1) {
|
||||
if (groups.length === 1) {
|
||||
return groups[0].title || t('rightSidePanel.fallbackGroupTitle')
|
||||
}
|
||||
if (nodes.length === 1) {
|
||||
return (
|
||||
nodes[0].title || nodes[0].type || t('rightSidePanel.fallbackNodeTitle')
|
||||
)
|
||||
}
|
||||
}
|
||||
return t('rightSidePanel.title', { count: items.length })
|
||||
}
|
||||
|
||||
const panelTitle = ref(resolveTitle())
|
||||
watchEffect(() => (panelTitle.value = resolveTitle()))
|
||||
|
||||
const isEditing = ref(false)
|
||||
|
||||
const allowTitleEdit = computed(() => {
|
||||
return (
|
||||
directlySelectedItems.value.length === 1 &&
|
||||
(selectedGroups.value.length === 1 || selectedNodes.value.length === 1)
|
||||
)
|
||||
})
|
||||
|
||||
function handleTitleEdit(newTitle: string) {
|
||||
isEditing.value = false
|
||||
|
||||
const trimmedTitle = newTitle.trim()
|
||||
if (!trimmedTitle) return
|
||||
|
||||
const node = toValue(selectedNode)
|
||||
const node = selectedGroups.value[0] || selectedNodes.value[0]
|
||||
if (!node) return
|
||||
|
||||
if (trimmedTitle === node.title) return
|
||||
|
||||
node.title = trimmedTitle
|
||||
canvasStore.canvas?.setDirty(true, false)
|
||||
panelTitle.value = trimmedTitle
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
function handleTitleCancel() {
|
||||
@@ -132,21 +190,28 @@ function handleTitleCancel() {
|
||||
<template>
|
||||
<div
|
||||
data-testid="properties-panel"
|
||||
class="flex size-full flex-col bg-interface-panel-surface"
|
||||
class="flex size-full flex-col bg-comfy-menu-bg"
|
||||
>
|
||||
<!-- Panel Header -->
|
||||
<section class="pt-1">
|
||||
<div class="flex items-center justify-between pl-4 pr-3">
|
||||
<h3 class="my-3.5 text-sm font-semibold line-clamp-2">
|
||||
<EditableText
|
||||
v-if="isSingleNodeSelected"
|
||||
:model-value="panelTitle"
|
||||
:is-editing="isEditing"
|
||||
:input-attrs="{ 'data-testid': 'node-title-input' }"
|
||||
@edit="handleTitleEdit"
|
||||
@cancel="handleTitleCancel"
|
||||
@dblclick="isEditing = true"
|
||||
/>
|
||||
<h3 class="my-3.5 text-sm font-semibold line-clamp-2 cursor-default">
|
||||
<template v-if="allowTitleEdit">
|
||||
<EditableText
|
||||
:model-value="panelTitle"
|
||||
:is-editing="isEditing"
|
||||
:input-attrs="{ 'data-testid': 'node-title-input' }"
|
||||
class="cursor-text"
|
||||
@edit="handleTitleEdit"
|
||||
@cancel="handleTitleCancel"
|
||||
@click="isEditing = true"
|
||||
/>
|
||||
<i
|
||||
v-if="!isEditing"
|
||||
class="icon-[lucide--pencil] size-4 text-muted-foreground ml-2 content-center relative top-[2px] hover:text-base-foreground cursor-pointer shrink-0"
|
||||
@click="isEditing = true"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ panelTitle }}
|
||||
</template>
|
||||
@@ -154,7 +219,7 @@ function handleTitleCancel() {
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
v-if="isSubgraphNode"
|
||||
v-if="isSingleSubgraphNode"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:class="cn(isEditingSubgraph && 'bg-secondary-background-selected')"
|
||||
@@ -177,7 +242,7 @@ function handleTitleCancel() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<nav v-if="hasSelection" class="px-4 pb-2 pt-1">
|
||||
<nav class="px-4 pb-2 pt-1 overflow-x-auto">
|
||||
<TabList
|
||||
:model-value="activeTab"
|
||||
@update:model-value="
|
||||
@@ -189,7 +254,7 @@ function handleTitleCancel() {
|
||||
<Tab
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
class="text-sm py-1 px-2 font-inter"
|
||||
class="text-sm py-1 px-2 font-inter transition-all active:scale-95"
|
||||
:value="tab.value"
|
||||
>
|
||||
{{ tab.label() }}
|
||||
@@ -200,25 +265,29 @@ function handleTitleCancel() {
|
||||
|
||||
<!-- Panel Content -->
|
||||
<div class="scrollbar-thin flex-1 overflow-y-auto">
|
||||
<div
|
||||
v-if="!hasSelection"
|
||||
class="flex size-full p-4 items-start justify-start text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('rightSidePanel.noSelection') }}
|
||||
</div>
|
||||
<template v-if="!hasSelection">
|
||||
<TabGlobalParameters v-if="activeTab === 'parameters'" />
|
||||
<TabNodes v-else-if="activeTab === 'nodes'" />
|
||||
<TabGlobalSettings v-else-if="activeTab === 'settings'" />
|
||||
</template>
|
||||
<SubgraphEditor
|
||||
v-else-if="isSubgraphNode && isEditingSubgraph"
|
||||
:node="selectedNode"
|
||||
v-else-if="isSingleSubgraphNode && isEditingSubgraph"
|
||||
:node="selectedSingleNode"
|
||||
/>
|
||||
<template v-else>
|
||||
<TabParameters
|
||||
v-if="activeTab === 'parameters'"
|
||||
<TabSubgraphInputs
|
||||
v-if="activeTab === 'parameters' && isSingleSubgraphNode"
|
||||
:node="selectedSingleNode as SubgraphNode"
|
||||
/>
|
||||
<TabNormalInputs
|
||||
v-else-if="activeTab === 'parameters'"
|
||||
:nodes="selectedNodes"
|
||||
:must-show-node-title="selectedGroups.length > 0"
|
||||
/>
|
||||
<TabInfo v-else-if="activeTab === 'info'" :nodes="selectedNodes" />
|
||||
<TabSettings
|
||||
v-else-if="activeTab === 'settings'"
|
||||
:nodes="selectedNodes"
|
||||
:nodes="flattedItems"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -1,54 +1,70 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineProps<{
|
||||
isEmpty?: boolean
|
||||
import TransitionCollapse from './TransitionCollapse.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
disabled?: boolean
|
||||
label?: string
|
||||
enableEmptyState?: boolean
|
||||
tooltip?: string
|
||||
}>()
|
||||
|
||||
const isCollapse = defineModel<boolean>('collapse', { default: false })
|
||||
|
||||
const isExpanded = computed(() => !isCollapse.value && !props.disabled)
|
||||
|
||||
const tooltipConfig = computed(() => {
|
||||
if (!props.tooltip) return undefined
|
||||
return { value: props.tooltip, showDelay: 1000 }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col bg-interface-panel-surface">
|
||||
<div class="flex flex-col bg-comfy-menu-bg">
|
||||
<div
|
||||
class="sticky top-0 z-10 flex items-center justify-between backdrop-blur-xl bg-inherit"
|
||||
>
|
||||
<button
|
||||
v-tooltip="
|
||||
isEmpty
|
||||
? {
|
||||
value: $t('rightSidePanel.inputsNoneTooltip'),
|
||||
showDelay: 1_000
|
||||
}
|
||||
: undefined
|
||||
"
|
||||
v-tooltip="tooltipConfig"
|
||||
type="button"
|
||||
:class="
|
||||
cn(
|
||||
'group min-h-12 bg-transparent border-0 outline-0 ring-0 w-full text-left flex items-center justify-between pl-4 pr-3',
|
||||
!isEmpty && 'cursor-pointer'
|
||||
!disabled && 'cursor-pointer'
|
||||
)
|
||||
"
|
||||
:disabled="isEmpty"
|
||||
:disabled="disabled"
|
||||
@click="isCollapse = !isCollapse"
|
||||
>
|
||||
<span class="text-sm font-semibold line-clamp-2">
|
||||
<slot name="label" />
|
||||
<span class="text-sm font-semibold line-clamp-2 flex-1">
|
||||
<slot name="label">
|
||||
{{ label }}
|
||||
</slot>
|
||||
</span>
|
||||
|
||||
<i
|
||||
v-if="!isEmpty"
|
||||
:class="
|
||||
cn(
|
||||
'text-muted-foreground group-hover:text-base-foreground group-focus:text-base-foreground icon-[lucide--chevron-up] size-4 transition-all',
|
||||
isCollapse && '-rotate-180'
|
||||
'text-muted-foreground group-hover:text-base-foreground group-has-[.subbutton:hover]:text-muted-foreground group-focus:text-base-foreground icon-[lucide--chevron-up] size-4 transition-all',
|
||||
isCollapse && '-rotate-180',
|
||||
disabled && 'opacity-0'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="!isCollapse && !isEmpty" class="pb-4">
|
||||
<slot />
|
||||
</div>
|
||||
<TransitionCollapse>
|
||||
<div v-if="isExpanded" class="pb-4">
|
||||
<slot />
|
||||
</div>
|
||||
<slot v-else-if="enableEmptyState && disabled" name="empty">
|
||||
<div>
|
||||
{{ $t('g.empty') }}
|
||||
</div>
|
||||
</slot>
|
||||
</TransitionCollapse>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
144
src/components/rightSidePanel/layout/TransitionCollapse.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
// From: https://stackoverflow.com/a/71426342/22392721
|
||||
interface Props {
|
||||
duration?: number
|
||||
easingEnter?: string
|
||||
easingLeave?: string
|
||||
opacityClosed?: number
|
||||
opacityOpened?: number
|
||||
disable?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
duration: 150,
|
||||
easingEnter: 'ease-in-out',
|
||||
easingLeave: 'ease-in-out',
|
||||
opacityClosed: 0,
|
||||
opacityOpened: 1
|
||||
})
|
||||
|
||||
const closed = '0px'
|
||||
|
||||
const isMounted = ref(false)
|
||||
onMounted(() => (isMounted.value = true))
|
||||
|
||||
const duration = computed(() =>
|
||||
isMounted.value && !props.disable ? props.duration : 0
|
||||
)
|
||||
|
||||
interface initialStyle {
|
||||
height: string
|
||||
width: string
|
||||
position: string
|
||||
visibility: string
|
||||
overflow: string
|
||||
paddingTop: string
|
||||
paddingBottom: string
|
||||
borderTopWidth: string
|
||||
borderBottomWidth: string
|
||||
marginTop: string
|
||||
marginBottom: string
|
||||
}
|
||||
|
||||
function getElementStyle(element: HTMLElement) {
|
||||
return {
|
||||
height: element.style.height,
|
||||
width: element.style.width,
|
||||
position: element.style.position,
|
||||
visibility: element.style.visibility,
|
||||
overflow: element.style.overflow,
|
||||
paddingTop: element.style.paddingTop,
|
||||
paddingBottom: element.style.paddingBottom,
|
||||
borderTopWidth: element.style.borderTopWidth,
|
||||
borderBottomWidth: element.style.borderBottomWidth,
|
||||
marginTop: element.style.marginTop,
|
||||
marginBottom: element.style.marginBottom
|
||||
}
|
||||
}
|
||||
|
||||
function prepareElement(element: HTMLElement, initialStyle: initialStyle) {
|
||||
const { width } = getComputedStyle(element)
|
||||
element.style.width = width
|
||||
element.style.position = 'absolute'
|
||||
element.style.visibility = 'hidden'
|
||||
element.style.height = ''
|
||||
const { height } = getComputedStyle(element)
|
||||
element.style.width = initialStyle.width
|
||||
element.style.position = initialStyle.position
|
||||
element.style.visibility = initialStyle.visibility
|
||||
element.style.height = closed
|
||||
element.style.overflow = 'hidden'
|
||||
return initialStyle.height && initialStyle.height !== closed
|
||||
? initialStyle.height
|
||||
: height
|
||||
}
|
||||
|
||||
function animateTransition(
|
||||
element: HTMLElement,
|
||||
initialStyle: initialStyle,
|
||||
done: () => void,
|
||||
keyframes: Keyframe[] | PropertyIndexedKeyframes | null,
|
||||
options?: number | KeyframeAnimationOptions
|
||||
) {
|
||||
const animation = element.animate(keyframes, options)
|
||||
// Set height to 'auto' to restore it after animation
|
||||
element.style.height = initialStyle.height
|
||||
animation.onfinish = () => {
|
||||
element.style.overflow = initialStyle.overflow
|
||||
done()
|
||||
}
|
||||
}
|
||||
|
||||
function getEnterKeyframes(height: string, initialStyle: initialStyle) {
|
||||
return [
|
||||
{
|
||||
height: closed,
|
||||
opacity: props.opacityClosed,
|
||||
paddingTop: closed,
|
||||
paddingBottom: closed,
|
||||
borderTopWidth: closed,
|
||||
borderBottomWidth: closed,
|
||||
marginTop: closed,
|
||||
marginBottom: closed
|
||||
},
|
||||
{
|
||||
height,
|
||||
opacity: props.opacityOpened,
|
||||
paddingTop: initialStyle.paddingTop,
|
||||
paddingBottom: initialStyle.paddingBottom,
|
||||
borderTopWidth: initialStyle.borderTopWidth,
|
||||
borderBottomWidth: initialStyle.borderBottomWidth,
|
||||
marginTop: initialStyle.marginTop,
|
||||
marginBottom: initialStyle.marginBottom
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function enterTransition(element: Element, done: () => void) {
|
||||
const HTMLElement = element as HTMLElement
|
||||
const initialStyle = getElementStyle(HTMLElement)
|
||||
const height = prepareElement(HTMLElement, initialStyle)
|
||||
const keyframes = getEnterKeyframes(height, initialStyle)
|
||||
const options = { duration: duration.value, easing: props.easingEnter }
|
||||
animateTransition(HTMLElement, initialStyle, done, keyframes, options)
|
||||
}
|
||||
|
||||
function leaveTransition(element: Element, done: () => void) {
|
||||
const HTMLElement = element as HTMLElement
|
||||
const initialStyle = getElementStyle(HTMLElement)
|
||||
const { height } = getComputedStyle(HTMLElement)
|
||||
HTMLElement.style.height = height
|
||||
HTMLElement.style.overflow = 'hidden'
|
||||
const keyframes = getEnterKeyframes(height, initialStyle).reverse()
|
||||
const options = { duration: duration.value, easing: props.easingLeave }
|
||||
animateTransition(HTMLElement, initialStyle, done, keyframes, options)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<slot />
|
||||
</Transition>
|
||||
</template>
|
||||
@@ -1,87 +1,185 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, provide } from 'vue'
|
||||
import { computed, inject, provide, ref, shallowRef, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useReactiveWidgetValue } from '@/composables/graph/useGraphNodeManager'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
|
||||
import type {
|
||||
LGraphGroup,
|
||||
LGraphNode,
|
||||
SubgraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
|
||||
import {
|
||||
getComponent,
|
||||
shouldExpand
|
||||
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
import { GetNodeParentGroupKey } from '../shared'
|
||||
import WidgetItem from './WidgetItem.vue'
|
||||
|
||||
const { label, widgets } = defineProps<{
|
||||
const {
|
||||
label,
|
||||
node,
|
||||
widgets: widgetsProp,
|
||||
showLocateButton = false,
|
||||
isDraggable = false,
|
||||
hiddenFavoriteIndicator = false,
|
||||
showNodeName = false,
|
||||
parents = [],
|
||||
enableEmptyState = false,
|
||||
tooltip
|
||||
} = defineProps<{
|
||||
label?: string
|
||||
parents?: SubgraphNode[]
|
||||
node?: LGraphNode
|
||||
widgets: { widget: IBaseWidget; node: LGraphNode }[]
|
||||
showLocateButton?: boolean
|
||||
isDraggable?: boolean
|
||||
hiddenFavoriteIndicator?: boolean
|
||||
showNodeName?: boolean
|
||||
/**
|
||||
* Whether to show the empty state slot when there are no widgets.
|
||||
*/
|
||||
enableEmptyState?: boolean
|
||||
tooltip?: string
|
||||
}>()
|
||||
|
||||
const collapse = defineModel<boolean>('collapse', { default: false })
|
||||
|
||||
const widgetsContainer = ref<HTMLElement>()
|
||||
const rootElement = ref<HTMLElement>()
|
||||
|
||||
const widgets = shallowRef(widgetsProp)
|
||||
watchEffect(() => (widgets.value = widgetsProp))
|
||||
|
||||
provide('hideLayoutField', true)
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
function getWidgetComponent(widget: IBaseWidget) {
|
||||
const component = getComponent(widget.type, widget.name)
|
||||
return component || WidgetLegacy
|
||||
const getNodeParentGroup = inject(GetNodeParentGroupKey, null)
|
||||
|
||||
function isWidgetShownOnParents(
|
||||
widgetNode: LGraphNode,
|
||||
widget: IBaseWidget
|
||||
): boolean {
|
||||
if (!parents.length) return false
|
||||
const proxyWidgets = parseProxyWidgets(parents[0].properties.proxyWidgets)
|
||||
|
||||
// For proxy widgets (already promoted), check using overlay information
|
||||
if (isProxyWidget(widget)) {
|
||||
return proxyWidgets.some(
|
||||
([nodeId, widgetName]) =>
|
||||
widget._overlay.nodeId == nodeId &&
|
||||
widget._overlay.widgetName === widgetName
|
||||
)
|
||||
}
|
||||
|
||||
// For regular widgets (not yet promoted), check using node ID and widget name
|
||||
return proxyWidgets.some(
|
||||
([nodeId, widgetName]) =>
|
||||
widgetNode.id == nodeId && widget.name === widgetName
|
||||
)
|
||||
}
|
||||
|
||||
function onWidgetValueChange(
|
||||
widget: IBaseWidget,
|
||||
value: string | number | boolean | object
|
||||
) {
|
||||
widget.value = value
|
||||
widget.callback?.(value)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
const isEmpty = computed(() => widgets.length === 0)
|
||||
const isEmpty = computed(() => widgets.value.length === 0)
|
||||
|
||||
const displayLabel = computed(
|
||||
() =>
|
||||
label ??
|
||||
(isEmpty.value
|
||||
? t('rightSidePanel.inputsNone')
|
||||
: t('rightSidePanel.inputs'))
|
||||
() => label ?? (node ? node.title : t('rightSidePanel.inputs'))
|
||||
)
|
||||
|
||||
const targetNode = computed<LGraphNode | null>(() => {
|
||||
if (node) return node
|
||||
if (isEmpty.value) return null
|
||||
|
||||
const firstNodeId = widgets.value[0].node.id
|
||||
const allSameNode = widgets.value.every(({ node }) => node.id === firstNodeId)
|
||||
|
||||
return allSameNode ? widgets.value[0].node : null
|
||||
})
|
||||
|
||||
const parentGroup = computed<LGraphGroup | null>(() => {
|
||||
if (!targetNode.value || !getNodeParentGroup) return null
|
||||
return getNodeParentGroup(targetNode.value)
|
||||
})
|
||||
|
||||
const canShowLocateButton = computed(
|
||||
() => showLocateButton && targetNode.value !== null
|
||||
)
|
||||
|
||||
function handleLocateNode() {
|
||||
if (!targetNode.value || !canvasStore.canvas) return
|
||||
|
||||
const graphNode = canvasStore.canvas.graph?.getNodeById(targetNode.value.id)
|
||||
if (graphNode) {
|
||||
canvasStore.canvas.animateToBounds(graphNode.boundingRect)
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
widgetsContainer,
|
||||
rootElement
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PropertiesAccordionItem :is-empty>
|
||||
<template #label>
|
||||
<slot name="label">
|
||||
{{ displayLabel }}
|
||||
</slot>
|
||||
</template>
|
||||
|
||||
<div v-if="!isEmpty" class="space-y-4 rounded-lg bg-interface-surface px-4">
|
||||
<div
|
||||
v-for="({ widget, node }, index) in widgets"
|
||||
:key="`widget-${index}-${widget.name}`"
|
||||
class="widget-item gap-1.5 col-span-full grid grid-cols-subgrid"
|
||||
>
|
||||
<div class="min-h-8">
|
||||
<p v-if="widget.name" class="text-sm leading-8 p-0 m-0 line-clamp-1">
|
||||
{{ widget.label || widget.name }}
|
||||
</p>
|
||||
<div ref="rootElement">
|
||||
<PropertiesAccordionItem
|
||||
v-model:collapse="collapse"
|
||||
:enable-empty-state
|
||||
:disabled="isEmpty"
|
||||
:tooltip
|
||||
>
|
||||
<template #label>
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<span class="flex-1 flex items-center gap-2 min-w-0">
|
||||
<span class="truncate">
|
||||
<slot name="label">
|
||||
{{ displayLabel }}
|
||||
</slot>
|
||||
</span>
|
||||
<span
|
||||
v-if="parentGroup"
|
||||
class="text-xs text-muted-foreground truncate flex-1 text-right min-w-11"
|
||||
:title="parentGroup.title"
|
||||
>
|
||||
{{ parentGroup.title }}
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
v-if="canShowLocateButton"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="subbutton shrink-0 mr-3 size-8 cursor-pointer text-muted-foreground hover:text-base-foreground"
|
||||
:title="t('rightSidePanel.locateNode')"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click.stop="handleLocateNode"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<component
|
||||
:is="getWidgetComponent(widget)"
|
||||
:widget="widget"
|
||||
:model-value="useReactiveWidgetValue(widget)"
|
||||
:node-id="String(node.id)"
|
||||
:node-type="node.type"
|
||||
:class="cn('col-span-1', shouldExpand(widget.type) && 'min-h-36')"
|
||||
@update:model-value="
|
||||
(value: string | number | boolean | object) =>
|
||||
onWidgetValueChange(widget, value)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #empty><slot name="empty" /></template>
|
||||
|
||||
<div
|
||||
ref="widgetsContainer"
|
||||
class="space-y-2 rounded-lg px-4 pt-1 relative"
|
||||
>
|
||||
<TransitionGroup name="list-scale">
|
||||
<WidgetItem
|
||||
v-for="{ widget, node } in widgets"
|
||||
:key="`${node.id}-${widget.name}-${widget.type}`"
|
||||
:widget="widget"
|
||||
:node="node"
|
||||
:is-draggable="isDraggable"
|
||||
:hidden-favorite-indicator="hiddenFavoriteIndicator"
|
||||
:show-node-name="showNodeName"
|
||||
:parents="parents"
|
||||
:is-shown-on-parents="isWidgetShownOnParents(node, widget)"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
</PropertiesAccordionItem>
|
||||
</PropertiesAccordionItem>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
142
src/components/rightSidePanel/parameters/TabGlobalParameters.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import { useMounted, watchDebounced } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
shallowRef
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import { DraggableList } from '@/scripts/ui/draggableList'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import type { ValidFavoritedWidget } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
import { searchWidgets } from '../shared'
|
||||
import SectionWidgets from './SectionWidgets.vue'
|
||||
|
||||
const favoritedWidgetsStore = useFavoritedWidgetsStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { searchQuery } = storeToRefs(rightSidePanelStore)
|
||||
const { t } = useI18n()
|
||||
|
||||
const draggableList = ref<DraggableList | undefined>(undefined)
|
||||
const sectionWidgetsRef = ref<{ widgetsContainer: HTMLElement }>()
|
||||
const isSearching = ref(false)
|
||||
|
||||
const favoritedWidgets = computed(
|
||||
() => favoritedWidgetsStore.validFavoritedWidgets
|
||||
)
|
||||
|
||||
const label = computed(() =>
|
||||
favoritedWidgets.value.length === 0
|
||||
? t('rightSidePanel.favoritesNone')
|
||||
: t('rightSidePanel.favorites')
|
||||
)
|
||||
|
||||
const searchedFavoritedWidgets = shallowRef<ValidFavoritedWidget[]>(
|
||||
favoritedWidgets.value
|
||||
)
|
||||
|
||||
async function searcher(query: string) {
|
||||
isSearching.value = query.trim().length > 0
|
||||
searchedFavoritedWidgets.value = searchWidgets(favoritedWidgets.value, query)
|
||||
}
|
||||
|
||||
const isMounted = useMounted()
|
||||
|
||||
function setDraggableState() {
|
||||
if (!isMounted.value) return
|
||||
draggableList.value?.dispose()
|
||||
const container = sectionWidgetsRef.value?.widgetsContainer
|
||||
if (isSearching.value || !container?.children?.length) return
|
||||
|
||||
draggableList.value = new DraggableList(container, '.draggable-item')
|
||||
|
||||
draggableList.value.applyNewItemsOrder = function () {
|
||||
const reorderedItems: HTMLElement[] = []
|
||||
|
||||
let oldPosition = -1
|
||||
this.getAllItems().forEach((item, index) => {
|
||||
if (item === this.draggableItem) {
|
||||
oldPosition = index
|
||||
return
|
||||
}
|
||||
if (!this.isItemToggled(item)) {
|
||||
reorderedItems[index] = item
|
||||
return
|
||||
}
|
||||
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
|
||||
reorderedItems[newIndex] = item
|
||||
})
|
||||
|
||||
for (let index = 0; index < this.getAllItems().length; index++) {
|
||||
const item = reorderedItems[index]
|
||||
if (typeof item === 'undefined') {
|
||||
reorderedItems[index] = this.draggableItem as HTMLElement
|
||||
}
|
||||
}
|
||||
|
||||
const newPosition = reorderedItems.indexOf(
|
||||
this.draggableItem as HTMLElement
|
||||
)
|
||||
const widgets = [...searchedFavoritedWidgets.value]
|
||||
const [widget] = widgets.splice(oldPosition, 1)
|
||||
widgets.splice(newPosition, 0, widget)
|
||||
searchedFavoritedWidgets.value = widgets
|
||||
favoritedWidgetsStore.reorderFavorites(widgets)
|
||||
}
|
||||
}
|
||||
|
||||
watchDebounced(
|
||||
searchedFavoritedWidgets,
|
||||
() => {
|
||||
setDraggableState()
|
||||
},
|
||||
{ debounce: 100 }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
setDraggableState()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
draggableList.value?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-4 pt-1 pb-4 flex gap-2 border-b border-interface-stroke">
|
||||
<FormSearchInput
|
||||
v-model="searchQuery"
|
||||
:searcher
|
||||
:update-key="favoritedWidgets"
|
||||
/>
|
||||
</div>
|
||||
<SectionWidgets
|
||||
ref="sectionWidgetsRef"
|
||||
:label
|
||||
:widgets="searchedFavoritedWidgets"
|
||||
:is-draggable="!isSearching"
|
||||
hidden-favorite-indicator
|
||||
show-node-name
|
||||
enable-empty-state
|
||||
class="border-b border-interface-stroke"
|
||||
@update:collapse="nextTick(setDraggableState)"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="text-sm text-muted-foreground px-4 text-center py-10">
|
||||
{{
|
||||
isSearching
|
||||
? t('rightSidePanel.noneSearchDesc')
|
||||
: t('rightSidePanel.favoritesNoneDesc')
|
||||
}}
|
||||
</div>
|
||||
</template>
|
||||
</SectionWidgets>
|
||||
</template>
|
||||
82
src/components/rightSidePanel/parameters/TabNodes.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
import { searchWidgetsAndNodes } from '../shared'
|
||||
import type { NodeWidgetsListList } from '../shared'
|
||||
import SectionWidgets from './SectionWidgets.vue'
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const nodes = computed((): LGraphNode[] => {
|
||||
// Depend on activeWorkflow to trigger recomputation when workflow changes
|
||||
void workflowStore.activeWorkflow?.path
|
||||
return (canvasStore.canvas?.graph?.nodes ?? []) as LGraphNode[]
|
||||
})
|
||||
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { searchQuery } = storeToRefs(rightSidePanelStore)
|
||||
|
||||
const widgetsSectionDataList = computed((): NodeWidgetsListList => {
|
||||
return nodes.value.map((node) => {
|
||||
const { widgets = [] } = node
|
||||
const shownWidgets = widgets
|
||||
.filter((w) => !(w.options?.canvasOnly || w.options?.hidden))
|
||||
.map((widget) => ({ node, widget }))
|
||||
return {
|
||||
widgets: shownWidgets,
|
||||
node
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const searchedWidgetsSectionDataList = shallowRef<NodeWidgetsListList>(
|
||||
widgetsSectionDataList.value
|
||||
)
|
||||
const isSearching = ref(false)
|
||||
async function searcher(query: string) {
|
||||
const list = widgetsSectionDataList.value
|
||||
const target = searchedWidgetsSectionDataList
|
||||
isSearching.value = query.trim() !== ''
|
||||
target.value = searchWidgetsAndNodes(list, query)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-4 pt-1 pb-4 flex gap-2 border-b border-interface-stroke">
|
||||
<FormSearchInput
|
||||
v-model="searchQuery"
|
||||
:searcher
|
||||
:update-key="widgetsSectionDataList"
|
||||
/>
|
||||
</div>
|
||||
<TransitionGroup tag="div" name="list-scale" class="relative">
|
||||
<div
|
||||
v-if="isSearching && searchedWidgetsSectionDataList.length === 0"
|
||||
class="text-sm text-muted-foreground px-4 text-center pt-5 pb-15"
|
||||
>
|
||||
{{ $t('rightSidePanel.noneSearchDesc') }}
|
||||
</div>
|
||||
<SectionWidgets
|
||||
v-for="{ node, widgets } in searchedWidgetsSectionDataList"
|
||||
:key="node.id"
|
||||
:node
|
||||
:widgets
|
||||
:collapse="!isSearching"
|
||||
:tooltip="
|
||||
isSearching || widgets.length
|
||||
? ''
|
||||
: $t('rightSidePanel.inputsNoneTooltip')
|
||||
"
|
||||
show-locate-button
|
||||
class="border-b border-interface-stroke"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
96
src/components/rightSidePanel/parameters/TabNormalInputs.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
import { searchWidgetsAndNodes } from '../shared'
|
||||
import type { NodeWidgetsListList } from '../shared'
|
||||
import SectionWidgets from './SectionWidgets.vue'
|
||||
|
||||
const { nodes, mustShowNodeTitle } = defineProps<{
|
||||
mustShowNodeTitle?: boolean
|
||||
nodes: LGraphNode[]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { searchQuery } = storeToRefs(rightSidePanelStore)
|
||||
|
||||
const widgetsSectionDataList = computed((): NodeWidgetsListList => {
|
||||
return nodes.map((node) => {
|
||||
const { widgets = [] } = node
|
||||
const shownWidgets = widgets
|
||||
.filter((w) => !(w.options?.canvasOnly || w.options?.hidden))
|
||||
.map((widget) => ({ node, widget }))
|
||||
|
||||
return { widgets: shownWidgets, node }
|
||||
})
|
||||
})
|
||||
|
||||
const isMultipleNodesSelected = computed(
|
||||
() => widgetsSectionDataList.value.length > 1
|
||||
)
|
||||
|
||||
const searchedWidgetsSectionDataList = shallowRef<NodeWidgetsListList>(
|
||||
widgetsSectionDataList.value
|
||||
)
|
||||
const isSearching = ref(false)
|
||||
|
||||
async function searcher(query: string) {
|
||||
const list = widgetsSectionDataList.value
|
||||
const target = searchedWidgetsSectionDataList
|
||||
isSearching.value = query.trim() !== ''
|
||||
target.value = searchWidgetsAndNodes(list, query)
|
||||
}
|
||||
|
||||
const label = computed(() => {
|
||||
const sections = widgetsSectionDataList.value
|
||||
return !mustShowNodeTitle && sections.length === 1
|
||||
? sections[0].widgets.length !== 0
|
||||
? t('rightSidePanel.inputs')
|
||||
: t('rightSidePanel.inputsNone')
|
||||
: undefined // SectionWidgets display node titles by default
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-4 pt-1 pb-4 flex gap-2 border-b border-interface-stroke">
|
||||
<FormSearchInput
|
||||
v-model="searchQuery"
|
||||
:searcher
|
||||
:update-key="widgetsSectionDataList"
|
||||
/>
|
||||
</div>
|
||||
<TransitionGroup tag="div" name="list-scale" class="relative">
|
||||
<div
|
||||
v-if="searchedWidgetsSectionDataList.length === 0"
|
||||
class="text-sm text-muted-foreground px-4 py-10 text-center"
|
||||
>
|
||||
{{
|
||||
isSearching
|
||||
? t('rightSidePanel.noneSearchDesc')
|
||||
: t('rightSidePanel.nodesNoneDesc')
|
||||
}}
|
||||
</div>
|
||||
<SectionWidgets
|
||||
v-for="{ widgets, node } in searchedWidgetsSectionDataList"
|
||||
:key="node.id"
|
||||
:node
|
||||
:label
|
||||
:widgets
|
||||
:collapse="isMultipleNodesSelected && !isSearching"
|
||||
:show-locate-button="isMultipleNodesSelected"
|
||||
:tooltip="
|
||||
isSearching || widgets.length
|
||||
? ''
|
||||
: t('rightSidePanel.inputsNoneTooltip')
|
||||
"
|
||||
class="border-b border-interface-stroke"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
@@ -1,84 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, shallowRef } from 'vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import SidePanelSearch from '../layout/SidePanelSearch.vue'
|
||||
import SectionWidgets from './SectionWidgets.vue'
|
||||
|
||||
const { nodes } = defineProps<{
|
||||
nodes: LGraphNode[]
|
||||
}>()
|
||||
|
||||
type NodeWidgetsList = Array<{ node: LGraphNode; widget: IBaseWidget }>
|
||||
type NodeWidgetsListList = Array<{
|
||||
node: LGraphNode
|
||||
widgets: NodeWidgetsList
|
||||
}>
|
||||
|
||||
const widgetsSectionDataList = computed((): NodeWidgetsListList => {
|
||||
return nodes.map((node) => {
|
||||
const { widgets = [] } = node
|
||||
const shownWidgets = widgets
|
||||
.filter((w) => !(w.options?.canvasOnly || w.options?.hidden))
|
||||
.map((widget) => ({ node, widget }))
|
||||
return {
|
||||
widgets: shownWidgets,
|
||||
node
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const searchedWidgetsSectionDataList = shallowRef<NodeWidgetsListList>([])
|
||||
|
||||
/**
|
||||
* Searches widgets in all selected nodes and returns search results.
|
||||
* Filters by name, localized label, type, and user-input value.
|
||||
* Performs basic tokenization of the query string.
|
||||
*/
|
||||
async function searcher(query: string) {
|
||||
if (query.trim() === '') {
|
||||
searchedWidgetsSectionDataList.value = widgetsSectionDataList.value
|
||||
return
|
||||
}
|
||||
const words = query.trim().toLowerCase().split(' ')
|
||||
searchedWidgetsSectionDataList.value = widgetsSectionDataList.value
|
||||
.map((item) => {
|
||||
return {
|
||||
...item,
|
||||
widgets: item.widgets.filter(({ widget }) => {
|
||||
const label = widget.label?.toLowerCase()
|
||||
const name = widget.name.toLowerCase()
|
||||
const type = widget.type.toLowerCase()
|
||||
const value = widget.value?.toString().toLowerCase()
|
||||
return words.every(
|
||||
(word) =>
|
||||
name.includes(word) ||
|
||||
label?.includes(word) ||
|
||||
type?.includes(word) ||
|
||||
value?.includes(word)
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
.filter((item) => item.widgets.length > 0)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-4 pb-4 flex gap-2 border-b border-interface-stroke">
|
||||
<SidePanelSearch :searcher :update-key="widgetsSectionDataList" />
|
||||
</div>
|
||||
<SectionWidgets
|
||||
v-for="section in searchedWidgetsSectionDataList"
|
||||
:key="section.node.id"
|
||||
:label="widgetsSectionDataList.length > 1 ? section.node.title : undefined"
|
||||
:widgets="section.widgets"
|
||||
:default-collapse="
|
||||
widgetsSectionDataList.length > 1 &&
|
||||
widgetsSectionDataList === searchedWidgetsSectionDataList
|
||||
"
|
||||
class="border-b border-interface-stroke"
|
||||
/>
|
||||
</template>
|
||||
244
src/components/rightSidePanel/parameters/TabSubgraphInputs.vue
Normal file
@@ -0,0 +1,244 @@
|
||||
<script setup lang="ts">
|
||||
import { useMounted, watchDebounced } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import {
|
||||
computed,
|
||||
customRef,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
shallowRef,
|
||||
triggerRef,
|
||||
useTemplateRef,
|
||||
watch
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
|
||||
import type { ProxyWidgetsProperty } from '@/core/schemas/proxyWidget'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import { DraggableList } from '@/scripts/ui/draggableList'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
import { searchWidgets } from '../shared'
|
||||
import type { NodeWidgetsList } from '../shared'
|
||||
import SectionWidgets from './SectionWidgets.vue'
|
||||
|
||||
const { node } = defineProps<{
|
||||
node: SubgraphNode
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const canvasStore = useCanvasStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { focusedSection, searchQuery } = storeToRefs(rightSidePanelStore)
|
||||
|
||||
const advancedInputsCollapsed = ref(true)
|
||||
const draggableList = ref<DraggableList | undefined>(undefined)
|
||||
const sectionWidgetsRef = useTemplateRef('sectionWidgetsRef')
|
||||
const advancedInputsSectionRef = useTemplateRef('advancedInputsSectionRef')
|
||||
|
||||
// Use customRef to track proxyWidgets changes
|
||||
const proxyWidgets = customRef<ProxyWidgetsProperty>((track, trigger) => ({
|
||||
get() {
|
||||
track()
|
||||
return parseProxyWidgets(node.properties.proxyWidgets)
|
||||
},
|
||||
set(value?: ProxyWidgetsProperty) {
|
||||
trigger()
|
||||
if (!value) return
|
||||
// eslint-disable-next-line vue/no-mutating-props
|
||||
node.properties.proxyWidgets = value
|
||||
}
|
||||
}))
|
||||
|
||||
watch(
|
||||
focusedSection,
|
||||
async (section) => {
|
||||
if (section === 'advanced-inputs') {
|
||||
advancedInputsCollapsed.value = false
|
||||
rightSidePanelStore.clearFocusedSection()
|
||||
|
||||
await nextTick()
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
|
||||
const sectionComponent = advancedInputsSectionRef.value
|
||||
const sectionElement = sectionComponent?.rootElement
|
||||
if (sectionElement) {
|
||||
sectionElement.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const widgetsList = computed((): NodeWidgetsList => {
|
||||
const proxyWidgetsOrder = proxyWidgets.value
|
||||
const { widgets = [] } = node
|
||||
|
||||
// Map proxyWidgets to actual proxy widgets in the correct order
|
||||
const result: NodeWidgetsList = []
|
||||
for (const [nodeId, widgetName] of proxyWidgetsOrder) {
|
||||
// Find the proxy widget that matches this nodeId and widgetName
|
||||
const widget = widgets.find((w) => {
|
||||
// Check if this is a proxy widget with _overlay
|
||||
if (isProxyWidget(w)) {
|
||||
return (
|
||||
String(w._overlay.nodeId) === nodeId &&
|
||||
w._overlay.widgetName === widgetName
|
||||
)
|
||||
}
|
||||
// For non-proxy widgets (like linked widgets), match by name
|
||||
return w.name === widgetName
|
||||
})
|
||||
if (widget) {
|
||||
result.push({ node, widget })
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const advancedInputsWidgets = computed((): NodeWidgetsList => {
|
||||
const interiorNodes = node.subgraph.nodes
|
||||
const proxyWidgetsValue = parseProxyWidgets(node.properties.proxyWidgets)
|
||||
|
||||
// Get all widgets from interior nodes
|
||||
const allInteriorWidgets = interiorNodes.flatMap((interiorNode) => {
|
||||
const { widgets = [] } = interiorNode
|
||||
return widgets
|
||||
.filter((w) => !w.computedDisabled)
|
||||
.map((widget) => ({ node: interiorNode, widget }))
|
||||
})
|
||||
|
||||
// Filter out widgets that are already promoted using tuple matching
|
||||
return allInteriorWidgets.filter(({ node: interiorNode, widget }) => {
|
||||
return !proxyWidgetsValue.some(
|
||||
([nodeId, widgetName]) =>
|
||||
interiorNode.id == nodeId && widget.name === widgetName
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const parents = computed<SubgraphNode[]>(() => [node])
|
||||
|
||||
const searchedWidgetsList = shallowRef<NodeWidgetsList>(widgetsList.value)
|
||||
const isSearching = ref(false)
|
||||
|
||||
async function searcher(query: string) {
|
||||
isSearching.value = query.trim() !== ''
|
||||
searchedWidgetsList.value = searchWidgets(widgetsList.value, query)
|
||||
}
|
||||
|
||||
const isMounted = useMounted()
|
||||
|
||||
function setDraggableState() {
|
||||
if (!isMounted.value) return
|
||||
|
||||
draggableList.value?.dispose()
|
||||
const container = sectionWidgetsRef.value?.widgetsContainer
|
||||
if (isSearching.value || !container?.children?.length) return
|
||||
|
||||
draggableList.value = new DraggableList(container, '.draggable-item')
|
||||
|
||||
draggableList.value.applyNewItemsOrder = function () {
|
||||
const reorderedItems: HTMLElement[] = []
|
||||
|
||||
let oldPosition = -1
|
||||
this.getAllItems().forEach((item, index) => {
|
||||
if (item === this.draggableItem) {
|
||||
oldPosition = index
|
||||
return
|
||||
}
|
||||
if (!this.isItemToggled(item)) {
|
||||
reorderedItems[index] = item
|
||||
return
|
||||
}
|
||||
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
|
||||
reorderedItems[newIndex] = item
|
||||
})
|
||||
|
||||
if (oldPosition === -1) {
|
||||
console.error('[TabSubgraphInputs] draggableItem not found in items')
|
||||
return
|
||||
}
|
||||
|
||||
for (let index = 0; index < this.getAllItems().length; index++) {
|
||||
const item = reorderedItems[index]
|
||||
if (typeof item === 'undefined') {
|
||||
reorderedItems[index] = this.draggableItem as HTMLElement
|
||||
}
|
||||
}
|
||||
|
||||
const newPosition = reorderedItems.indexOf(
|
||||
this.draggableItem as HTMLElement
|
||||
)
|
||||
|
||||
// Update proxyWidgets order
|
||||
const pw = proxyWidgets.value
|
||||
const [w] = pw.splice(oldPosition, 1)
|
||||
pw.splice(newPosition, 0, w)
|
||||
proxyWidgets.value = pw
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
triggerRef(proxyWidgets)
|
||||
}
|
||||
}
|
||||
|
||||
watchDebounced(searchedWidgetsList, () => setDraggableState(), {
|
||||
debounce: 100
|
||||
})
|
||||
onMounted(() => setDraggableState())
|
||||
onBeforeUnmount(() => draggableList.value?.dispose())
|
||||
|
||||
const label = computed(() => {
|
||||
return searchedWidgetsList.value.length !== 0
|
||||
? t('rightSidePanel.inputs')
|
||||
: t('rightSidePanel.inputsNone')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-4 pt-1 pb-4 flex gap-2 border-b border-interface-stroke">
|
||||
<FormSearchInput
|
||||
v-model="searchQuery"
|
||||
:searcher
|
||||
:update-key="widgetsList"
|
||||
/>
|
||||
</div>
|
||||
<SectionWidgets
|
||||
ref="sectionWidgetsRef"
|
||||
:node
|
||||
:label
|
||||
:parents
|
||||
:widgets="searchedWidgetsList"
|
||||
:is-draggable="!isSearching"
|
||||
:enable-empty-state="isSearching"
|
||||
:tooltip="
|
||||
isSearching || searchedWidgetsList.length
|
||||
? ''
|
||||
: t('rightSidePanel.inputsNoneTooltip')
|
||||
"
|
||||
class="border-b border-interface-stroke"
|
||||
@update:collapse="nextTick(setDraggableState)"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="text-sm text-muted-foreground px-4 text-center pt-5 pb-15">
|
||||
{{ t('rightSidePanel.noneSearchDesc') }}
|
||||
</div>
|
||||
</template>
|
||||
</SectionWidgets>
|
||||
<SectionWidgets
|
||||
v-if="advancedInputsWidgets.length > 0"
|
||||
ref="advancedInputsSectionRef"
|
||||
v-model:collapse="advancedInputsCollapsed"
|
||||
:label="t('rightSidePanel.advancedInputs')"
|
||||
:parents="parents"
|
||||
:widgets="advancedInputsWidgets"
|
||||
show-node-name
|
||||
class="border-b border-interface-stroke"
|
||||
/>
|
||||
</template>
|
||||
167
src/components/rightSidePanel/parameters/WidgetActions.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import MoreButton from '@/components/button/MoreButton.vue'
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
import {
|
||||
demoteWidget,
|
||||
promoteWidget
|
||||
} from '@/core/graph/subgraph/proxyWidgetUtils'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
|
||||
const {
|
||||
widget,
|
||||
node,
|
||||
parents = [],
|
||||
isShownOnParents = false
|
||||
} = defineProps<{
|
||||
widget: IBaseWidget
|
||||
node: LGraphNode
|
||||
parents?: SubgraphNode[]
|
||||
isShownOnParents?: boolean
|
||||
}>()
|
||||
|
||||
const label = defineModel<string>('label', { required: true })
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const favoritedWidgetsStore = useFavoritedWidgetsStore()
|
||||
const dialogService = useDialogService()
|
||||
const { t } = useI18n()
|
||||
|
||||
const hasParents = computed(() => parents?.length > 0)
|
||||
const favoriteNode = computed(() =>
|
||||
isShownOnParents && hasParents.value ? parents[0] : node
|
||||
)
|
||||
const isFavorited = computed(() =>
|
||||
favoritedWidgetsStore.isFavorited(favoriteNode.value, widget.name)
|
||||
)
|
||||
|
||||
async function handleRename() {
|
||||
const newLabel = await dialogService.prompt({
|
||||
title: t('g.rename'),
|
||||
message: t('g.enterNewName') + ':',
|
||||
defaultValue: widget.label,
|
||||
placeholder: widget.name
|
||||
})
|
||||
|
||||
if (newLabel === null) return
|
||||
label.value = newLabel
|
||||
}
|
||||
|
||||
function handleHideInput() {
|
||||
if (!parents?.length) return
|
||||
|
||||
// For proxy widgets (already promoted), we need to find the original interior node and widget
|
||||
if (isProxyWidget(widget)) {
|
||||
const subgraph = parents[0].subgraph
|
||||
const interiorNode = subgraph.getNodeById(parseInt(widget._overlay.nodeId))
|
||||
|
||||
if (!interiorNode) {
|
||||
console.error('Could not find interior node for proxy widget')
|
||||
return
|
||||
}
|
||||
|
||||
const originalWidget = interiorNode.widgets?.find(
|
||||
(w) => w.name === widget._overlay.widgetName
|
||||
)
|
||||
|
||||
if (!originalWidget) {
|
||||
console.error('Could not find original widget for proxy widget')
|
||||
return
|
||||
}
|
||||
|
||||
demoteWidget(interiorNode, originalWidget, parents)
|
||||
} else {
|
||||
// For regular widgets (not yet promoted), use them directly
|
||||
demoteWidget(node, widget, parents)
|
||||
}
|
||||
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
function handleShowInput() {
|
||||
if (!parents?.length) return
|
||||
|
||||
promoteWidget(node, widget, parents)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
function handleToggleFavorite() {
|
||||
favoritedWidgetsStore.toggleFavorite(favoriteNode.value, widget.name)
|
||||
}
|
||||
|
||||
const buttonClasses = cn([
|
||||
'border-none bg-transparent',
|
||||
'w-full flex items-center gap-2 rounded px-3 py-2 text-sm',
|
||||
'cursor-pointer transition-all hover:bg-secondary-background-hover active:scale-95'
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MoreButton
|
||||
is-vertical
|
||||
class="text-muted-foreground bg-transparent hover:text-base-foreground hover:bg-secondary-background-hover active:scale-95 transition-all"
|
||||
>
|
||||
<template #default="{ close }">
|
||||
<button
|
||||
:class="buttonClasses"
|
||||
@click="
|
||||
() => {
|
||||
handleRename()
|
||||
close()
|
||||
}
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--edit] size-4" />
|
||||
<span>{{ t('g.rename') }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="hasParents"
|
||||
:class="buttonClasses"
|
||||
@click="
|
||||
() => {
|
||||
if (isShownOnParents) handleHideInput()
|
||||
else handleShowInput()
|
||||
close()
|
||||
}
|
||||
"
|
||||
>
|
||||
<template v-if="isShownOnParents">
|
||||
<i class="icon-[lucide--eye-off] size-4" />
|
||||
<span>{{ t('rightSidePanel.hideInput') }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="icon-[lucide--eye] size-4" />
|
||||
<span>{{ t('rightSidePanel.showInput') }}</span>
|
||||
</template>
|
||||
</button>
|
||||
|
||||
<button
|
||||
:class="buttonClasses"
|
||||
@click="
|
||||
() => {
|
||||
handleToggleFavorite()
|
||||
close()
|
||||
}
|
||||
"
|
||||
>
|
||||
<template v-if="isFavorited">
|
||||
<i class="icon-[lucide--star]" />
|
||||
<span>{{ t('rightSidePanel.removeFavorite') }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="icon-[lucide--star]" />
|
||||
<span>{{ t('rightSidePanel.addFavorite') }}</span>
|
||||
</template>
|
||||
</button>
|
||||
</template>
|
||||
</MoreButton>
|
||||
</template>
|
||||
183
src/components/rightSidePanel/parameters/WidgetItem.vue
Normal file
@@ -0,0 +1,183 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, customRef, ref } from 'vue'
|
||||
|
||||
import EditableText from '@/components/common/EditableText.vue'
|
||||
import { getSharedWidgetEnhancements } from '@/composables/graph/useGraphNodeManager'
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
|
||||
import {
|
||||
getComponent,
|
||||
shouldExpand
|
||||
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { renameWidget } from '../shared'
|
||||
import WidgetActions from './WidgetActions.vue'
|
||||
|
||||
const {
|
||||
widget,
|
||||
node,
|
||||
isDraggable = false,
|
||||
hiddenFavoriteIndicator = false,
|
||||
showNodeName = false,
|
||||
parents = [],
|
||||
isShownOnParents = false
|
||||
} = defineProps<{
|
||||
widget: IBaseWidget
|
||||
node: LGraphNode
|
||||
isDraggable?: boolean
|
||||
hiddenFavoriteIndicator?: boolean
|
||||
showNodeName?: boolean
|
||||
parents?: SubgraphNode[]
|
||||
isShownOnParents?: boolean
|
||||
}>()
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const favoritedWidgetsStore = useFavoritedWidgetsStore()
|
||||
const isEditing = ref(false)
|
||||
|
||||
const widgetComponent = computed(() => {
|
||||
const component = getComponent(widget.type, widget.name)
|
||||
return component || WidgetLegacy
|
||||
})
|
||||
|
||||
const enhancedWidget = computed(() => {
|
||||
// Get shared enhancements (reactive value, controlWidget, spec, nodeType, etc.)
|
||||
const enhancements = getSharedWidgetEnhancements(node, widget)
|
||||
return { ...widget, ...enhancements }
|
||||
})
|
||||
|
||||
const sourceNodeName = computed((): string | null => {
|
||||
let sourceNode: LGraphNode | null = node
|
||||
if (isProxyWidget(widget)) {
|
||||
const { graph, nodeId } = widget._overlay
|
||||
sourceNode = getNodeByExecutionId(graph, nodeId)
|
||||
}
|
||||
return sourceNode ? sourceNode.title || sourceNode.type : null
|
||||
})
|
||||
|
||||
const hasParents = computed(() => parents?.length > 0)
|
||||
const favoriteNode = computed(() =>
|
||||
isShownOnParents && hasParents.value ? parents[0] : node
|
||||
)
|
||||
|
||||
const widgetValue = computed({
|
||||
get: () => {
|
||||
widget.vueTrack?.()
|
||||
return widget.value
|
||||
},
|
||||
set: (newValue: string | number | boolean | object) => {
|
||||
// eslint-disable-next-line vue/no-mutating-props
|
||||
widget.value = newValue
|
||||
widget.callback?.(newValue)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
})
|
||||
|
||||
const displayLabel = customRef((track, trigger) => {
|
||||
return {
|
||||
get() {
|
||||
track()
|
||||
return widget.label || widget.name
|
||||
},
|
||||
set(newValue: string) {
|
||||
isEditing.value = false
|
||||
|
||||
const trimmedLabel = newValue.trim()
|
||||
|
||||
const success = renameWidget(widget, node, trimmedLabel, parents)
|
||||
|
||||
if (success) {
|
||||
canvasStore.canvas?.setDirty(true)
|
||||
trigger()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'widget-item col-span-full grid grid-cols-subgrid rounded-lg group',
|
||||
isDraggable &&
|
||||
'draggable-item !will-change-auto drag-handle cursor-grab bg-comfy-menu-bg [&.is-draggable]:cursor-grabbing outline-comfy-menu-bg [&.is-draggable]:outline-4 [&.is-draggable]:outline-offset-0 [&.is-draggable]:opacity-70'
|
||||
)
|
||||
"
|
||||
>
|
||||
<!-- widget header -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'min-h-8 flex items-center justify-between gap-1 mb-1.5 min-w-0',
|
||||
isDraggable && 'pointer-events-none'
|
||||
)
|
||||
"
|
||||
>
|
||||
<EditableText
|
||||
v-if="widget.name"
|
||||
:model-value="displayLabel"
|
||||
:is-editing="isEditing"
|
||||
:input-attrs="{ placeholder: widget.name }"
|
||||
class="text-sm leading-8 p-0 m-0 truncate pointer-events-auto cursor-text"
|
||||
@edit="displayLabel = $event"
|
||||
@cancel="isEditing = false"
|
||||
@click="isEditing = true"
|
||||
/>
|
||||
|
||||
<span
|
||||
v-if="(showNodeName || hasParents) && sourceNodeName"
|
||||
class="text-xs text-muted-foreground flex-1 p-0 my-0 mx-1 truncate text-right min-w-10"
|
||||
>
|
||||
{{ sourceNodeName }}
|
||||
</span>
|
||||
<div class="flex items-center gap-1 shrink-0 pointer-events-auto">
|
||||
<WidgetActions
|
||||
v-model:label="displayLabel"
|
||||
:widget="widget"
|
||||
:node="node"
|
||||
:parents="parents"
|
||||
:is-shown-on-parents="isShownOnParents"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- favorite indicator -->
|
||||
<div
|
||||
v-if="
|
||||
!hiddenFavoriteIndicator &&
|
||||
favoritedWidgetsStore.isFavorited(favoriteNode, widget.name)
|
||||
"
|
||||
class="relative z-2 pointer-events-none"
|
||||
>
|
||||
<i
|
||||
class="absolute -right-1 -top-1 pi pi-star-fill text-xs text-muted-foreground pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
<!-- widget content -->
|
||||
<component
|
||||
:is="widgetComponent"
|
||||
v-model="widgetValue"
|
||||
:widget="enhancedWidget"
|
||||
:node-id="String(node.id)"
|
||||
:node-type="node.type"
|
||||
:class="cn('col-span-1', shouldExpand(widget.type) && 'min-h-36')"
|
||||
/>
|
||||
<!-- Drag handle -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-none mt-1.5 mx-auto max-w-40 w-1/2 h-1 rounded-lg bg-transparent transition-colors duration-150',
|
||||
'group-hover:bg-interface-stroke group-[.is-draggable]:bg-component-node-widget-background-highlighted',
|
||||
!isDraggable && 'opacity-0'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
21
src/components/rightSidePanel/settings/FieldSwitch.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
|
||||
import LayoutField from './LayoutField.vue'
|
||||
|
||||
defineProps<{
|
||||
label: string
|
||||
tooltip?: string
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<boolean>({ default: false })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LayoutField singleline :label :tooltip>
|
||||
<ToggleSwitch
|
||||
v-model="modelValue"
|
||||
class="transition-transform active:scale-90"
|
||||
/>
|
||||
</LayoutField>
|
||||
</template>
|
||||
38
src/components/rightSidePanel/settings/LayoutField.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
defineProps<{
|
||||
label: string
|
||||
tooltip?: string
|
||||
singleline?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn('flex gap-2', singleline ? 'items-center justify-between' : 'flex-col')
|
||||
"
|
||||
>
|
||||
<span
|
||||
v-tooltip.left="
|
||||
tooltip
|
||||
? {
|
||||
value: tooltip,
|
||||
showDelay: 300
|
||||
}
|
||||
: null
|
||||
"
|
||||
:class="
|
||||
cn(
|
||||
'text-sm text-muted-foreground truncate',
|
||||
tooltip ? 'cursor-help' : '',
|
||||
singleline ? 'flex-1' : ''
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
61
src/components/rightSidePanel/settings/NodeSettings.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div class="space-y-4 text-sm text-muted-foreground">
|
||||
<SetNodeState
|
||||
v-if="isNodes(targetNodes)"
|
||||
:nodes="targetNodes"
|
||||
@changed="handleChanged"
|
||||
/>
|
||||
<SetNodeColor :nodes="targetNodes" @changed="handleChanged" />
|
||||
<SetPinned :nodes="targetNodes" @changed="handleChanged" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { shallowRef, watchEffect } from 'vue'
|
||||
|
||||
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { isLGraphGroup } from '@/utils/litegraphUtil'
|
||||
|
||||
import SetNodeColor from './SetNodeColor.vue'
|
||||
import SetNodeState from './SetNodeState.vue'
|
||||
import SetPinned from './SetPinned.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
/**
|
||||
* - If the item is a Group, Node State cannot be set
|
||||
* as Groups do not have a 'mode' property.
|
||||
*
|
||||
* - The nodes array can contain either all Nodes or all Groups,
|
||||
* but it must not be a mix of both.
|
||||
*/
|
||||
nodes?: LGraphNode[] | LGraphGroup[]
|
||||
}>()
|
||||
|
||||
const targetNodes = shallowRef<LGraphNode[] | LGraphGroup[]>([])
|
||||
watchEffect(() => {
|
||||
if (props.nodes) {
|
||||
targetNodes.value = props.nodes
|
||||
} else {
|
||||
targetNodes.value = []
|
||||
}
|
||||
})
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
function isNodes(nodes: LGraphNode[] | LGraphGroup[]): nodes is LGraphNode[] {
|
||||
return !nodes.some((node) => isLGraphGroup(node))
|
||||
}
|
||||
|
||||
function handleChanged() {
|
||||
/**
|
||||
* This is not a random comment—it's crucial.
|
||||
* Otherwise, the UI cannot update correctly.
|
||||
* There is a bug with triggerRef here, so we can't use triggerRef.
|
||||
* We'll work around it for now and later submit a Vue issue and pull request to fix it.
|
||||
*/
|
||||
targetNodes.value = targetNodes.value.slice()
|
||||
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
</script>
|
||||
144
src/components/rightSidePanel/settings/SetNodeColor.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ColorOption } from '@/lib/litegraph/src/litegraph'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import LayoutField from './LayoutField.vue'
|
||||
|
||||
/**
|
||||
* Good design limits dependencies and simplifies the interface of the abstraction layer.
|
||||
* Here, we only care about the getColorOption and setColorOption methods,
|
||||
* and do not concern ourselves with other methods.
|
||||
*/
|
||||
type PickedNode = Pick<LGraphNode, 'getColorOption' | 'setColorOption'>
|
||||
|
||||
const { nodes } = defineProps<{ nodes: PickedNode[] }>()
|
||||
const emit = defineEmits<{ (e: 'changed'): void }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
|
||||
type NodeColorOption = {
|
||||
name: string
|
||||
localizedName: () => string
|
||||
value: {
|
||||
dark: string
|
||||
light: string
|
||||
ringDark: string
|
||||
ringLight: string
|
||||
}
|
||||
}
|
||||
|
||||
const nodeColorEntries = Object.entries(LGraphCanvas.node_colors)
|
||||
|
||||
function getColorValue(color: string): NodeColorOption['value'] {
|
||||
return {
|
||||
dark: adjustColor(color, { lightness: 0.3 }),
|
||||
light: adjustColor(color, { lightness: 0.4 }),
|
||||
ringDark: adjustColor(color, { lightness: 0.5 }),
|
||||
ringLight: adjustColor(color, { lightness: 0.1 })
|
||||
}
|
||||
}
|
||||
|
||||
const NO_COLOR_OPTION: NodeColorOption = {
|
||||
name: 'noColor',
|
||||
localizedName: () => t('color.noColor'),
|
||||
value: getColorValue(LiteGraph.NODE_DEFAULT_BGCOLOR)
|
||||
}
|
||||
|
||||
const colorOptions: NodeColorOption[] = [
|
||||
NO_COLOR_OPTION,
|
||||
...nodeColorEntries.map(([name, color]) => ({
|
||||
name,
|
||||
localizedName: () => t(`color.${name}`),
|
||||
value: getColorValue(color.bgcolor)
|
||||
}))
|
||||
]
|
||||
|
||||
const isLightTheme = computed(
|
||||
() => colorPaletteStore.completedActivePalette.light_theme
|
||||
)
|
||||
|
||||
const nodeColor = computed<NodeColorOption['name'] | null>({
|
||||
get() {
|
||||
if (nodes.length === 0) return null
|
||||
const theColorOptions = nodes.map((item) => item.getColorOption())
|
||||
|
||||
let colorOption: ColorOption | null | false = theColorOptions[0]
|
||||
if (!theColorOptions.every((option) => option === colorOption)) {
|
||||
colorOption = false
|
||||
}
|
||||
|
||||
if (colorOption === false) return null
|
||||
if (colorOption == null || (!colorOption.bgcolor && !colorOption.color))
|
||||
return NO_COLOR_OPTION.name
|
||||
return (
|
||||
nodeColorEntries.find(
|
||||
([_, color]) =>
|
||||
color.bgcolor === colorOption.bgcolor &&
|
||||
color.color === colorOption.color
|
||||
)?.[0] ?? null
|
||||
)
|
||||
},
|
||||
set(colorName) {
|
||||
if (colorName === null) return
|
||||
|
||||
const canvasColorOption =
|
||||
colorName === NO_COLOR_OPTION.name
|
||||
? null
|
||||
: LGraphCanvas.node_colors[colorName]
|
||||
|
||||
for (const item of nodes) {
|
||||
item.setColorOption(canvasColorOption)
|
||||
}
|
||||
|
||||
emit('changed')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LayoutField :label="t('rightSidePanel.color')">
|
||||
<div
|
||||
class="bg-secondary-background border-none rounded-lg p-1 grid grid-cols-5 gap-1 justify-items-center"
|
||||
>
|
||||
<button
|
||||
v-for="option of colorOptions"
|
||||
:key="option.name"
|
||||
:class="
|
||||
cn(
|
||||
'size-8 rounded-lg bg-transparent border-0 outline-0 ring-0 text-left flex justify-center items-center cursor-pointer',
|
||||
option.name === nodeColor
|
||||
? 'bg-interface-menu-component-surface-selected'
|
||||
: 'hover:bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
"
|
||||
@click="nodeColor = option.name"
|
||||
>
|
||||
<div
|
||||
v-tooltip.top="option.localizedName()"
|
||||
:class="cn('size-4 rounded-full ring-2 ring-gray-500/10')"
|
||||
:style="{
|
||||
backgroundColor: isLightTheme
|
||||
? option.value.light
|
||||
: option.value.dark,
|
||||
'--tw-ring-color':
|
||||
option.name === nodeColor
|
||||
? isLightTheme
|
||||
? option.value.ringLight
|
||||
: option.value.ringDark
|
||||
: undefined
|
||||
}"
|
||||
:data-testid="option.name"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</LayoutField>
|
||||
</template>
|
||||
71
src/components/rightSidePanel/settings/SetNodeState.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
|
||||
import FormSelectButton from '@/renderer/extensions/vueNodes/widgets/components/form/FormSelectButton.vue'
|
||||
|
||||
import LayoutField from './LayoutField.vue'
|
||||
|
||||
/**
|
||||
* Good design limits dependencies and simplifies the interface of the abstraction layer.
|
||||
* Here, we only care about the mode method,
|
||||
* and do not concern ourselves with other methods.
|
||||
*/
|
||||
type PickedNode = Pick<LGraphNode, 'mode'>
|
||||
|
||||
const { nodes } = defineProps<{ nodes: PickedNode[] }>()
|
||||
const emit = defineEmits<{ (e: 'changed'): void }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const nodeState = computed({
|
||||
get() {
|
||||
let mode: LGraphNode['mode'] | null = null
|
||||
|
||||
if (nodes.length === 0) return null
|
||||
|
||||
// For multiple nodes, if all nodes have the same mode, return that mode, otherwise return null
|
||||
if (nodes.length > 1) {
|
||||
mode = nodes[0].mode
|
||||
if (!nodes.every((node) => node.mode === mode)) {
|
||||
mode = null
|
||||
}
|
||||
} else {
|
||||
mode = nodes[0].mode
|
||||
}
|
||||
|
||||
return mode
|
||||
},
|
||||
set(value: LGraphNode['mode']) {
|
||||
nodes.forEach((node) => {
|
||||
node.mode = value
|
||||
})
|
||||
emit('changed')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LayoutField :label="t('rightSidePanel.nodeState')">
|
||||
<FormSelectButton
|
||||
v-model="nodeState"
|
||||
class="w-full"
|
||||
:options="[
|
||||
{
|
||||
label: t('rightSidePanel.normal'),
|
||||
value: LGraphEventMode.ALWAYS
|
||||
},
|
||||
{
|
||||
label: t('rightSidePanel.bypass'),
|
||||
value: LGraphEventMode.BYPASS
|
||||
},
|
||||
{
|
||||
label: t('rightSidePanel.mute'),
|
||||
value: LGraphEventMode.NEVER
|
||||
}
|
||||
]"
|
||||
/>
|
||||
</LayoutField>
|
||||
</template>
|
||||
35
src/components/rightSidePanel/settings/SetPinned.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
import FieldSwitch from './FieldSwitch.vue'
|
||||
|
||||
type PickedNode = Pick<LGraphNode, 'pinned' | 'pin'>
|
||||
|
||||
/**
|
||||
* Good design limits dependencies and simplifies the interface of the abstraction layer.
|
||||
* Here, we only care about the pinned and pin methods,
|
||||
* and do not concern ourselves with other methods.
|
||||
*/
|
||||
const { nodes } = defineProps<{ nodes: PickedNode[] }>()
|
||||
const emit = defineEmits<{ (e: 'changed'): void }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Pinned state
|
||||
const isPinned = computed<boolean>({
|
||||
get() {
|
||||
return nodes.some((node) => node.pinned)
|
||||
},
|
||||
set(value) {
|
||||
nodes.forEach((node) => node.pin(value))
|
||||
emit('changed')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FieldSwitch v-model="isPinned" :label="t('rightSidePanel.pinned')" />
|
||||
</template>
|
||||
205
src/components/rightSidePanel/settings/TabGlobalSettings.vue
Normal file
@@ -0,0 +1,205 @@
|
||||
<script setup lang="ts">
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
import Select from 'primevue/select'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LinkRenderType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { LinkMarkerShape } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { WidgetInputBaseClass } from '@/renderer/extensions/vueNodes/widgets/components/layout'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
import FieldSwitch from './FieldSwitch.vue'
|
||||
import LayoutField from './LayoutField.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const settingStore = useSettingStore()
|
||||
const dialogService = useDialogService()
|
||||
|
||||
// NODES settings
|
||||
const showAdvancedParameters = ref(false) // Placeholder for future implementation
|
||||
|
||||
const showToolbox = computed({
|
||||
get: () => settingStore.get('Comfy.Canvas.SelectionToolbox'),
|
||||
set: (value) => settingStore.set('Comfy.Canvas.SelectionToolbox', value)
|
||||
})
|
||||
|
||||
const nodes2Enabled = computed({
|
||||
get: () => settingStore.get('Comfy.VueNodes.Enabled'),
|
||||
set: (value) => settingStore.set('Comfy.VueNodes.Enabled', value)
|
||||
})
|
||||
|
||||
// CANVAS settings
|
||||
const gridSpacing = computed({
|
||||
get: () => settingStore.get('Comfy.SnapToGrid.GridSize'),
|
||||
set: (value) => settingStore.set('Comfy.SnapToGrid.GridSize', value)
|
||||
})
|
||||
|
||||
const snapToGrid = computed({
|
||||
get: () => settingStore.get('pysssss.SnapToGrid'),
|
||||
set: (value) => settingStore.set('pysssss.SnapToGrid', value)
|
||||
})
|
||||
|
||||
// CONNECTION LINKS settings
|
||||
const linkShape = computed({
|
||||
get: () => settingStore.get('Comfy.Graph.LinkMarkers'),
|
||||
set: (value) => settingStore.set('Comfy.Graph.LinkMarkers', value)
|
||||
})
|
||||
|
||||
const linkShapeOptions = computed(() => [
|
||||
{ value: LinkMarkerShape.None, label: t('g.none') },
|
||||
{ value: LinkMarkerShape.Circle, label: t('shape.circle') },
|
||||
{ value: LinkMarkerShape.Arrow, label: t('shape.arrow') }
|
||||
])
|
||||
|
||||
let theOldLinkRenderMode: LinkRenderType = LiteGraph.SPLINE_LINK
|
||||
const showConnectedLinks = computed({
|
||||
get: () => settingStore.get('Comfy.LinkRenderMode') !== LiteGraph.HIDDEN_LINK,
|
||||
set: (value) => {
|
||||
let oldLinkRenderMode = settingStore.get('Comfy.LinkRenderMode')
|
||||
if (oldLinkRenderMode !== LiteGraph.HIDDEN_LINK) {
|
||||
theOldLinkRenderMode = oldLinkRenderMode
|
||||
}
|
||||
const newMode = value ? theOldLinkRenderMode : LiteGraph.HIDDEN_LINK
|
||||
settingStore.set('Comfy.LinkRenderMode', newMode)
|
||||
}
|
||||
})
|
||||
|
||||
const GRID_SIZE_MIN = 1
|
||||
const GRID_SIZE_MAX = 100
|
||||
const GRID_SIZE_STEP = 1
|
||||
|
||||
function updateGridSpacingFromSlider(values?: number[]) {
|
||||
if (!values?.length) return
|
||||
gridSpacing.value = values[0]
|
||||
}
|
||||
|
||||
function updateGridSpacingFromInput(value: number | null | undefined) {
|
||||
if (typeof value !== 'number') return
|
||||
|
||||
const clampedValue = Math.min(GRID_SIZE_MAX, Math.max(GRID_SIZE_MIN, value))
|
||||
gridSpacing.value = Math.round(clampedValue / GRID_SIZE_STEP) * GRID_SIZE_STEP
|
||||
}
|
||||
|
||||
function openFullSettings() {
|
||||
dialogService.showSettingsDialog()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col border-t border-interface-stroke">
|
||||
<!-- NODES Section -->
|
||||
<PropertiesAccordionItem class="border-b border-interface-stroke">
|
||||
<template #label>
|
||||
{{ t('rightSidePanel.globalSettings.nodes') }}
|
||||
</template>
|
||||
<div class="space-y-4 px-4 py-3">
|
||||
<FieldSwitch
|
||||
v-model="showAdvancedParameters"
|
||||
:label="t('rightSidePanel.globalSettings.showAdvanced')"
|
||||
:tooltip="t('rightSidePanel.globalSettings.showAdvancedTooltip')"
|
||||
/>
|
||||
<FieldSwitch
|
||||
v-model="showToolbox"
|
||||
:label="t('rightSidePanel.globalSettings.showToolbox')"
|
||||
/>
|
||||
<FieldSwitch
|
||||
v-model="nodes2Enabled"
|
||||
:label="t('rightSidePanel.globalSettings.nodes2')"
|
||||
/>
|
||||
</div>
|
||||
</PropertiesAccordionItem>
|
||||
|
||||
<!-- CANVAS Section -->
|
||||
<PropertiesAccordionItem class="border-b border-interface-stroke">
|
||||
<template #label>
|
||||
{{ t('rightSidePanel.globalSettings.canvas') }}
|
||||
</template>
|
||||
<div class="space-y-4 px-4 py-3">
|
||||
<LayoutField :label="t('rightSidePanel.globalSettings.gridSpacing')">
|
||||
<div
|
||||
:class="
|
||||
cn(WidgetInputBaseClass, 'flex items-center gap-2 pl-3 pr-2')
|
||||
"
|
||||
>
|
||||
<Slider
|
||||
:model-value="[gridSpacing]"
|
||||
class="flex-grow text-xs"
|
||||
:min="GRID_SIZE_MIN"
|
||||
:max="GRID_SIZE_MAX"
|
||||
:step="GRID_SIZE_STEP"
|
||||
@update:model-value="updateGridSpacingFromSlider"
|
||||
/>
|
||||
<InputNumber
|
||||
:model-value="gridSpacing"
|
||||
class="w-16"
|
||||
size="small"
|
||||
pt:pc-input-text:root="min-w-[4ch] bg-transparent border-none text-center truncate"
|
||||
:min="GRID_SIZE_MIN"
|
||||
:max="GRID_SIZE_MAX"
|
||||
:step="GRID_SIZE_STEP"
|
||||
:allow-empty="false"
|
||||
@update:model-value="updateGridSpacingFromInput"
|
||||
/>
|
||||
</div>
|
||||
</LayoutField>
|
||||
<FieldSwitch
|
||||
v-model="snapToGrid"
|
||||
:label="t('rightSidePanel.globalSettings.snapNodesToGrid')"
|
||||
/>
|
||||
</div>
|
||||
</PropertiesAccordionItem>
|
||||
|
||||
<!-- CONNECTION LINKS Section -->
|
||||
<PropertiesAccordionItem class="border-b border-interface-stroke">
|
||||
<template #label>
|
||||
{{ t('rightSidePanel.globalSettings.connectionLinks') }}
|
||||
</template>
|
||||
<div class="space-y-4 px-4 py-3">
|
||||
<LayoutField :label="t('rightSidePanel.globalSettings.linkShape')">
|
||||
<Select
|
||||
v-model="linkShape"
|
||||
:options="linkShapeOptions"
|
||||
:aria-label="t('rightSidePanel.globalSettings.linkShape')"
|
||||
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
|
||||
size="small"
|
||||
:pt="{
|
||||
option: 'text-xs',
|
||||
dropdown: 'w-8',
|
||||
label: cn('truncate min-w-[4ch]', $slots.default && 'mr-5'),
|
||||
overlay: 'w-fit min-w-full'
|
||||
}"
|
||||
data-capture-wheel="true"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
/>
|
||||
</LayoutField>
|
||||
<FieldSwitch
|
||||
v-model="showConnectedLinks"
|
||||
:label="t('rightSidePanel.globalSettings.showConnectedLinks')"
|
||||
/>
|
||||
</div>
|
||||
</PropertiesAccordionItem>
|
||||
|
||||
<!-- View all settings button -->
|
||||
<div
|
||||
class="flex items-center justify-center p-4 border-b border-interface-stroke"
|
||||
>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="sm"
|
||||
class="gap-2 text-sm"
|
||||
@click="openFullSettings"
|
||||
>
|
||||
{{ t('rightSidePanel.globalSettings.viewAllSettings') }}
|
||||
<i class="icon-[lucide--settings] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,245 +1,58 @@
|
||||
<template>
|
||||
<div class="space-y-4 p-3 text-sm text-muted-foreground">
|
||||
<!-- Node State -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<span>
|
||||
{{ t('rightSidePanel.nodeState') }}
|
||||
</span>
|
||||
<FormSelectButton
|
||||
v-model="nodeState"
|
||||
class="w-full"
|
||||
:options="[
|
||||
{
|
||||
label: t('rightSidePanel.normal'),
|
||||
value: LGraphEventMode.ALWAYS
|
||||
},
|
||||
{
|
||||
label: t('rightSidePanel.bypass'),
|
||||
value: LGraphEventMode.BYPASS
|
||||
},
|
||||
{
|
||||
label: t('rightSidePanel.mute'),
|
||||
value: LGraphEventMode.NEVER
|
||||
}
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Color Picker -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<span>
|
||||
{{ t('rightSidePanel.color') }}
|
||||
</span>
|
||||
<div
|
||||
class="bg-secondary-background border-none rounded-lg p-1 grid grid-cols-5 gap-1 justify-items-center"
|
||||
>
|
||||
<button
|
||||
v-for="option of colorOptions"
|
||||
:key="option.name"
|
||||
:class="
|
||||
cn(
|
||||
'size-8 rounded-lg bg-transparent border-0 outline-0 ring-0 text-left flex justify-center items-center cursor-pointer',
|
||||
option.name === nodeColor
|
||||
? 'bg-interface-menu-component-surface-selected'
|
||||
: 'hover:bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
"
|
||||
@click="nodeColor = option.name"
|
||||
>
|
||||
<div
|
||||
v-tooltip.top="option.localizedName()"
|
||||
:class="cn('size-4 rounded-full ring-2 ring-gray-500/10')"
|
||||
:style="{
|
||||
backgroundColor: isLightTheme
|
||||
? option.value.light
|
||||
: option.value.dark,
|
||||
'--tw-ring-color':
|
||||
option.name === nodeColor
|
||||
? isLightTheme
|
||||
? option.value.ringLight
|
||||
: option.value.ringDark
|
||||
: undefined
|
||||
}"
|
||||
:data-testid="option.name"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pinned Toggle -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span>
|
||||
{{ t('rightSidePanel.pinned') }}
|
||||
</span>
|
||||
<ToggleSwitch v-model="isPinned" />
|
||||
</div>
|
||||
<div v-if="isOnlyHasNodes" class="p-4">
|
||||
<NodeSettings :nodes="theNodes" />
|
||||
</div>
|
||||
<div v-else class="border-t border-interface-stroke">
|
||||
<PropertiesAccordionItem
|
||||
v-if="hasNodes"
|
||||
class="border-b border-interface-stroke"
|
||||
:label="$t('rightSidePanel.nodes')"
|
||||
>
|
||||
<NodeSettings :nodes="theNodes" class="p-4" />
|
||||
</PropertiesAccordionItem>
|
||||
<PropertiesAccordionItem
|
||||
v-if="hasGroups"
|
||||
class="border-b border-interface-stroke"
|
||||
:label="$t('rightSidePanel.groups')"
|
||||
>
|
||||
<NodeSettings :nodes="theGroups" class="p-4" />
|
||||
</PropertiesAccordionItem>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
import { computed, shallowRef, triggerRef, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
/**
|
||||
* If we only need to show settings for Nodes,
|
||||
* there's no need to wrap them in PropertiesAccordionItem,
|
||||
* making the UI cleaner.
|
||||
* But if there are multiple types of settings,
|
||||
* it's better to wrap them; otherwise,
|
||||
* the UI would look messy.
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import type { Raw } from 'vue'
|
||||
|
||||
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ColorOption, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import FormSelectButton from '@/renderer/extensions/vueNodes/widgets/components/form/FormSelectButton.vue'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { Positionable } from '@/lib/litegraph/src/interfaces'
|
||||
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
|
||||
|
||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
import NodeSettings from './NodeSettings.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
nodes?: LGraphNode[]
|
||||
nodes: Raw<Positionable>[]
|
||||
}>()
|
||||
|
||||
/**
|
||||
* This is not random writing. It is very important.
|
||||
* Otherwise, the UI cannot be updated correctly.
|
||||
*/
|
||||
const targetNodes = shallowRef<LGraphNode[]>([])
|
||||
watchEffect(() => {
|
||||
if (props.nodes) {
|
||||
targetNodes.value = props.nodes
|
||||
} else {
|
||||
targetNodes.value = []
|
||||
}
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const isLightTheme = computed(
|
||||
() => colorPaletteStore.completedActivePalette.light_theme
|
||||
const theNodes = computed<LGraphNode[]>(() =>
|
||||
props.nodes.filter((node) => isLGraphNode(node))
|
||||
)
|
||||
|
||||
const nodeState = computed({
|
||||
get() {
|
||||
let mode: LGraphNode['mode'] | null = null
|
||||
const nodes = targetNodes.value
|
||||
const theGroups = computed<LGraphGroup[]>(() =>
|
||||
props.nodes.filter((node) => isLGraphGroup(node))
|
||||
)
|
||||
|
||||
if (nodes.length === 0) return null
|
||||
|
||||
// For multiple nodes, if all nodes have the same mode, return that mode, otherwise return null
|
||||
if (nodes.length > 1) {
|
||||
mode = nodes[0].mode
|
||||
if (!nodes.every((node) => node.mode === mode)) {
|
||||
mode = null
|
||||
}
|
||||
} else {
|
||||
mode = nodes[0].mode
|
||||
}
|
||||
|
||||
return mode
|
||||
},
|
||||
set(value: LGraphNode['mode']) {
|
||||
targetNodes.value.forEach((node) => {
|
||||
node.mode = value
|
||||
})
|
||||
/*
|
||||
* This is not random writing. It is very important.
|
||||
* Otherwise, the UI cannot be updated correctly.
|
||||
*/
|
||||
triggerRef(targetNodes)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
})
|
||||
|
||||
// Pinned state
|
||||
const isPinned = computed<boolean>({
|
||||
get() {
|
||||
return targetNodes.value.some((node) => node.pinned)
|
||||
},
|
||||
set(value) {
|
||||
targetNodes.value.forEach((node) => node.pin(value))
|
||||
/*
|
||||
* This is not random writing. It is very important.
|
||||
* Otherwise, the UI cannot be updated correctly.
|
||||
*/
|
||||
triggerRef(targetNodes)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
})
|
||||
|
||||
type NodeColorOption = {
|
||||
name: string
|
||||
localizedName: () => string
|
||||
value: {
|
||||
dark: string
|
||||
light: string
|
||||
ringDark: string
|
||||
ringLight: string
|
||||
}
|
||||
}
|
||||
|
||||
function getColorValue(color: string): NodeColorOption['value'] {
|
||||
return {
|
||||
dark: adjustColor(color, { lightness: 0.3 }),
|
||||
light: adjustColor(color, { lightness: 0.4 }),
|
||||
ringDark: adjustColor(color, { lightness: 0.5 }),
|
||||
ringLight: adjustColor(color, { lightness: 0.1 })
|
||||
}
|
||||
}
|
||||
|
||||
const NO_COLOR_OPTION: NodeColorOption = {
|
||||
name: 'noColor',
|
||||
localizedName: () => t('color.noColor'),
|
||||
value: getColorValue(LiteGraph.NODE_DEFAULT_BGCOLOR)
|
||||
}
|
||||
|
||||
const nodeColorEntries = Object.entries(LGraphCanvas.node_colors)
|
||||
|
||||
const colorOptions: NodeColorOption[] = [
|
||||
NO_COLOR_OPTION,
|
||||
...nodeColorEntries.map(([name, color]) => ({
|
||||
name,
|
||||
localizedName: () => t(`color.${name}`),
|
||||
value: getColorValue(color.bgcolor)
|
||||
}))
|
||||
]
|
||||
|
||||
const nodeColor = computed<NodeColorOption['name'] | null>({
|
||||
get() {
|
||||
if (targetNodes.value.length === 0) return null
|
||||
const theColorOptions = targetNodes.value.map((item) =>
|
||||
item.getColorOption()
|
||||
)
|
||||
|
||||
let colorOption: ColorOption | null | false = theColorOptions[0]
|
||||
if (!theColorOptions.every((option) => option === colorOption)) {
|
||||
colorOption = false
|
||||
}
|
||||
|
||||
if (colorOption === false) return null
|
||||
if (colorOption == null || (!colorOption.bgcolor && !colorOption.color))
|
||||
return NO_COLOR_OPTION.name
|
||||
return (
|
||||
nodeColorEntries.find(
|
||||
([_, color]) =>
|
||||
color.bgcolor === colorOption.bgcolor &&
|
||||
color.color === colorOption.color
|
||||
)?.[0] ?? null
|
||||
)
|
||||
},
|
||||
set(colorName) {
|
||||
if (colorName === null) return
|
||||
|
||||
const canvasColorOption =
|
||||
colorName === NO_COLOR_OPTION.name
|
||||
? null
|
||||
: LGraphCanvas.node_colors[colorName]
|
||||
|
||||
for (const item of targetNodes.value) {
|
||||
item.setColorOption(canvasColorOption)
|
||||
}
|
||||
/*
|
||||
* This is not random writing. It is very important.
|
||||
* Otherwise, the UI cannot be updated correctly.
|
||||
*/
|
||||
triggerRef(targetNodes)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
})
|
||||
const hasGroups = computed(() => theGroups.value.length > 0)
|
||||
const hasNodes = computed(() => theNodes.value.length > 0)
|
||||
const isOnlyHasNodes = computed(() => hasNodes.value && !hasGroups.value)
|
||||
</script>
|
||||
|
||||
178
src/components/rightSidePanel/shared.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { Positionable } from '@/lib/litegraph/src/interfaces'
|
||||
import { describe, expect, it, beforeEach } from 'vitest'
|
||||
import { flatAndCategorizeSelectedItems, searchWidgets } from './shared'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
describe('searchWidgets', () => {
|
||||
const createWidget = (
|
||||
name: string,
|
||||
type: string,
|
||||
value?: string,
|
||||
label?: string
|
||||
): { widget: IBaseWidget } => ({
|
||||
widget: {
|
||||
name,
|
||||
type,
|
||||
value,
|
||||
label
|
||||
} as IBaseWidget
|
||||
})
|
||||
|
||||
it('should return all widgets when query is empty', () => {
|
||||
const widgets = [
|
||||
createWidget('width', 'number', '100'),
|
||||
createWidget('height', 'number', '200')
|
||||
]
|
||||
const result = searchWidgets(widgets, '')
|
||||
expect(result).toEqual(widgets)
|
||||
})
|
||||
|
||||
it('should filter widgets by name, label, type, or value', () => {
|
||||
const widgets = [
|
||||
createWidget('width', 'number', '100', 'Size Control'),
|
||||
createWidget('height', 'slider', '200', 'Image Height'),
|
||||
createWidget('quality', 'text', 'high', 'Quality')
|
||||
]
|
||||
|
||||
expect(searchWidgets(widgets, 'width')).toHaveLength(1)
|
||||
expect(searchWidgets(widgets, 'slider')).toHaveLength(1)
|
||||
expect(searchWidgets(widgets, 'high')).toHaveLength(1)
|
||||
expect(searchWidgets(widgets, 'image')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should handle multiple search words', () => {
|
||||
const widgets = [
|
||||
createWidget('width', 'number', '100', 'Image Width'),
|
||||
createWidget('height', 'number', '200', 'Image Height')
|
||||
]
|
||||
const result = searchWidgets(widgets, 'image width')
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].widget.name).toBe('width')
|
||||
})
|
||||
|
||||
it('should be case insensitive', () => {
|
||||
const widgets = [createWidget('Width', 'Number', '100', 'Image Width')]
|
||||
const result = searchWidgets(widgets, 'IMAGE width')
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('flatAndCategorizeSelectedItems', () => {
|
||||
let testGroup1: LGraphGroup
|
||||
let testGroup2: LGraphGroup
|
||||
let testNode1: LGraphNode
|
||||
let testNode2: LGraphNode
|
||||
let testNode3: LGraphNode
|
||||
|
||||
beforeEach(() => {
|
||||
testGroup1 = new LGraphGroup('Group 1', 1)
|
||||
testGroup2 = new LGraphGroup('Group 2', 2)
|
||||
testNode1 = new LGraphNode('Node 1')
|
||||
testNode2 = new LGraphNode('Node 2')
|
||||
testNode3 = new LGraphNode('Node 3')
|
||||
})
|
||||
|
||||
it('should return empty arrays for empty input', () => {
|
||||
const result = flatAndCategorizeSelectedItems([])
|
||||
|
||||
expect(result.all).toEqual([])
|
||||
expect(result.nodes).toEqual([])
|
||||
expect(result.groups).toEqual([])
|
||||
expect(result.others).toEqual([])
|
||||
expect(result.nodeToParentGroup.size).toBe(0)
|
||||
})
|
||||
|
||||
it('should categorize nodes', () => {
|
||||
const result = flatAndCategorizeSelectedItems([testNode1])
|
||||
|
||||
expect(result.all).toEqual([testNode1])
|
||||
expect(result.nodes).toEqual([testNode1])
|
||||
expect(result.groups).toEqual([])
|
||||
expect(result.others).toEqual([])
|
||||
expect(result.nodeToParentGroup.size).toBe(0)
|
||||
})
|
||||
|
||||
it('should categorize single group without children', () => {
|
||||
const result = flatAndCategorizeSelectedItems([testGroup1])
|
||||
|
||||
expect(result.all).toEqual([testGroup1])
|
||||
expect(result.nodes).toEqual([])
|
||||
expect(result.groups).toEqual([testGroup1])
|
||||
expect(result.others).toEqual([])
|
||||
})
|
||||
|
||||
it('should flatten group with child nodes', () => {
|
||||
testGroup1._children.add(testNode1)
|
||||
testGroup1._children.add(testNode2)
|
||||
|
||||
const result = flatAndCategorizeSelectedItems([testGroup1])
|
||||
|
||||
expect(result.all).toEqual([testGroup1, testNode1, testNode2])
|
||||
expect(result.nodes).toEqual([testNode1, testNode2])
|
||||
expect(result.groups).toEqual([testGroup1])
|
||||
expect(result.nodeToParentGroup.get(testNode1)).toBe(testGroup1)
|
||||
expect(result.nodeToParentGroup.get(testNode2)).toBe(testGroup1)
|
||||
})
|
||||
|
||||
it('should handle nested groups', () => {
|
||||
testGroup1._children.add(testGroup2)
|
||||
testGroup2._children.add(testNode1)
|
||||
|
||||
const result = flatAndCategorizeSelectedItems([testGroup1])
|
||||
|
||||
expect(result.all).toEqual([testGroup1, testGroup2, testNode1])
|
||||
expect(result.nodes).toEqual([testNode1])
|
||||
expect(result.groups).toEqual([testGroup1, testGroup2])
|
||||
expect(result.nodeToParentGroup.get(testNode1)).toBe(testGroup2)
|
||||
expect(result.nodeToParentGroup.has(testGroup2 as any)).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle mixed selection of nodes and groups', () => {
|
||||
testGroup1._children.add(testNode2)
|
||||
|
||||
const result = flatAndCategorizeSelectedItems([
|
||||
testNode1,
|
||||
testGroup1,
|
||||
testNode3
|
||||
])
|
||||
|
||||
expect(result.all).toContain(testNode1)
|
||||
expect(result.all).toContain(testNode2)
|
||||
expect(result.all).toContain(testNode3)
|
||||
expect(result.all).toContain(testGroup1)
|
||||
|
||||
expect(result.nodes).toEqual([testNode1, testNode2, testNode3])
|
||||
expect(result.groups).toEqual([testGroup1])
|
||||
expect(result.nodeToParentGroup.get(testNode1)).toBeUndefined()
|
||||
expect(result.nodeToParentGroup.get(testNode2)).toBe(testGroup1)
|
||||
expect(result.nodeToParentGroup.get(testNode3)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should remove duplicate items across group and direct selection', () => {
|
||||
testGroup1._children.add(testNode1)
|
||||
|
||||
const result = flatAndCategorizeSelectedItems([testGroup1, testNode1])
|
||||
|
||||
expect(result.all).toEqual([testGroup1, testNode1])
|
||||
expect(result.nodes).toEqual([testNode1])
|
||||
expect(result.groups).toEqual([testGroup1])
|
||||
expect(result.nodeToParentGroup.get(testNode1)).toBe(testGroup1)
|
||||
})
|
||||
|
||||
it('should handle non-node/non-group items as others', () => {
|
||||
const unknownItem = { pos: [0, 0], size: [100, 100] } as Positionable
|
||||
|
||||
const result = flatAndCategorizeSelectedItems([
|
||||
testNode1,
|
||||
unknownItem,
|
||||
testGroup1
|
||||
])
|
||||
|
||||
expect(result.nodes).toEqual([testNode1])
|
||||
expect(result.groups).toEqual([testGroup1])
|
||||
expect(result.others).toEqual([unknownItem])
|
||||
expect(result.all).not.toContain(unknownItem)
|
||||
})
|
||||
})
|
||||
271
src/components/rightSidePanel/shared.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import type { InjectionKey, MaybeRefOrGetter } from 'vue'
|
||||
import { computed, toValue } from 'vue'
|
||||
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
import type { Positionable } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
|
||||
|
||||
export const GetNodeParentGroupKey: InjectionKey<
|
||||
(node: LGraphNode) => LGraphGroup | null
|
||||
> = Symbol('getNodeParentGroup')
|
||||
|
||||
export type NodeWidgetsList = Array<{ node: LGraphNode; widget: IBaseWidget }>
|
||||
export type NodeWidgetsListList = Array<{
|
||||
node: LGraphNode
|
||||
widgets: NodeWidgetsList
|
||||
}>
|
||||
|
||||
/**
|
||||
* Searches widgets in a list and returns search results.
|
||||
* Filters by name, localized label, type, and user-input value.
|
||||
* Performs basic tokenization of the query string.
|
||||
*/
|
||||
export function searchWidgets<T extends { widget: IBaseWidget }[]>(
|
||||
list: T,
|
||||
query: string
|
||||
): T {
|
||||
if (query.trim() === '') {
|
||||
return list
|
||||
}
|
||||
const words = query.trim().toLowerCase().split(' ')
|
||||
return list.filter(({ widget }) => {
|
||||
const label = widget.label?.toLowerCase()
|
||||
const name = widget.name.toLowerCase()
|
||||
const type = widget.type.toLowerCase()
|
||||
const value = widget.value?.toString().toLowerCase()
|
||||
return words.every(
|
||||
(word) =>
|
||||
name.includes(word) ||
|
||||
label?.includes(word) ||
|
||||
type?.includes(word) ||
|
||||
value?.includes(word)
|
||||
)
|
||||
}) as T
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches widgets and nodes in a list and returns search results.
|
||||
* First checks if the node title matches the query (if so, keeps entire node).
|
||||
* Otherwise, filters widgets using searchWidgets.
|
||||
* Performs basic tokenization of the query string.
|
||||
*/
|
||||
export function searchWidgetsAndNodes(
|
||||
list: NodeWidgetsListList,
|
||||
query: string
|
||||
): NodeWidgetsListList {
|
||||
if (query.trim() === '') {
|
||||
return list
|
||||
}
|
||||
const words = query.trim().toLowerCase().split(' ')
|
||||
return list
|
||||
.map((item) => {
|
||||
const { node } = item
|
||||
const title = node.getTitle().toLowerCase()
|
||||
if (words.every((word) => title.includes(word))) {
|
||||
return { ...item, keep: true }
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
keep: false,
|
||||
widgets: searchWidgets(item.widgets, query)
|
||||
}
|
||||
})
|
||||
.filter((item) => item.keep || item.widgets.length > 0)
|
||||
}
|
||||
|
||||
type MixedSelectionItem = LGraphGroup | LGraphNode
|
||||
type FlatAndCategorizeSelectedItemsResult = {
|
||||
all: MixedSelectionItem[]
|
||||
nodes: LGraphNode[]
|
||||
groups: LGraphGroup[]
|
||||
others: Positionable[]
|
||||
nodeToParentGroup: Map<LGraphNode, LGraphGroup>
|
||||
}
|
||||
|
||||
type FlatItemsContext = {
|
||||
nodeToParentGroup: Map<LGraphNode, LGraphGroup>
|
||||
depth: number
|
||||
parentGroup?: LGraphGroup
|
||||
}
|
||||
|
||||
/**
|
||||
* The selected items may contain "Group" nodes, which can include child nodes.
|
||||
* This function flattens such structures and categorizes items into:
|
||||
* - all: all categorizable nodes (does not include nodes in "others")
|
||||
* - nodes: node items
|
||||
* - groups: group items
|
||||
* - others: items not currently supported
|
||||
* - nodeToParentGroup: a map from each node to its direct parent group (if any)
|
||||
* @param items The selected items to flatten and categorize
|
||||
* @returns An object containing arrays: all, nodes, groups, others, and nodeToParentGroup map
|
||||
*/
|
||||
export function flatAndCategorizeSelectedItems(
|
||||
items: Positionable[]
|
||||
): FlatAndCategorizeSelectedItemsResult {
|
||||
const ctx: FlatItemsContext = {
|
||||
nodeToParentGroup: new Map<LGraphNode, LGraphGroup>(),
|
||||
depth: 0
|
||||
}
|
||||
const { all, nodes, groups, others } = flatItems(items, ctx)
|
||||
return {
|
||||
all: repeatItems(all),
|
||||
nodes: repeatItems(nodes),
|
||||
groups: repeatItems(groups),
|
||||
others: repeatItems(others),
|
||||
nodeToParentGroup: ctx.nodeToParentGroup
|
||||
}
|
||||
}
|
||||
|
||||
export function useFlatAndCategorizeSelectedItems(
|
||||
items: MaybeRefOrGetter<Positionable[]>
|
||||
) {
|
||||
const result = computed(() => flatAndCategorizeSelectedItems(toValue(items)))
|
||||
|
||||
return {
|
||||
flattedItems: computed(() => result.value.all),
|
||||
selectedNodes: computed(() => result.value.nodes),
|
||||
selectedGroups: computed(() => result.value.groups),
|
||||
selectedOthers: computed(() => result.value.others),
|
||||
nodeToParentGroup: computed(() => result.value.nodeToParentGroup)
|
||||
}
|
||||
}
|
||||
|
||||
function flatItems(
|
||||
items: Positionable[],
|
||||
ctx: FlatItemsContext
|
||||
): Omit<FlatAndCategorizeSelectedItemsResult, 'nodeToParentGroup'> {
|
||||
const result: MixedSelectionItem[] = []
|
||||
const nodes: LGraphNode[] = []
|
||||
const groups: LGraphGroup[] = []
|
||||
const others: Positionable[] = []
|
||||
|
||||
if (ctx.depth > 1000) {
|
||||
return {
|
||||
all: [],
|
||||
nodes: [],
|
||||
groups: [],
|
||||
others: []
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i] as Positionable
|
||||
|
||||
if (isLGraphGroup(item)) {
|
||||
result.push(item)
|
||||
groups.push(item)
|
||||
|
||||
const children = Array.from(item.children)
|
||||
const childCtx: FlatItemsContext = {
|
||||
nodeToParentGroup: ctx.nodeToParentGroup,
|
||||
depth: ctx.depth + 1,
|
||||
parentGroup: item
|
||||
}
|
||||
const {
|
||||
all: childAll,
|
||||
nodes: childNodes,
|
||||
groups: childGroups,
|
||||
others: childOthers
|
||||
} = flatItems(children, childCtx)
|
||||
result.push(...childAll)
|
||||
nodes.push(...childNodes)
|
||||
groups.push(...childGroups)
|
||||
others.push(...childOthers)
|
||||
} else if (isLGraphNode(item)) {
|
||||
result.push(item)
|
||||
nodes.push(item)
|
||||
if (ctx.parentGroup) {
|
||||
ctx.nodeToParentGroup.set(item, ctx.parentGroup)
|
||||
}
|
||||
} else {
|
||||
// Other types of items are not supported yet
|
||||
// Do not add to all
|
||||
others.push(item)
|
||||
}
|
||||
}
|
||||
return {
|
||||
all: result,
|
||||
nodes,
|
||||
groups,
|
||||
others
|
||||
}
|
||||
}
|
||||
|
||||
function repeatItems<T>(items: T[]): T[] {
|
||||
const itemSet = new Set<T>()
|
||||
const result: T[] = []
|
||||
for (const item of items) {
|
||||
if (itemSet.has(item)) continue
|
||||
itemSet.add(item)
|
||||
result.push(item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames a widget and its corresponding input.
|
||||
* Handles both regular widgets and proxy widgets in subgraphs.
|
||||
*
|
||||
* @param widget The widget to rename
|
||||
* @param node The node containing the widget
|
||||
* @param newLabel The new label for the widget (empty string or undefined to clear)
|
||||
* @param parents Optional array of parent SubgraphNodes (for proxy widgets)
|
||||
* @returns true if the rename was successful, false otherwise
|
||||
*/
|
||||
export function renameWidget(
|
||||
widget: IBaseWidget,
|
||||
node: LGraphNode,
|
||||
newLabel: string,
|
||||
parents?: SubgraphNode[]
|
||||
): boolean {
|
||||
// For proxy widgets in subgraphs, we need to rename the original interior widget
|
||||
if (isProxyWidget(widget) && parents?.length) {
|
||||
const subgraph = parents[0].subgraph
|
||||
if (!subgraph) {
|
||||
console.error('Could not find subgraph for proxy widget')
|
||||
return false
|
||||
}
|
||||
const interiorNode = subgraph.getNodeById(parseInt(widget._overlay.nodeId))
|
||||
|
||||
if (!interiorNode) {
|
||||
console.error('Could not find interior node for proxy widget')
|
||||
return false
|
||||
}
|
||||
|
||||
const originalWidget = interiorNode.widgets?.find(
|
||||
(w) => w.name === widget._overlay.widgetName
|
||||
)
|
||||
|
||||
if (!originalWidget) {
|
||||
console.error('Could not find original widget for proxy widget')
|
||||
return false
|
||||
}
|
||||
|
||||
// Rename the original widget
|
||||
originalWidget.label = newLabel || undefined
|
||||
|
||||
// Also rename the corresponding input on the interior node
|
||||
const interiorInput = interiorNode.inputs?.find(
|
||||
(inp) => inp.widget?.name === widget._overlay.widgetName
|
||||
)
|
||||
if (interiorInput) {
|
||||
interiorInput.label = newLabel || undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Always rename the widget on the current node (either regular widget or proxy widget)
|
||||
const input = node.inputs?.find((inp) => inp.widget?.name === widget.name)
|
||||
|
||||
// Intentionally mutate the widget object here as it's a reference
|
||||
// to the actual widget in the graph
|
||||
widget.label = newLabel || undefined
|
||||
if (input) {
|
||||
input.label = newLabel || undefined
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import {
|
||||
computed,
|
||||
customRef,
|
||||
@@ -26,17 +27,19 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import { DraggableList } from '@/scripts/ui/draggableList'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
import SidePanelSearch from '../layout/SidePanelSearch.vue'
|
||||
import SubgraphNodeWidget from './SubgraphNodeWidget.vue'
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { searchQuery } = storeToRefs(rightSidePanelStore)
|
||||
|
||||
const draggableList = ref<DraggableList | undefined>(undefined)
|
||||
const draggableItems = ref()
|
||||
const searchQuery = ref<string>('')
|
||||
const proxyWidgets = customRef<ProxyWidgetsProperty>((track, trigger) => ({
|
||||
get() {
|
||||
track()
|
||||
@@ -56,10 +59,6 @@ const proxyWidgets = customRef<ProxyWidgetsProperty>((track, trigger) => ({
|
||||
}
|
||||
}))
|
||||
|
||||
async function searcher(query: string) {
|
||||
searchQuery.value = query
|
||||
}
|
||||
|
||||
const activeNode = computed(() => {
|
||||
const node = canvasStore.selectedItems[0]
|
||||
if (node instanceof SubgraphNode) return node
|
||||
@@ -244,14 +243,25 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<div v-if="activeNode" class="subgraph-edit-section flex h-full flex-col">
|
||||
<div class="p-4 flex gap-2">
|
||||
<SidePanelSearch :searcher />
|
||||
<div class="px-4 pb-4 pt-1 flex gap-2 border-b border-interface-stroke">
|
||||
<FormSearchInput v-model="searchQuery" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<div
|
||||
v-if="
|
||||
searchQuery &&
|
||||
filteredActive.length === 0 &&
|
||||
filteredCandidates.length === 0
|
||||
"
|
||||
class="text-sm text-muted-foreground px-4 py-10 text-center"
|
||||
>
|
||||
{{ $t('rightSidePanel.noneSearchDesc') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="filteredActive.length"
|
||||
class="flex flex-col border-t border-interface-stroke"
|
||||
class="flex flex-col border-b border-interface-stroke"
|
||||
>
|
||||
<div
|
||||
class="sticky top-0 z-10 flex items-center justify-between backdrop-blur-xl min-h-12 px-4"
|
||||
@@ -270,7 +280,7 @@ onBeforeUnmount(() => {
|
||||
<SubgraphNodeWidget
|
||||
v-for="[node, widget] in filteredActive"
|
||||
:key="toKey([node, widget])"
|
||||
class="bg-interface-panel-surface"
|
||||
class="bg-comfy-menu-bg"
|
||||
:node-title="node.title"
|
||||
:widget-name="widget.name"
|
||||
:is-shown="true"
|
||||
@@ -283,7 +293,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div
|
||||
v-if="filteredCandidates.length"
|
||||
class="flex flex-col border-t border-interface-stroke"
|
||||
class="flex flex-col border-b border-interface-stroke"
|
||||
>
|
||||
<div
|
||||
class="sticky top-0 z-10 flex items-center justify-between backdrop-blur-xl min-h-12 px-4"
|
||||
@@ -302,7 +312,7 @@ onBeforeUnmount(() => {
|
||||
<SubgraphNodeWidget
|
||||
v-for="[node, widget] in filteredCandidates"
|
||||
:key="toKey([node, widget])"
|
||||
class="bg-interface-panel-surface"
|
||||
class="bg-comfy-menu-bg"
|
||||
:node-title="node.title"
|
||||
:widget-name="widget.name"
|
||||
@toggle-visibility="promote([node, widget])"
|
||||
@@ -312,7 +322,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div
|
||||
v-if="recommendedWidgets.length"
|
||||
class="flex justify-center border-t border-interface-stroke py-4"
|
||||
class="flex justify-center border-b border-interface-stroke py-4"
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
28
src/components/sidebar/ModeToggle.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { t } from '@/i18n'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
</script>
|
||||
<template>
|
||||
<div class="p-1 bg-secondary-background rounded-lg w-10">
|
||||
<Button
|
||||
size="icon"
|
||||
:title="t('linearMode.linearMode')"
|
||||
:variant="canvasStore.linearMode ? 'inverted' : 'secondary'"
|
||||
@click="useCommandStore().execute('Comfy.ToggleLinear')"
|
||||
>
|
||||
<i class="icon-[lucide--panels-top-left]" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
:title="t('linearMode.graphMode')"
|
||||
:variant="canvasStore.linearMode ? 'secondary' : 'inverted'"
|
||||
@click="useCommandStore().execute('Comfy.ToggleLinear')"
|
||||
>
|
||||
<i class="icon-[comfy--workflow]" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -44,6 +44,7 @@
|
||||
<SidebarBottomPanelToggleButton :is-small="isSmall" />
|
||||
<SidebarShortcutsToggleButton :is-small="isSmall" />
|
||||
<SidebarSettingsButton :is-small="isSmall" />
|
||||
<ModeToggle v-if="showLinearToggle" />
|
||||
</div>
|
||||
</div>
|
||||
<HelpCenterPopups :is-small="isSmall" />
|
||||
@@ -51,15 +52,17 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useResizeObserver } from '@vueuse/core'
|
||||
import { useResizeObserver, whenever } from '@vueuse/core'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import HelpCenterPopups from '@/components/helpcenter/HelpCenterPopups.vue'
|
||||
import ComfyMenuButton from '@/components/sidebar/ComfyMenuButton.vue'
|
||||
import ModeToggle from '@/components/sidebar/ModeToggle.vue'
|
||||
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
|
||||
import SidebarSettingsButton from '@/components/sidebar/SidebarSettingsButton.vue'
|
||||
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
@@ -84,6 +87,12 @@ const sideToolbarRef = ref<HTMLElement>()
|
||||
const topToolbarRef = ref<HTMLElement>()
|
||||
const bottomToolbarRef = ref<HTMLElement>()
|
||||
|
||||
const showLinearToggle = ref(useFeatureFlags().flags.linearToggleEnabled)
|
||||
whenever(
|
||||
() => canvasStore.linearMode,
|
||||
() => (showLinearToggle.value = true)
|
||||
)
|
||||
|
||||
const isSmall = computed(
|
||||
() => settingStore.get('Comfy.Sidebar.Size') === 'small'
|
||||
)
|
||||
|
||||
@@ -74,8 +74,22 @@
|
||||
"
|
||||
: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>
|
||||
@@ -111,12 +125,14 @@ 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 }
|
||||
|
||||
@@ -192,6 +208,16 @@ 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)
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
:assets="displayAssets"
|
||||
:is-selected="isSelected"
|
||||
@select-asset="handleAssetSelect"
|
||||
@context-menu="handleAssetContextMenu"
|
||||
@approach-end="handleApproachEnd"
|
||||
/>
|
||||
<VirtualGrid
|
||||
@@ -120,17 +121,10 @@
|
||||
:selected="isSelected(item.id)"
|
||||
:show-output-count="shouldShowOutputCount(item)"
|
||||
:output-count="getOutputCount(item)"
|
||||
:show-delete-button="shouldShowDeleteButton"
|
||||
:open-context-menu-id="openContextMenuId"
|
||||
:selected-assets="getSelectedAssets(displayAssets)"
|
||||
:has-selection="hasSelection"
|
||||
@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>
|
||||
@@ -196,6 +190,24 @@
|
||||
v-model:active-index="galleryActiveIndex"
|
||||
:all-gallery-items="galleryItems"
|
||||
/>
|
||||
<MediaAssetContextMenu
|
||||
v-if="contextMenuAsset"
|
||||
ref="contextMenuRef"
|
||||
:asset="contextMenuAsset"
|
||||
:asset-type="contextMenuAssetType"
|
||||
:file-kind="contextMenuFileKind"
|
||||
:show-delete-button="shouldShowDeleteButton"
|
||||
:selected-assets="selectedAssets"
|
||||
:is-bulk-mode="isBulkMode"
|
||||
@zoom="handleZoomClick(contextMenuAsset)"
|
||||
@hide="handleContextMenuHide"
|
||||
@asset-deleted="refreshAssets"
|
||||
@bulk-download="handleBulkDownload"
|
||||
@bulk-delete="handleBulkDelete"
|
||||
@bulk-add-to-workflow="handleBulkAddToWorkflow"
|
||||
@bulk-open-workflow="handleBulkOpenWorkflow"
|
||||
@bulk-export-workflow="handleBulkExportWorkflow"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -203,7 +215,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, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
@@ -216,13 +228,16 @@ 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'
|
||||
@@ -248,8 +263,8 @@ const isListView = computed(
|
||||
() => isQueuePanelV2Enabled.value && viewMode.value === 'list'
|
||||
)
|
||||
|
||||
// Track which asset's context menu is open (for single-instance context menu management)
|
||||
const openContextMenuId = ref<string | null>(null)
|
||||
const contextMenuRef = ref<InstanceType<typeof MediaAssetContextMenu>>()
|
||||
const contextMenuAsset = ref<AssetItem | null>(null)
|
||||
|
||||
// Determine if delete button should be shown
|
||||
// Hide delete button when in input tab and not in cloud (OSS mode - files are from local folders)
|
||||
@@ -258,6 +273,14 @@ const shouldShowDeleteButton = computed(() => {
|
||||
return true
|
||||
})
|
||||
|
||||
const contextMenuAssetType = computed(() =>
|
||||
contextMenuAsset.value ? getAssetType(contextMenuAsset.value.tags) : 'input'
|
||||
)
|
||||
|
||||
const contextMenuFileKind = computed<MediaKind>(() =>
|
||||
getMediaTypeFromFilename(contextMenuAsset.value?.name ?? '')
|
||||
)
|
||||
|
||||
const shouldShowOutputCount = (item: AssetItem): boolean => {
|
||||
if (activeTab.value !== 'output' || isInFolderView.value) {
|
||||
return false
|
||||
@@ -301,7 +324,13 @@ const {
|
||||
deactivate: deactivateSelection
|
||||
} = useAssetSelection()
|
||||
|
||||
const { downloadMultipleAssets, deleteMultipleAssets } = useMediaAssetActions()
|
||||
const {
|
||||
downloadMultipleAssets,
|
||||
deleteMultipleAssets,
|
||||
addMultipleToWorkflow,
|
||||
openMultipleWorkflows,
|
||||
exportMultipleWorkflows
|
||||
} = useMediaAssetActions()
|
||||
|
||||
// Footer responsive behavior
|
||||
const footerRef = ref<HTMLElement | null>(null)
|
||||
@@ -327,8 +356,7 @@ const isHoveringSelectionCount = useElementHover(selectionCountButtonRef)
|
||||
|
||||
// Total output count for all selected assets
|
||||
const totalOutputCount = computed(() => {
|
||||
const selectedAssets = getSelectedAssets(displayAssets.value)
|
||||
return getTotalOutputCount(selectedAssets)
|
||||
return getTotalOutputCount(selectedAssets.value)
|
||||
})
|
||||
|
||||
const currentAssets = computed(() =>
|
||||
@@ -359,6 +387,12 @@ 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 &&
|
||||
@@ -444,6 +478,17 @@ 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() {
|
||||
contextMenuAsset.value = null
|
||||
}
|
||||
|
||||
const handleZoomClick = (asset: AssetItem) => {
|
||||
const mediaType = getMediaTypeFromFilename(asset.name)
|
||||
|
||||
@@ -552,14 +597,12 @@ const copyJobId = async () => {
|
||||
}
|
||||
|
||||
const handleDownloadSelected = () => {
|
||||
const selectedAssets = getSelectedAssets(displayAssets.value)
|
||||
downloadMultipleAssets(selectedAssets)
|
||||
downloadMultipleAssets(selectedAssets.value)
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
const handleDeleteSelected = async () => {
|
||||
const selectedAssets = getSelectedAssets(displayAssets.value)
|
||||
await deleteMultipleAssets(selectedAssets)
|
||||
await deleteMultipleAssets(selectedAssets.value)
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
@@ -573,6 +616,21 @@ 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')
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<SidebarTabTemplate
|
||||
:title="$t('sideToolbar.workflows')"
|
||||
v-bind="$attrs"
|
||||
class="workflows-sidebar-tab"
|
||||
>
|
||||
<template #tool-buttons>
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
>
|
||||
<i class="pi pi-bars" />
|
||||
</Button>
|
||||
<i
|
||||
v-else-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>
|
||||
|
||||
76
src/components/ui/Popover.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<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>
|
||||
29
src/components/ui/TypeformPopoverButton.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<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>
|
||||
59
src/components/ui/ZoomPane.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<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>
|
||||
@@ -1,5 +1,10 @@
|
||||
<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
|
||||
@@ -7,19 +12,22 @@
|
||||
: '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 class="flex items-center break-all">
|
||||
<span ref="textRef" class="min-w-0 truncate">
|
||||
<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'
|
||||
@@ -29,4 +37,15 @@ 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>
|
||||
|
||||
133
src/composables/graph/useGraphHierarchy.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import * as measure from '@/lib/litegraph/src/measure'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
import { useGraphHierarchy } from './useGraphHierarchy'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore')
|
||||
|
||||
describe('useGraphHierarchy', () => {
|
||||
let mockCanvasStore: ReturnType<typeof useCanvasStore>
|
||||
let mockNode: LGraphNode
|
||||
let mockGroups: LGraphGroup[]
|
||||
|
||||
beforeEach(() => {
|
||||
mockNode = {
|
||||
boundingRect: [100, 100, 50, 50]
|
||||
} as unknown as LGraphNode
|
||||
|
||||
mockGroups = []
|
||||
|
||||
mockCanvasStore = {
|
||||
canvas: {
|
||||
graph: {
|
||||
groups: mockGroups
|
||||
}
|
||||
}
|
||||
} as any
|
||||
|
||||
vi.mocked(useCanvasStore).mockReturnValue(mockCanvasStore)
|
||||
})
|
||||
|
||||
describe('findParentGroup', () => {
|
||||
it('returns null when no groups exist', () => {
|
||||
const { findParentGroup } = useGraphHierarchy()
|
||||
|
||||
const result = findParentGroup(mockNode)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when node is not in any group', () => {
|
||||
const group = {
|
||||
boundingRect: [0, 0, 50, 50]
|
||||
} as unknown as LGraphGroup
|
||||
mockGroups.push(group)
|
||||
|
||||
vi.spyOn(measure, 'containsCentre').mockReturnValue(false)
|
||||
|
||||
const { findParentGroup } = useGraphHierarchy()
|
||||
const result = findParentGroup(mockNode)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns the only group when node is in exactly one group', () => {
|
||||
const group = {
|
||||
boundingRect: [0, 0, 200, 200]
|
||||
} as unknown as LGraphGroup
|
||||
mockGroups.push(group)
|
||||
|
||||
vi.spyOn(measure, 'containsCentre').mockReturnValue(true)
|
||||
|
||||
const { findParentGroup } = useGraphHierarchy()
|
||||
const result = findParentGroup(mockNode)
|
||||
|
||||
expect(result).toBe(group)
|
||||
})
|
||||
|
||||
it('returns the smallest group when node is in multiple groups', () => {
|
||||
const largeGroup = {
|
||||
boundingRect: [0, 0, 300, 300]
|
||||
} as unknown as LGraphGroup
|
||||
const smallGroup = {
|
||||
boundingRect: [50, 50, 100, 100]
|
||||
} as unknown as LGraphGroup
|
||||
mockGroups.push(largeGroup, smallGroup)
|
||||
|
||||
vi.spyOn(measure, 'containsCentre').mockReturnValue(true)
|
||||
vi.spyOn(measure, 'containsRect').mockReturnValue(false)
|
||||
|
||||
const { findParentGroup } = useGraphHierarchy()
|
||||
const result = findParentGroup(mockNode)
|
||||
|
||||
expect(result).toBe(smallGroup)
|
||||
})
|
||||
|
||||
it('returns the inner group when one group contains another', () => {
|
||||
const outerGroup = {
|
||||
boundingRect: [0, 0, 300, 300]
|
||||
} as unknown as LGraphGroup
|
||||
const innerGroup = {
|
||||
boundingRect: [50, 50, 100, 100]
|
||||
} as unknown as LGraphGroup
|
||||
mockGroups.push(outerGroup, innerGroup)
|
||||
|
||||
vi.spyOn(measure, 'containsCentre').mockReturnValue(true)
|
||||
vi.spyOn(measure, 'containsRect').mockImplementation(
|
||||
(container, contained) => {
|
||||
// outerGroup contains innerGroup
|
||||
if (container === outerGroup.boundingRect) {
|
||||
return contained === innerGroup.boundingRect
|
||||
}
|
||||
return false
|
||||
}
|
||||
)
|
||||
|
||||
const { findParentGroup } = useGraphHierarchy()
|
||||
const result = findParentGroup(mockNode)
|
||||
|
||||
expect(result).toBe(innerGroup)
|
||||
})
|
||||
|
||||
it('handles null canvas gracefully', () => {
|
||||
mockCanvasStore.canvas = null as any
|
||||
|
||||
const { findParentGroup } = useGraphHierarchy()
|
||||
const result = findParentGroup(mockNode)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('handles null graph gracefully', () => {
|
||||
mockCanvasStore.canvas!.graph = null as any
|
||||
|
||||
const { findParentGroup } = useGraphHierarchy()
|
||||
const result = findParentGroup(mockNode)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
57
src/composables/graph/useGraphHierarchy.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { containsCentre, containsRect } from '@/lib/litegraph/src/measure'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
/**
|
||||
* Composable for working with graph hierarchy, specifically group containment.
|
||||
*/
|
||||
export function useGraphHierarchy() {
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
/**
|
||||
* Finds the smallest group that contains the center of a node's bounding box.
|
||||
* When multiple groups contain the node, returns the one with the smallest area.
|
||||
*
|
||||
* TODO: This traverses the entire graph and could be very slow; needs optimization.
|
||||
* Consider spatial indexing or caching for large graphs.
|
||||
*
|
||||
* @param node - The node to find the parent group for
|
||||
* @returns The parent group if found, otherwise null
|
||||
*/
|
||||
function findParentGroup(node: LGraphNode): LGraphGroup | null {
|
||||
const graphGroups = (canvasStore.canvas?.graph?.groups ??
|
||||
[]) as LGraphGroup[]
|
||||
|
||||
let parent: LGraphGroup | null = null
|
||||
|
||||
for (const group of graphGroups) {
|
||||
const groupRect = group.boundingRect
|
||||
if (!containsCentre(groupRect, node.boundingRect)) continue
|
||||
|
||||
if (!parent) {
|
||||
parent = group
|
||||
continue
|
||||
}
|
||||
|
||||
const parentRect = parent.boundingRect
|
||||
const candidateInsideParent = containsRect(parentRect, groupRect)
|
||||
const parentInsideCandidate = containsRect(groupRect, parentRect)
|
||||
|
||||
if (candidateInsideParent && !parentInsideCandidate) {
|
||||
parent = group
|
||||
continue
|
||||
}
|
||||
|
||||
const candidateArea = groupRect[2] * groupRect[3]
|
||||
const parentArea = parentRect[2] * parentRect[3]
|
||||
|
||||
if (candidateArea < parentArea) parent = group
|
||||
}
|
||||
|
||||
return parent
|
||||
}
|
||||
|
||||
return {
|
||||
findParentGroup
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,7 @@ function widgetWithVueTrack(
|
||||
return { get() {}, set() {} }
|
||||
})
|
||||
}
|
||||
export function useReactiveWidgetValue(widget: IBaseWidget) {
|
||||
function useReactiveWidgetValue(widget: IBaseWidget) {
|
||||
widgetWithVueTrack(widget)
|
||||
widget.vueTrack()
|
||||
return widget.value
|
||||
@@ -120,12 +120,59 @@ function getControlWidget(widget: IBaseWidget): SafeControlWidget | undefined {
|
||||
update: (value) => (cagWidget.value = normalizeControlOption(value))
|
||||
}
|
||||
}
|
||||
|
||||
function getNodeType(node: LGraphNode, widget: IBaseWidget) {
|
||||
if (!node.isSubgraphNode() || !isProxyWidget(widget)) return undefined
|
||||
const subNode = node.subgraph.getNodeById(widget._overlay.nodeId)
|
||||
return subNode?.type
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared widget enhancements used by both safeWidgetMapper and Right Side Panel
|
||||
*/
|
||||
interface SharedWidgetEnhancements {
|
||||
/** Reactive widget value that updates when the widget changes */
|
||||
value: WidgetValue
|
||||
/** Control widget for seed randomization/increment/decrement */
|
||||
controlWidget?: SafeControlWidget
|
||||
/** Input specification from node definition */
|
||||
spec?: InputSpec
|
||||
/** Node type (for subgraph promoted widgets) */
|
||||
nodeType?: string
|
||||
/** Border style for promoted/advanced widgets */
|
||||
borderStyle?: string
|
||||
/** Widget label */
|
||||
label?: string
|
||||
/** Widget options */
|
||||
options?: Record<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts common widget enhancements shared across different rendering contexts.
|
||||
* This function centralizes the logic for extracting metadata and reactive values
|
||||
* from widgets, ensuring consistency between Nodes 2.0 and Right Side Panel.
|
||||
*/
|
||||
export function getSharedWidgetEnhancements(
|
||||
node: LGraphNode,
|
||||
widget: IBaseWidget
|
||||
): SharedWidgetEnhancements {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
return {
|
||||
value: useReactiveWidgetValue(widget),
|
||||
controlWidget: getControlWidget(widget),
|
||||
spec: nodeDefStore.getInputSpecForWidget(node, widget.name),
|
||||
nodeType: getNodeType(node, widget),
|
||||
borderStyle: widget.promoted
|
||||
? 'ring ring-component-node-widget-promoted'
|
||||
: widget.advanced
|
||||
? 'ring ring-component-node-widget-advanced'
|
||||
: undefined,
|
||||
label: widget.label,
|
||||
options: widget.options
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a value is a valid WidgetValue type
|
||||
*/
|
||||
@@ -157,20 +204,17 @@ const normalizeWidgetValue = (value: unknown): WidgetValue => {
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function safeWidgetMapper(
|
||||
function safeWidgetMapper(
|
||||
node: LGraphNode,
|
||||
slotMetadata: Map<string, WidgetSlotMetadata>
|
||||
): (widget: IBaseWidget) => SafeWidgetData {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
return function (widget) {
|
||||
try {
|
||||
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
|
||||
// Get shared enhancements used by both Nodes 2.0 and Right Side Panel
|
||||
const sharedEnhancements = getSharedWidgetEnhancements(node, widget)
|
||||
const slotInfo = slotMetadata.get(widget.name)
|
||||
const borderStyle = widget.promoted
|
||||
? 'ring ring-component-node-widget-promoted'
|
||||
: widget.advanced
|
||||
? 'ring ring-component-node-widget-advanced'
|
||||
: undefined
|
||||
|
||||
// Wrapper callback specific to Nodes 2.0 rendering
|
||||
const callback = (v: unknown) => {
|
||||
const value = normalizeWidgetValue(v)
|
||||
widget.value = value ?? undefined
|
||||
@@ -185,16 +229,10 @@ export function safeWidgetMapper(
|
||||
return {
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
value: useReactiveWidgetValue(widget),
|
||||
borderStyle,
|
||||
...sharedEnhancements,
|
||||
callback,
|
||||
controlWidget: getControlWidget(widget),
|
||||
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
|
||||
isDOMWidget: isDOMWidget(widget),
|
||||
label: widget.label,
|
||||
nodeType: getNodeType(node, widget),
|
||||
options: widget.options,
|
||||
spec,
|
||||
slotMetadata: slotInfo
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -207,15 +245,77 @@ export function safeWidgetMapper(
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidWidgetValue(value: unknown): value is WidgetValue {
|
||||
return (
|
||||
value === null ||
|
||||
value === undefined ||
|
||||
typeof value === 'string' ||
|
||||
typeof value === 'number' ||
|
||||
typeof value === 'boolean' ||
|
||||
typeof value === 'object'
|
||||
)
|
||||
// Extract safe data from LiteGraph node for Vue consumption
|
||||
export function extractVueNodeData(node: LGraphNode): VueNodeData {
|
||||
// Determine subgraph ID - null for root graph, string for subgraphs
|
||||
const subgraphId =
|
||||
node.graph && 'id' in node.graph && node.graph !== node.graph.rootGraph
|
||||
? String(node.graph.id)
|
||||
: null
|
||||
// Extract safe widget data
|
||||
const slotMetadata = new Map<string, WidgetSlotMetadata>()
|
||||
|
||||
const reactiveWidgets = shallowReactive<IBaseWidget[]>(node.widgets ?? [])
|
||||
Object.defineProperty(node, 'widgets', {
|
||||
get() {
|
||||
return reactiveWidgets
|
||||
},
|
||||
set(v) {
|
||||
reactiveWidgets.splice(0, reactiveWidgets.length, ...v)
|
||||
}
|
||||
})
|
||||
const reactiveInputs = shallowReactive<INodeInputSlot[]>(node.inputs ?? [])
|
||||
Object.defineProperty(node, 'inputs', {
|
||||
get() {
|
||||
return reactiveInputs
|
||||
},
|
||||
set(v) {
|
||||
reactiveInputs.splice(0, reactiveInputs.length, ...v)
|
||||
}
|
||||
})
|
||||
|
||||
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
|
||||
node.inputs?.forEach((input, index) => {
|
||||
if (!input?.widget?.name) return
|
||||
slotMetadata.set(input.widget.name, {
|
||||
index,
|
||||
linked: input.link != null
|
||||
})
|
||||
})
|
||||
return node.widgets?.map(safeWidgetMapper(node, slotMetadata)) ?? []
|
||||
})
|
||||
|
||||
const nodeType =
|
||||
node.type ||
|
||||
node.constructor?.comfyClass ||
|
||||
node.constructor?.title ||
|
||||
node.constructor?.name ||
|
||||
'Unknown'
|
||||
|
||||
const apiNode = node.constructor?.nodeData?.api_node ?? false
|
||||
const badges = node.badges
|
||||
|
||||
return {
|
||||
id: String(node.id),
|
||||
title: typeof node.title === 'string' ? node.title : '',
|
||||
type: nodeType,
|
||||
mode: node.mode || 0,
|
||||
titleMode: node.title_mode,
|
||||
selected: node.selected || false,
|
||||
executing: false, // Will be updated separately based on execution state
|
||||
subgraphId,
|
||||
apiNode,
|
||||
badges,
|
||||
hasErrors: !!node.has_errors,
|
||||
widgets: safeWidgets,
|
||||
inputs: reactiveInputs,
|
||||
outputs: node.outputs ? [...node.outputs] : undefined,
|
||||
flags: node.flags ? { ...node.flags } : undefined,
|
||||
color: node.color || undefined,
|
||||
bgcolor: node.bgcolor || undefined,
|
||||
resizable: node.resizable,
|
||||
shape: node.shape
|
||||
}
|
||||
}
|
||||
|
||||
export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
@@ -251,79 +351,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Extract safe data from LiteGraph node for Vue consumption
|
||||
function extractVueNodeData(node: LGraphNode): VueNodeData {
|
||||
// Determine subgraph ID - null for root graph, string for subgraphs
|
||||
const subgraphId =
|
||||
node.graph && 'id' in node.graph && node.graph !== node.graph.rootGraph
|
||||
? String(node.graph.id)
|
||||
: null
|
||||
// Extract safe widget data
|
||||
const slotMetadata = new Map<string, WidgetSlotMetadata>()
|
||||
|
||||
const reactiveWidgets = shallowReactive<IBaseWidget[]>(node.widgets ?? [])
|
||||
Object.defineProperty(node, 'widgets', {
|
||||
get() {
|
||||
return reactiveWidgets
|
||||
},
|
||||
set(v) {
|
||||
reactiveWidgets.splice(0, reactiveWidgets.length, ...v)
|
||||
}
|
||||
})
|
||||
const reactiveInputs = shallowReactive<INodeInputSlot[]>(node.inputs ?? [])
|
||||
Object.defineProperty(node, 'inputs', {
|
||||
get() {
|
||||
return reactiveInputs
|
||||
},
|
||||
set(v) {
|
||||
reactiveInputs.splice(0, reactiveInputs.length, ...v)
|
||||
}
|
||||
})
|
||||
|
||||
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
|
||||
node.inputs?.forEach((input, index) => {
|
||||
if (!input?.widget?.name) return
|
||||
slotMetadata.set(input.widget.name, {
|
||||
index,
|
||||
linked: input.link != null
|
||||
})
|
||||
})
|
||||
return node.widgets?.map(safeWidgetMapper(node, slotMetadata)) ?? []
|
||||
})
|
||||
|
||||
const nodeType =
|
||||
node.type ||
|
||||
node.constructor?.comfyClass ||
|
||||
node.constructor?.title ||
|
||||
node.constructor?.name ||
|
||||
'Unknown'
|
||||
|
||||
const apiNode = node.constructor?.nodeData?.api_node ?? false
|
||||
const badges = node.badges
|
||||
|
||||
return {
|
||||
id: String(node.id),
|
||||
title: typeof node.title === 'string' ? node.title : '',
|
||||
type: nodeType,
|
||||
mode: node.mode || 0,
|
||||
titleMode: node.title_mode,
|
||||
selected: node.selected || false,
|
||||
executing: false, // Will be updated separately based on execution state
|
||||
subgraphId,
|
||||
apiNode,
|
||||
badges,
|
||||
hasErrors: !!node.has_errors,
|
||||
widgets: safeWidgets,
|
||||
inputs: reactiveInputs,
|
||||
outputs: node.outputs ? [...node.outputs] : undefined,
|
||||
flags: node.flags ? { ...node.flags } : undefined,
|
||||
color: node.color || undefined,
|
||||
bgcolor: node.bgcolor || undefined,
|
||||
resizable: node.resizable,
|
||||
shape: node.shape
|
||||
}
|
||||
}
|
||||
|
||||
// Get access to original LiteGraph node (non-reactive)
|
||||
const getNode = (id: string): LGraphNode | undefined => {
|
||||
return nodeRefs.get(id)
|
||||
|
||||
@@ -1235,7 +1235,12 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.ToggleLinear',
|
||||
icon: 'pi pi-database',
|
||||
label: 'toggle linear mode',
|
||||
function: () => (canvasStore.linearMode = !canvasStore.linearMode)
|
||||
function: () => {
|
||||
const newMode = !canvasStore.linearMode
|
||||
app.rootGraph.extra.linearMode = newMode
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
canvasStore.linearMode = newMode
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export enum ServerFeatureFlag {
|
||||
PRIVATE_MODELS_ENABLED = 'private_models_enabled',
|
||||
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
|
||||
HUGGINGFACE_MODEL_IMPORT_ENABLED = 'huggingface_model_import_enabled',
|
||||
LINEAR_TOGGLE_ENABLED = 'linear_toggle_enabled',
|
||||
ASYNC_MODEL_UPLOAD_ENABLED = 'async_model_upload_enabled'
|
||||
}
|
||||
|
||||
@@ -77,6 +78,12 @@ export function useFeatureFlags() {
|
||||
)
|
||||
)
|
||||
},
|
||||
get linearToggleEnabled() {
|
||||
return (
|
||||
remoteConfig.value.linear_toggle_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.LINEAR_TOGGLE_ENABLED, false)
|
||||
)
|
||||
},
|
||||
get asyncModelUploadEnabled() {
|
||||
return (
|
||||
remoteConfig.value.async_model_upload_enabled ??
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { registerProxyWidgets } from '@/core/graph/subgraph/proxyWidget'
|
||||
import { promoteWidget } from '@/core/graph/subgraph/proxyWidgetUtils'
|
||||
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphCanvas, SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -118,4 +119,23 @@ describe('Subgraph proxyWidgets', () => {
|
||||
subgraphNode.widgets[0].computedHeight = 10
|
||||
expect(subgraphNode.widgets[0].value).toBe('value')
|
||||
})
|
||||
test('Prevents duplicate promotion', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
|
||||
const widget = innerNodes[0].widgets![0]
|
||||
|
||||
// Promote once
|
||||
promoteWidget(innerNodes[0], widget, [subgraphNode])
|
||||
expect(subgraphNode.widgets.length).toBe(1)
|
||||
expect(subgraphNode.properties.proxyWidgets).toHaveLength(1)
|
||||
|
||||
// Try to promote again - should not create duplicate
|
||||
promoteWidget(innerNodes[0], widget, [subgraphNode])
|
||||
expect(subgraphNode.widgets.length).toBe(1)
|
||||
expect(subgraphNode.properties.proxyWidgets).toHaveLength(1)
|
||||
expect(subgraphNode.properties.proxyWidgets).toStrictEqual([
|
||||
['1', 'stringWidget']
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,8 +29,13 @@ export function promoteWidget(
|
||||
parents: SubgraphNode[]
|
||||
) {
|
||||
for (const parent of parents) {
|
||||
const existingProxyWidgets = getProxyWidgets(parent)
|
||||
// Prevent duplicate promotion
|
||||
if (existingProxyWidgets.some(matchesPropertyItem([node, widget]))) {
|
||||
continue
|
||||
}
|
||||
const proxyWidgets = [
|
||||
...getProxyWidgets(parent),
|
||||
...existingProxyWidgets,
|
||||
widgetItemToProperty([node, widget])
|
||||
]
|
||||
parent.properties.proxyWidgets = proxyWidgets
|
||||
|
||||
@@ -842,8 +842,13 @@ export class LGraph
|
||||
if (!list_of_graphcanvas) return
|
||||
|
||||
for (const c of list_of_graphcanvas) {
|
||||
// eslint-disable-next-line prefer-spread
|
||||
c[action]?.apply(c, params)
|
||||
const method = c[action]
|
||||
|
||||
if (typeof method === 'function') {
|
||||
const args =
|
||||
params == null ? [] : Array.isArray(params) ? params : [params]
|
||||
;(method as (...args: unknown[]) => unknown).apply(c, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,11 @@ import type {
|
||||
LinkSegment,
|
||||
NewNodePosition,
|
||||
NullableProperties,
|
||||
Panel,
|
||||
PanelButton,
|
||||
PanelWidget,
|
||||
PanelWidgetCallback,
|
||||
PanelWidgetOptions,
|
||||
Point,
|
||||
Positionable,
|
||||
ReadOnlyRect,
|
||||
@@ -94,7 +99,7 @@ import type {
|
||||
SubgraphIO
|
||||
} from './types/serialisation'
|
||||
import type { NeverNever, PickNevers } from './types/utility'
|
||||
import type { IBaseWidget } from './types/widgets'
|
||||
import type { IBaseWidget, TWidgetValue } from './types/widgets'
|
||||
import { alignNodes, distributeNodes, getBoundaryNodes } from './utils/arrange'
|
||||
import { findFirstNode, getAllNestedItems } from './utils/collections'
|
||||
import { resolveConnectingLinkColor } from './utils/linkColors'
|
||||
@@ -232,6 +237,15 @@ interface ICreatePanelOptions {
|
||||
height?: number | string
|
||||
}
|
||||
|
||||
interface SlotTypeDefaultNodeOpts {
|
||||
node?: string
|
||||
title?: string
|
||||
properties?: Record<string, NodeProperty>
|
||||
inputs?: [string, string][]
|
||||
outputs?: [string, string][]
|
||||
json?: Parameters<LGraphNode['configure']>[0]
|
||||
}
|
||||
|
||||
const cursors = {
|
||||
NE: 'nesw-resize',
|
||||
SE: 'nwse-resize',
|
||||
@@ -693,9 +707,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
_highlight_input?: INodeInputSlot
|
||||
// TODO: Check if panels are used
|
||||
/** @deprecated Panels */
|
||||
node_panel?: any
|
||||
node_panel?: Panel
|
||||
/** @deprecated Panels */
|
||||
options_panel?: any
|
||||
options_panel?: Panel
|
||||
_bg_img?: HTMLImageElement
|
||||
_pattern?: CanvasPattern
|
||||
_pattern_img?: HTMLImageElement
|
||||
@@ -1249,11 +1263,13 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
function inner_clicked(
|
||||
this: ContextMenuDivElement<INodeSlotContextItem>,
|
||||
v: IContextMenuValue<INodeSlotContextItem>,
|
||||
e: any,
|
||||
prev: any
|
||||
v?: string | IContextMenuValue<INodeSlotContextItem>,
|
||||
_options?: unknown,
|
||||
e?: MouseEvent,
|
||||
prev?: ContextMenu<INodeSlotContextItem>
|
||||
) {
|
||||
if (!node) return
|
||||
if (!v || typeof v === 'string') return
|
||||
|
||||
// TODO: This is a static method, so the below "that" appears broken.
|
||||
if (v.callback) void v.callback.call(this, node, v, e, prev)
|
||||
@@ -6354,8 +6370,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
? LiteGraph.slot_types_default_out
|
||||
: LiteGraph.slot_types_default_in
|
||||
if (slotTypesDefault?.[fromSlotType]) {
|
||||
// TODO: Remove "any" kludge
|
||||
let nodeNewType: any = false
|
||||
let nodeNewType: string | Record<string, unknown> | false = false
|
||||
if (typeof slotTypesDefault[fromSlotType] == 'object') {
|
||||
for (const typeX in slotTypesDefault[fromSlotType]) {
|
||||
if (
|
||||
@@ -6373,11 +6388,13 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
nodeNewType = slotTypesDefault[fromSlotType]
|
||||
}
|
||||
if (nodeNewType) {
|
||||
// TODO: Remove "any" kludge
|
||||
let nodeNewOpts: any = false
|
||||
if (typeof nodeNewType == 'object' && nodeNewType.node) {
|
||||
nodeNewOpts = nodeNewType
|
||||
nodeNewType = nodeNewType.node
|
||||
let nodeNewOpts: SlotTypeDefaultNodeOpts | undefined
|
||||
let nodeTypeStr: string
|
||||
if (typeof nodeNewType == 'object') {
|
||||
nodeNewOpts = nodeNewType as SlotTypeDefaultNodeOpts
|
||||
nodeTypeStr = nodeNewOpts.node ?? ''
|
||||
} else {
|
||||
nodeTypeStr = nodeNewType
|
||||
}
|
||||
|
||||
// that.graph.beforeChange();
|
||||
@@ -6386,7 +6403,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
const nodeX = opts.position[0] + opts.posAdd[0] + xSizeFix
|
||||
const nodeY = opts.position[1] + opts.posAdd[1] + ySizeFix
|
||||
const pos = [nodeX, nodeY]
|
||||
const newNode = LiteGraph.createNode(nodeNewType, nodeNewOpts.title, {
|
||||
const newNode = LiteGraph.createNode(nodeTypeStr, nodeNewOpts?.title, {
|
||||
pos
|
||||
})
|
||||
if (newNode) {
|
||||
@@ -6399,20 +6416,14 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
if (nodeNewOpts.inputs) {
|
||||
newNode.inputs = []
|
||||
for (const i in nodeNewOpts.inputs) {
|
||||
newNode.addOutput(
|
||||
nodeNewOpts.inputs[i][0],
|
||||
nodeNewOpts.inputs[i][1]
|
||||
)
|
||||
for (const input of nodeNewOpts.inputs) {
|
||||
newNode.addInput(input[0], input[1])
|
||||
}
|
||||
}
|
||||
if (nodeNewOpts.outputs) {
|
||||
newNode.outputs = []
|
||||
for (const i in nodeNewOpts.outputs) {
|
||||
newNode.addOutput(
|
||||
nodeNewOpts.outputs[i][0],
|
||||
nodeNewOpts.outputs[i][1]
|
||||
)
|
||||
for (const output of nodeNewOpts.outputs) {
|
||||
newNode.addOutput(output[0], output[1])
|
||||
}
|
||||
}
|
||||
if (nodeNewOpts.json) {
|
||||
@@ -6909,8 +6920,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
// hide on mouse leave
|
||||
if (options.hide_on_mouse_leave) {
|
||||
// FIXME: Remove "any" kludge
|
||||
let prevent_timeout: any = false
|
||||
let prevent_timeout = 0
|
||||
let timeout_close: ReturnType<typeof setTimeout> | null = null
|
||||
LiteGraph.pointerListenerAdd(dialog, 'enter', function () {
|
||||
if (timeout_close) {
|
||||
@@ -7104,8 +7114,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
// join node after inserting
|
||||
if (options.node_from) {
|
||||
// FIXME: any
|
||||
let iS: any = false
|
||||
let iS: number | false = false
|
||||
switch (typeof options.slot_from) {
|
||||
case 'string':
|
||||
iS = options.node_from.findOutputSlot(options.slot_from)
|
||||
@@ -7131,8 +7140,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
// try with first if no name set
|
||||
iS = 0
|
||||
}
|
||||
if (options.node_from.outputs[iS] !== undefined) {
|
||||
if (iS !== false && iS > -1) {
|
||||
if (iS !== false && options.node_from.outputs[iS] !== undefined) {
|
||||
if (iS > -1) {
|
||||
if (node == null)
|
||||
throw new TypeError(
|
||||
'options.slot_from was null when showing search box'
|
||||
@@ -7149,8 +7158,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
}
|
||||
if (options.node_to) {
|
||||
// FIXME: any
|
||||
let iS: any = false
|
||||
let iS: number | false = false
|
||||
switch (typeof options.slot_from) {
|
||||
case 'string':
|
||||
iS = options.node_to.findInputSlot(options.slot_from)
|
||||
@@ -7176,8 +7184,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
// try with first if no name set
|
||||
iS = 0
|
||||
}
|
||||
if (options.node_to.inputs[iS] !== undefined) {
|
||||
if (iS !== false && iS > -1) {
|
||||
if (iS !== false && options.node_to.inputs[iS] !== undefined) {
|
||||
if (iS > -1) {
|
||||
if (node == null)
|
||||
throw new TypeError(
|
||||
'options.slot_from was null when showing search box'
|
||||
@@ -7240,13 +7248,16 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
const filter = graphcanvas.filter || graphcanvas.graph.filter
|
||||
|
||||
// FIXME: any
|
||||
// filter by type preprocess
|
||||
let sIn: any = false
|
||||
let sOut: any = false
|
||||
let sIn: HTMLSelectElement | null = null
|
||||
let sOut: HTMLSelectElement | null = null
|
||||
if (options.do_type_filter && that.search_box) {
|
||||
sIn = that.search_box.querySelector('.slot_in_type_filter')
|
||||
sOut = that.search_box.querySelector('.slot_out_type_filter')
|
||||
sIn = that.search_box.querySelector<HTMLSelectElement>(
|
||||
'.slot_in_type_filter'
|
||||
)
|
||||
sOut = that.search_box.querySelector<HTMLSelectElement>(
|
||||
'.slot_out_type_filter'
|
||||
)
|
||||
}
|
||||
|
||||
const keys = Object.keys(LiteGraph.registered_node_types)
|
||||
@@ -7264,7 +7275,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
// add general type if filtering
|
||||
if (
|
||||
options.show_general_after_typefiltered &&
|
||||
(sIn.value || sOut.value)
|
||||
(sIn?.value || sOut?.value)
|
||||
) {
|
||||
const filtered_extra: string[] = []
|
||||
for (const i in LiteGraph.registered_node_types) {
|
||||
@@ -7289,7 +7300,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
// check il filtering gave no results
|
||||
if (
|
||||
(sIn.value || sOut.value) &&
|
||||
(sIn?.value || sOut?.value) &&
|
||||
helper.childNodes.length == 0 &&
|
||||
options.show_general_if_none_on_typefilter
|
||||
) {
|
||||
@@ -7337,8 +7348,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
if (options.do_type_filter && !opts.skipFilter) {
|
||||
const sType = type
|
||||
|
||||
let sV =
|
||||
opts.inTypeOverride !== false ? opts.inTypeOverride : sIn.value
|
||||
let sV: string | undefined =
|
||||
typeof opts.inTypeOverride === 'string'
|
||||
? opts.inTypeOverride
|
||||
: sIn?.value
|
||||
// type is stored
|
||||
if (sIn && sV && LiteGraph.registered_slot_in_types[sV]?.nodes) {
|
||||
const doesInc =
|
||||
@@ -7346,8 +7359,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
if (doesInc === false) return false
|
||||
}
|
||||
|
||||
sV = sOut.value
|
||||
if (opts.outTypeOverride !== false) sV = opts.outTypeOverride
|
||||
sV = sOut?.value
|
||||
if (typeof opts.outTypeOverride === 'string')
|
||||
sV = opts.outTypeOverride
|
||||
// type is stored
|
||||
if (sOut && sV && LiteGraph.registered_slot_out_types[sV]?.nodes) {
|
||||
const doesInc =
|
||||
@@ -7628,15 +7642,14 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
return dialog
|
||||
}
|
||||
|
||||
createPanel(title: string, options: ICreatePanelOptions) {
|
||||
createPanel(title: string, options: ICreatePanelOptions): Panel {
|
||||
options = options || {}
|
||||
|
||||
// TODO: any kludge
|
||||
const root: any = document.createElement('div')
|
||||
const root = document.createElement('div') as Panel
|
||||
root.className = 'litegraph dialog'
|
||||
root.innerHTML =
|
||||
"<div class='dialog-header'><span class='dialog-title'></span></div><div class='dialog-content'></div><div style='display:none;' class='dialog-alt-content'></div><div class='dialog-footer'></div>"
|
||||
root.header = root.querySelector('.dialog-header')
|
||||
root.header = root.querySelector('.dialog-header')!
|
||||
|
||||
if (options.width)
|
||||
root.style.width =
|
||||
@@ -7653,11 +7666,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
})
|
||||
root.header.append(close)
|
||||
}
|
||||
root.title_element = root.querySelector('.dialog-title')
|
||||
root.title_element = root.querySelector('.dialog-title')!
|
||||
root.title_element.textContent = title
|
||||
root.content = root.querySelector('.dialog-content')
|
||||
root.alt_content = root.querySelector('.dialog-alt-content')
|
||||
root.footer = root.querySelector('.dialog-footer')
|
||||
root.content = root.querySelector('.dialog-content')!
|
||||
root.alt_content = root.querySelector('.dialog-alt-content')!
|
||||
root.footer = root.querySelector('.dialog-footer')!
|
||||
root.footer.style.marginTop = '-96px'
|
||||
|
||||
root.close = function () {
|
||||
@@ -7667,7 +7680,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
// function to swap panel content
|
||||
root.toggleAltContent = function (force: unknown) {
|
||||
root.toggleAltContent = function (force?: boolean) {
|
||||
let vTo: string
|
||||
let vAlt: string
|
||||
if (force !== undefined) {
|
||||
@@ -7681,7 +7694,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
root.content.style.display = vAlt
|
||||
}
|
||||
|
||||
root.toggleFooterVisibility = function (force: unknown) {
|
||||
root.toggleFooterVisibility = function (force?: boolean) {
|
||||
let vTo: string
|
||||
if (force !== undefined) {
|
||||
vTo = force ? 'block' : 'none'
|
||||
@@ -7695,7 +7708,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
this.content.innerHTML = ''
|
||||
}
|
||||
|
||||
root.addHTML = function (code: string, classname: string, on_footer: any) {
|
||||
root.addHTML = function (
|
||||
code: string,
|
||||
classname?: string,
|
||||
on_footer?: boolean
|
||||
) {
|
||||
const elem = document.createElement('div')
|
||||
if (classname) elem.className = classname
|
||||
elem.innerHTML = code
|
||||
@@ -7704,9 +7721,12 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
return elem
|
||||
}
|
||||
|
||||
root.addButton = function (name: any, callback: any, options: any) {
|
||||
// TODO: any kludge
|
||||
const elem: any = document.createElement('button')
|
||||
root.addButton = function (
|
||||
name: string,
|
||||
callback: () => void,
|
||||
options?: unknown
|
||||
): PanelButton {
|
||||
const elem = document.createElement('button') as PanelButton
|
||||
elem.textContent = name
|
||||
elem.options = options
|
||||
elem.classList.add('btn')
|
||||
@@ -7723,20 +7743,18 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
root.addWidget = function (
|
||||
type: string,
|
||||
name: any,
|
||||
value: unknown,
|
||||
options: { label?: any; type?: any; values?: any; callback?: any },
|
||||
callback: (arg0: any, arg1: any, arg2: any) => void
|
||||
) {
|
||||
name: string,
|
||||
value: TWidgetValue,
|
||||
options?: PanelWidgetOptions,
|
||||
callback?: PanelWidgetCallback
|
||||
): PanelWidget {
|
||||
options = options || {}
|
||||
let str_value = String(value)
|
||||
type = type.toLowerCase()
|
||||
if (type == 'number' && typeof value === 'number')
|
||||
str_value = value.toFixed(3)
|
||||
|
||||
// FIXME: any kludge
|
||||
const elem: HTMLDivElement & { options?: unknown; value?: unknown } =
|
||||
document.createElement('div')
|
||||
const elem: PanelWidget = document.createElement('div') as PanelWidget
|
||||
elem.className = 'property'
|
||||
elem.innerHTML =
|
||||
"<span class='property_name'></span><span class='property_value'></span>"
|
||||
@@ -7744,7 +7762,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
if (!nameSpan) throw new TypeError('Property name element was null.')
|
||||
|
||||
nameSpan.textContent = options.label || name
|
||||
// TODO: any kludge
|
||||
const value_element: HTMLSpanElement | null =
|
||||
elem.querySelector('.property_value')
|
||||
if (!value_element) throw new TypeError('Property name element was null.')
|
||||
@@ -7756,7 +7773,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
if (type == 'code') {
|
||||
elem.addEventListener('click', function () {
|
||||
root.inner_showCodePad(this.dataset['property'])
|
||||
const property = this.dataset['property']
|
||||
if (property) root.inner_showCodePad?.(property)
|
||||
})
|
||||
} else if (type == 'boolean') {
|
||||
elem.classList.add('boolean')
|
||||
@@ -7799,19 +7817,16 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
value_element.textContent = str_value ?? ''
|
||||
|
||||
value_element.addEventListener('click', function (event) {
|
||||
const values = options.values || []
|
||||
const values = options?.values || []
|
||||
const propname = this.parentElement?.dataset['property']
|
||||
const inner_clicked = (v: string | null) => {
|
||||
// node.setProperty(propname,v);
|
||||
// graphcanvas.dirty_canvas = true;
|
||||
this.textContent = v
|
||||
const inner_clicked = (v?: string) => {
|
||||
this.textContent = v ?? null
|
||||
innerChange(propname, v)
|
||||
return false
|
||||
}
|
||||
new LiteGraph.ContextMenu(values, {
|
||||
event,
|
||||
className: 'dark',
|
||||
// @ts-expect-error fixme ts strict error - callback signature mismatch
|
||||
callback: inner_clicked
|
||||
})
|
||||
})
|
||||
@@ -7819,9 +7834,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
root.content.append(elem)
|
||||
|
||||
function innerChange(name: string | undefined, value: unknown) {
|
||||
options.callback?.(name, value, options)
|
||||
callback?.(name, value, options)
|
||||
function innerChange(name: string | undefined, value: TWidgetValue) {
|
||||
const opts = options || {}
|
||||
opts.callback?.(name, value, opts)
|
||||
callback?.(name, value, opts)
|
||||
}
|
||||
|
||||
return elem
|
||||
@@ -7850,7 +7866,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
},
|
||||
onClose: () => {
|
||||
this.NODEPANEL_IS_OPEN = false
|
||||
this.node_panel = null
|
||||
this.node_panel = undefined
|
||||
}
|
||||
})
|
||||
this.node_panel = panel
|
||||
@@ -7871,11 +7887,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
panel.addHTML('<h3>Properties</h3>')
|
||||
|
||||
const fUpdate = (
|
||||
name: string,
|
||||
value: string | number | boolean | object | undefined
|
||||
) => {
|
||||
const fUpdate: PanelWidgetCallback = (name, value) => {
|
||||
if (!this.graph) throw new NullGraphError()
|
||||
if (!name) return
|
||||
this.graph.beforeChange(node)
|
||||
switch (name) {
|
||||
case 'Title':
|
||||
@@ -7979,7 +7993,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
panel.alt_content.innerHTML = "<textarea class='code'></textarea>"
|
||||
const textarea: HTMLTextAreaElement =
|
||||
panel.alt_content.querySelector('textarea')
|
||||
panel.alt_content.querySelector('textarea')!
|
||||
const fDoneWith = function () {
|
||||
panel.toggleAltContent(false)
|
||||
panel.toggleFooterVisibility(true)
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { LGraphNodeProperties } from '@/lib/litegraph/src/LGraphNodeProperties'
|
||||
import {
|
||||
calculateInputSlotPos,
|
||||
calculateInputSlotPosFromSlot,
|
||||
calculateOutputSlotPos,
|
||||
getSlotPosition
|
||||
} from '@/renderer/core/canvas/litegraph/slotCalculations'
|
||||
import type { SlotPositionContext } from '@/renderer/core/canvas/litegraph/slotCalculations'
|
||||
@@ -41,6 +39,7 @@ import type {
|
||||
INodeSlotContextItem,
|
||||
IPinnable,
|
||||
ISlotType,
|
||||
Panel,
|
||||
Point,
|
||||
Positionable,
|
||||
ReadOnlyRect,
|
||||
@@ -96,9 +95,12 @@ export type NodeId = number | string
|
||||
export type NodeProperty = string | number | boolean | object
|
||||
|
||||
interface INodePropertyInfo {
|
||||
name: string
|
||||
name?: string
|
||||
type?: string
|
||||
default_value: NodeProperty | undefined
|
||||
default_value?: NodeProperty
|
||||
widget?: string
|
||||
label?: string
|
||||
values?: TWidgetValue[]
|
||||
}
|
||||
|
||||
interface IMouseOverData {
|
||||
@@ -606,8 +608,8 @@ export class LGraphNode
|
||||
target_slot: number,
|
||||
requested_slot?: number | string
|
||||
): number | false | null
|
||||
onShowCustomPanelInfo?(this: LGraphNode, panel: any): void
|
||||
onAddPropertyToPanel?(this: LGraphNode, pName: string, panel: any): boolean
|
||||
onShowCustomPanelInfo?(this: LGraphNode, panel: Panel): void
|
||||
onAddPropertyToPanel?(this: LGraphNode, pName: string, panel: Panel): boolean
|
||||
onWidgetChanged?(
|
||||
this: LGraphNode,
|
||||
name: string,
|
||||
@@ -667,8 +669,7 @@ export class LGraphNode
|
||||
index: number,
|
||||
e: CanvasPointerEvent
|
||||
): void
|
||||
// TODO: Return type
|
||||
onGetPropertyInfo?(this: LGraphNode, property: string): any
|
||||
onGetPropertyInfo?(this: LGraphNode, property: string): INodePropertyInfo
|
||||
onNodeOutputAdd?(this: LGraphNode, value: unknown): void
|
||||
onNodeInputAdd?(this: LGraphNode, value: unknown): void
|
||||
onMenuNodeInputs?(
|
||||
@@ -3346,7 +3347,7 @@ export class LGraphNode
|
||||
* @returns Position of the input slot
|
||||
*/
|
||||
getInputPos(slot: number): Point {
|
||||
return calculateInputSlotPos(this.#getSlotPositionContext(), slot)
|
||||
return getSlotPosition(this, slot, true)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3366,10 +3367,7 @@ export class LGraphNode
|
||||
* @returns Position of the output slot
|
||||
*/
|
||||
getOutputPos(outputSlotIndex: number): Point {
|
||||
return calculateOutputSlotPos(
|
||||
this.#getSlotPositionContext(),
|
||||
outputSlotIndex
|
||||
)
|
||||
return getSlotPosition(this, outputSlotIndex, false)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -81,6 +81,7 @@ class LegacyMenuCompat {
|
||||
return currentImpl
|
||||
},
|
||||
set: (newImpl: LGraphCanvas[K]) => {
|
||||
if (!newImpl) return
|
||||
const fnKey = `${methodName as string}:${newImpl.toString().slice(0, 100)}`
|
||||
if (!this.hasWarned.has(fnKey) && this.currentExtension) {
|
||||
this.hasWarned.add(fnKey)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import type { ContextMenu } from './ContextMenu'
|
||||
import type { LGraphNode, NodeId } from './LGraphNode'
|
||||
@@ -493,3 +494,68 @@ export interface Hoverable extends HasBoundingRect {
|
||||
onPointerEnter?(e?: CanvasPointerEvent): void
|
||||
onPointerLeave?(e?: CanvasPointerEvent): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for panel widget value changes.
|
||||
*/
|
||||
export type PanelWidgetCallback = (
|
||||
name: string | undefined,
|
||||
value: TWidgetValue,
|
||||
options: PanelWidgetOptions
|
||||
) => void
|
||||
|
||||
/**
|
||||
* Options for panel widgets.
|
||||
*/
|
||||
export interface PanelWidgetOptions {
|
||||
label?: string
|
||||
type?: string
|
||||
widget?: string
|
||||
values?: Array<string | IContextMenuValue<unknown, unknown, unknown> | null>
|
||||
callback?: PanelWidgetCallback
|
||||
}
|
||||
|
||||
/**
|
||||
* A button element with optional options property.
|
||||
*/
|
||||
export interface PanelButton extends HTMLButtonElement {
|
||||
options?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* A widget element with options and value properties.
|
||||
*/
|
||||
export interface PanelWidget extends HTMLDivElement {
|
||||
options?: PanelWidgetOptions
|
||||
value?: TWidgetValue
|
||||
}
|
||||
|
||||
/**
|
||||
* A dialog panel created by LGraphCanvas.createPanel().
|
||||
* Extends HTMLDivElement with additional properties and methods for panel management.
|
||||
*/
|
||||
export interface Panel extends HTMLDivElement {
|
||||
header: HTMLElement
|
||||
title_element: HTMLSpanElement
|
||||
content: HTMLDivElement
|
||||
alt_content: HTMLDivElement
|
||||
footer: HTMLDivElement
|
||||
node?: LGraphNode
|
||||
onOpen?: () => void
|
||||
onClose?: () => void
|
||||
close(): void
|
||||
toggleAltContent(force?: boolean): void
|
||||
toggleFooterVisibility(force?: boolean): void
|
||||
clear(): void
|
||||
addHTML(code: string, classname?: string, on_footer?: boolean): HTMLDivElement
|
||||
addButton(name: string, callback: () => void, options?: unknown): PanelButton
|
||||
addSeparator(): void
|
||||
addWidget(
|
||||
type: string,
|
||||
name: string,
|
||||
value: TWidgetValue,
|
||||
options?: PanelWidgetOptions,
|
||||
callback?: PanelWidgetCallback
|
||||
): PanelWidget
|
||||
inner_showCodePad?(property: string): void
|
||||
}
|
||||
|
||||
@@ -195,7 +195,7 @@ export abstract class SubgraphIONodeBase<
|
||||
if (!(options.length > 0)) return
|
||||
|
||||
new LiteGraph.ContextMenu(options, {
|
||||
event: event as any,
|
||||
event,
|
||||
title: slot.name || 'Subgraph Output',
|
||||
callback: (item: IContextMenuValue) => {
|
||||
this.#onSlotMenuAction(item, slot, event)
|
||||
|
||||
@@ -2478,6 +2478,7 @@
|
||||
"dropPrompt": "أسقط ملفك أو"
|
||||
},
|
||||
"widgets": {
|
||||
"node2only": "فقط Node 2.0",
|
||||
"selectModel": "اختر نموذج",
|
||||
"uploadSelect": {
|
||||
"placeholder": "اختر...",
|
||||
|
||||
@@ -190,6 +190,7 @@
|
||||
"failed": "Failed",
|
||||
"cancelled": "Cancelled",
|
||||
"job": "Job",
|
||||
"asset": "{count} assets | {count} asset | {count} assets",
|
||||
"untitled": "Untitled",
|
||||
"emDash": "—",
|
||||
"enabling": "Enabling {id}",
|
||||
@@ -435,6 +436,8 @@
|
||||
"Save Image": "Save Image",
|
||||
"Rename": "Rename",
|
||||
"RenameWidget": "Rename Widget",
|
||||
"FavoriteWidget": "Favorite Widget",
|
||||
"UnfavoriteWidget": "Unfavorite Widget",
|
||||
"Copy": "Copy",
|
||||
"Duplicate": "Duplicate",
|
||||
"Paste": "Paste",
|
||||
@@ -677,7 +680,8 @@
|
||||
"filterImage": "Image",
|
||||
"filterVideo": "Video",
|
||||
"filterAudio": "Audio",
|
||||
"filter3D": "3D"
|
||||
"filter3D": "3D",
|
||||
"filterText": "Text"
|
||||
},
|
||||
"backToAssets": "Back to all assets",
|
||||
"searchAssets": "Search Assets",
|
||||
@@ -1183,7 +1187,7 @@
|
||||
"Experimental: Enable AssetAPI": "Experimental: Enable AssetAPI",
|
||||
"Canvas Performance": "Canvas Performance",
|
||||
"Help Center": "Help Center",
|
||||
"toggle linear mode": "toggle linear mode",
|
||||
"toggle linear mode": "toggle simple mode",
|
||||
"Toggle Queue Panel V2": "Toggle Queue Panel V2",
|
||||
"Toggle Theme (Dark/Light)": "Toggle Theme (Dark/Light)",
|
||||
"Undo": "Undo",
|
||||
@@ -2410,7 +2414,7 @@
|
||||
"zoom": "Zoom in",
|
||||
"moreOptions": "More options",
|
||||
"seeMoreOutputs": "See more outputs",
|
||||
"addToWorkflow": "Add to current workflow",
|
||||
"insertAsNodeInWorkflow": "Insert as node in workflow",
|
||||
"download": "Download",
|
||||
"openWorkflow": "Open as workflow in new tab",
|
||||
"exportWorkflow": "Export workflow",
|
||||
@@ -2431,11 +2435,23 @@
|
||||
"downloadSelectedAll": "Download all",
|
||||
"deleteSelected": "Delete",
|
||||
"deleteSelectedAll": "Delete all",
|
||||
"insertAllAssetsAsNodes": "Insert all assets as nodes",
|
||||
"openWorkflowAll": "Open all workflows",
|
||||
"exportWorkflowAll": "Export all workflows",
|
||||
"downloadStarted": "Downloading {count} files...",
|
||||
"downloadsStarted": "Started downloading {count} file(s)",
|
||||
"assetsDeletedSuccessfully": "{count} asset(s) deleted successfully",
|
||||
"failedToDeleteAssets": "Failed to delete selected assets",
|
||||
"partialDeleteSuccess": "{succeeded} deleted successfully, {failed} failed"
|
||||
"partialDeleteSuccess": "{succeeded} deleted successfully, {failed} failed",
|
||||
"nodesAddedToWorkflow": "{count} node(s) added to workflow",
|
||||
"failedToAddNodes": "Failed to add nodes to workflow",
|
||||
"partialAddNodesSuccess": "{succeeded} added successfully, {failed} failed",
|
||||
"workflowsOpened": "{count} workflow(s) opened in new tabs",
|
||||
"noWorkflowsFound": "No workflow data found in selected assets",
|
||||
"partialWorkflowsOpened": "{succeeded} workflow(s) opened, {failed} failed",
|
||||
"workflowsExported": "{count} workflow(s) exported successfully",
|
||||
"noWorkflowsToExport": "No workflow data found to export",
|
||||
"partialWorkflowsExported": "{succeeded} exported successfully, {failed} failed"
|
||||
},
|
||||
"noJobIdFound": "No job ID found for this asset",
|
||||
"unsupportedFileType": "Unsupported file type for loader node",
|
||||
@@ -2474,8 +2490,14 @@
|
||||
"message": "Switch back to Nodes 2.0 anytime from the main menu."
|
||||
},
|
||||
"linearMode": {
|
||||
"share": "Share",
|
||||
"openWorkflow": "Open Workflow"
|
||||
"linearMode": "Simple Mode",
|
||||
"beta": "Beta - Give Feedback",
|
||||
"graphMode": "Graph Mode",
|
||||
"dragAndDropImage": "Drag and drop an image",
|
||||
"runCount": "Run count:",
|
||||
"rerun": "Rerun",
|
||||
"reuseParameters": "Reuse Parameters",
|
||||
"downloadAll": "Download All"
|
||||
},
|
||||
"missingNodes": {
|
||||
"cloud": {
|
||||
@@ -2495,8 +2517,10 @@
|
||||
"rightSidePanel": {
|
||||
"togglePanel": "Toggle properties panel",
|
||||
"noSelection": "Select a node to see its properties and info.",
|
||||
"title": "No node(s) selected | 1 node selected | {count} nodes selected",
|
||||
"workflowOverview": "Workflow Overview",
|
||||
"title": "No item(s) selected | 1 item selected | {count} items selected",
|
||||
"parameters": "Parameters",
|
||||
"nodes": "Nodes",
|
||||
"info": "Info",
|
||||
"color": "Node color",
|
||||
"pinned": "Pinned",
|
||||
@@ -2506,9 +2530,43 @@
|
||||
"inputs": "INPUTS",
|
||||
"inputsNone": "NO INPUTS",
|
||||
"inputsNoneTooltip": "Node has no inputs",
|
||||
"advancedInputs": "ADVANCED INPUTS",
|
||||
"showAdvancedInputsButton": "Show advanced inputs",
|
||||
"properties": "Properties",
|
||||
"nodeState": "Node state",
|
||||
"settings": "Settings"
|
||||
"settings": "Settings",
|
||||
"addFavorite": "Favorite",
|
||||
"removeFavorite": "Unfavorite",
|
||||
"hideInput": "Hide input",
|
||||
"showInput": "Show input",
|
||||
"locateNode": "Locate node on canvas",
|
||||
"favorites": "FAVORITED INPUTS",
|
||||
"favoritesNone": "NO FAVORITED INPUTS",
|
||||
"favoritesNoneTooltip": "Star widgets to quickly access them without selecting nodes",
|
||||
"globalSettings": {
|
||||
"title": "Global Settings",
|
||||
"searchPlaceholder": "Search quick settings...",
|
||||
"nodes": "NODES",
|
||||
"canvas": "CANVAS",
|
||||
"connectionLinks": "CONNECTION LINKS",
|
||||
"showAdvanced": "Show advanced parameters",
|
||||
"showAdvancedTooltip": "This is an important setting that when set to TRUE, reveals all advanced parameters for nodes",
|
||||
"showInfoBadges": "Show info badges",
|
||||
"showToolbox": "Show toolbox on selection",
|
||||
"nodes2": "Nodes 2.0",
|
||||
"gridSpacing": "Grid spacing",
|
||||
"snapNodesToGrid": "Snap nodes to grid",
|
||||
"linkShape": "Link shape",
|
||||
"showConnectedLinks": "Show connected links",
|
||||
"viewAllSettings": "View all settings"
|
||||
},
|
||||
"groupSettings": "Group Settings",
|
||||
"groups": "Groups",
|
||||
"favoritesNoneDesc": "Inputs you favorite will show up here",
|
||||
"noneSearchDesc": "No items match your search",
|
||||
"nodesNoneDesc": "NO NODES",
|
||||
"fallbackGroupTitle": "Group",
|
||||
"fallbackNodeTitle": "Node"
|
||||
},
|
||||
"help": {
|
||||
"recentReleases": "Recent releases",
|
||||
@@ -2530,4 +2588,4 @@
|
||||
"failed": "Failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2478,6 +2478,7 @@
|
||||
"dropPrompt": "Suelta tu archivo o"
|
||||
},
|
||||
"widgets": {
|
||||
"node2only": "Solo Node 2.0",
|
||||
"selectModel": "Seleccionar modelo",
|
||||
"uploadSelect": {
|
||||
"placeholder": "Seleccionar...",
|
||||
|
||||
@@ -2488,6 +2488,7 @@
|
||||
"dropPrompt": "فایل خود را رها کنید یا"
|
||||
},
|
||||
"widgets": {
|
||||
"node2only": "فقط Node 2.0",
|
||||
"selectModel": "انتخاب مدل",
|
||||
"uploadSelect": {
|
||||
"placeholder": "انتخاب...",
|
||||
|
||||
@@ -2478,6 +2478,7 @@
|
||||
"dropPrompt": "Déposez votre fichier ou"
|
||||
},
|
||||
"widgets": {
|
||||
"node2only": "Node 2.0 uniquement",
|
||||
"selectModel": "Sélectionner un modèle",
|
||||
"uploadSelect": {
|
||||
"placeholder": "Sélectionner...",
|
||||
|
||||
@@ -2478,6 +2478,7 @@
|
||||
"dropPrompt": "ファイルをドロップするか"
|
||||
},
|
||||
"widgets": {
|
||||
"node2only": "Node 2.0専用",
|
||||
"selectModel": "モデルを選択",
|
||||
"uploadSelect": {
|
||||
"placeholder": "選択...",
|
||||
|
||||
@@ -2478,6 +2478,7 @@
|
||||
"dropPrompt": "파일을 끌어다 놓거나"
|
||||
},
|
||||
"widgets": {
|
||||
"node2only": "Node 2.0 전용",
|
||||
"selectModel": "모델 선택",
|
||||
"uploadSelect": {
|
||||
"placeholder": "선택...",
|
||||
|
||||
@@ -2488,6 +2488,7 @@
|
||||
"dropPrompt": "Solte seu arquivo ou"
|
||||
},
|
||||
"widgets": {
|
||||
"node2only": "Apenas Node 2.0",
|
||||
"selectModel": "Selecionar modelo",
|
||||
"uploadSelect": {
|
||||
"placeholder": "Selecionar...",
|
||||
|
||||
@@ -2478,6 +2478,7 @@
|
||||
"dropPrompt": "Перетащите ваш файл или"
|
||||
},
|
||||
"widgets": {
|
||||
"node2only": "Только Node 2.0",
|
||||
"selectModel": "Выбрать модель",
|
||||
"uploadSelect": {
|
||||
"placeholder": "Выбрать...",
|
||||
|
||||
@@ -2478,6 +2478,7 @@
|
||||
"dropPrompt": "Dosyanızı bırakın veya"
|
||||
},
|
||||
"widgets": {
|
||||
"node2only": "Yalnızca Node 2.0",
|
||||
"selectModel": "Model seç",
|
||||
"uploadSelect": {
|
||||
"placeholder": "Seç...",
|
||||
|
||||
@@ -2478,6 +2478,7 @@
|
||||
"dropPrompt": "拖放您的檔案或"
|
||||
},
|
||||
"widgets": {
|
||||
"node2only": "僅限 Node 2.0",
|
||||
"selectModel": "選擇模型",
|
||||
"uploadSelect": {
|
||||
"placeholder": "選擇...",
|
||||
|
||||
@@ -2488,6 +2488,7 @@
|
||||
"dropPrompt": "将文件拖到此处或"
|
||||
},
|
||||
"widgets": {
|
||||
"node2only": "仅限 Node 2.0",
|
||||
"selectModel": "选择模型",
|
||||
"uploadSelect": {
|
||||
"placeholder": "请选择...",
|
||||
|
||||
@@ -22,7 +22,9 @@
|
||||
"
|
||||
:data-selected="selected"
|
||||
@click.stop="$emit('click')"
|
||||
@contextmenu.prevent="handleContextMenu"
|
||||
@contextmenu.prevent.stop="
|
||||
asset ? emit('context-menu', $event, asset) : undefined
|
||||
"
|
||||
>
|
||||
<!-- Top Area: Media Preview -->
|
||||
<div class="relative aspect-square overflow-hidden p-0">
|
||||
@@ -64,7 +66,9 @@
|
||||
variant="overlay-white"
|
||||
size="icon"
|
||||
:aria-label="$t('mediaAsset.actions.moreOptions')"
|
||||
@click.stop="handleContextMenu"
|
||||
@click.stop="
|
||||
asset ? emit('context-menu', $event, asset) : undefined
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--ellipsis] size-4" />
|
||||
</Button>
|
||||
@@ -119,25 +123,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MediaAssetContextMenu
|
||||
v-if="asset"
|
||||
ref="contextMenu"
|
||||
:asset="asset"
|
||||
:asset-type="assetType"
|
||||
:file-kind="fileKind"
|
||||
:show-delete-button="showDeleteButton"
|
||||
:selected-assets="selectedAssets"
|
||||
:is-bulk-mode="hasSelection && (selectedAssets?.length ?? 0) > 1"
|
||||
@zoom="handleZoomClick"
|
||||
@asset-deleted="emit('asset-deleted')"
|
||||
@bulk-download="emit('bulk-download', $event)"
|
||||
@bulk-delete="emit('bulk-delete', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useElementHover, whenever } from '@vueuse/core'
|
||||
import { useElementHover } from '@vueuse/core'
|
||||
import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
|
||||
|
||||
import IconGroup from '@/components/button/IconGroup.vue'
|
||||
@@ -155,7 +144,6 @@ import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
||||
import type { AssetItem } from '../schemas/assetSchema'
|
||||
import type { MediaKind } from '../schemas/mediaAssetSchema'
|
||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||
import MediaAssetContextMenu from './MediaAssetContextMenu.vue'
|
||||
import MediaTitle from './MediaTitle.vue'
|
||||
|
||||
const mediaComponents = {
|
||||
@@ -171,40 +159,22 @@ function getTopComponent(kind: MediaKind) {
|
||||
return mediaComponents.top[kind] || mediaComponents.top.image
|
||||
}
|
||||
|
||||
const {
|
||||
asset,
|
||||
loading,
|
||||
selected,
|
||||
showOutputCount,
|
||||
outputCount,
|
||||
showDeleteButton,
|
||||
openContextMenuId,
|
||||
selectedAssets,
|
||||
hasSelection
|
||||
} = defineProps<{
|
||||
const { asset, loading, selected, showOutputCount, outputCount } = defineProps<{
|
||||
asset?: AssetItem
|
||||
loading?: boolean
|
||||
selected?: boolean
|
||||
showOutputCount?: boolean
|
||||
outputCount?: number
|
||||
showDeleteButton?: boolean
|
||||
openContextMenuId?: string | null
|
||||
selectedAssets?: AssetItem[]
|
||||
hasSelection?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: []
|
||||
zoom: [asset: AssetItem]
|
||||
'output-count-click': []
|
||||
'asset-deleted': []
|
||||
'context-menu-opened': []
|
||||
'bulk-download': [assets: AssetItem[]]
|
||||
'bulk-delete': [assets: AssetItem[]]
|
||||
'context-menu': [event: MouseEvent, asset: AssetItem]
|
||||
}>()
|
||||
|
||||
const cardContainerRef = ref<HTMLElement>()
|
||||
const contextMenu = ref<InstanceType<typeof MediaAssetContextMenu>>()
|
||||
|
||||
const isVideoPlaying = ref(false)
|
||||
const showVideoControls = ref(false)
|
||||
@@ -299,17 +269,4 @@ const handleImageLoaded = (width: number, height: number) => {
|
||||
const handleOutputCountClick = () => {
|
||||
emit('output-count-click')
|
||||
}
|
||||
|
||||
const handleContextMenu = (event: MouseEvent) => {
|
||||
emit('context-menu-opened')
|
||||
contextMenu.value?.show(event)
|
||||
}
|
||||
|
||||
// Close this context menu when another opens
|
||||
whenever(
|
||||
() => openContextMenuId && openContextMenuId !== asset?.id,
|
||||
() => {
|
||||
contextMenu.value?.hide()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
)
|
||||
}
|
||||
}"
|
||||
@hide="emit('hide')"
|
||||
>
|
||||
<template #item="{ item, props }">
|
||||
<Button
|
||||
@@ -65,6 +66,10 @@ const emit = defineEmits<{
|
||||
'asset-deleted': []
|
||||
'bulk-download': [assets: AssetItem[]]
|
||||
'bulk-delete': [assets: AssetItem[]]
|
||||
hide: []
|
||||
'bulk-add-to-workflow': [assets: AssetItem[]]
|
||||
'bulk-open-workflow': [assets: AssetItem[]]
|
||||
'bulk-export-workflow': [assets: AssetItem[]]
|
||||
}>()
|
||||
|
||||
const contextMenu = ref<InstanceType<typeof ContextMenu>>()
|
||||
@@ -140,6 +145,27 @@ const contextMenuItems = computed<MenuItem[]>(() => {
|
||||
disabled: true
|
||||
})
|
||||
|
||||
// Bulk Add to Workflow
|
||||
items.push({
|
||||
label: t('mediaAsset.selection.insertAllAssetsAsNodes'),
|
||||
icon: 'icon-[comfy--node]',
|
||||
command: () => emit('bulk-add-to-workflow', selectedAssets)
|
||||
})
|
||||
|
||||
// Bulk Open Workflow
|
||||
items.push({
|
||||
label: t('mediaAsset.selection.openWorkflowAll'),
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
command: () => emit('bulk-open-workflow', selectedAssets)
|
||||
})
|
||||
|
||||
// Bulk Export Workflow
|
||||
items.push({
|
||||
label: t('mediaAsset.selection.exportWorkflowAll'),
|
||||
icon: 'icon-[lucide--file-output]',
|
||||
command: () => emit('bulk-export-workflow', selectedAssets)
|
||||
})
|
||||
|
||||
// Bulk Download
|
||||
items.push({
|
||||
label: t('mediaAsset.selection.downloadSelectedAll'),
|
||||
@@ -173,7 +199,7 @@ const contextMenuItems = computed<MenuItem[]>(() => {
|
||||
// Add to workflow (conditional)
|
||||
if (showAddToWorkflow.value) {
|
||||
items.push({
|
||||
label: t('mediaAsset.actions.addToWorkflow'),
|
||||
label: t('mediaAsset.actions.insertAsNodeInWorkflow'),
|
||||
icon: 'icon-[comfy--node]',
|
||||
command: () => actions.addWorkflow(asset)
|
||||
})
|
||||
|
||||
@@ -365,6 +365,208 @@ export function useMediaAssetActions() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple assets to the current workflow
|
||||
* Creates loader nodes for each asset
|
||||
*/
|
||||
const addMultipleToWorkflow = async (assets: AssetItem[]) => {
|
||||
if (!assets || assets.length === 0) return
|
||||
|
||||
const NODE_OFFSET = 50
|
||||
let nodeIndex = 0
|
||||
let succeeded = 0
|
||||
let failed = 0
|
||||
|
||||
for (const asset of assets) {
|
||||
const { nodeType, widgetName } = detectNodeTypeFromFilename(asset.name)
|
||||
|
||||
if (!nodeType || !widgetName) {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
const nodeDef = nodeDefStore.nodeDefsByName[nodeType]
|
||||
if (!nodeDef) {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
const center = litegraphService.getCanvasCenter()
|
||||
const node = litegraphService.addNodeOnGraph(nodeDef, {
|
||||
pos: [
|
||||
center[0] + nodeIndex * NODE_OFFSET,
|
||||
center[1] + nodeIndex * NODE_OFFSET
|
||||
]
|
||||
})
|
||||
|
||||
if (!node) {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
const assetType = getAssetType(asset, 'input')
|
||||
|
||||
const annotated = createAnnotatedPath(
|
||||
{
|
||||
filename: asset.name,
|
||||
subfolder: metadata?.subfolder || '',
|
||||
type: isResultItemType(assetType) ? assetType : undefined
|
||||
},
|
||||
{
|
||||
rootFolder: isResultItemType(assetType) ? assetType : undefined
|
||||
}
|
||||
)
|
||||
|
||||
const widget = node.widgets?.find((w) => w.name === widgetName)
|
||||
if (widget) {
|
||||
widget.value = annotated
|
||||
widget.callback?.(annotated)
|
||||
}
|
||||
node.graph?.setDirtyCanvas(true, true)
|
||||
succeeded++
|
||||
nodeIndex++
|
||||
}
|
||||
|
||||
if (failed === 0) {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('mediaAsset.selection.nodesAddedToWorkflow', {
|
||||
count: succeeded
|
||||
}),
|
||||
life: 2000
|
||||
})
|
||||
} else if (succeeded === 0) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('mediaAsset.selection.failedToAddNodes'),
|
||||
life: 3000
|
||||
})
|
||||
} else {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: t('g.warning'),
|
||||
detail: t('mediaAsset.selection.partialAddNodesSuccess', {
|
||||
succeeded,
|
||||
failed
|
||||
}),
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open workflows from multiple assets in new tabs
|
||||
*/
|
||||
const openMultipleWorkflows = async (assets: AssetItem[]) => {
|
||||
if (!assets || assets.length === 0) return
|
||||
|
||||
let succeeded = 0
|
||||
let failed = 0
|
||||
|
||||
for (const asset of assets) {
|
||||
try {
|
||||
const { workflow, filename } = await extractWorkflowFromAsset(asset)
|
||||
const result = await workflowActions.openWorkflowAction(
|
||||
workflow,
|
||||
filename
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
succeeded++
|
||||
} else {
|
||||
failed++
|
||||
}
|
||||
} catch {
|
||||
failed++
|
||||
}
|
||||
}
|
||||
|
||||
if (failed === 0) {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('mediaAsset.selection.workflowsOpened', { count: succeeded }),
|
||||
life: 2000
|
||||
})
|
||||
} else if (succeeded === 0) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: t('g.warning'),
|
||||
detail: t('mediaAsset.selection.noWorkflowsFound'),
|
||||
life: 3000
|
||||
})
|
||||
} else {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: t('g.warning'),
|
||||
detail: t('mediaAsset.selection.partialWorkflowsOpened', {
|
||||
succeeded,
|
||||
failed
|
||||
}),
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export workflows from multiple assets as JSON files
|
||||
*/
|
||||
const exportMultipleWorkflows = async (assets: AssetItem[]) => {
|
||||
if (!assets || assets.length === 0) return
|
||||
|
||||
let succeeded = 0
|
||||
let failed = 0
|
||||
|
||||
for (const asset of assets) {
|
||||
try {
|
||||
const { workflow, filename } = await extractWorkflowFromAsset(asset)
|
||||
const result = await workflowActions.exportWorkflowAction(
|
||||
workflow,
|
||||
filename
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
succeeded++
|
||||
} else {
|
||||
failed++
|
||||
}
|
||||
} catch {
|
||||
failed++
|
||||
}
|
||||
}
|
||||
|
||||
if (failed === 0) {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('mediaAsset.selection.workflowsExported', {
|
||||
count: succeeded
|
||||
}),
|
||||
life: 2000
|
||||
})
|
||||
} else if (succeeded === 0) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: t('g.warning'),
|
||||
detail: t('mediaAsset.selection.noWorkflowsToExport'),
|
||||
life: 3000
|
||||
})
|
||||
} else {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: t('g.warning'),
|
||||
detail: t('mediaAsset.selection.partialWorkflowsExported', {
|
||||
succeeded,
|
||||
failed
|
||||
}),
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete multiple assets with confirmation dialog
|
||||
* @param assets Array of assets to delete
|
||||
@@ -482,7 +684,10 @@ export function useMediaAssetActions() {
|
||||
deleteMultipleAssets,
|
||||
copyJobId,
|
||||
addWorkflow,
|
||||
addMultipleToWorkflow,
|
||||
openWorkflow,
|
||||
exportWorkflow
|
||||
openMultipleWorkflows,
|
||||
exportWorkflow,
|
||||
exportMultipleWorkflows
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,35 +257,66 @@ describe('fetchJobs', () => {
|
||||
})
|
||||
|
||||
describe('extractWorkflow', () => {
|
||||
it('extracts workflow from nested structure', () => {
|
||||
const validWorkflow = {
|
||||
version: 0.4,
|
||||
last_node_id: 1,
|
||||
last_link_id: 0,
|
||||
nodes: [],
|
||||
links: []
|
||||
}
|
||||
|
||||
it('extracts and validates workflow from nested structure', async () => {
|
||||
const jobDetail = {
|
||||
...createMockJob('job1', 'completed'),
|
||||
workflow: {
|
||||
extra_data: {
|
||||
extra_pnginfo: {
|
||||
workflow: { nodes: [], links: [] }
|
||||
workflow: validWorkflow
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const workflow = extractWorkflow(jobDetail)
|
||||
const workflow = await extractWorkflow(jobDetail)
|
||||
|
||||
expect(workflow).toEqual({ nodes: [], links: [] })
|
||||
expect(workflow).toEqual(validWorkflow)
|
||||
})
|
||||
|
||||
it('returns undefined if workflow not present', () => {
|
||||
it('returns undefined if workflow not present', async () => {
|
||||
const jobDetail = createMockJob('job1', 'completed')
|
||||
|
||||
const workflow = extractWorkflow(jobDetail)
|
||||
const workflow = await extractWorkflow(jobDetail)
|
||||
|
||||
expect(workflow).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for undefined input', () => {
|
||||
const workflow = extractWorkflow(undefined)
|
||||
it('returns undefined for undefined input', async () => {
|
||||
const workflow = await extractWorkflow(undefined)
|
||||
|
||||
expect(workflow).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for invalid workflow and logs warning', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const jobDetail = {
|
||||
...createMockJob('job1', 'completed'),
|
||||
workflow: {
|
||||
extra_data: {
|
||||
extra_pnginfo: {
|
||||
workflow: { invalid: 'data' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const workflow = await extractWorkflow(jobDetail)
|
||||
|
||||
expect(workflow).toBeUndefined()
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'[extractWorkflow] Workflow validation failed:',
|
||||
expect.any(String)
|
||||
)
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
* All distributions use the /jobs endpoint.
|
||||
*/
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { validateComfyWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { PromptId } from '@/schemas/apiSchema'
|
||||
|
||||
import type {
|
||||
@@ -135,12 +137,25 @@ export async function fetchJobDetail(
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts workflow from job detail response.
|
||||
* Extracts and validates workflow from job detail response.
|
||||
* The workflow is nested at: workflow.extra_data.extra_pnginfo.workflow
|
||||
* Full workflow validation happens downstream via validateComfyWorkflow.
|
||||
*
|
||||
* Uses Zod validation via validateComfyWorkflow to ensure the workflow
|
||||
* conforms to the expected schema. Logs validation failures for debugging
|
||||
* but still returns undefined to allow graceful degradation.
|
||||
*/
|
||||
export function extractWorkflow(job: JobDetail | undefined): unknown {
|
||||
export async function extractWorkflow(
|
||||
job: JobDetail | undefined
|
||||
): Promise<ComfyWorkflowJSON | undefined> {
|
||||
const parsed = zWorkflowContainer.safeParse(job?.workflow)
|
||||
if (!parsed.success) return undefined
|
||||
return parsed.data.extra_data?.extra_pnginfo?.workflow
|
||||
|
||||
const rawWorkflow = parsed.data.extra_data?.extra_pnginfo?.workflow
|
||||
if (!rawWorkflow) return undefined
|
||||
|
||||
const validated = await validateComfyWorkflow(rawWorkflow, (error) => {
|
||||
console.warn('[extractWorkflow] Workflow validation failed:', error)
|
||||
})
|
||||
|
||||
return validated ?? undefined
|
||||
}
|
||||
|
||||
@@ -40,5 +40,6 @@ export type RemoteConfig = {
|
||||
private_models_enabled?: boolean
|
||||
onboarding_survey_enabled?: boolean
|
||||
huggingface_model_import_enabled?: boolean
|
||||
linear_toggle_enabled?: boolean
|
||||
async_model_upload_enabled?: boolean
|
||||
}
|
||||
|
||||
@@ -1158,6 +1158,13 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
defaultValue: false,
|
||||
versionAdded: '1.34.1'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.RightSidePanel.IsOpen',
|
||||
name: 'Right side panel open state',
|
||||
type: 'hidden',
|
||||
defaultValue: false,
|
||||
versionAdded: '1.37.0'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Queue.QPOV2',
|
||||
name: 'Queue Panel V2',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { computed, nextTick, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useToastStore } from './toastStore'
|
||||
@@ -65,9 +65,12 @@ export function useFrontendVersionMismatchWarning(
|
||||
versionCompatibilityStore.dismissWarning()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
// Only set up the watcher if immediate is true
|
||||
if (immediate) {
|
||||
// Wait for next tick to ensure reactive updates from settings load have propagated
|
||||
await nextTick()
|
||||
|
||||
whenever(
|
||||
() => versionCompatibilityStore.shouldShowWarning,
|
||||
() => {
|
||||
|
||||
@@ -88,11 +88,16 @@ export const useVersionCompatibilityStore = defineStore(
|
||||
return Date.now() < dismissedUntil
|
||||
})
|
||||
|
||||
const warningsDisabled = computed(() =>
|
||||
settingStore.get('Comfy.VersionCompatibility.DisableWarnings')
|
||||
)
|
||||
|
||||
const shouldShowWarning = computed(() => {
|
||||
const warningsDisabled = settingStore.get(
|
||||
'Comfy.VersionCompatibility.DisableWarnings'
|
||||
return (
|
||||
hasVersionMismatch.value &&
|
||||
!isDismissed.value &&
|
||||
!warningsDisabled.value
|
||||
)
|
||||
return hasVersionMismatch.value && !isDismissed.value && !warningsDisabled
|
||||
})
|
||||
|
||||
const warningMessage = computed(() => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
|
||||
import { app } from '@/scripts/app'
|
||||
import { blankGraph, defaultGraph } from '@/scripts/defaultGraph'
|
||||
@@ -311,6 +312,11 @@ export const useWorkflowService = () => {
|
||||
workflowData: ComfyWorkflowJSON
|
||||
) => {
|
||||
const workflowStore = useWorkspaceStore().workflow
|
||||
if (
|
||||
workflowData.extra?.linearMode !== undefined ||
|
||||
!workflowData.nodes.length
|
||||
)
|
||||
useCanvasStore().linearMode = !!workflowData.extra?.linearMode
|
||||
|
||||
if (value === null || typeof value === 'string') {
|
||||
const path = value as string | null
|
||||
@@ -332,6 +338,11 @@ export const useWorkflowService = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (useCanvasStore().linearMode) {
|
||||
app.rootGraph.extra ??= {}
|
||||
app.rootGraph.extra.linearMode = true
|
||||
}
|
||||
|
||||
const tempWorkflow = workflowStore.createNewTemporary(
|
||||
path ? appendJsonExt(path) : undefined,
|
||||
workflowData
|
||||
|
||||
@@ -13,7 +13,6 @@ import type {
|
||||
ComfyWorkflowJSON,
|
||||
NodeId
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
@@ -334,7 +333,6 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
tabActivationHistory.value.shift()
|
||||
}
|
||||
|
||||
useCanvasStore().linearMode = !!loadedWorkflow.activeState.extra?.linearMode
|
||||
return loadedWorkflow
|
||||
}
|
||||
|
||||
|
||||
@@ -121,28 +121,7 @@ export function useTemplateWorkflows() {
|
||||
if (!template || !template.sourceModule) return false
|
||||
|
||||
// Use the stored source module for loading
|
||||
const actualSourceModule = template.sourceModule
|
||||
json = await fetchTemplateJson(id, actualSourceModule)
|
||||
|
||||
// Use source module for name
|
||||
const workflowName =
|
||||
actualSourceModule === 'default'
|
||||
? t(`templateWorkflows.template.${id}`, id)
|
||||
: id
|
||||
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackTemplate({
|
||||
workflow_name: id,
|
||||
template_source: actualSourceModule
|
||||
})
|
||||
}
|
||||
|
||||
dialogStore.closeDialog()
|
||||
await app.loadGraphData(json, true, true, workflowName, {
|
||||
openSource: 'template'
|
||||
})
|
||||
|
||||
return true
|
||||
sourceModule = template.sourceModule
|
||||
}
|
||||
|
||||
// Regular case for normal categories
|
||||
|
||||
@@ -45,7 +45,7 @@ export interface SlotPositionContext {
|
||||
* @param slot The input slot index
|
||||
* @returns Position of the input slot center in graph coordinates
|
||||
*/
|
||||
export function calculateInputSlotPos(
|
||||
function calculateInputSlotPos(
|
||||
context: SlotPositionContext,
|
||||
slot: number
|
||||
): Point {
|
||||
@@ -93,7 +93,7 @@ export function calculateInputSlotPosFromSlot(
|
||||
* @param slot The output slot index
|
||||
* @returns Position of the output slot center in graph coordinates
|
||||
*/
|
||||
export function calculateOutputSlotPos(
|
||||
function calculateOutputSlotPos(
|
||||
context: SlotPositionContext,
|
||||
slot: number
|
||||
): Point {
|
||||
@@ -138,38 +138,41 @@ export function getSlotPosition(
|
||||
slotIndex: number,
|
||||
isInput: boolean
|
||||
): Point {
|
||||
// Try to get precise position from slot layout (DOM-registered)
|
||||
const slotKey = getSlotKey(String(node.id), slotIndex, isInput)
|
||||
const slotLayout = layoutStore.getSlotLayout(slotKey)
|
||||
if (slotLayout) {
|
||||
return [slotLayout.position.x, slotLayout.position.y]
|
||||
}
|
||||
|
||||
// Fallback: derive position from node layout tree and slot model
|
||||
const nodeLayout = layoutStore.getNodeLayoutRef(String(node.id)).value
|
||||
|
||||
if (nodeLayout) {
|
||||
// Create context from layout tree data
|
||||
const context: SlotPositionContext = {
|
||||
nodeX: nodeLayout.position.x,
|
||||
nodeY: nodeLayout.position.y,
|
||||
nodeWidth: nodeLayout.size.width,
|
||||
nodeHeight: nodeLayout.size.height,
|
||||
collapsed: node.flags.collapsed || false,
|
||||
collapsedWidth: node._collapsed_width,
|
||||
slotStartY: node.constructor.slot_start_y,
|
||||
inputs: node.inputs,
|
||||
outputs: node.outputs,
|
||||
widgets: node.widgets
|
||||
// Only use DOM-registered slot positions when Vue nodes mode is enabled
|
||||
if (LiteGraph.vueNodesMode) {
|
||||
// Try to get precise position from slot layout (DOM-registered)
|
||||
const slotKey = getSlotKey(String(node.id), slotIndex, isInput)
|
||||
const slotLayout = layoutStore.getSlotLayout(slotKey)
|
||||
if (slotLayout) {
|
||||
return [slotLayout.position.x, slotLayout.position.y]
|
||||
}
|
||||
|
||||
// Use helper to calculate position
|
||||
return isInput
|
||||
? calculateInputSlotPos(context, slotIndex)
|
||||
: calculateOutputSlotPos(context, slotIndex)
|
||||
// Fallback: derive position from node layout tree and slot model
|
||||
const nodeLayout = layoutStore.getNodeLayoutRef(String(node.id)).value
|
||||
|
||||
if (nodeLayout) {
|
||||
// Create context from layout tree data
|
||||
const context: SlotPositionContext = {
|
||||
nodeX: nodeLayout.position.x,
|
||||
nodeY: nodeLayout.position.y,
|
||||
nodeWidth: nodeLayout.size.width,
|
||||
nodeHeight: nodeLayout.size.height,
|
||||
collapsed: node.flags.collapsed || false,
|
||||
collapsedWidth: node._collapsed_width,
|
||||
slotStartY: node.constructor.slot_start_y,
|
||||
inputs: node.inputs,
|
||||
outputs: node.outputs,
|
||||
widgets: node.widgets
|
||||
}
|
||||
|
||||
// Use helper to calculate position
|
||||
return isInput
|
||||
? calculateInputSlotPos(context, slotIndex)
|
||||
: calculateOutputSlotPos(context, slotIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: calculate directly from node properties if layout not available
|
||||
// Fallback: calculate directly from node properties (legacy litegraph behavior)
|
||||
const context: SlotPositionContext = {
|
||||
nodeX: node.pos[0],
|
||||
nodeY: node.pos[1],
|
||||
|
||||
52
src/renderer/extensions/linearMode/DropZone.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineProps<{
|
||||
onDragOver?: (e: DragEvent) => boolean
|
||||
onDragDrop?: (e: DragEvent) => Promise<boolean> | boolean
|
||||
dropIndicator?: {
|
||||
label?: string
|
||||
iconClass?: string
|
||||
onClick?: (e: MouseEvent) => void
|
||||
}
|
||||
}>()
|
||||
|
||||
const canAcceptDrop = ref(false)
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
v-if="onDragOver && onDragDrop"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-lg ring-inset ring-primary-500',
|
||||
canAcceptDrop && 'ring-4 bg-primary-500/10'
|
||||
)
|
||||
"
|
||||
@dragover.prevent="canAcceptDrop = onDragOver?.($event)"
|
||||
@dragleave="canAcceptDrop = false"
|
||||
@drop.stop.prevent="
|
||||
(e: DragEvent) => {
|
||||
onDragDrop!(e)
|
||||
canAcceptDrop = false
|
||||
}
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<div
|
||||
v-if="dropIndicator"
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col items-center justify-center gap-2 border-dashed rounded-lg border h-25 border-border-subtle m-3 py-2',
|
||||
dropIndicator?.onClick && 'cursor-pointer'
|
||||
)
|
||||
"
|
||||
@click.prevent="dropIndicator?.onClick?.($event)"
|
||||
>
|
||||
<span v-if="dropIndicator.label" v-text="dropIndicator.label" />
|
||||
<i v-if="dropIndicator.iconClass" :class="dropIndicator.iconClass" />
|
||||
</div>
|
||||
</div>
|
||||
<slot v-else />
|
||||
</template>
|
||||
44
src/renderer/extensions/linearMode/ImagePreview.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, useTemplateRef } from 'vue'
|
||||
|
||||
import ZoomPane from '@/components/ui/ZoomPane.vue'
|
||||
|
||||
const { src } = defineProps<{
|
||||
src: string
|
||||
mobile?: boolean
|
||||
}>()
|
||||
|
||||
const imageRef = useTemplateRef('imageRef')
|
||||
const width = ref('')
|
||||
const height = ref('')
|
||||
</script>
|
||||
<template>
|
||||
<ZoomPane v-if="!mobile" v-slot="slotProps" class="flex-1 w-full">
|
||||
<img
|
||||
ref="imageRef"
|
||||
:src
|
||||
v-bind="slotProps"
|
||||
@load="
|
||||
() => {
|
||||
if (!imageRef) return
|
||||
width = `${imageRef.naturalWidth}`
|
||||
height = `${imageRef.naturalHeight}`
|
||||
}
|
||||
"
|
||||
/>
|
||||
</ZoomPane>
|
||||
<img
|
||||
v-else
|
||||
ref="imageRef"
|
||||
class="w-full"
|
||||
:src
|
||||
@load="
|
||||
() => {
|
||||
if (!imageRef) return
|
||||
width = `${imageRef.naturalWidth}`
|
||||
height = `${imageRef.naturalHeight}`
|
||||
}
|
||||
"
|
||||
/>
|
||||
<span class="self-center md:z-10" v-text="`${width} x ${height}`" />
|
||||
</template>
|
||||
284
src/renderer/extensions/linearMode/LinearControls.vue
Normal file
@@ -0,0 +1,284 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventListener, useTimeout } from '@vueuse/core'
|
||||
import { partition } from 'es-toolkit'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import DropZone from '@/renderer/extensions/linearMode/DropZone.vue'
|
||||
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
|
||||
import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils'
|
||||
import WidgetInputNumberInput from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputNumber.vue'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueSettingsStore } from '@/stores/queueStore'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const { batchCount } = storeToRefs(useQueueSettingsStore())
|
||||
const { isActiveSubscription } = useSubscription()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const props = defineProps<{
|
||||
toastTo?: string | HTMLElement
|
||||
notesTo?: string | HTMLElement
|
||||
mobile?: boolean
|
||||
}>()
|
||||
|
||||
const jobFinishedQueue = ref(true)
|
||||
const { ready: jobToastTimeout, start: resetJobToastTimeout } = useTimeout(
|
||||
5000,
|
||||
{ controls: true, immediate: false }
|
||||
)
|
||||
|
||||
const graphNodes = shallowRef<LGraphNode[]>(app.rootGraph.nodes)
|
||||
useEventListener(
|
||||
app.rootGraph.events,
|
||||
'configured',
|
||||
() => (graphNodes.value = app.rootGraph.nodes)
|
||||
)
|
||||
|
||||
function nodeToNodeData(node: LGraphNode) {
|
||||
const dropIndicator =
|
||||
node.type !== 'LoadImage'
|
||||
? undefined
|
||||
: {
|
||||
iconClass: 'icon-[lucide--image]',
|
||||
label: t('linearMode.dragAndDropImage')
|
||||
}
|
||||
const nodeData = extractVueNodeData(node)
|
||||
for (const widget of nodeData.widgets ?? []) widget.slotMetadata = undefined
|
||||
|
||||
return {
|
||||
...nodeData,
|
||||
|
||||
dropIndicator,
|
||||
onDragDrop: node.onDragDrop,
|
||||
onDragOver: node.onDragOver
|
||||
}
|
||||
}
|
||||
const partitionedNodes = computed(() => {
|
||||
return partition(
|
||||
graphNodes.value
|
||||
.filter((node) => node.mode === 0 && node.widgets?.length)
|
||||
.map(nodeToNodeData)
|
||||
.reverse(),
|
||||
(node) => ['MarkdownNote', 'Note'].includes(node.type)
|
||||
)
|
||||
})
|
||||
|
||||
const batchCountWidget: SimplifiedWidget<number> = {
|
||||
options: { precision: 0, min: 1, max: isCloud ? 4 : 99 },
|
||||
value: 1,
|
||||
name: t('linearMode.runCount'),
|
||||
type: 'number'
|
||||
} as const
|
||||
|
||||
//TODO: refactor out of this file.
|
||||
//code length is small, but changes should propagate
|
||||
async function runButtonClick(e: Event) {
|
||||
if (!jobFinishedQueue.value) return
|
||||
try {
|
||||
jobFinishedQueue.value = false
|
||||
resetJobToastTimeout()
|
||||
const isShiftPressed = 'shiftKey' in e && e.shiftKey
|
||||
const commandId = isShiftPressed
|
||||
? 'Comfy.QueuePromptFront'
|
||||
: 'Comfy.QueuePrompt'
|
||||
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'queue_run_linear'
|
||||
})
|
||||
if (batchCount.value > 1) {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'queue_run_multiple_batches_submitted'
|
||||
})
|
||||
}
|
||||
await commandStore.execute(commandId, {
|
||||
metadata: {
|
||||
subscribe_to_run: false,
|
||||
trigger_source: 'button'
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
//TODO: Error state indicator for failed queue?
|
||||
jobFinishedQueue.value = true
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ runButtonClick })
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex flex-col min-w-80 md:h-full">
|
||||
<section
|
||||
v-if="mobile"
|
||||
data-testid="linear-run-button"
|
||||
class="p-4 pb-6 border-t border-node-component-border"
|
||||
>
|
||||
<WidgetInputNumberInput
|
||||
v-model="batchCount"
|
||||
:widget="batchCountWidget"
|
||||
class="*:[.min-w-0]:w-24 grid-cols-[auto_96px]!"
|
||||
/>
|
||||
<SubscribeToRunButton v-if="!isActiveSubscription" class="w-full mt-4" />
|
||||
<div v-else class="flex mt-4 gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
class="grow-1"
|
||||
size="lg"
|
||||
@click="runButtonClick"
|
||||
>
|
||||
<i class="icon-[lucide--play]" />
|
||||
{{ t('menu.run') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!executionStore.isIdle"
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
class="w-10 p-2"
|
||||
@click="commandStore.execute('Comfy.Interrupt')"
|
||||
>
|
||||
<i class="icon-[lucide--x]" />
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
data-testid="linear-workflow-info"
|
||||
class="h-12 border-x border-border-subtle py-2 px-4 gap-2 bg-comfy-menu-bg flex items-center md:contain-size"
|
||||
>
|
||||
<span
|
||||
class="font-bold truncate"
|
||||
v-text="workflowStore.activeWorkflow?.filename"
|
||||
/>
|
||||
<div class="flex-1" />
|
||||
<Popover
|
||||
v-if="partitionedNodes[0].length"
|
||||
align="start"
|
||||
class="overflow-y-auto overflow-x-clip max-h-(--reka-popover-content-available-height)"
|
||||
:reference="notesTo"
|
||||
side="left"
|
||||
:to="notesTo"
|
||||
>
|
||||
<template #button>
|
||||
<Button variant="muted-textonly">
|
||||
<i class="icon-[lucide--info]" />
|
||||
</Button>
|
||||
</template>
|
||||
<div>
|
||||
<template
|
||||
v-for="(nodeData, index) in partitionedNodes[0]"
|
||||
:key="nodeData.id"
|
||||
>
|
||||
<div
|
||||
v-if="index !== 0"
|
||||
class="w-full border-t border-border-subtle"
|
||||
/>
|
||||
<NodeWidgets
|
||||
:node-data
|
||||
:style="{ background: applyLightThemeColor(nodeData.bgcolor) }"
|
||||
class="py-3 gap-y-3 **:[.col-span-2]:grid-cols-1 not-has-[textarea]:flex-0 rounded-lg"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</Popover>
|
||||
<Button v-if="false"> {{ t('menuLabels.publish') }} </Button>
|
||||
</section>
|
||||
<div
|
||||
class="border gap-2 md:h-full border-[var(--interface-stroke)] bg-comfy-menu-bg flex flex-col px-2"
|
||||
>
|
||||
<section
|
||||
data-testid="linear-widgets"
|
||||
class="grow-1 md:overflow-y-auto md:contain-size"
|
||||
>
|
||||
<template
|
||||
v-for="(nodeData, index) of partitionedNodes[1]"
|
||||
:key="nodeData.id"
|
||||
>
|
||||
<div
|
||||
v-if="index !== 0"
|
||||
class="w-full border-t-1 border-node-component-border"
|
||||
/>
|
||||
<DropZone
|
||||
:on-drag-over="nodeData.onDragOver"
|
||||
:on-drag-drop="nodeData.onDragDrop"
|
||||
:drop-indicator="mobile ? undefined : nodeData.dropIndicator"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
<NodeWidgets
|
||||
:node-data
|
||||
class="py-3 gap-y-4 **:[.col-span-2]:grid-cols-1 text-sm **:[.p-floatlabel]:h-35 rounded-lg"
|
||||
:style="{ background: applyLightThemeColor(nodeData.bgcolor) }"
|
||||
/>
|
||||
</DropZone>
|
||||
</template>
|
||||
</section>
|
||||
<section
|
||||
v-if="!mobile"
|
||||
data-testid="linear-run-button"
|
||||
class="p-4 pb-6 border-t border-node-component-border"
|
||||
>
|
||||
<WidgetInputNumberInput
|
||||
v-model="batchCount"
|
||||
:widget="batchCountWidget"
|
||||
class="*:[.min-w-0]:w-24 grid-cols-[auto_96px]!"
|
||||
/>
|
||||
<SubscribeToRunButton
|
||||
v-if="!isActiveSubscription"
|
||||
class="w-full mt-4"
|
||||
/>
|
||||
<div v-else class="flex mt-4 gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
class="grow-1"
|
||||
size="lg"
|
||||
@click="runButtonClick"
|
||||
>
|
||||
<i class="icon-[lucide--play]" />
|
||||
{{ t('menu.run') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!executionStore.isIdle"
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
class="w-10 p-2"
|
||||
@click="commandStore.execute('Comfy.Interrupt')"
|
||||
>
|
||||
<i class="icon-[lucide--x]" />
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<Teleport
|
||||
v-if="(!jobToastTimeout || !jobFinishedQueue) && toastTo"
|
||||
defer
|
||||
:to="toastTo"
|
||||
>
|
||||
<div
|
||||
class="bg-base-foreground text-base-background rounded-sm flex h-8 p-1 pr-2 gap-2 items-center"
|
||||
>
|
||||
<i
|
||||
v-if="jobFinishedQueue"
|
||||
class="icon-[lucide--check] size-5 bg-success-background"
|
||||
/>
|
||||
<i v-else class="icon-[lucide--loader-circle] size-4 animate-spin" />
|
||||
<span v-text="t('queue.jobAddedToQueue')" />
|
||||
</div>
|
||||
</Teleport>
|
||||
<Teleport v-if="false" defer :to="notesTo">
|
||||
<div
|
||||
class="bg-base-background text-muted-foreground flex flex-col w-90 gap-2 rounded-2xl border-1 border-border-subtle py-3"
|
||||
></div>
|
||||
</Teleport>
|
||||
</template>
|
||||
184
src/renderer/extensions/linearMode/LinearPreview.vue
Normal file
@@ -0,0 +1,184 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { d, t } from '@/i18n'
|
||||
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { extractWorkflowFromAsset } from '@/platform/workflow/utils/workflowExtractionUtil'
|
||||
import ImagePreview from '@/renderer/extensions/linearMode/ImagePreview.vue'
|
||||
import VideoPreview from '@/renderer/extensions/linearMode/VideoPreview.vue'
|
||||
import {
|
||||
getMediaType,
|
||||
mediaTypes
|
||||
} from '@/renderer/extensions/linearMode/mediaTypes'
|
||||
import type { StatItem } from '@/renderer/extensions/linearMode/mediaTypes'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
import { formatDuration } from '@/utils/dateTimeUtil'
|
||||
import { collectAllNodes } from '@/utils/graphTraversalUtil'
|
||||
import { executeWidgetsCallback } from '@/utils/litegraphUtil'
|
||||
|
||||
const mediaActions = useMediaAssetActions()
|
||||
|
||||
const { runButtonClick, selectedItem, selectedOutput } = defineProps<{
|
||||
latentPreview?: string
|
||||
runButtonClick?: (e: Event) => void
|
||||
selectedItem?: AssetItem
|
||||
selectedOutput?: ResultItemImpl
|
||||
mobile?: boolean
|
||||
}>()
|
||||
|
||||
const dateOptions = {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
} as const
|
||||
const timeOptions = {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
second: 'numeric'
|
||||
} as const
|
||||
|
||||
function formatTime(time: string) {
|
||||
if (!time) return ''
|
||||
const date = new Date(time)
|
||||
return `${d(date, dateOptions)} | ${d(date, timeOptions)}`
|
||||
}
|
||||
|
||||
const itemStats = computed<StatItem[]>(() => {
|
||||
if (!selectedItem) return []
|
||||
const user_metadata = getOutputAssetMetadata(selectedItem.user_metadata)
|
||||
if (!user_metadata) return []
|
||||
|
||||
const { allOutputs } = user_metadata
|
||||
return [
|
||||
{ content: formatTime(selectedItem.created_at) },
|
||||
{ content: formatDuration(user_metadata.executionTimeInSeconds) },
|
||||
allOutputs && { content: t('g.asset', allOutputs.length) },
|
||||
(selectedOutput && mediaTypes[getMediaType(selectedOutput)]) ?? {}
|
||||
].filter((i) => !!i)
|
||||
})
|
||||
|
||||
function downloadAsset(item?: AssetItem) {
|
||||
const user_metadata = getOutputAssetMetadata(item?.user_metadata)
|
||||
for (const output of user_metadata?.allOutputs ?? [])
|
||||
downloadFile(output.url, output.filename)
|
||||
}
|
||||
|
||||
async function loadWorkflow(item: AssetItem | undefined) {
|
||||
if (!item) return
|
||||
const { workflow } = await extractWorkflowFromAsset(item)
|
||||
if (!workflow) return
|
||||
|
||||
if (workflow.id !== app.rootGraph.id) return app.loadGraphData(workflow)
|
||||
//update graph to new version, set old to top of undo queue
|
||||
const changeTracker = useWorkflowStore().activeWorkflow?.changeTracker
|
||||
if (!changeTracker) return app.loadGraphData(workflow)
|
||||
changeTracker.redoQueue = []
|
||||
changeTracker.updateState([workflow], changeTracker.undoQueue)
|
||||
}
|
||||
|
||||
async function rerun(e: Event) {
|
||||
if (!runButtonClick) return
|
||||
await loadWorkflow(selectedItem)
|
||||
//FIXME don't use timeouts here
|
||||
//Currently seeds fail to properly update even with timeouts?
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
executeWidgetsCallback(collectAllNodes(app.rootGraph), 'afterQueued')
|
||||
|
||||
runButtonClick(e)
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<section
|
||||
v-if="selectedItem"
|
||||
data-testid="linear-output-info"
|
||||
class="flex flex-wrap gap-2 p-1 w-full md:z-10 tabular-nums justify-between text-sm"
|
||||
>
|
||||
<div class="flex gap-3 text-nowrap">
|
||||
<div
|
||||
v-for="({ content, iconClass }, index) in itemStats"
|
||||
:key="index"
|
||||
class="flex items-center justify-items-center gap-1 tabular-nums"
|
||||
>
|
||||
<i v-if="iconClass" :class="iconClass" />
|
||||
{{ content }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 justify-self-end">
|
||||
<Button size="md" @click="rerun">
|
||||
{{ t('linearMode.rerun') }}
|
||||
<i class="icon-[lucide--refresh-cw]" />
|
||||
</Button>
|
||||
<Button size="md" @click="() => loadWorkflow(selectedItem)">
|
||||
{{ t('linearMode.reuseParameters') }}
|
||||
<i class="icon-[lucide--list-restart]" />
|
||||
</Button>
|
||||
<div class="border-r border-border-subtle mx-1" />
|
||||
<Button
|
||||
size="icon"
|
||||
@click="
|
||||
() => {
|
||||
if (selectedOutput?.url) downloadFile(selectedOutput.url)
|
||||
}
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--download]" />
|
||||
</Button>
|
||||
<Popover
|
||||
:entries="[
|
||||
[
|
||||
{
|
||||
icon: 'icon-[lucide--download]',
|
||||
label: t('linearMode.downloadAll'),
|
||||
action: () => downloadAsset(selectedItem!)
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
icon: 'icon-[lucide--trash-2]',
|
||||
label: t('queue.jobMenu.deleteAsset'),
|
||||
action: () => mediaActions.confirmDelete(selectedItem!)
|
||||
}
|
||||
]
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<ImagePreview
|
||||
v-if="latentPreview ?? getMediaType(selectedOutput) === 'images'"
|
||||
:mobile
|
||||
:src="latentPreview ?? selectedOutput!.url"
|
||||
/>
|
||||
<VideoPreview
|
||||
v-else-if="getMediaType(selectedOutput) === 'video'"
|
||||
:src="selectedOutput!.url"
|
||||
class="object-contain flex-1 contain-size"
|
||||
/>
|
||||
<audio
|
||||
v-else-if="getMediaType(selectedOutput) === 'audio'"
|
||||
class="w-full m-auto"
|
||||
controls
|
||||
:src="selectedOutput!.url"
|
||||
/>
|
||||
<article
|
||||
v-else-if="getMediaType(selectedOutput) === 'text'"
|
||||
class="w-full max-w-128 m-auto my-12 overflow-y-auto"
|
||||
v-text="selectedOutput!.url"
|
||||
/>
|
||||
<Load3dViewerContent
|
||||
v-else-if="getMediaType(selectedOutput) === '3d'"
|
||||
:model-url="selectedOutput!.url"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
class="pointer-events-none object-contain flex-1 max-h-full brightness-50 opacity-10"
|
||||
src="/assets/images/comfy-logo-mono.svg"
|
||||
/>
|
||||
</template>
|
||||
309
src/renderer/extensions/linearMode/OutputHistory.vue
Normal file
@@ -0,0 +1,309 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventListener, useInfiniteScroll, useScroll } from '@vueuse/core'
|
||||
import { computed, ref, toRaw, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import ModeToggle from '@/components/sidebar/ModeToggle.vue'
|
||||
import SidebarIcon from '@/components/sidebar/SidebarIcon.vue'
|
||||
import SidebarTemplatesButton from '@/components/sidebar/SidebarTemplatesButton.vue'
|
||||
import WorkflowsSidebarTab from '@/components/sidebar/tabs/WorkflowsSidebarTab.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
|
||||
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import {
|
||||
getMediaType,
|
||||
mediaTypes
|
||||
} from '@/renderer/extensions/linearMode/mediaTypes'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const displayWorkflows = ref(false)
|
||||
const outputs = useMediaAssets('output')
|
||||
const queueStore = useQueueStore()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const workflowTab = useWorkspaceStore()
|
||||
.getSidebarTabs()
|
||||
.find((w) => w.id === 'workflows')
|
||||
|
||||
void outputs.fetchMediaList()
|
||||
|
||||
defineProps<{
|
||||
scrollResetButtonTo?: string | HTMLElement
|
||||
mobile?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
updateSelection: [
|
||||
selection: [AssetItem | undefined, ResultItemImpl | undefined, boolean]
|
||||
]
|
||||
}>()
|
||||
|
||||
defineExpose({ onWheel })
|
||||
|
||||
const selectedIndex = ref<[number, number]>([-1, 0])
|
||||
|
||||
watch(selectedIndex, () => {
|
||||
const [index] = selectedIndex.value
|
||||
emit('updateSelection', [
|
||||
outputs.media.value[index],
|
||||
selectedOutput.value,
|
||||
selectedIndex.value[0] <= 0
|
||||
])
|
||||
})
|
||||
|
||||
const outputsRef = useTemplateRef('outputsRef')
|
||||
const { reset: resetInfiniteScroll } = useInfiniteScroll(
|
||||
outputsRef,
|
||||
outputs.loadMore,
|
||||
{ canLoadMore: () => outputs.hasMore.value }
|
||||
)
|
||||
function resetOutputsScroll() {
|
||||
//TODO need to also prune outputs entries?
|
||||
resetInfiniteScroll()
|
||||
outputsRef.value?.scrollTo(0, 0)
|
||||
}
|
||||
const { y: outputScrollState } = useScroll(outputsRef)
|
||||
|
||||
watch(selectedIndex, () => {
|
||||
const [index, key] = selectedIndex.value
|
||||
if (!outputsRef.value) return
|
||||
|
||||
const outputElement = outputsRef.value?.children?.[index]?.children?.[key]
|
||||
if (!outputElement) return
|
||||
|
||||
//container: 'nearest' is nice, but bleeding edge and chrome only
|
||||
outputElement.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
|
||||
function allOutputs(item?: AssetItem) {
|
||||
const user_metadata = getOutputAssetMetadata(item?.user_metadata)
|
||||
if (!user_metadata?.allOutputs) return []
|
||||
|
||||
return user_metadata.allOutputs
|
||||
}
|
||||
|
||||
const selectedOutput = computed(() => {
|
||||
const [index, key] = selectedIndex.value
|
||||
if (index < 0) return undefined
|
||||
|
||||
const output = allOutputs(outputs.media.value[index])[key]
|
||||
if (output) return output
|
||||
|
||||
return allOutputs(outputs.media.value[0])[0]
|
||||
})
|
||||
|
||||
watch(
|
||||
() => outputs.media.value,
|
||||
(newAssets, oldAssets) => {
|
||||
if (newAssets.length === oldAssets.length || oldAssets.length === 0) return
|
||||
if (selectedIndex.value[0] <= 0) {
|
||||
//force update
|
||||
selectedIndex.value = [0, 0]
|
||||
return
|
||||
}
|
||||
|
||||
const oldId = toRaw(oldAssets[selectedIndex.value[0]]?.id)
|
||||
const newIndex = toRaw(newAssets).findIndex((asset) => asset?.id === oldId)
|
||||
|
||||
if (newIndex === -1) selectedIndex.value = [0, 0]
|
||||
else selectedIndex.value = [newIndex, selectedIndex.value[1]]
|
||||
}
|
||||
)
|
||||
|
||||
function gotoNextOutput() {
|
||||
const [index, key] = selectedIndex.value
|
||||
if (index < 0 || key < 0) {
|
||||
selectedIndex.value = [0, 0]
|
||||
return
|
||||
}
|
||||
const currentItem = outputs.media.value[index]
|
||||
if (allOutputs(currentItem)[key + 1]) {
|
||||
selectedIndex.value = [index, key + 1]
|
||||
return
|
||||
}
|
||||
if (outputs.media.value[index + 1]) {
|
||||
selectedIndex.value = [index + 1, 0]
|
||||
}
|
||||
//do nothing, no next output
|
||||
}
|
||||
|
||||
function gotoPreviousOutput() {
|
||||
const [index, key] = selectedIndex.value
|
||||
if (key > 0) {
|
||||
selectedIndex.value = [index, key - 1]
|
||||
return
|
||||
}
|
||||
|
||||
if (index > 0) {
|
||||
const currentItem = outputs.media.value[index - 1]
|
||||
selectedIndex.value = [index - 1, allOutputs(currentItem).length - 1]
|
||||
return
|
||||
}
|
||||
|
||||
selectedIndex.value = [0, 0]
|
||||
}
|
||||
|
||||
let pointer = new CanvasPointer(document.body)
|
||||
let scrollOffset = 0
|
||||
function onWheel(e: WheelEvent) {
|
||||
if (!e.ctrlKey && !e.metaKey) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (!pointer.isTrackpadGesture(e)) {
|
||||
if (e.deltaY > 0) gotoNextOutput()
|
||||
else gotoPreviousOutput()
|
||||
return
|
||||
}
|
||||
scrollOffset += e.deltaY
|
||||
while (scrollOffset >= 60) {
|
||||
scrollOffset -= 60
|
||||
gotoNextOutput()
|
||||
}
|
||||
while (scrollOffset <= -60) {
|
||||
scrollOffset += 60
|
||||
gotoPreviousOutput()
|
||||
}
|
||||
}
|
||||
|
||||
useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
|
||||
if (
|
||||
(e.key !== 'ArrowDown' && e.key !== 'ArrowUp') ||
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
e.target instanceof HTMLInputElement
|
||||
)
|
||||
return
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (e.key === 'ArrowDown') gotoNextOutput()
|
||||
else gotoPreviousOutput()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'min-w-38 flex bg-comfy-menu-bg md:h-full border-border-subtle',
|
||||
settingStore.get('Comfy.Sidebar.Location') === 'right'
|
||||
? 'flex-row-reverse border-l'
|
||||
: 'md:border-r'
|
||||
)
|
||||
"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<div
|
||||
v-if="!mobile"
|
||||
class="h-full flex flex-col w-14 shrink-0 overflow-hidden items-center p-2"
|
||||
>
|
||||
<template v-if="workflowTab">
|
||||
<SidebarIcon
|
||||
:icon="workflowTab.icon"
|
||||
:icon-badge="workflowTab.iconBadge"
|
||||
:tooltip="workflowTab.tooltip"
|
||||
:label="workflowTab.label || workflowTab.title"
|
||||
:class="workflowTab.id + '-tab-button'"
|
||||
:selected="displayWorkflows"
|
||||
:is-small="settingStore.get('Comfy.Sidebar.Size') === 'small'"
|
||||
@click="displayWorkflows = !displayWorkflows"
|
||||
/>
|
||||
</template>
|
||||
<SidebarTemplatesButton />
|
||||
<div class="flex-1" />
|
||||
<ModeToggle />
|
||||
</div>
|
||||
<div class="border-border-subtle md:border-r" />
|
||||
<WorkflowsSidebarTab v-if="displayWorkflows" class="min-w-50" />
|
||||
<article
|
||||
v-else
|
||||
ref="outputsRef"
|
||||
data-testid="linear-outputs"
|
||||
class="h-24 md:h-full min-w-24 grow-1 p-3 overflow-x-auto overflow-y-clip md:overflow-y-auto md:overflow-x-clip md:border-r-1 border-node-component-border flex md:flex-col items-center contain-size"
|
||||
>
|
||||
<section
|
||||
v-if="
|
||||
queueStore.runningTasks.length > 0 ||
|
||||
queueStore.pendingTasks.length > 0
|
||||
"
|
||||
data-testid="linear-job"
|
||||
class="py-3 not-md:h-24 md:w-full aspect-square px-1 relative"
|
||||
>
|
||||
<i
|
||||
v-if="queueStore.runningTasks.length > 0"
|
||||
class="icon-[lucide--loader-circle] size-full animate-spin"
|
||||
/>
|
||||
<i v-else class="icon-[lucide--ellipsis] size-full animate-pulse" />
|
||||
<div
|
||||
v-if="
|
||||
queueStore.runningTasks.length + queueStore.pendingTasks.length > 1
|
||||
"
|
||||
class="absolute top-0 right-0 p-1 min-w-5 h-5 flex justify-center items-center rounded-full bg-primary-background text-text-primary"
|
||||
v-text="
|
||||
queueStore.runningTasks.length + queueStore.pendingTasks.length
|
||||
"
|
||||
/>
|
||||
</section>
|
||||
<section
|
||||
v-for="(item, index) in outputs.media.value"
|
||||
:key="index"
|
||||
data-testid="linear-job"
|
||||
class="py-3 not-md:h-24 border-border-subtle flex md:flex-col md:w-full px-1 first:border-t-0 first:border-l-0 md:border-t-2 not-md:border-l-2"
|
||||
>
|
||||
<template v-for="(output, key) in allOutputs(item)" :key>
|
||||
<img
|
||||
v-if="getMediaType(output) === 'images'"
|
||||
:class="
|
||||
cn(
|
||||
'p-1 rounded-lg aspect-square object-cover',
|
||||
index === selectedIndex[0] &&
|
||||
key === selectedIndex[1] &&
|
||||
'border-2'
|
||||
)
|
||||
"
|
||||
:src="output.url"
|
||||
@click="selectedIndex = [index, key]"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
:class="
|
||||
cn(
|
||||
'p-1 rounded-lg aspect-square w-full',
|
||||
index === selectedIndex[0] &&
|
||||
key === selectedIndex[1] &&
|
||||
'border-2'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(mediaTypes[getMediaType(output)]?.iconClass, 'size-full')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</article>
|
||||
</div>
|
||||
<Teleport
|
||||
v-if="outputScrollState && scrollResetButtonTo"
|
||||
:to="scrollResetButtonTo"
|
||||
>
|
||||
<Button
|
||||
:class="
|
||||
cn(
|
||||
'p-3 size-10 bg-base-foreground',
|
||||
settingStore.get('Comfy.Sidebar.Location') === 'left'
|
||||
? 'left-4'
|
||||
: 'right-4'
|
||||
)
|
||||
"
|
||||
@click="resetOutputsScroll"
|
||||
>
|
||||
<i class="icon-[lucide--arrow-up] size-4 bg-base-background" />
|
||||
</Button>
|
||||
</Teleport>
|
||||
</template>
|
||||
27
src/renderer/extensions/linearMode/VideoPreview.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, useTemplateRef } from 'vue'
|
||||
|
||||
const { src } = defineProps<{
|
||||
src: string
|
||||
}>()
|
||||
|
||||
const videoRef = useTemplateRef('videoRef')
|
||||
const width = ref('')
|
||||
const height = ref('')
|
||||
</script>
|
||||
<template>
|
||||
<video
|
||||
ref="videoRef"
|
||||
:src
|
||||
controls
|
||||
v-bind="$attrs"
|
||||
@loadedmetadata="
|
||||
() => {
|
||||
if (!videoRef) return
|
||||
width = `${videoRef.videoWidth}`
|
||||
height = `${videoRef.videoHeight}`
|
||||
}
|
||||
"
|
||||
/>
|
||||
<span class="self-center z-10" v-text="`${width} x ${height}`" />
|
||||
</template>
|
||||
33
src/renderer/extensions/linearMode/mediaTypes.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
export type StatItem = { content?: string; iconClass?: string }
|
||||
export const mediaTypes: Record<string, StatItem> = {
|
||||
'3d': {
|
||||
content: t('sideToolbar.mediaAssets.filter3D'),
|
||||
iconClass: 'icon-[lucide--box]'
|
||||
},
|
||||
audio: {
|
||||
content: t('sideToolbar.mediaAssets.filterAudio'),
|
||||
iconClass: 'icon-[lucide--audio-lines]'
|
||||
},
|
||||
images: {
|
||||
content: t('sideToolbar.mediaAssets.filterImage'),
|
||||
iconClass: 'icon-[lucide--image]'
|
||||
},
|
||||
text: {
|
||||
content: t('sideToolbar.mediaAssets.filterText'),
|
||||
iconClass: 'icon-[lucide--text]'
|
||||
},
|
||||
video: {
|
||||
content: t('sideToolbar.mediaAssets.filterVideo'),
|
||||
iconClass: 'icon-[lucide--video]'
|
||||
}
|
||||
}
|
||||
|
||||
export function getMediaType(output?: ResultItemImpl) {
|
||||
if (!output) return ''
|
||||
if (output.isVideo) return 'video'
|
||||
return output.mediaType
|
||||
}
|
||||
@@ -40,7 +40,7 @@
|
||||
transform: `translate(${position.x ?? 0}px, ${(position.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
|
||||
zIndex: zIndex,
|
||||
opacity: nodeOpacity,
|
||||
'--component-node-background': nodeBodyBackgroundColor
|
||||
'--component-node-background': applyLightThemeColor(nodeData.bgcolor)
|
||||
}
|
||||
]"
|
||||
v-bind="remainingPointerHandlers"
|
||||
@@ -119,6 +119,23 @@
|
||||
<div v-if="shouldShowPreviewImg" class="min-h-0 flex-1 px-4">
|
||||
<LivePreview :image-url="latestPreviewUrl || null" />
|
||||
</div>
|
||||
|
||||
<!-- Show advanced inputs button for subgraph nodes -->
|
||||
<div v-if="showAdvancedInputsButton" class="flex justify-center px-3">
|
||||
<button
|
||||
:class="
|
||||
cn(
|
||||
WidgetInputBaseClass,
|
||||
'w-full h-7 flex justify-center items-center gap-2 text-sm px-3 outline-0 ring-0 truncate',
|
||||
'transition-all cursor-pointer hover:bg-accent-background duration-150 active:scale-95'
|
||||
)
|
||||
"
|
||||
@click.stop="handleShowAdvancedInputs"
|
||||
>
|
||||
<i class="icon-[lucide--settings-2] size-4" />
|
||||
<span>{{ t('rightSidePanel.showAdvancedInputsButton') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -148,6 +165,7 @@ import {
|
||||
LiteGraph,
|
||||
RenderShape
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
@@ -168,7 +186,7 @@ import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeS
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { isTransparent } from '@/utils/colorUtil'
|
||||
import {
|
||||
getLocatorIdFromNodeData,
|
||||
@@ -177,6 +195,7 @@ import {
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { useNodeResize } from '../interactions/resize/useNodeResize'
|
||||
import { WidgetInputBaseClass } from '../widgets/components/layout'
|
||||
import LivePreview from './LivePreview.vue'
|
||||
import NodeContent from './NodeContent.vue'
|
||||
import NodeHeader from './NodeHeader.vue'
|
||||
@@ -228,19 +247,6 @@ const bypassed = computed(
|
||||
)
|
||||
const muted = computed((): boolean => nodeData.mode === LGraphEventMode.NEVER)
|
||||
|
||||
const nodeBodyBackgroundColor = computed(() => {
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
|
||||
if (!nodeData.bgcolor) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return applyLightThemeColor(
|
||||
nodeData.bgcolor,
|
||||
Boolean(colorPaletteStore.completedActivePalette.light_theme)
|
||||
)
|
||||
})
|
||||
|
||||
const nodeOpacity = computed(() => {
|
||||
const globalOpacity = useSettingStore().get('Comfy.Node.Opacity') ?? 1
|
||||
|
||||
@@ -474,6 +480,22 @@ const lgraphNode = computed(() => {
|
||||
return getNodeByLocatorId(app.rootGraph, locatorId)
|
||||
})
|
||||
|
||||
const showAdvancedInputsButton = computed(() => {
|
||||
const node = lgraphNode.value
|
||||
if (!node || !(node instanceof SubgraphNode)) return false
|
||||
|
||||
// Check if there are hidden inputs (widgets not promoted)
|
||||
const interiorNodes = node.subgraph.nodes
|
||||
const allInteriorWidgets = interiorNodes.flatMap((n) => n.widgets ?? [])
|
||||
|
||||
return allInteriorWidgets.some((w) => !w.computedDisabled && !w.promoted)
|
||||
})
|
||||
|
||||
function handleShowAdvancedInputs() {
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
rightSidePanelStore.focusSection('advanced-inputs')
|
||||
}
|
||||
|
||||
const nodeMedia = computed(() => {
|
||||
const newOutputs = nodeOutputs.nodeOutputs[nodeOutputLocatorId.value]
|
||||
const node = lgraphNode.value
|
||||
|
||||
@@ -11,7 +11,10 @@
|
||||
headerShapeClass
|
||||
)
|
||||
"
|
||||
:style="headerStyle"
|
||||
:style="{
|
||||
backgroundColor: applyLightThemeColor(nodeData?.color),
|
||||
opacity: useSettingStore().get('Comfy.Node.Opacity') ?? 1
|
||||
}"
|
||||
:data-testid="`node-header-${nodeData?.id || ''}`"
|
||||
@dblclick="handleDoubleClick"
|
||||
>
|
||||
@@ -104,7 +107,6 @@ import NodeBadge from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
|
||||
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
||||
import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import {
|
||||
getLocatorIdFromNodeData,
|
||||
@@ -156,23 +158,6 @@ const enterSubgraphTooltipConfig = computed(() => {
|
||||
return createTooltipConfig(st('enterSubgraph', 'Enter Subgraph'))
|
||||
})
|
||||
|
||||
const headerStyle = computed(() => {
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
|
||||
const opacity = useSettingStore().get('Comfy.Node.Opacity') ?? 1
|
||||
|
||||
if (!nodeData?.color) {
|
||||
return { backgroundColor: '', opacity }
|
||||
}
|
||||
|
||||
const headerColor = applyLightThemeColor(
|
||||
nodeData.color,
|
||||
Boolean(colorPaletteStore.completedActivePalette.light_theme)
|
||||
)
|
||||
|
||||
return { backgroundColor: headerColor, opacity }
|
||||
})
|
||||
|
||||
const resolveTitle = (info: VueNodeData | undefined) => {
|
||||
const title = (info?.title ?? '').trim()
|
||||
if (title.length > 0) return title
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
|
||||
/**
|
||||
* Applies light theme color adjustments to a color
|
||||
*/
|
||||
export function applyLightThemeColor(
|
||||
color: string,
|
||||
isLightTheme: boolean
|
||||
): string {
|
||||
if (!color || !isLightTheme) {
|
||||
return color
|
||||
}
|
||||
export function applyLightThemeColor(color?: string): string {
|
||||
if (!color) return ''
|
||||
|
||||
if (!useColorPaletteStore().completedActivePalette.light_theme) return color
|
||||
|
||||
return adjustColor(color, { lightness: 0.5 })
|
||||
}
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { refDebounced } from '@vueuse/core'
|
||||
import { ref, toRef, toValue, watch } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import type { HTMLAttributes, MaybeRefOrGetter } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { searcher = async () => {}, updateKey } = defineProps<{
|
||||
const {
|
||||
searcher = async () => {},
|
||||
updateKey,
|
||||
autofocus = false,
|
||||
class: customClass
|
||||
} = 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, 100, {
|
||||
maxWait: 100
|
||||
const debouncedSearchQuery = refDebounced(searchQuery, 250, {
|
||||
maxWait: 1000
|
||||
})
|
||||
watch(searchQuery, (value) => {
|
||||
isQuerying.value = value !== debouncedSearchQuery.value
|
||||
@@ -44,34 +51,54 @@ watch(
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function handleFocus(event: FocusEvent) {
|
||||
const target = event.target as HTMLInputElement
|
||||
target.select()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label
|
||||
:class="
|
||||
cn(
|
||||
'mt-1 py-1.5 bg-secondary-background rounded-lg transition-all duration-150',
|
||||
'flex-1 flex gap-2 px-2 items-center',
|
||||
'group',
|
||||
'bg-component-node-widget-background rounded-lg transition-all duration-150',
|
||||
'flex-1 flex items-center',
|
||||
'text-base-foreground border-0',
|
||||
'focus-within:ring focus-within:ring-component-node-widget-background-highlighted/80'
|
||||
'focus-within:ring focus-within:ring-component-node-widget-background-highlighted/80',
|
||||
customClass
|
||||
)
|
||||
"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'size-4 text-muted-foreground',
|
||||
'size-4 ml-2 shrink-0 transition-colors duration-150',
|
||||
isQuerying
|
||||
? 'icon-[lucide--loader-circle] animate-spin'
|
||||
: 'icon-[lucide--search]'
|
||||
: 'icon-[lucide--search]',
|
||||
searchQuery?.trim() !== ''
|
||||
? 'text-base-foreground'
|
||||
: 'text-muted-foreground group-hover:text-base-foreground group-focus-within:text-base-foreground'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="bg-transparent border-0 outline-0 ring-0 h-5"
|
||||
class="bg-transparent border-0 outline-0 ring-0 h-5 w-full my-1.5 mx-2"
|
||||
: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>
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { refDebounced } from '@vueuse/core'
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, ref, useTemplateRef, watch } from 'vue'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
@@ -68,10 +67,6 @@ const layoutMode = defineModel<LayoutMode>('layoutMode', {
|
||||
const files = defineModel<File[]>('files', { default: [] })
|
||||
const searchQuery = defineModel<string>('searchQuery', { default: '' })
|
||||
|
||||
const debouncedSearchQuery = refDebounced(searchQuery, 700, {
|
||||
maxWait: 700
|
||||
})
|
||||
const isQuerying = ref(false)
|
||||
const toastStore = useToastStore()
|
||||
const popoverRef = ref<InstanceType<typeof Popover>>()
|
||||
const triggerRef = useTemplateRef('triggerRef')
|
||||
@@ -85,36 +80,6 @@ const maxSelectable = computed(() => {
|
||||
|
||||
const filteredItems = ref<DropdownItem[]>([])
|
||||
|
||||
watch(searchQuery, (value) => {
|
||||
isQuerying.value = value !== debouncedSearchQuery.value
|
||||
})
|
||||
|
||||
watch(
|
||||
[debouncedSearchQuery, () => props.items],
|
||||
(_, __, onCleanup) => {
|
||||
let isCleanup = false
|
||||
let cleanupFn: undefined | (() => void)
|
||||
onCleanup(() => {
|
||||
isCleanup = true
|
||||
cleanupFn?.()
|
||||
})
|
||||
|
||||
void props
|
||||
.searcher(
|
||||
debouncedSearchQuery.value,
|
||||
props.items,
|
||||
(cb) => (cleanupFn = cb)
|
||||
)
|
||||
.then((result) => {
|
||||
if (!isCleanup) filteredItems.value = result
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isCleanup) isQuerying.value = false
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const defaultSorter = computed<SortOption['sorter']>(() => {
|
||||
const sorter = props.sortOptions.find(
|
||||
(option) => option.id === 'default'
|
||||
@@ -183,6 +148,23 @@ function handleSelection(item: DropdownItem, index: number) {
|
||||
closeDropdown()
|
||||
}
|
||||
}
|
||||
|
||||
async function customSearcher(
|
||||
query: string,
|
||||
onCleanup: (cleanupFn: () => void) => void
|
||||
) {
|
||||
let isCleanup = false
|
||||
let cleanupFn: undefined | (() => void)
|
||||
onCleanup(() => {
|
||||
isCleanup = true
|
||||
cleanupFn?.()
|
||||
})
|
||||
await props
|
||||
.searcher(query, props.items, (cb) => (cleanupFn = cb))
|
||||
.then((results) => {
|
||||
if (!isCleanup) filteredItems.value = results
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -223,7 +205,7 @@ function handleSelection(item: DropdownItem, index: number) {
|
||||
:filter-options="filterOptions"
|
||||
:sort-options="sortOptions"
|
||||
:disabled="disabled"
|
||||
:is-querying="isQuerying"
|
||||
:searcher="customSearcher"
|
||||
:items="sortedItems"
|
||||
:is-selected="internalIsSelected"
|
||||
:max-selectable="maxSelectable"
|
||||
|
||||
@@ -15,9 +15,12 @@ import type {
|
||||
interface Props {
|
||||
items: DropdownItem[]
|
||||
isSelected: (item: DropdownItem, index: number) => boolean
|
||||
isQuerying: boolean
|
||||
filterOptions: FilterOption[]
|
||||
sortOptions: SortOption[]
|
||||
searcher?: (
|
||||
query: string,
|
||||
onCleanup: (cleanupFn: () => void) => void
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
@@ -50,7 +53,7 @@ const searchQuery = defineModel<string>('searchQuery')
|
||||
v-model:sort-selected="sortSelected"
|
||||
v-model:search-query="searchQuery"
|
||||
:sort-options="sortOptions"
|
||||
:is-querying="isQuerying"
|
||||
:searcher
|
||||
/>
|
||||
<!-- List -->
|
||||
<div class="relative flex h-full mt-2 overflow-y-scroll">
|
||||
|
||||
@@ -4,10 +4,14 @@ import { ref, useTemplateRef } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import FormSearchInput from '../FormSearchInput.vue'
|
||||
import type { LayoutMode, OptionId, SortOption } from './types'
|
||||
|
||||
defineProps<{
|
||||
isQuerying: boolean
|
||||
searcher?: (
|
||||
query: string,
|
||||
onCleanup: (cleanupFn: () => void) => void
|
||||
) => Promise<void>
|
||||
sortOptions: SortOption[]
|
||||
}>()
|
||||
|
||||
@@ -46,33 +50,18 @@ function handleSortSelected(item: SortOption) {
|
||||
|
||||
<template>
|
||||
<div class="text-secondary flex gap-2 px-4">
|
||||
<!-- TODO: Replace with a common Search input -->
|
||||
<label
|
||||
<FormSearchInput
|
||||
v-model="searchQuery"
|
||||
:searcher
|
||||
:class="
|
||||
cn(
|
||||
actionButtonStyle,
|
||||
'flex-1 flex px-2 items-center text-base leading-none cursor-text',
|
||||
searchQuery?.trim() !== '' ? 'text-base-foreground' : '',
|
||||
'hover:outline-component-node-widget-background-highlighted/80',
|
||||
'focus-within:outline-component-node-widget-background-highlighted/80'
|
||||
'focus-within:outline-component-node-widget-background-highlighted/80 focus-within:ring-0'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i
|
||||
v-if="isQuerying"
|
||||
class="mr-2 icon-[lucide--loader-circle] size-4 animate-spin"
|
||||
/>
|
||||
<i v-else class="mr-2 icon-[lucide--search] size-4" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
autofocus
|
||||
:class="resetInputStyle"
|
||||
:placeholder="$t('g.search')"
|
||||
/>
|
||||
</label>
|
||||
/>
|
||||
|
||||
<!-- Sort Select -->
|
||||
<button
|
||||
ref="sortTriggerRef"
|
||||
:class="
|
||||
|
||||
@@ -554,7 +554,8 @@ const zSettings = z.object({
|
||||
'single.setting': z.any(),
|
||||
'LiteGraph.Node.DefaultPadding': z.boolean(),
|
||||
'LiteGraph.Pointer.TrackpadGestures': z.boolean(),
|
||||
'Comfy.VersionCompatibility.DisableWarnings': z.boolean()
|
||||
'Comfy.VersionCompatibility.DisableWarnings': z.boolean(),
|
||||
'Comfy.RightSidePanel.IsOpen': z.boolean()
|
||||
})
|
||||
|
||||
export type EmbeddingsResponse = z.infer<typeof zEmbeddingsResponse>
|
||||
|
||||
@@ -1224,6 +1224,8 @@ export class ComfyApp {
|
||||
// Fit view if no nodes visible in restored viewport
|
||||
this.canvas.ds.computeVisibleArea(this.canvas.viewport)
|
||||
if (
|
||||
this.canvas.visible_area.width &&
|
||||
this.canvas.visible_area.height &&
|
||||
!anyItemOverlapsRect(
|
||||
this.rootGraph._nodes,
|
||||
this.canvas.visible_area
|
||||
|
||||