Compare commits

...

8 Commits

Author SHA1 Message Date
github-actions
0dbd437a51 [automated] Update test expectations 2026-05-18 22:56:20 +00:00
Connor Byrne
6f7ae72646 feat: add drag-to-resize for widget row heights
Add direct mouse drag resizing on widget rows instead of
context menu → dialog approach. Users can now drag the
bottom edge of any widget row to resize it.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-18 15:33:44 -07:00
Connor Byrne
7891439a12 feat: add drag-to-resize for widget row heights
Add direct mouse drag resizing on widget rows instead of
context menu → dialog approach. Users can now drag the
bottom edge of any widget row to resize it.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-18 15:33:34 -07:00
github-actions
9cb9dd9f1c [automated] Update test expectations 2026-05-18 15:33:27 -07:00
Glary-Bot
8de8031ecb fix: address PR review (round 2)
- isValidGridTrack helper using CSS.supports('grid-template-rows', value),
  cached per-string. Applied in gridTemplateRows so a bad persisted value
  falls back to the existing auto/min-content default instead of breaking
  the whole node's row layout.
- writeGridOverrides: skip node.properties initialization on empty
  (clear) writes so clearing overrides on a node that never had any
  properties stays a true no-op.
2026-05-18 15:33:27 -07:00
Connor Byrne
6dc819b15b refactor: consolidate grid overrides logic, improve UX
- Extract shared utility for grid override read/write operations
- Replace window.prompt with proper dialog using PromptDialogContent
- Center widgets vertically when they have a grid override applied
- Remove code duplication between extension and node manager
- Add hasGridOverride flag to ProcessedWidget for layout decisions

Addresses PoC improvements discussed in PR.
2026-05-18 15:33:26 -07:00
Glary-Bot
5bf8ea01d5 fix: address PR review
- Guard node.properties before writing overrides (??= initialization).
- Resolve override key via widget.slotName ?? widget.name so promoted
  (subgraph input) widgets match the same key used at write time.
  buildWidgetMenuItem writes under the LiteGraph widget.name, which for
  promoted widgets is the subgraph input slot name; SafeWidgetData.name
  is the interior widget name and slotName is the subgraph slot name,
  so the read side has to prefer slotName when present.
- i18n: route all new menu/prompt strings through t() and add
  widgetGridOverrides entries in locales/en/main.json.
2026-05-18 15:33:26 -07:00
Glary-Bot
bc966a39b7 feat: per-widget grid override via node.properties (PoC)
Adds a 'grid overrides' concept on node properties keyed by widget input
name. Each entry overrides the CSS grid-template-rows track for that
widget in the node's widget grid (e.g. '200px', 'minmax(150px, 300px)',
'1fr'), letting users size individual textareas/widgets independently of
the node's auto-fill layout.

Storage: node.properties.gridOverrides[widgetName] = '<css-track>'
(persists with the workflow JSON).

Rendering: useProcessedWidgets consults overrides before falling back to
the existing shouldExpand/hasLayoutSize default.

Testing UI: right-click a node -> 'Widget Grid Sizes' submenu lists each
widget; pick one to set or clear a custom track value.
2026-05-18 15:33:26 -07:00
17 changed files with 360 additions and 7 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -39,6 +39,10 @@ import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { app } from '@/scripts/app'
import { getExecutionIdByNode } from '@/utils/graphTraversalUtil'
import type { WidgetGridOverrides } from '@/utils/widgetGridOverrides'
import { readGridOverrides } from '@/utils/widgetGridOverrides'
export type { WidgetGridOverrides }
export interface WidgetSlotMetadata {
index: number
@@ -115,6 +119,7 @@ export interface VueNodeData {
ghost?: boolean
pinned?: boolean
}
gridOverrides?: WidgetGridOverrides
hasErrors?: boolean
inputs?: INodeInputSlot[]
outputs?: INodeOutputSlot[]
@@ -133,6 +138,9 @@ export interface GraphNodeManager {
// Access to original LiteGraph nodes (non-reactive)
getNode(id: string): LGraphNode | undefined
// Re-extract VueNodeData for fields not covered by tracked-property events
refreshNode(id: string): void
// Lifecycle methods
cleanup(): void
}
@@ -513,6 +521,7 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
flags: node.flags ? { ...node.flags } : undefined,
color: node.color || undefined,
bgcolor: node.bgcolor || undefined,
gridOverrides: readGridOverrides(node),
resizable: node.resizable,
shape: node.shape,
showAdvanced: node.showAdvanced
@@ -858,9 +867,15 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
})
}
const refreshNode = (id: string) => {
const nodeRef = nodeRefs.get(id)
if (nodeRef) vueNodeData.set(id, extractVueNodeData(nodeRef))
}
return {
vueNodeData,
getNode,
refreshNode,
cleanup
}
}

