Compare commits

...

7 Commits

Author SHA1 Message Date
jaeone94
5b8d21bfa1 refactor: address test code review feedback
- Rename loadWithPositions to repositionNodes, decompose into
  getSerializedGraph + applyNodePositions (pure) + loadGraph
- Extract measureSelectionBounds to shared boundsUtils helper
- Add JSDoc to setCollapsed
- Move test helpers out of spec file into fixture utils
2026-03-30 02:54:59 +09:00
jaeone94
8b97cfb28d fix: accurate bounding rect for Vue nodes with footer
- Measure body (node-inner-wrapper) for node.size to exclude footer
  height, preventing size accumulation on Vue/legacy mode switching
- Store footer height separately (_footerHeight) for boundingRect
- Store collapsed width/height from DOM in ResizeObserver instead of
  relying on canvas text measurement
- Skip _collapsed_width text measurement in Vue nodes mode since
  measure() ctx overwrites ResizeObserver values
- Restore selectionBorder.ts to use createBounds (no per-frame DOM)
2026-03-30 02:12:45 +09:00
jaeone94
e93c5569c6 style: fine-tune footer overlap and inline sizing constants 2026-03-30 00:21:21 +09:00
jaeone94
0a3d4fd5bc fix: resize handles for footer nodes
- Split SE/SW handles: body handles hide when footer exists,
  footer-level handles render after NodeFooter
- Use body element (node-inner-wrapper) for resize start size
  instead of root element to exclude footer height
- Add z-10 to resize handles so they appear above footer buttons
- Restore hasFooter computed for handle visibility control
2026-03-30 00:04:14 +09:00
jaeone94
0d0cf610f2 refactor: unify node border and selection outline on root element
- Move border from overlay div to root element directly
- Replace separate selection/execution overlay with outline on root
- Remove overlay div, selectionShapeClass computed, and hasFooter
- Inline all footer sizing constants
- Add ring-inset border for Enter/Advanced buttons in error state
- Hide root border in error state (transparent) since ring-4 covers it
- Remove isCollapsed prop from NodeFooter (no longer needed)
2026-03-29 23:41:24 +09:00
jaeone94
ea878535e2 refactor: inline node footer layout with z-index layering
- Replace absolute overlay footer with inline flow layout
- Use z-index layering: body(z-5) > footer(z-0/z-2) to keep footer
  behind body while maintaining hover interactivity
- Move resize handles inside body to avoid footer overlap
- Use border-2 with negative inset for root border overlay to render
  outside body bounds, preventing slot dot occlusion (z-0)
- Shape-aware radius classes for error/enter/footer buttons
- Remove hasFooter computed and all footer offset classes
2026-03-29 18:46:00 +09:00
Dante
82242f1b00 refactor: add Badge component and fix twMerge font-size detection (#10580)
## Summary
- Rename `text-xxxs`/`text-xxs` to `text-3xs`/`text-2xs` in design
system CSS — fixes `tailwind-merge` incorrectly classifying custom
font-size utilities as color classes, which clobbered text color
- Add `Badge` component with updated severity colors matching Figma
design (white text on colored backgrounds)
- Add Badge stories under `Components/Badges/Badge`
- Add unit tests including twMerge regression coverage

Split from #10438 per review feedback — this PR contains the
foundational Badge component; migration of consumers follows in a
separate PR.

## Test plan
- [x] Unit tests pass (`Badge.test.ts` — 12 tests)
- [x] Typecheck passes
- [x] Lint passes
- [ ] Verify Badge stories render correctly in Storybook
- [ ] Verify existing components using `text-2xs`/`text-3xs` render
unchanged

Fixes #10438 (partial)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10580-refactor-add-Badge-component-and-fix-twMerge-font-size-detection-32f6d73d3650810dae7cd0d4af67fd1c)
by [Unito](https://www.unito.io)
2026-03-27 19:23:59 -07:00
29 changed files with 847 additions and 302 deletions

View File

@@ -0,0 +1,116 @@
{
"id": "selection-bbox-test",
"revision": 0,
"last_node_id": 3,
"last_link_id": 1,
"nodes": [
{
"id": 2,
"type": "e5fb1765-9323-4548-801a-5aead34d879e",
"pos": [300, 200],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "positive",
"type": "CONDITIONING",
"link": null
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [1]
}
],
"properties": {},
"widgets_values": []
},
{
"id": 3,
"type": "EmptyLatentImage",
"pos": [800, 200],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "latent",
"type": "LATENT",
"link": 1
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {},
"widgets_values": [512, 512, 1]
}
],
"links": [[1, 2, 0, 3, 0, "LATENT"]],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "e5fb1765-9323-4548-801a-5aead34d879e",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 1,
"lastLinkId": 0,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Test Subgraph",
"inputNode": {
"id": -10,
"bounding": [100, 200, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [500, 200, 120, 60]
},
"inputs": [
{
"id": "c5cc99d8-a2b6-4bf3-8be7-d4949ef736cd",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [],
"pos": { "0": 200, "1": 220 }
}
],
"outputs": [
{
"id": "9bd488b9-e907-4c95-a7a4-85c5597a87af",
"name": "LATENT",
"type": "LATENT",
"linkIds": [],
"pos": { "0": 520, "1": 220 }
}
],
"widgets": [],
"nodes": [],
"groups": [],
"links": [],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"version": 0.4
}

View File

@@ -4,7 +4,10 @@ import type {
LGraph,
LGraphNode
} from '../../../src/lib/litegraph/src/litegraph'
import type { NodeId } from '../../../src/platform/workflow/validation/schemas/workflowSchema'
import type {
ComfyWorkflowJSON,
NodeId
} from '../../../src/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyPage } from '../ComfyPage'
import { DefaultGraphPositions } from '../constants/defaultGraphPositions'
import type { Position, Size } from '../types'
@@ -111,6 +114,27 @@ export class NodeOperationsHelper {
}
}
async getSerializedGraph(): Promise<ComfyWorkflowJSON> {
return this.page.evaluate(
() => window.app!.graph.serialize() as ComfyWorkflowJSON
)
}
async loadGraph(data: ComfyWorkflowJSON): Promise<void> {
await this.page.evaluate(
(d) => window.app!.loadGraphData(d, true, true, null),
data
)
}
async repositionNodes(
positions: Record<string, [number, number]>
): Promise<void> {
const data = await this.getSerializedGraph()
applyNodePositions(data, positions)
await this.loadGraph(data)
}
async resizeNode(
nodePos: Position,
nodeSize: Size,
@@ -185,3 +209,13 @@ export class NodeOperationsHelper {
await this.comfyPage.nextFrame()
}
}
function applyNodePositions(
data: ComfyWorkflowJSON,
positions: Record<string, [number, number]>
): void {
for (const node of data.nodes) {
const pos = positions[String(node.id)]
if (pos) node.pos = pos
}
}

