mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-02 03:30:04 +00:00
Render app builder in arrange mode (#9260)
## Summary Adds app builder in arrange/preview mode with re-orderable widgets, maintaining size (as much as possible) between the select + preview steps ## Changes - **What**: - Extract sidebar size constants for sharing between canvas splitter + app mode - Add widget list using DraggableList and render inert WidgetItems ## Screenshots (if applicable) <img width="1391" height="923" alt="image" src="https://github.com/user-attachments/assets/3e17eafe-db1e-40a3-83b5-15a7d0672909" /> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9260-Render-app-builder-in-arrange-mode-3136d73d365081ef875acab683d01d9e) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -18,7 +18,7 @@
|
||||
<Splitter
|
||||
:key="splitterRefreshKey"
|
||||
class="bg-transparent pointer-events-none border-none flex-1 overflow-hidden"
|
||||
:state-key="sidebarStateKey"
|
||||
:state-key="isSelectMode ? 'builder-splitter' : sidebarStateKey"
|
||||
state-storage="local"
|
||||
@resizestart="onResizestart"
|
||||
>
|
||||
@@ -35,8 +35,10 @@
|
||||
)
|
||||
: 'bg-comfy-menu-bg pointer-events-auto'
|
||||
"
|
||||
:min-size="sidebarLocation === 'left' ? 10 : 15"
|
||||
:size="20"
|
||||
:min-size="
|
||||
sidebarLocation === 'left' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:style="firstPanelStyle"
|
||||
:role="sidebarLocation === 'left' ? 'complementary' : undefined"
|
||||
:aria-label="
|
||||
@@ -54,7 +56,7 @@
|
||||
</SplitterPanel>
|
||||
|
||||
<!-- Main panel (always present) -->
|
||||
<SplitterPanel :size="80" class="flex flex-col">
|
||||
<SplitterPanel :size="CENTER_PANEL_SIZE" class="flex flex-col">
|
||||
<slot name="topmenu" :sidebar-panel-visible />
|
||||
|
||||
<Splitter
|
||||
@@ -95,8 +97,10 @@
|
||||
)
|
||||
: 'bg-comfy-menu-bg pointer-events-auto'
|
||||
"
|
||||
:min-size="sidebarLocation === 'right' ? 10 : 15"
|
||||
:size="20"
|
||||
:min-size="
|
||||
sidebarLocation === 'right' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:style="lastPanelStyle"
|
||||
:role="sidebarLocation === 'right' ? 'complementary' : undefined"
|
||||
:aria-label="
|
||||
@@ -123,8 +127,14 @@ import SplitterPanel from 'primevue/splitterpanel'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import {
|
||||
BUILDER_MIN_SIZE,
|
||||
CENTER_PANEL_SIZE,
|
||||
SIDEBAR_MIN_SIZE,
|
||||
SIDE_PANEL_SIZE
|
||||
} from '@/constants/splitterConstants'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
@@ -145,12 +155,12 @@ const unifiedWidth = computed(() =>
|
||||
|
||||
const { focusMode } = storeToRefs(workspaceStore)
|
||||
|
||||
const { mode } = useAppMode()
|
||||
const { isSelectMode } = useAppMode()
|
||||
const { activeSidebarTabId, activeSidebarTab } = storeToRefs(sidebarTabStore)
|
||||
const { bottomPanelVisible } = storeToRefs(useBottomPanelStore())
|
||||
const { isOpen: rightSidePanelVisible } = storeToRefs(rightSidePanelStore)
|
||||
const showOffsideSplitter = computed(
|
||||
() => rightSidePanelVisible.value || mode.value === 'builder:select'
|
||||
() => rightSidePanelVisible.value || isSelectMode.value
|
||||
)
|
||||
|
||||
const sidebarPanelVisible = computed(() => activeSidebarTab.value !== null)
|
||||
@@ -174,7 +184,7 @@ function onResizestart({ originalEvent: event }: SplitterResizeStartEvent) {
|
||||
* to recalculate the width and panel order
|
||||
*/
|
||||
const splitterRefreshKey = computed(() => {
|
||||
return `main-splitter${rightSidePanelVisible.value ? '-with-right-panel' : ''}-${sidebarLocation.value}`
|
||||
return `main-splitter${rightSidePanelVisible.value ? '-with-right-panel' : ''}${isSelectMode.value ? '-builder' : ''}-${sidebarLocation.value}`
|
||||
})
|
||||
|
||||
const firstPanelStyle = computed(() => {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { remove } from 'es-toolkit'
|
||||
import { computed, ref, toValue } from 'vue'
|
||||
import { computed, provide, ref, toValue, watchEffect } from 'vue'
|
||||
import type { MaybeRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import DraggableList from '@/components/common/DraggableList.vue'
|
||||
import IoItem from '@/components/builder/IoItem.vue'
|
||||
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
|
||||
import WidgetItem from '@/components/rightSidePanel/parameters/WidgetItem.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
@@ -23,8 +24,10 @@ import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
|
||||
import { app } from '@/scripts/app'
|
||||
import { DOMWidgetImpl } from '@/scripts/domWidget'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
|
||||
type BoundStyle = { top: string; left: string; width: string; height: string }
|
||||
|
||||
@@ -36,10 +39,36 @@ const workflowStore = useWorkflowStore()
|
||||
const { t } = useI18n()
|
||||
const canvas: LGraphCanvas = canvasStore.getCanvas()
|
||||
|
||||
const { mode, isArrangeMode } = useAppMode()
|
||||
const hoveringSelectable = ref(false)
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
|
||||
workflowStore.activeWorkflow?.changeTracker?.reset()
|
||||
|
||||
// Prune stale entries whose node/widget no longer exists, so the
|
||||
// DraggableList model always matches the rendered items.
|
||||
watchEffect(() => {
|
||||
const valid = appModeStore.selectedInputs.filter(([nodeId, widgetName]) => {
|
||||
const node = app.rootGraph.getNodeById(nodeId)
|
||||
return node?.widgets?.some((w) => w.name === widgetName)
|
||||
})
|
||||
if (valid.length < appModeStore.selectedInputs.length) {
|
||||
appModeStore.selectedInputs = valid
|
||||
}
|
||||
})
|
||||
|
||||
const arrangeInputs = computed(() =>
|
||||
appModeStore.selectedInputs
|
||||
.map(([nodeId, widgetName]) => {
|
||||
const node = app.rootGraph.getNodeById(nodeId)
|
||||
const widget = node?.widgets?.find((w) => w.name === widgetName)
|
||||
if (!node || !widget) return null
|
||||
return { nodeId, widgetName, node, widget }
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null)
|
||||
)
|
||||
|
||||
const inputsWithState = computed(() =>
|
||||
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
|
||||
const node = app.rootGraph.getNodeById(nodeId)
|
||||
@@ -179,12 +208,36 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex font-bold p-2 border-border-subtle border-b items-center">
|
||||
{{ t('linearMode.builder.title') }}
|
||||
{{
|
||||
isArrangeMode ? t('nodeHelpPage.inputs') : t('linearMode.builder.title')
|
||||
}}
|
||||
<Button class="ml-auto" @click="appModeStore.exitBuilder">
|
||||
{{ t('linearMode.builder.exit') }}
|
||||
</Button>
|
||||
</div>
|
||||
<DraggableList
|
||||
v-if="isArrangeMode"
|
||||
v-slot="{ dragClass }"
|
||||
v-model="appModeStore.selectedInputs"
|
||||
>
|
||||
<div
|
||||
v-for="{ nodeId, widgetName, node, widget } in arrangeInputs"
|
||||
:key="`${nodeId}: ${widgetName}`"
|
||||
:class="cn(dragClass, 'p-2 my-2 pointer-events-auto')"
|
||||
:aria-label="`${widget.label ?? widgetName} — ${node.title}`"
|
||||
>
|
||||
<div class="pointer-events-none" inert>
|
||||
<WidgetItem
|
||||
:widget="widget"
|
||||
:node="node"
|
||||
show-node-name
|
||||
hidden-widget-actions
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DraggableList>
|
||||
<PropertiesAccordionItem
|
||||
v-else
|
||||
:label="t('nodeHelpPage.inputs')"
|
||||
enable-empty-state
|
||||
:disabled="!appModeStore.selectedInputs.length"
|
||||
@@ -232,6 +285,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
</DraggableList>
|
||||
</PropertiesAccordionItem>
|
||||
<PropertiesAccordionItem
|
||||
v-if="!isArrangeMode"
|
||||
:label="t('nodeHelpPage.outputs')"
|
||||
enable-empty-state
|
||||
:disabled="!appModeStore.selectedOutputs.length"
|
||||
@@ -274,7 +328,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
</DraggableList>
|
||||
</PropertiesAccordionItem>
|
||||
|
||||
<Teleport to="body">
|
||||
<Teleport v-if="mode === 'builder:select'" to="body">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
|
||||
@@ -34,6 +34,7 @@ const {
|
||||
node,
|
||||
isDraggable = false,
|
||||
hiddenFavoriteIndicator = false,
|
||||
hiddenWidgetActions = false,
|
||||
showNodeName = false,
|
||||
parents = [],
|
||||
isShownOnParents = false
|
||||
@@ -42,6 +43,7 @@ const {
|
||||
node: LGraphNode
|
||||
isDraggable?: boolean
|
||||
hiddenFavoriteIndicator?: boolean
|
||||
hiddenWidgetActions?: boolean
|
||||
showNodeName?: boolean
|
||||
parents?: SubgraphNode[]
|
||||
isShownOnParents?: boolean
|
||||
@@ -170,7 +172,10 @@ const displayLabel = customRef((track, trigger) => {
|
||||
>
|
||||
{{ sourceNodeName }}
|
||||
</span>
|
||||
<div class="flex items-center gap-1 shrink-0 pointer-events-auto">
|
||||
<div
|
||||
v-if="!hiddenWidgetActions"
|
||||
class="flex items-center gap-1 shrink-0 pointer-events-auto"
|
||||
>
|
||||
<WidgetActions
|
||||
v-model:label="displayLabel"
|
||||
:widget="widget"
|
||||
|
||||
@@ -16,8 +16,10 @@ export function useAppMode() {
|
||||
)
|
||||
|
||||
const isBuilderMode = computed(
|
||||
() => mode.value === 'builder:select' || mode.value === 'builder:arrange'
|
||||
() => isSelectMode.value || isArrangeMode.value
|
||||
)
|
||||
const isSelectMode = computed(() => mode.value === 'builder:select')
|
||||
const isArrangeMode = computed(() => mode.value === 'builder:arrange')
|
||||
const isAppMode = computed(
|
||||
() => mode.value === 'app' || mode.value === 'builder:arrange'
|
||||
)
|
||||
@@ -36,6 +38,8 @@ export function useAppMode() {
|
||||
mode,
|
||||
enableAppBuilder,
|
||||
isBuilderMode,
|
||||
isSelectMode,
|
||||
isArrangeMode,
|
||||
isAppMode,
|
||||
isGraphMode,
|
||||
setMode
|
||||
|
||||
11
src/constants/splitterConstants.ts
Normal file
11
src/constants/splitterConstants.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/** Default panel size (%) for sidebar and builder panels */
|
||||
export const SIDE_PANEL_SIZE = 20
|
||||
|
||||
/** Default panel size (%) for the center/main panel */
|
||||
export const CENTER_PANEL_SIZE = 80
|
||||
|
||||
/** Minimum panel size (%) for the sidebar */
|
||||
export const SIDEBAR_MIN_SIZE = 10
|
||||
|
||||
/** Minimum panel size (%) for the builder panel */
|
||||
export const BUILDER_MIN_SIZE = 15
|
||||
@@ -6,6 +6,7 @@ import { storeToRefs } from 'pinia'
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import AppBuilder from '@/components/builder/AppBuilder.vue'
|
||||
import AppModeToolbar from '@/components/appMode/AppModeToolbar.vue'
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import ModeToggle from '@/components/sidebar/ModeToggle.vue'
|
||||
@@ -20,13 +21,20 @@ import LinearProgressBar from '@/renderer/extensions/linearMode/LinearProgressBa
|
||||
import MobileMenu from '@/renderer/extensions/linearMode/MobileMenu.vue'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import {
|
||||
BUILDER_MIN_SIZE,
|
||||
CENTER_PANEL_SIZE,
|
||||
SIDEBAR_MIN_SIZE,
|
||||
SIDE_PANEL_SIZE
|
||||
} from '@/constants/splitterConstants'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const settingStore = useSettingStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const { isBuilderMode } = useAppMode()
|
||||
const { hasOutputs } = storeToRefs(useAppModeStore())
|
||||
const { isBuilderMode, isArrangeMode } = useAppMode()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { hasOutputs } = storeToRefs(appModeStore)
|
||||
|
||||
const mobileDisplay = useBreakpoints(breakpointsTailwind).smaller('md')
|
||||
|
||||
@@ -34,17 +42,34 @@ const activeTab = computed(() => workspaceStore.sidebarTab.activeSidebarTab)
|
||||
const sidebarOnLeft = computed(
|
||||
() => settingStore.get('Comfy.Sidebar.Location') === 'left'
|
||||
)
|
||||
// Builder panel is always on the opposite side of the sidebar.
|
||||
// In arrange mode we render 3 panels to match the overlay structure,
|
||||
// so the same stateKey percentage maps to the same pixel width.
|
||||
const showLeftBuilder = computed(
|
||||
() => !sidebarOnLeft.value && isArrangeMode.value
|
||||
)
|
||||
const showRightBuilder = computed(
|
||||
() => sidebarOnLeft.value && isArrangeMode.value
|
||||
)
|
||||
const hasLeftPanel = computed(
|
||||
() =>
|
||||
isArrangeMode.value ||
|
||||
(sidebarOnLeft.value && activeTab.value) ||
|
||||
(!sidebarOnLeft.value && !isBuilderMode.value && hasOutputs.value)
|
||||
)
|
||||
const hasRightPanel = computed(
|
||||
() =>
|
||||
isArrangeMode.value ||
|
||||
(sidebarOnLeft.value && !isBuilderMode.value && hasOutputs.value) ||
|
||||
(!sidebarOnLeft.value && activeTab.value)
|
||||
)
|
||||
|
||||
function sidePanelMinSize(isBuilder: boolean, isHidden: boolean) {
|
||||
if (isBuilder) return BUILDER_MIN_SIZE
|
||||
if (isHidden) return undefined
|
||||
return SIDEBAR_MIN_SIZE
|
||||
}
|
||||
|
||||
const bottomLeftRef = useTemplateRef('bottomLeftRef')
|
||||
const bottomRightRef = useTemplateRef('bottomRightRef')
|
||||
const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
|
||||
@@ -52,7 +77,7 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
|
||||
<template>
|
||||
<div class="absolute w-full h-full">
|
||||
<div
|
||||
class="workflow-tabs-container pointer-events-auto h-(--workflow-tabs-height) w-full"
|
||||
class="workflow-tabs-container pointer-events-auto h-(--workflow-tabs-height) w-full border-b border-interface-stroke shadow-interface"
|
||||
>
|
||||
<div class="flex h-full items-center">
|
||||
<WorkflowTabs />
|
||||
@@ -82,31 +107,47 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
|
||||
</div>
|
||||
<Splitter
|
||||
v-else
|
||||
class="h-[calc(100%-38px)] w-full bg-comfy-menu-secondary-bg"
|
||||
:pt="{ gutter: { class: 'bg-transparent w-4 -mx-1' } }"
|
||||
:key="isArrangeMode ? 'arrange' : 'normal'"
|
||||
class="h-[calc(100%-var(--workflow-tabs-height))] w-full border-none bg-comfy-menu-secondary-bg"
|
||||
:state-key="isArrangeMode ? 'builder-splitter' : undefined"
|
||||
:state-storage="isArrangeMode ? 'local' : undefined"
|
||||
@resizestart="({ originalEvent }) => originalEvent.preventDefault()"
|
||||
>
|
||||
<SplitterPanel
|
||||
v-if="hasLeftPanel"
|
||||
id="linearLeftPanel"
|
||||
:size="1"
|
||||
class="min-w-min outline-none"
|
||||
:size="isArrangeMode ? SIDE_PANEL_SIZE : 1"
|
||||
:min-size="
|
||||
sidePanelMinSize(showLeftBuilder, showRightBuilder && !activeTab)
|
||||
"
|
||||
:style="
|
||||
showRightBuilder && !activeTab ? { display: 'none' } : undefined
|
||||
"
|
||||
:class="
|
||||
cn(
|
||||
'outline-none arrange-panel',
|
||||
showLeftBuilder ? 'bg-comfy-menu-bg min-w-78' : 'min-w-min'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div v-if="showLeftBuilder" class="h-full overflow-y-auto">
|
||||
<AppBuilder />
|
||||
</div>
|
||||
<div
|
||||
v-if="sidebarOnLeft && activeTab"
|
||||
v-else-if="sidebarOnLeft && activeTab"
|
||||
class="flex h-full border-border-subtle border-r"
|
||||
>
|
||||
<ExtensionSlot :extension="activeTab" />
|
||||
</div>
|
||||
<LinearControls
|
||||
v-else
|
||||
v-else-if="!isArrangeMode"
|
||||
ref="linearWorkflowRef"
|
||||
:toast-to="unrefElement(bottomLeftRef) ?? undefined"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
id="linearCenterPanel"
|
||||
:size="98"
|
||||
:size="isArrangeMode ? CENTER_PANEL_SIZE : 98"
|
||||
class="flex flex-col min-w-0 gap-4 px-10 pt-8 pb-4 relative text-muted-foreground outline-none"
|
||||
>
|
||||
<LinearProgressBar
|
||||
@@ -139,11 +180,23 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
|
||||
<SplitterPanel
|
||||
v-if="hasRightPanel"
|
||||
id="linearRightPanel"
|
||||
:size="1"
|
||||
class="min-w-min outline-none"
|
||||
:size="isArrangeMode ? SIDE_PANEL_SIZE : 1"
|
||||
:min-size="
|
||||
sidePanelMinSize(showRightBuilder, showLeftBuilder && !activeTab)
|
||||
"
|
||||
:style="showLeftBuilder && !activeTab ? { display: 'none' } : undefined"
|
||||
:class="
|
||||
cn(
|
||||
'outline-none arrange-panel',
|
||||
showRightBuilder ? 'bg-comfy-menu-bg min-w-78' : 'min-w-min'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div v-if="showRightBuilder" class="h-full overflow-y-auto">
|
||||
<AppBuilder />
|
||||
</div>
|
||||
<LinearControls
|
||||
v-if="sidebarOnLeft"
|
||||
v-else-if="sidebarOnLeft && !isArrangeMode"
|
||||
ref="linearWorkflowRef"
|
||||
:toast-to="unrefElement(bottomRightRef) ?? undefined"
|
||||
/>
|
||||
@@ -157,3 +210,21 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
|
||||
</Splitter>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.p-splitter-gutter) {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
:deep(.p-splitter-gutter:hover),
|
||||
:deep(.p-splitter-gutter[data-p-gutter-resizing='true']) {
|
||||
transition: background-color 0.2s ease 300ms;
|
||||
background-color: var(--p-primary-color);
|
||||
}
|
||||
|
||||
/* Hide gutter next to hidden arrange panels */
|
||||
:deep(.arrange-panel[style*='display: none'] + .p-splitter-gutter),
|
||||
:deep(.p-splitter-gutter + .arrange-panel[style*='display: none']) {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user