View File

@@ -30,6 +30,7 @@ import './slotDefaults'
import './uploadAudio'
import './uploadImage'
import './webcamCapture'
import './widgetGridOverrides'
import './widgetInputs'
// Cloud-only extensions - tree-shaken in OSS builds

View File

@@ -0,0 +1,140 @@
import PromptDialogContent from '@/components/dialog/content/PromptDialogContent.vue'
import { t } from '@/i18n'
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { app } from '@/scripts/app'
import { useExtensionService } from '@/services/extensionService'
import { useDialogStore } from '@/stores/dialogStore'
import {
clearAllGridOverrides,
clearGridOverride,
readGridOverrides,
setGridOverride
} from '@/utils/widgetGridOverrides'
const DEFAULT_SIZE = '200px'
function refreshVueNode(nodeId: string): void {
const manager = useVueNodeLifecycle().nodeManager.value
manager?.refreshNode(nodeId)
}
function applyOverrideAndRefresh(
node: LGraphNode,
widgetName: string,
value: string
): void {
setGridOverride(node, widgetName, value)
refreshVueNode(String(node.id))
app.canvas?.setDirty(true, true)
}
function removeOverrideAndRefresh(node: LGraphNode, widgetName: string): void {
clearGridOverride(node, widgetName)
refreshVueNode(String(node.id))
app.canvas?.setDirty(true, true)
}
function removeAllOverridesAndRefresh(node: LGraphNode): void {
clearAllGridOverrides(node)
refreshVueNode(String(node.id))
app.canvas?.setDirty(true, true)
}
function openSizeDialog(
currentValue: string | undefined,
onSubmit: (value: string) => void
): void {
useDialogStore().showDialog({
key: 'widget-grid-size',
title: t('widgetGridOverrides.sizeLabel'),
component: PromptDialogContent,
props: {
message: t('widgetGridOverrides.prompt'),
defaultValue: currentValue ?? DEFAULT_SIZE,
placeholder: '200px',
onConfirm: (value: string) => {
const trimmed = value.trim()
if (trimmed.length > 0) {
onSubmit(trimmed)
}
}
},
dialogComponentProps: {
modal: true,
closable: true,
dismissableMask: true
}
})
}
function buildWidgetMenuItem(
node: LGraphNode,
widgetName: string
): IContextMenuValue {
const overrides = readGridOverrides(node) ?? {}
const current = overrides[widgetName]
const label = current
? `${widgetName}${current}`
: `${widgetName}${t('widgetGridOverrides.auto')}`
const openSetSize = () => {
openSizeDialog(current, (value) =>
applyOverrideAndRefresh(node, widgetName, value)
)
}
return {
content: label,
has_submenu: true,
callback: openSetSize,
submenu: {
options: [
{
content: t('widgetGridOverrides.setSize'),
callback: openSetSize
},
{
content: t('widgetGridOverrides.clearOverride'),
disabled: !current,
callback: () => removeOverrideAndRefresh(node, widgetName)
}
]
}
}
}
useExtensionService().registerExtension({
name: 'Comfy.WidgetGridOverrides',
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
const widgets = node.widgets ?? []
if (widgets.length === 0) return []
const overrides = readGridOverrides(node) ?? {}
const hasAny = Object.keys(overrides).length > 0
const widgetItems: (IContextMenuValue | null)[] = widgets.map((widget) =>
buildWidgetMenuItem(node, widget.name)
)
if (hasAny) {
widgetItems.push(null, {
content: t('widgetGridOverrides.clearAll'),
callback: () => removeAllOverridesAndRefresh(node)
})
}
return [
null,
{
content: t('widgetGridOverrides.menuLabel'),
has_submenu: true,
callback: () => {},
submenu: {
options: widgetItems
}
}
]
}
})

View File