View File

@@ -0,0 +1,83 @@
import type { Page } from '@playwright/test'
export interface CanvasRect {
x: number
y: number
w: number
h: number
}
export interface MeasureResult {
selectionBounds: CanvasRect | null
nodeVisualBounds: Record<string, CanvasRect>
}
// Must match the padding value passed to createBounds() in selectionBorder.ts
const SELECTION_PADDING = 10
export async function measureSelectionBounds(
page: Page,
nodeIds: string[]
): Promise<MeasureResult> {
return page.evaluate(
({ ids, padding }) => {
const canvas = window.app!.canvas
const ds = canvas.ds
const selectedItems = canvas.selectedItems
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const item of selectedItems) {
const rect = item.boundingRect
minX = Math.min(minX, rect[0])
minY = Math.min(minY, rect[1])
maxX = Math.max(maxX, rect[0] + rect[2])
maxY = Math.max(maxY, rect[1] + rect[3])
}
const selectionBounds =
selectedItems.size > 0
? {
x: minX - padding,
y: minY - padding,
w: maxX - minX + 2 * padding,
h: maxY - minY + 2 * padding
}
: null
const canvasEl = canvas.canvas as HTMLCanvasElement
const canvasRect = canvasEl.getBoundingClientRect()
const nodeVisualBounds: Record<
string,
{ x: number; y: number; w: number; h: number }
> = {}
for (const id of ids) {
const nodeEl = document.querySelector(
`[data-node-id="${id}"]`
) as HTMLElement | null
if (!nodeEl) continue
const domRect = nodeEl.getBoundingClientRect()
const footerEls = nodeEl.querySelectorAll(
'[data-testid="subgraph-enter-button"], [data-testid="node-footer"]'
)
let bottom = domRect.bottom
for (const footerEl of footerEls) {
bottom = Math.max(bottom, footerEl.getBoundingClientRect().bottom)
}
nodeVisualBounds[id] = {
x: (domRect.left - canvasRect.left) / ds.scale - ds.offset[0],
y: (domRect.top - canvasRect.top) / ds.scale - ds.offset[1],
w: domRect.width / ds.scale,
h: (bottom - domRect.top) / ds.scale
}
}
return { selectionBounds, nodeVisualBounds }
},
{ ids: nodeIds, padding: SELECTION_PADDING }
) as Promise<MeasureResult>
}

View File

@@ -332,6 +332,17 @@ export class NodeReference {
async isCollapsed() {
return !!(await this.getFlags()).collapsed
}
/** Deterministic setter using node.collapse() API (not a toggle). */
async setCollapsed(collapsed: boolean) {
await this.comfyPage.page.evaluate(
([id, collapsed]) => {
const node = window.app!.canvas.graph!.getNodeById(id)
if (!node) throw new Error('Node not found')
if (node.collapsed !== collapsed) node.collapse(true)
},
[this.id, collapsed] as const
)
}
async isBypassed() {
return (await this.getProperty<number | null | undefined>('mode')) === 4
}

View File

@@ -0,0 +1,92 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { measureSelectionBounds } from '../fixtures/utils/boundsUtils'
const SUBGRAPH_ID = '2'
const REGULAR_ID = '3'
const WORKFLOW = 'selection/subgraph-with-regular-node'
const REF_POS: [number, number] = [100, 100]
const TARGET_POSITIONS: Record<string, [number, number]> = {
'bottom-left': [50, 500],
'bottom-right': [600, 500]
}
type NodeType = 'subgraph' | 'regular'
type NodeState = 'expanded' | 'collapsed'
type Position = 'bottom-left' | 'bottom-right'
function getTargetId(type: NodeType): string {
return type === 'subgraph' ? SUBGRAPH_ID : REGULAR_ID
}
function getRefId(type: NodeType): string {
return type === 'subgraph' ? REGULAR_ID : SUBGRAPH_ID
}
test.describe('Selection bounding box', { tag: ['@canvas', '@node'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.resetView()
})
const nodeTypes: NodeType[] = ['subgraph', 'regular']
const nodeStates: NodeState[] = ['expanded', 'collapsed']
const positions: Position[] = ['bottom-left', 'bottom-right']
for (const type of nodeTypes) {
for (const state of nodeStates) {
for (const pos of positions) {
test(`${type} node (${state}) at ${pos}: selection bounds encompass node`, async ({
comfyPage
}) => {
const page = comfyPage.page
const targetId = getTargetId(type)
const refId = getRefId(type)
await comfyPage.nodeOps.repositionNodes({
[refId]: REF_POS,
[targetId]: TARGET_POSITIONS[pos]
})
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.getNodeLocator(targetId).waitFor()
await comfyPage.vueNodes.getNodeLocator(refId).waitFor()
if (state === 'collapsed') {
const nodeRef = await comfyPage.nodeOps.getNodeRefById(targetId)
await nodeRef.setCollapsed(true)
}
await comfyPage.canvas.press('Control+a')
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(2)
await comfyPage.nextFrame()
const result = await measureSelectionBounds(page, [refId, targetId])
expect(result.selectionBounds).not.toBeNull()
const sel = result.selectionBounds!
const vis = result.nodeVisualBounds[targetId]
expect(vis).toBeDefined()
const selRight = sel.x + sel.w
const selBottom = sel.y + sel.h
const visRight = vis.x + vis.w
const visBottom = vis.y + vis.h
expect(sel.x).toBeLessThanOrEqual(vis.x)
expect(selRight).toBeGreaterThanOrEqual(visRight)
expect(sel.y).toBeLessThanOrEqual(vis.y)
expect(selBottom).toBeGreaterThanOrEqual(visBottom)
})
}
}
}
})

View File

@@ -28,11 +28,8 @@
--text-2xs: 0.625rem;
--text-2xs--line-height: calc(1 / 0.625);
--text-xxs: 0.625rem;
--text-xxs--line-height: calc(1 / 0.625);
--text-xxxs: 0.5625rem;
--text-xxxs--line-height: calc(1 / 0.5625);
--text-3xs: 0.5625rem;
--text-3xs--line-height: calc(1 / 0.5625);
/* Font Families */
--font-inter: 'Inter', sans-serif;

