From c090d189f08d8c953f18a9a0f23e56aeca0bcccd Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:32:44 +0000 Subject: [PATCH] Render app builder in arrange mode (#9260) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) ┆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) --- .../LiteGraphCanvasSplitterOverlay.vue | 30 ++++-- src/components/builder/AppBuilder.vue | 60 +++++++++++- .../rightSidePanel/parameters/WidgetItem.vue | 7 +- src/composables/useAppMode.ts | 6 +- src/constants/splitterConstants.ts | 11 +++ src/views/LinearView.vue | 97 ++++++++++++++++--- 6 files changed, 183 insertions(+), 28 deletions(-) create mode 100644 src/constants/splitterConstants.ts diff --git a/src/components/LiteGraphCanvasSplitterOverlay.vue b/src/components/LiteGraphCanvasSplitterOverlay.vue index cbf257146b..316fc258d3 100644 --- a/src/components/LiteGraphCanvasSplitterOverlay.vue +++ b/src/components/LiteGraphCanvasSplitterOverlay.vue @@ -18,7 +18,7 @@ @@ -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 @@ - + 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 => 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 | undefined][]>( - {{ t('linearMode.builder.title') }} + {{ + isArrangeMode ? t('nodeHelpPage.inputs') : t('linearMode.builder.title') + }} {{ t('linearMode.builder.exit') }} + + + + + + + | undefined][]>( | undefined][]>( - + + 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 diff --git a/src/constants/splitterConstants.ts b/src/constants/splitterConstants.ts new file mode 100644 index 0000000000..792a5a8887 --- /dev/null +++ b/src/constants/splitterConstants.ts @@ -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 diff --git a/src/views/LinearView.vue b/src/views/LinearView.vue index bdc1b5b6fa..aa0583ba57 100644 --- a/src/views/LinearView.vue +++ b/src/views/LinearView.vue @@ -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') @@ -82,31 +107,47 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef') originalEvent.preventDefault()" > + + + + + + @@ -157,3 +210,21 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef') + +