@@ -3816,5 +3816,14 @@
"training": "Training…",
"processingVideo": "Processing video…",
"running": "Running…"
},
"widgetGridOverrides": {
"menuLabel": "Widget Grid Sizes",
"setSize": "Set size…",
"clearOverride": "Clear override",
"clearAll": "Clear all overrides",
"auto": "(auto)",
"sizeLabel": "Row height (CSS grid value)",
"prompt": "Grid row size (e.g. 200px, minmax(150px, 300px), 1fr, auto)"
}
}

View File

@@ -26,8 +26,19 @@
<div
v-if="!widget.hidden && (!widget.advanced || showAdvanced)"
data-testid="node-widget"
class="lg-node-widget group col-span-full grid grid-cols-subgrid items-stretch"
:class="
cn(
'lg-node-widget group relative col-span-full grid grid-cols-subgrid',
widget.hasGridOverride ? 'items-center' : 'items-stretch'
)
"
>
<div
class="absolute inset-x-0 bottom-0 h-1 cursor-ns-resize opacity-0 transition-opacity hover:bg-node-stroke hover:opacity-50"
@pointerdown="
handleResizePointerDown($event, widget.slotName ?? widget.name)
"
/>
<!-- Widget Input Slot Dot -->
<div
:class="
@@ -91,6 +102,7 @@ import AppInput from '@/renderer/extensions/linearMode/AppInput.vue'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
import { useProcessedWidgets } from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
import { useWidgetRowResize } from '@/renderer/extensions/vueNodes/composables/useWidgetRowResize'
import { cn } from '@comfyorg/tailwind-utils'
import InputSlot from './InputSlot.vue'
@@ -140,4 +152,18 @@ const {
if (nodeData?.id != null) {
useVueElementTracking(String(nodeData.id), 'widgets-grid')
}
const { startResize } = useWidgetRowResize()
function handleResizePointerDown(
event: PointerEvent,
widgetOverrideKey: string
) {
const handle = event.currentTarget as HTMLElement
const rowElement = handle.closest(
"[data-testid='node-widget']"
) as HTMLElement
if (!rowElement || nodeData?.id == null) return
startResize(event, String(nodeData.id), widgetOverrideKey, rowElement)
}
</script>

View File

@@ -42,15 +42,18 @@ import {
getExecutionIdFromNodeData,
getLocatorIdFromNodeData
} from '@/utils/graphTraversalUtil'
import { isValidGridTrack } from '@/utils/widgetGridOverrides'
interface ProcessedWidget {
advanced: boolean
handleContextMenu: (e: PointerEvent) => void
hasGridOverride: boolean
hasLayoutSize: boolean
hasError: boolean
hidden: boolean
id: string
name: string
slotName?: string
renderKey: string
simplified: SimplifiedWidget
tooltipConfig: TooltipOptions
@@ -332,9 +335,13 @@ export function computeProcessedWidgets({
)
}
const widgetKey = widget.slotName ?? widget.name
const hasGridOverride = !!nodeData.gridOverrides?.[widgetKey]
result.push({
advanced: mergedOptions.advanced ?? false,
handleContextMenu,
hasGridOverride,
hasLayoutSize: widget.hasLayoutSize ?? false,
hasError: hasWidgetError(
widget,
@@ -346,6 +353,7 @@ export function computeProcessedWidgets({
hidden: mergedOptions.hidden ?? false,
id: String(bareWidgetId),
name: widget.name,
slotName: widget.slotName,
renderKey,
type: widget.type,
vueComponent,
@@ -412,13 +420,16 @@ export function useProcessedWidgets(
)
)
const gridTemplateRows = computed((): string =>
visibleWidgets.value
.map((w) =>
shouldExpand(w.type) || w.hasLayoutSize ? 'auto' : 'min-content'
)
const gridTemplateRows = computed((): string => {
const overrides = nodeDataGetter()?.gridOverrides
return visibleWidgets.value
.map((w) => {
const override = overrides?.[w.slotName ?? w.name]
if (override && isValidGridTrack(override)) return override
return shouldExpand(w.type) || w.hasLayoutSize ? 'auto' : 'min-content'
})
.join(' ')
)
})
return {
canSelectInputs,

View File

@@ -0,0 +1,84 @@
import { ref } from 'vue'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { app } from '@/scripts/app'
import { setGridOverride } from '@/utils/widgetGridOverrides'
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
const MIN_ROW_HEIGHT = 24
export function useWidgetRowResize() {
const transformState = useTransformState()
const isResizing = ref(false)
const resizeStartY = ref(0)
const resizeStartHeight = ref(0)
const activeNodeId = ref<string | null>(null)
const activeWidgetName = ref<string | null>(null)
function startResize(
event: PointerEvent,
nodeId: string,
widgetName: string,
rowElement: HTMLElement
) {
event.preventDefault()
event.stopPropagation()
const target = event.currentTarget
if (!(target instanceof HTMLElement)) return
target.setPointerCapture(event.pointerId)
const safeZoom = () => transformState.camera.z || 1
isResizing.value = true
resizeStartY.value = event.clientY
resizeStartHeight.value =
rowElement.getBoundingClientRect().height / safeZoom()
activeNodeId.value = nodeId
activeWidgetName.value = widgetName
const handlePointerMove = (moveEvent: PointerEvent) => {
if (!isResizing.value) return
const deltaY = (moveEvent.clientY - resizeStartY.value) / safeZoom()
const newHeight = Math.max(
MIN_ROW_HEIGHT,
resizeStartHeight.value + deltaY
)
rowElement.style.height = `${newHeight}px`
}
const handlePointerUp = () => {
if (!isResizing.value || !activeNodeId.value || !activeWidgetName.value)
return
const finalHeight = rowElement.getBoundingClientRect().height / safeZoom()
const heightPx = `${Math.round(finalHeight)}px`
const node = app.graph?.getNodeById(activeNodeId.value)
if (node) {
setGridOverride(node, activeWidgetName.value, heightPx)
const manager = useVueNodeLifecycle().nodeManager.value
manager?.refreshNode(activeNodeId.value)
app.canvas?.setDirty(true, true)
}
rowElement.style.height = ''
isResizing.value = false
activeNodeId.value = null
activeWidgetName.value = null
target.removeEventListener('pointermove', handlePointerMove)
target.removeEventListener('pointerup', handlePointerUp)
target.removeEventListener('pointercancel', handlePointerUp)
}
target.addEventListener('pointermove', handlePointerMove)
target.addEventListener('pointerup', handlePointerUp)
target.addEventListener('pointercancel', handlePointerUp)
}
return { isResizing, startResize }
}

View File

@@ -0,0 +1,67 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
const GRID_OVERRIDES_PROPERTY_KEY = 'gridOverrides'
const gridTrackValidityCache = new Map<string, boolean>()
export function isValidGridTrack(value: string): boolean {
const cached = gridTrackValidityCache.get(value)
if (cached !== undefined) return cached
const valid =
typeof CSS !== 'undefined' &&
typeof CSS.supports === 'function' &&
CSS.supports('grid-template-rows', value)
gridTrackValidityCache.set(value, valid)
return valid
}
/**
* Maps widget name -> CSS grid-template-rows track value
* (e.g. '200px', 'minmax(150px, 300px)', '1fr', 'auto').
* Persisted on `node.properties.gridOverrides`.
*/
export type WidgetGridOverrides = Record<string, string>
export function readGridOverrides(
node: LGraphNode
): WidgetGridOverrides | undefined {
const raw = node.properties?.[GRID_OVERRIDES_PROPERTY_KEY]
if (!raw || typeof raw !== 'object') return undefined
const entries = Object.entries(raw as Record<string, unknown>).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string'
)
if (entries.length === 0) return undefined
return Object.fromEntries(entries)
}
function writeGridOverrides(
node: LGraphNode,
overrides: WidgetGridOverrides
): void {
if (Object.keys(overrides).length === 0) {
if (node.properties) delete node.properties[GRID_OVERRIDES_PROPERTY_KEY]
return
}
node.properties ??= {}
node.properties[GRID_OVERRIDES_PROPERTY_KEY] = overrides
}
export function setGridOverride(
node: LGraphNode,
widgetName: string,
value: string
): void {
const current = readGridOverrides(node) ?? {}
current[widgetName] = value
writeGridOverrides(node, current)
}
export function clearGridOverride(node: LGraphNode, widgetName: string): void {
const current = readGridOverrides(node) ?? {}
delete current[widgetName]
writeGridOverrides(node, current)
}
export function clearAllGridOverrides(node: LGraphNode): void {
writeGridOverrides(node, {})
}