View File

@@ -0,0 +1,116 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Badge from './Badge.vue'
const meta = {
title: 'Components/Badges/Badge',
component: Badge,
tags: ['autodocs'],
argTypes: {
label: { control: 'text' },
severity: {
control: 'select',
options: ['default', 'secondary', 'warn', 'danger', 'contrast']
},
variant: {
control: 'select',
options: ['label', 'dot', 'circle']
}
},
args: {
label: 'NEW',
severity: 'default'
}
} satisfies Meta<typeof Badge>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const Secondary: Story = {
args: {
label: 'NEW',
severity: 'secondary'
}
}
export const Warn: Story = {
args: {
label: 'NEW',
severity: 'warn'
}
}
export const Danger: Story = {
args: {
label: 'NEW',
severity: 'danger'
}
}
export const Contrast: Story = {
args: {
label: 'NEW',
severity: 'contrast'
}
}
export const Circle: Story = {
args: {
label: '3',
variant: 'circle'
}
}
export const AllSeveritiesLabel: Story = {
render: () => ({
components: { Badge },
template: `
<div class="flex items-center gap-2">
<Badge label="NEW" severity="default" />
<Badge label="NEW" severity="secondary" />
<Badge label="NEW" severity="warn" />
<Badge label="NEW" severity="danger" />
<Badge label="NEW" severity="contrast" />
</div>
`
})
}
export const AllSeveritiesDot: Story = {
render: () => ({
components: { Badge },
template: `
<div class="flex items-center gap-2">
<Badge variant="dot" severity="default" />
<Badge variant="dot" severity="secondary" />
<Badge variant="dot" severity="warn" />
<Badge variant="dot" severity="danger" />
<Badge variant="dot" severity="contrast" />
</div>
`
})
}
export const AllVariants: Story = {
render: () => ({
components: { Badge },
template: `
<div class="flex items-center gap-4">
<div class="flex flex-col items-center gap-1">
<Badge label="NEW" variant="label" />
<span class="text-xs text-muted-foreground">label</span>
</div>
<div class="flex flex-col items-center gap-1">
<Badge variant="dot" severity="danger" />
<span class="text-xs text-muted-foreground">dot</span>
</div>
<div class="flex flex-col items-center gap-1">
<Badge label="5" variant="circle" />
<span class="text-xs text-muted-foreground">circle</span>
</div>
</div>
`
})
}

View File

@@ -0,0 +1,69 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import Badge from './Badge.vue'
import { badgeVariants } from './badge.variants'
describe('Badge', () => {
it('renders label text', () => {
const wrapper = mount(Badge, { props: { label: 'NEW' } })
expect(wrapper.text()).toBe('NEW')
})
it('renders numeric label', () => {
const wrapper = mount(Badge, { props: { label: 5 } })
expect(wrapper.text()).toBe('5')
})
it('defaults to dot variant when no label is provided', () => {
const wrapper = mount(Badge)
expect(wrapper.classes()).toContain('size-2')
})
it('defaults to label variant when label is provided', () => {
const wrapper = mount(Badge, { props: { label: 'NEW' } })
expect(wrapper.classes()).toContain('font-semibold')
expect(wrapper.classes()).toContain('uppercase')
})
it('applies circle variant', () => {
const wrapper = mount(Badge, {
props: { label: '3', variant: 'circle' }
})
expect(wrapper.classes()).toContain('size-3.5')
})
it('merges custom class via cn()', () => {
const wrapper = mount(Badge, {
props: { label: 'Test', class: 'ml-2' }
})
expect(wrapper.classes()).toContain('ml-2')
expect(wrapper.classes()).toContain('rounded-full')
})
describe('twMerge preserves color alongside text-3xs font size', () => {
it.each([
['default', 'text-white'],
['secondary', 'text-white'],
['warn', 'text-white'],
['danger', 'text-white'],
['contrast', 'text-base-background']
] as const)(
'%s severity retains its text color class',
(severity, expectedColor) => {
const classes = badgeVariants({ severity, variant: 'label' })
expect(classes).toContain(expectedColor)
expect(classes).toContain('text-3xs')
}
)
it('cn() does not clobber text-white when merging with text-3xs', () => {
const wrapper = mount(Badge, {
props: { label: 'Test', severity: 'danger' }
})
const classList = wrapper.classes()
expect(classList).toContain('text-white')
expect(classList).toContain('text-3xs')
})
})
})

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { badgeVariants } from './badge.variants'
import type { BadgeVariants } from './badge.variants'
const {
label,
severity = 'default',
variant,
class: className
} = defineProps<{
label?: string | number
severity?: BadgeVariants['severity']
variant?: BadgeVariants['variant']
class?: string
}>()
const badgeClass = computed(() =>
cn(
badgeVariants({
severity,
variant: variant ?? (label == null ? 'dot' : 'label')
}),
className
)
)
</script>
<template>
<span :class="badgeClass">
{{ label }}
</span>
</template>

View File

@@ -3,7 +3,7 @@
data-testid="badge-pill"
:class="
cn(
'flex items-center gap-1 rounded-sm border px-1.5 py-0.5 text-xxs',
'flex items-center gap-1 rounded-sm border px-1.5 py-0.5 text-2xs',
textColorClass
)
"

View File

@@ -59,7 +59,7 @@ defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
<i v-if="item.checked" class="icon-[lucide--check] shrink-0" />
<div
v-else-if="item.new"
class="flex shrink-0 items-center rounded-full bg-primary-background px-1 text-xxs leading-none font-bold"
class="flex shrink-0 items-center rounded-full bg-primary-background px-1 text-2xs leading-none font-bold"
v-text="t('contextMenu.new')"
/>
</DropdownMenuItem>

View File

@@ -1,95 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import StatusBadge from './StatusBadge.vue'
const meta = {
title: 'Common/StatusBadge',
component: StatusBadge,
tags: ['autodocs'],
argTypes: {
label: { control: 'text' },
severity: {
control: 'select',
options: ['default', 'secondary', 'warn', 'danger', 'contrast']
},
variant: {
control: 'select',
options: ['label', 'dot', 'circle']
}
},
args: {
label: 'Status',
severity: 'default'
}
} satisfies Meta<typeof StatusBadge>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const Failed: Story = {
args: {
label: 'Failed',
severity: 'danger'
}
}
export const Finished: Story = {
args: {
label: 'Finished',
severity: 'contrast'
}
}
export const Dot: Story = {
args: {
label: undefined,
variant: 'dot',
severity: 'danger'
}
}
export const Circle: Story = {
args: {
label: '3',
variant: 'circle'
}
}
export const AllSeverities: Story = {
render: () => ({
components: { StatusBadge },
template: `
<div class="flex items-center gap-2">
<StatusBadge label="Default" severity="default" />
<StatusBadge label="Secondary" severity="secondary" />
<StatusBadge label="Warn" severity="warn" />
<StatusBadge label="Danger" severity="danger" />
<StatusBadge label="Contrast" severity="contrast" />
</div>
`
})
}
export const AllVariants: Story = {
render: () => ({
components: { StatusBadge },
template: `
<div class="flex items-center gap-4">
<div class="flex flex-col items-center gap-1">
<StatusBadge label="Label" variant="label" />
<span class="text-xs text-muted">label</span>
</div>
<div class="flex flex-col items-center gap-1">
<StatusBadge variant="dot" severity="danger" />
<span class="text-xs text-muted">dot</span>
</div>
<div class="flex flex-col items-center gap-1">
<StatusBadge label="5" variant="circle" />
<span class="text-xs text-muted">circle</span>
</div>
</div>
`
})
}

View File

@@ -44,7 +44,7 @@ const {
<span class="flex-1">{{ item.label }}</span>
<span
v-if="item.badge"
class="ml-3 flex items-center gap-1 rounded-full bg-(--primary-background) px-1.5 py-0.5 text-xxs text-base-foreground uppercase"
class="ml-3 flex items-center gap-1 rounded-full bg-(--primary-background) px-1.5 py-0.5 text-2xs text-base-foreground uppercase"
>
{{ item.badge }}
</span>

View File

@@ -0,0 +1,26 @@
import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const badgeVariants = cva({
base: 'inline-flex items-center justify-center rounded-full',
variants: {
severity: {
default: 'bg-primary-background text-white',
secondary: 'bg-secondary-background-hover text-white',
warn: 'bg-warning-background text-white',
danger: 'bg-destructive-background text-white',
contrast: 'bg-base-foreground text-base-background'
},
variant: {
label: 'h-3.5 px-1 text-3xs font-semibold uppercase',
dot: 'size-2',
circle: 'size-3.5 text-3xs font-semibold'
}
},
defaultVariants: {
severity: 'default',
variant: 'label'
}
})
export type BadgeVariants = VariantProps<typeof badgeVariants>

View File

@@ -12,9 +12,9 @@ export const statusBadgeVariants = cva({
contrast: 'bg-base-foreground text-base-background'
},
variant: {
label: 'h-3.5 px-1 text-xxxs font-semibold uppercase',
label: 'h-3.5 px-1 text-3xs font-semibold uppercase',
dot: 'size-2',
circle: 'size-3.5 text-xxxs font-semibold'
circle: 'size-3.5 text-3xs font-semibold'
}
},
defaultVariants: {

View File

@@ -49,14 +49,14 @@
class="flex flex-col gap-1"
>
<h4
class="m-0 text-xxs font-semibold tracking-wide text-muted-foreground uppercase"
class="m-0 text-2xs font-semibold tracking-wide text-muted-foreground uppercase"
>
{{ $t('nodeHelpPage.inputs') }}
</h4>
<div
v-for="input in inputs"
:key="input.name"
class="flex items-center justify-between gap-2 text-xxs"
class="flex items-center justify-between gap-2 text-2xs"
>
<span class="text-foreground shrink-0">{{ input.name }}</span>
<span class="min-w-0 truncate text-muted-foreground">{{
@@ -71,14 +71,14 @@
class="flex flex-col gap-1"
>
<h4
class="m-0 text-xxs font-semibold tracking-wide text-muted-foreground uppercase"
class="m-0 text-2xs font-semibold tracking-wide text-muted-foreground uppercase"
>
{{ $t('nodeHelpPage.outputs') }}
</h4>
<div
v-for="output in outputs"
:key="output.name"
class="flex items-center justify-between gap-2 text-xxs"
class="flex items-center justify-between gap-2 text-2xs"
>
<span class="text-foreground shrink-0">{{ output.name }}</span>
<span class="min-w-0 truncate text-muted-foreground">{{

View File

@@ -8,7 +8,7 @@
>
<template #alt-title>
<span
class="ml-2 flex items-center rounded-full bg-primary-background px-1.5 py-0.5 text-xxs text-base-foreground uppercase"
class="ml-2 flex items-center rounded-full bg-primary-background px-1.5 py-0.5 text-2xs text-base-foreground uppercase"
>
{{ $t('g.beta') }}
</span>

View File

@@ -14,7 +14,7 @@
/>
<div
v-else-if="badge.label"
class="shrink-0 rounded-full px-1.5 py-0.5 text-xxxs font-semibold"
class="shrink-0 rounded-full px-1.5 py-0.5 text-3xs font-semibold"
:class="labelClasses"
>
{{ badge.label }}
@@ -33,7 +33,7 @@
<div class="flex max-w-xs min-w-40 flex-col gap-2 p-3">
<div
v-if="badge.label"
class="w-fit rounded-full px-1.5 py-0.5 text-xxxs font-semibold"
class="w-fit rounded-full px-1.5 py-0.5 text-3xs font-semibold"
:class="labelClasses"
>
{{ badge.label }}
@@ -68,7 +68,7 @@
/>
<div
v-if="badge.label"
class="shrink-0 rounded-full px-1.5 py-0.5 text-xxxs font-semibold"
class="shrink-0 rounded-full px-1.5 py-0.5 text-3xs font-semibold"
:class="labelClasses"
>
{{ badge.label }}
@@ -87,7 +87,7 @@
<div class="flex max-w-xs min-w-40 flex-col gap-2 p-3">
<div
v-if="badge.label"
class="w-fit rounded-full px-1.5 py-0.5 text-xxxs font-semibold"
class="w-fit rounded-full px-1.5 py-0.5 text-3xs font-semibold"
:class="labelClasses"
>
{{ badge.label }}
@@ -115,7 +115,7 @@
/>
<div
v-if="badge.label"
class="shrink-0 rounded-full px-1.5 py-0.5 text-xxxs font-semibold"
class="shrink-0 rounded-full px-1.5 py-0.5 text-3xs font-semibold"
:class="labelClasses"
>
{{ badge.label }}

View File

@@ -41,7 +41,7 @@ export const WithLabel: Story = {
},
template: `
<div class="relative max-w-sm rounded-lg bg-component-node-widget-background">
<label class="pointer-events-none absolute left-3 top-1.5 text-xxs text-muted-foreground z-10">
<label class="pointer-events-none absolute left-3 top-1.5 text-2xs text-muted-foreground z-10">
Prompt
</label>
<Textarea

View File

@@ -560,7 +560,7 @@ function drawDisconnectedPlaceholder(
'#333'
)
const textColor = readDesignToken('--color-text-secondary', '#999')
const fontSize = readDesignToken('--text-xxs', '11px')
const fontSize = readDesignToken('--text-2xs', '11px')
const fontFamily = readDesignToken('--font-inter', 'sans-serif')
ctx.save()

View File

@@ -422,6 +422,16 @@ export class LGraphNode
* Updated by {@link LGraphCanvas.drawNode}
*/
_collapsed_width?: number
/**
* The height of the node when collapsed (including footer).
* Set by ResizeObserver in Vue nodes mode.
*/
_collapsed_height?: number
/**
* The footer height in Vue nodes mode.
* Set by ResizeObserver, used by measure() to extend boundingRect.
*/
_footerHeight?: number
/**
* Called once at the start of every frame. Caller may change the values in {@link out}, which will be reflected in {@link boundingRect}.
* WARNING: Making changes to boundingRect via onBounding is poorly supported, and will likely result in strange behaviour.
@@ -2090,18 +2100,18 @@ export class LGraphNode
out[1] = this.pos[1] + -titleHeight
if (!this.flags?.collapsed) {
out[2] = this.size[0]
out[3] = this.size[1] + titleHeight
out[3] = this.size[1] + titleHeight + (this._footerHeight ?? 0)
} else {
if (ctx) ctx.font = this.innerFontStyle
this._collapsed_width = Math.min(
this.size[0],
ctx
? cachedMeasureText(ctx, this.getTitle() ?? '') +
LiteGraph.NODE_TITLE_HEIGHT * 2
: 0
)
if (ctx && !LiteGraph.vueNodesMode) {
ctx.font = this.innerFontStyle
this._collapsed_width = Math.min(
this.size[0],
cachedMeasureText(ctx, this.getTitle() ?? '') +
LiteGraph.NODE_TITLE_HEIGHT * 2
)
}
out[2] = this._collapsed_width || LiteGraph.NODE_COLLAPSED_WIDTH
out[3] = LiteGraph.NODE_TITLE_HEIGHT
out[3] = this._collapsed_height || LiteGraph.NODE_TITLE_HEIGHT
}
}

View File

@@ -2,7 +2,7 @@
<div class="flex items-center gap-2 p-4 font-bold">
<span>{{ $t('assetBrowser.uploadModelGeneric') }}</span>
<span
class="rounded-full bg-white px-1.5 py-0 font-inter text-xxs font-semibold text-black uppercase"
class="rounded-full bg-white px-1.5 py-0 font-inter text-2xs font-semibold text-black uppercase"
>
{{ $t('g.beta') }}
</span>

View File

@@ -41,7 +41,7 @@ const templateSelectorDialog = useWorkflowTemplateSelectorDialog()
<i18n-t keypath="linearMode.welcome.getStarted" tag="span">
<template #runButton>
<span
class="mx-0.5 inline-flex -translate-y-0.5 transform cursor-default items-center rounded-sm bg-primary-background px-3.5 py-0.5 text-xxs font-medium text-base-foreground"
class="mx-0.5 inline-flex -translate-y-0.5 transform cursor-default items-center rounded-sm bg-primary-background px-3.5 py-0.5 text-2xs font-medium text-base-foreground"
>
{{ t('menu.run') }}
</span>
@@ -86,7 +86,7 @@ const templateSelectorDialog = useWorkflowTemplateSelectorDialog()
<i class="icon-[lucide--hammer]" />
{{ t('linearMode.welcome.buildApp') }}
<div
class="absolute -top-2 -right-2 rounded-full bg-base-foreground px-1 text-xxs text-base-background"
class="absolute -top-2 -right-2 rounded-full bg-base-foreground px-1 text-2xs text-base-background"
>
{{ t('g.experimental') }}
</div>

View File

@@ -10,12 +10,12 @@
:data-collapsed="isCollapsed || undefined"
:class="
cn(
'group/node lg-node absolute isolate text-sm',
'flex flex-col contain-layout contain-style',
'group/node lg-node absolute isolate flex flex-col border border-solid text-sm contain-layout contain-style',
hasAnyError ? 'border-transparent' : 'border-component-node-border',
rootBorderShapeClass,
outlineClass,
isRerouteNode ? 'h-(--node-height)' : 'min-w-(--min-node-width)',
cursorClass,
isSelected && 'outline-node-component-outline',
executing && 'outline-node-stroke-executing',
shouldHandleNodePointerEvents && !nodeData.flags?.ghost
? 'pointer-events-auto'
: 'pointer-events-none'
@@ -45,37 +45,11 @@
"
:id="nodeData.id"
/>
<div
v-if="isSelected || executing"
data-testid="node-state-outline-overlay"
:class="
cn(
'pointer-events-none absolute z-0 border-3 outline-none',
selectionShapeClass,
hasAnyError ? '-inset-[7px]' : '-inset-[3px]',
isSelected
? 'border-node-component-outline'
: 'border-node-stroke-executing',
footerStateOutlineBottomClass
)
"
/>
<!-- Root Border Overlay -->
<div
:class="
cn(
'pointer-events-none absolute border border-solid border-component-node-border',
rootBorderShapeClass,
hasAnyError ? '-inset-1' : 'inset-0',
footerRootBorderBottomClass
)
"
/>
<div
data-testid="node-inner-wrapper"
:class="
cn(
'flex flex-1 flex-col border border-solid border-transparent bg-node-component-header-surface',
'relative z-5 flex flex-1 flex-col border border-solid border-transparent bg-node-component-header-surface',
'w-(--node-width)',
!isRerouteNode && 'min-h-(--node-height) min-w-(--min-node-width)',
shapeClass,
@@ -190,13 +164,58 @@
/>
</div>
</template>
<template
v-if="
!isCollapsed &&
!isRerouteNode &&
nodeData.resizable !== false &&
!isSelectMode
"
>
<div
v-for="handle in RESIZE_HANDLES"
v-show="
handle.corner === 'NE' || handle.corner === 'NW' || !hasFooter
"
:key="handle.corner"
role="button"
:aria-label="t(handle.i18nKey)"
:class="
cn(
baseResizeHandleClasses,
handle.positionClasses,
handle.cursorClass,
'group-hover/node:opacity-100'
)
"
@pointerdown.stop="handleResizePointerDown($event, handle.corner)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 12 12"
:class="cn('absolute size-2/5', handle.svgPositionClasses)"
:style="
handle.svgTransform
? { transform: handle.svgTransform }
: undefined
"
>
<path
d="M11 1L1 11M11 6L6 11"
stroke="var(--color-muted-foreground)"
stroke-width="0.975"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
</template>
</div>
<NodeFooter
v-if="!isRerouteNode"
:is-subgraph="!!lgraphNode?.isSubgraphNode()"
:has-any-error="hasAnyError"
:show-errors-tab-enabled="showErrorsTabEnabled"
:is-collapsed="isCollapsed"
:show-advanced-inputs-button="showAdvancedInputsButton"
:show-advanced-state="showAdvancedState"
:header-color="applyLightThemeColor(nodeData?.color)"
@@ -207,6 +226,7 @@
/>
<template
v-if="
hasFooter &&
!isCollapsed &&
!isRerouteNode &&
nodeData.resizable !== false &&
@@ -214,16 +234,16 @@
"
>
<div
v-for="handle in RESIZE_HANDLES"
:key="handle.corner"
v-for="handle in RESIZE_HANDLES.filter(
(h) => h.corner === 'SE' || h.corner === 'SW'
)"
:key="`footer-${handle.corner}`"
role="button"
:aria-label="t(handle.i18nKey)"
:class="
cn(
baseResizeHandleClasses,
handle.positionClasses,
(handle.corner === 'SE' || handle.corner === 'SW') &&
footerResizeHandleBottomClass,
handle.cursorClass,
'group-hover/node:opacity-100'
)
@@ -504,7 +524,7 @@ onUnmounted(() => {
})
const baseResizeHandleClasses =
'absolute h-5 w-5 opacity-0 pointer-events-auto focus-visible:outline focus-visible:outline-2 focus-visible:outline-white/40'
'absolute z-10 h-5 w-5 opacity-0 pointer-events-auto focus-visible:outline focus-visible:outline-2 focus-visible:outline-white/40'
const mutations = useLayoutMutations()
@@ -565,6 +585,25 @@ const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
}
)
const outlineClass = computed(() => {
const color = isSelected.value
? 'outline-node-component-outline'
: executing.value
? 'outline-node-stroke-executing'
: null
if (!color) return ''
if (!hasAnyError.value) return cn('outline-3', color)
const errorRadius =
nodeData.shape === RenderShape.BOX
? ''
: nodeData.shape === RenderShape.CARD
? 'rounded-tl-[16px] rounded-br-[16px]'
: 'rounded-[16px]'
return cn('outline-4 outline-offset-[3px]', errorRadius, color)
})
const hasFooter = computed(() => {
return !!(
(hasAnyError.value && showErrorsTabEnabled.value) ||
@@ -574,21 +613,6 @@ const hasFooter = computed(() => {
)
})
// Footer offset computed classes
const footerStateOutlineBottomClass = computed(() =>
hasFooter.value ? '-bottom-[35px]' : ''
)
const footerRootBorderBottomClass = computed(() =>
hasFooter.value ? '-bottom-8' : ''
)
const footerResizeHandleBottomClass = computed(() => {
if (!hasFooter.value) return ''
return hasAnyError.value ? 'bottom-[-31px]' : 'bottom-[-35px]'
})
const cursorClass = computed(() => {
if (nodeData.flags?.pinned) return 'cursor-default'
return layoutStore.isDraggingVueNodes.value
@@ -601,9 +625,9 @@ const bodyRoundingClass = computed(() => {
case RenderShape.BOX:
return ''
case RenderShape.CARD:
return 'rounded-br-2xl'
return 'rounded-br-[15px]'
default:
return 'rounded-b-2xl'
return 'rounded-b-[15px]'
}
})
@@ -612,9 +636,9 @@ const shapeClass = computed(() => {
case RenderShape.BOX:
return ''
case RenderShape.CARD:
return 'rounded-tl-2xl rounded-br-2xl'
return 'rounded-tl-[15px] rounded-br-[15px]'
default:
return 'rounded-2xl'
return 'rounded-[15px]'
}
})
@@ -627,33 +651,15 @@ const isTransparentHeaderless = computed(
const rootBorderShapeClass = computed(() => {
if (isTransparentHeaderless.value) return 'border-0'
const isExpanded = hasAnyError.value
switch (nodeData.shape) {
case RenderShape.BOX:
return ''
case RenderShape.CARD:
return isExpanded
? 'rounded-tl-[20px] rounded-br-[20px]'
: 'rounded-tl-2xl rounded-br-2xl'
return hasAnyError.value
? 'rounded-tl-[21px] rounded-br-[21px]'
: 'rounded-tl-[16px] rounded-br-[16px]'
default:
return isExpanded ? 'rounded-[20px]' : 'rounded-2xl'
}
})
const selectionShapeClass = computed(() => {
if (isTransparentHeaderless.value) return 'border-0'
const isExpanded = hasAnyError.value
switch (nodeData.shape) {
case RenderShape.BOX:
return ''
case RenderShape.CARD:
return isExpanded
? 'rounded-tl-[23px] rounded-br-[23px]'
: 'rounded-tl-[19px] rounded-br-[19px]'
default:
return isExpanded ? 'rounded-[23px]' : 'rounded-[19px]'
return hasAnyError.value ? 'rounded-[21px]' : 'rounded-[16px]'
}
})

View File

@@ -1,13 +1,16 @@
<template>
<!-- Case 1: Subgraph + Error (Dual Tabs) -->
<template v-if="isSubgraph && hasAnyError && showErrorsTabEnabled">
<div
v-if="isSubgraph && hasAnyError && showErrorsTabEnabled"
class="-mx-1 -mt-5 -mb-2 box-border flex w-[calc(100%+8px)] pb-1"
>
<Button
variant="textonly"
:class="
cn(
getTabStyles(false),
errorTabWidth,
'-z-5 bg-destructive-background text-white hover:bg-destructive-background-hover'
'box-border w-1/2 rounded-none bg-destructive-background pt-9 pb-4 text-white hover:bg-destructive-background-hover',
errorRadiusClass
)
"
@click.stop="$emit('openErrors')"
@@ -24,36 +27,37 @@
:class="
cn(
getTabStyles(true),
enterTabFullWidth,
'-z-10 bg-node-component-header-surface'
'-ml-5 box-border w-[calc(50%+20px)] rounded-none bg-node-component-header-surface pt-9 pb-4 pl-5 ring-1 ring-component-node-border ring-inset',
enterRadiusClass
)
"
:style="headerColorStyle"
@click.stop="$emit('enterSubgraph')"
>
<div class="ml-auto flex h-full w-1/2 items-center justify-center gap-2">
<div class="flex size-full items-center justify-center gap-2">
<span class="truncate">{{ t('g.enter') }}</span>
<i class="icon-[comfy--workflow] size-4 shrink-0" />
</div>
</Button>
</template>
</div>
<!-- Case 1b: Advanced + Error (Dual Tabs, Regular Nodes) -->
<template
<div
v-else-if="
!isSubgraph &&
hasAnyError &&
showErrorsTabEnabled &&
(showAdvancedInputsButton || showAdvancedState)
"
class="-mx-1 -mt-5 -mb-2 box-border flex w-[calc(100%+8px)] pb-1"
>
<Button
variant="textonly"
:class="
cn(
getTabStyles(false),
errorTabWidth,
'-z-5 bg-destructive-background text-white hover:bg-destructive-background-hover'
'box-border w-1/2 rounded-none bg-destructive-background pt-9 pb-4 text-white hover:bg-destructive-background-hover',
errorRadiusClass
)
"
@click.stop="$emit('openErrors')"
@@ -69,14 +73,14 @@
:class="
cn(
getTabStyles(true),
enterTabFullWidth,
'-z-10 bg-node-component-header-surface'
'-ml-5 box-border w-[calc(50%+20px)] rounded-none bg-node-component-header-surface pt-9 pb-4 pl-5 ring-1 ring-component-node-border ring-inset',
enterRadiusClass
)
"
:style="headerColorStyle"
@click.stop="$emit('toggleAdvanced')"
>
<div class="ml-auto flex h-full w-1/2 items-center justify-center gap-2">
<div class="flex size-full items-center justify-center gap-2">
<span class="truncate">{{
showAdvancedState
? t('rightSidePanel.hideAdvancedShort')
@@ -91,17 +95,20 @@
/>
</div>
</Button>
</template>
</div>
<!-- Case 2: Error Only (Full Width) -->
<template v-else-if="hasAnyError && showErrorsTabEnabled">
<div
v-else-if="hasAnyError && showErrorsTabEnabled"
class="-mx-1 -mt-5 -mb-2 box-border flex w-[calc(100%+8px)] pb-1"
>
<Button
variant="textonly"
:class="
cn(
getTabStyles(false),
enterTabFullWidth,
'-z-5 bg-destructive-background text-white hover:bg-destructive-background-hover'
'box-border w-full rounded-none bg-destructive-background pt-9 pb-4 text-white hover:bg-destructive-background-hover',
footerRadiusClass
)
"
@click.stop="$emit('openErrors')"
@@ -111,18 +118,29 @@
<i class="icon-[lucide--info] size-4 shrink-0" />
</div>
</Button>
</template>
</div>
<!-- Case 3: Subgraph only (Full Width) -->
<template v-else-if="isSubgraph">
<div
v-else-if="isSubgraph"
:class="
cn(
'-mt-5 box-border flex',
hasAnyError ? '-mx-1 -mb-2 w-[calc(100%+8px)] pb-1' : 'w-full'
)
"
>
<Button
variant="textonly"
data-testid="subgraph-enter-button"
:class="
cn(
getTabStyles(true),
hasAnyError ? 'w-[calc(100%+8px)]' : 'w-full',
'-z-10 bg-node-component-header-surface'
'box-border w-full rounded-none bg-node-component-header-surface',
hasAnyError
? 'pt-9 pb-4 ring-1 ring-component-node-border ring-inset'
: 'pt-8 pb-4',
footerRadiusClass
)
"
:style="headerColorStyle"
@@ -133,37 +151,49 @@
<i class="icon-[comfy--workflow] size-4 shrink-0" />
</div>
</Button>
</template>
</div>
<!-- Case 4: Advanced Footer (Regular Nodes) -->
<Button
<div
v-else-if="showAdvancedInputsButton || showAdvancedState"
variant="textonly"
:class="
cn(
getTabStyles(true),
hasAnyError ? 'w-[calc(100%+8px)]' : 'w-full',
'-z-10 bg-node-component-header-surface'
'-mt-5 box-border flex',
hasAnyError ? '-mx-1 -mb-2 w-[calc(100%+8px)] pb-1' : 'w-full'
)
"
:style="headerColorStyle"
@click.stop="$emit('toggleAdvanced')"
>
<div class="flex size-full items-center justify-center gap-2">
<template v-if="showAdvancedState">
<span class="truncate">{{
t('rightSidePanel.hideAdvancedInputsButton')
}}</span>
<i class="icon-[lucide--chevron-up] size-4 shrink-0" />
</template>
<template v-else>
<span class="truncate">{{
t('rightSidePanel.showAdvancedInputsButton')
}}</span>
<i class="icon-[lucide--settings-2] size-4 shrink-0" />
</template>
</div>
</Button>
<Button
variant="textonly"
:class="
cn(
getTabStyles(true),
'box-border w-full rounded-none bg-node-component-header-surface',
hasAnyError
? 'pt-9 pb-4 ring-1 ring-component-node-border ring-inset'
: 'pt-8 pb-4',
footerRadiusClass
)
"
:style="headerColorStyle"
@click.stop="$emit('toggleAdvanced')"
>
<div class="flex size-full items-center justify-center gap-2">
<template v-if="showAdvancedState">
<span class="truncate">{{
t('rightSidePanel.hideAdvancedInputsButton')
}}</span>
<i class="icon-[lucide--chevron-up] size-4 shrink-0" />
</template>
<template v-else>
<span class="truncate">{{
t('rightSidePanel.showAdvancedInputsButton')
}}</span>
<i class="icon-[lucide--settings-2] size-4 shrink-0" />
</template>
</div>
</Button>
</div>
</template>
<script setup lang="ts">
@@ -179,7 +209,6 @@ interface Props {
isSubgraph: boolean
hasAnyError: boolean
showErrorsTabEnabled: boolean
isCollapsed: boolean
showAdvancedInputsButton?: boolean
showAdvancedState?: boolean
headerColor?: string
@@ -195,51 +224,43 @@ defineEmits<{
}>()
const footerRadiusClass = computed(() => {
const isExpanded = props.hasAnyError
const isError = props.hasAnyError
switch (props.shape) {
case RenderShape.BOX:
return ''
case RenderShape.CARD:
return isExpanded ? 'rounded-br-[20px]' : 'rounded-br-2xl'
return isError ? 'rounded-br-[19px]' : 'rounded-br-[15px]'
default:
return isExpanded ? 'rounded-b-[20px]' : 'rounded-b-2xl'
return isError ? 'rounded-b-[19px]' : 'rounded-b-[15px]'
}
})
/**
* Returns shared size/position classes for footer tabs
* @param isBackground If true, calculates styles for the background/right tab (Enter Subgraph)
*/
const getTabStyles = (isBackground = false) => {
let sizeClasses = ''
if (props.isCollapsed) {
let pt = 'pt-10'
if (isBackground) {
pt = props.hasAnyError ? 'pt-10.5' : 'pt-9'
}
sizeClasses = cn('-mt-7.5 h-15', pt)
} else {
let pt = 'pt-12.5'
if (isBackground) {
pt = props.hasAnyError ? 'pt-12.5' : 'pt-11.5'
}
sizeClasses = cn('-mt-10 h-17.5', pt)
const errorRadiusClass = computed(() => {
switch (props.shape) {
case RenderShape.BOX:
return ''
case RenderShape.CARD:
return 'rounded-br-[19px]'
default:
return 'rounded-b-[19px]'
}
})
return cn(
'pointer-events-auto absolute top-full left-0 text-xs',
footerRadiusClass.value,
sizeClasses,
props.hasAnyError ? '-translate-x-1 translate-y-0.5' : 'translate-y-0.5'
)
const enterRadiusClass = computed(() => {
switch (props.shape) {
case RenderShape.BOX:
return ''
case RenderShape.CARD:
default:
return 'rounded-br-[19px]'
}
})
const getTabStyles = (isBackground = false) => {
return cn('pointer-events-auto h-9 text-xs', isBackground ? 'z-0' : 'z-2')
}
const headerColorStyle = computed(() =>
props.headerColor ? { backgroundColor: props.headerColor } : undefined
)
// Case 1 context: Split widths
const errorTabWidth = 'w-[calc(50%+4px)]'
const enterTabFullWidth = 'w-[calc(100%+8px)]'
</script>

View File

@@ -15,8 +15,9 @@ import { useDocumentVisibility } from '@vueuse/core'
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { app } from '@/scripts/app'
import type { Bounds, NodeId } from '@/renderer/core/layout/types'
import { LayoutSource } from '@/renderer/core/layout/types'
import {
@@ -139,25 +140,44 @@ const resizeObserver = new ResizeObserver((entries) => {
const nodeId: NodeId | undefined =
elementType === 'node' ? elementId : undefined
// Skip collapsed nodes — their DOM height is just the header, and writing
// that back to the layout store would overwrite the stored expanded size.
// Collapsed nodes: don't update layoutStore (preserve expanded size),
// but sync the collapsed DOM width to litegraph for boundingRect.
if (elementType === 'node' && element.dataset.collapsed != null) {
if (nodeId) {
const lgNode = app.graph?.getNodeById(nodeId)
if (lgNode) {
const body = element.querySelector(
'[data-testid^="node-inner-wrapper"]'
)
lgNode._collapsed_width =
body instanceof HTMLElement ? body.offsetWidth : element.offsetWidth
lgNode._collapsed_height = element.offsetHeight
}
nodesNeedingSlotResync.add(nodeId)
}
continue
}
// Use borderBoxSize when available; fall back to contentRect for older engines/tests
// Border box is the border included FULL wxh DOM value.
const borderBox = Array.isArray(entry.borderBoxSize)
? entry.borderBoxSize[0]
: {
inlineSize: entry.contentRect.width,
blockSize: entry.contentRect.height
// Measure body (node-inner-wrapper) for node.size to exclude footer,
// but store the full height (with footer) for boundingRect.
const bodyEl = element.querySelector('[data-testid^="node-inner-wrapper"]')
const measuredEl = bodyEl instanceof HTMLElement ? bodyEl : element
const width = Math.max(0, measuredEl.offsetWidth)
const height = Math.max(0, measuredEl.offsetHeight)
const fullHeight = Math.max(0, element.offsetHeight)
// Store footer-inclusive height for boundingRect calculation
if (nodeId) {
const lgNode = app.graph?.getNodeById(nodeId)
if (lgNode) {
const footerExtra = fullHeight - measuredEl.offsetHeight
if (footerExtra > 0) {
lgNode._footerHeight = footerExtra
} else {
lgNode._footerHeight = undefined
}
const width = Math.max(0, borderBox.inlineSize)
const height = Math.max(0, borderBox.blockSize)
}
}
const nodeLayout = nodeId
? layoutStore.getNodeLayoutRef(nodeId).value
: null

View File

@@ -51,7 +51,10 @@ export function useNodeResize(
const nodeId = nodeElement.dataset.nodeId
if (!nodeId) return
const rect = nodeElement.getBoundingClientRect()
const bodyElement =
nodeElement.querySelector('[data-testid^="node-inner-wrapper"]') ??
nodeElement
const rect = bodyElement.getBoundingClientRect()
const scale = transformState.camera.z
const startSize: Size = {
@@ -61,7 +64,7 @@ export function useNodeResize(
const savedNodeHeight = nodeElement.style.getPropertyValue('--node-height')
nodeElement.style.setProperty('--node-height', '0px')
const minContentHeight = nodeElement.getBoundingClientRect().height / scale
const minContentHeight = bodyElement.getBoundingClientRect().height / scale
nodeElement.style.setProperty('--node-height', savedNodeHeight || '')
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value

View File

@@ -10,7 +10,7 @@
<label
v-if="!hideLayoutField"
:for="id"
class="pointer-events-none absolute top-1.5 left-3 z-10 text-xxs text-muted-foreground"
class="pointer-events-none absolute top-1.5 left-3 z-10 text-2xs text-muted-foreground"
>
{{ displayName }}
</label>

View File

@@ -22,7 +22,7 @@
class="pi pi-arrow-circle-up text-xs text-blue-600"
/>
<span>{{ installedVersion }}</span>
<i v-if="!isDisabled" class="pi pi-chevron-right text-xxs" />
<i v-if="!isDisabled" class="pi pi-chevron-right text-2xs" />
</div>
<Popover