Compare commits

...

6 Commits

Author SHA1 Message Date
bymyself
f22f9409b7 test: assert fitView centers bounds within unobscured region
Replace the directional inset-position assertions (which encoded the
inverted-sign behavior) with precise centering invariants: the bounds
center must map to the center of the visible region for left/right/top
insets. Add a fit/animate parity test that locks in the full-canvas
scale recovery during animation.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11827#discussion_r3183573005
2026-06-29 20:28:46 -07:00
bymyself
aed10ab551 fix: derive animation scale from full canvas width, not inset width
The animation edge points (startX2/targetX2) span the full canvas width
(fullCw/fullCh), so the per-frame scale recovery must divide by
fullCw/fullCh. Using the inset-reduced cw/ch landed the animation on a
scale that did not match fitToBounds with the same insets.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11827#discussion_r3183576748
2026-06-29 20:26:47 -07:00
bymyself
4977c518e8 fix: shift fitView centering toward visible region, not into panels
The inset shift sign was inverted: subtracting insetLeft/insetTop placed
content at the obscured panel edge instead of centering it within the
unobscured viewport region. Per convertOffsetToCanvas
(canvas_px = (graph + offset) * scale), centering the bounds at
insetLeft + cw/2 requires adding insetLeft/targetScale. Applied to both
fitToBounds and animateToBounds.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11827#discussion_r3183573005
2026-06-29 20:25:54 -07:00
bymyself
1ebd371c92 fix: return zero insets when panel element is absent
useElementBounding returns 0s for a missing target, which previously
made the right/bottom insets balloon to the full canvas size. With new
menu disabled (no .graph-canvas-panel), fitView produced a degenerate
viewport and broke screenshot-stable workflow loads.
2026-05-04 16:26:28 -07:00
bymyself
435622709f refactor: make useCanvasViewportInsets a reactive composable
Use VueUse useElementBounding (passive ResizeObserver + scroll listener)
behind a singleton, mirroring useCanvasPositionConversion. Call sites read
a cached computed instead of querying the DOM per fitView invocation.
2026-05-04 16:07:05 -07:00
christian-byrne
c21fbedf15 fix: account for panel width in fitView() viewport bounds
fitView() and fitViewToSelectionAnimated() used the full canvas element
dimensions when calculating viewport bounds, ignoring overlay panels
(sidebar, right side panel). This caused fitted content to be partially
obscured by open panels.

Add ViewportInsets support to DragAndScale.fitToBounds() and
animateToBounds(). The insets reduce the effective viewport area and
shift the centering calculation so content is centered within the
unobscured region.

getCanvasViewportInsets() computes insets by comparing the canvas
element's bounding rect with the graph-canvas-panel (visible area).

Fixes #11154
2026-05-04 16:07:05 -07:00
6 changed files with 369 additions and 18 deletions

View File

@@ -0,0 +1,138 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
describe('useCanvasViewportInsets', () => {
let canvasEl: HTMLElement
let panelEl: HTMLElement
function mockRect(el: HTMLElement, rect: Partial<DOMRect>) {
vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
left: 0,
right: 0,
top: 0,
bottom: 0,
width: 0,
height: 0,
x: 0,
y: 0,
toJSON: () => ({}),
...rect
})
}
beforeEach(async () => {
vi.resetModules()
canvasEl = document.createElement('div')
canvasEl.id = 'graph-canvas'
document.body.appendChild(canvasEl)
panelEl = document.createElement('div')
panelEl.classList.add('graph-canvas-panel')
document.body.appendChild(panelEl)
})
afterEach(() => {
canvasEl.remove()
panelEl.remove()
vi.restoreAllMocks()
})
async function load() {
const mod = await import('./useCanvasViewportInsets')
return mod.useCanvasViewportInsets()
}
it('returns a singleton across calls', async () => {
const { useCanvasViewportInsets } =
await import('./useCanvasViewportInsets')
expect(useCanvasViewportInsets()).toBe(useCanvasViewportInsets())
})
it('reports zero insets when canvas and panel are coincident', async () => {
const rect = {
left: 0,
right: 1920,
top: 0,
bottom: 1080,
width: 1920,
height: 1080
}
mockRect(canvasEl, rect)
mockRect(panelEl, rect)
const insets = await load()
await nextTick()
expect(insets.value).toEqual({ left: 0, right: 0, top: 0, bottom: 0 })
})
it('computes left/right insets from a centered panel', async () => {
mockRect(canvasEl, {
left: 0,
right: 1920,
top: 0,
bottom: 1080,
width: 1920,
height: 1080
})
mockRect(panelEl, {
left: 300,
right: 1620,
top: 0,
bottom: 1080,
width: 1320,
height: 1080,
x: 300
})
const insets = await load()
await nextTick()
expect(insets.value).toMatchObject({
left: 300,
right: 300,
top: 0,
bottom: 0
})
})
it('returns zero insets when the panel element is absent', async () => {
panelEl.remove()
mockRect(canvasEl, {
left: 0,
right: 1920,
top: 0,
bottom: 1080,
width: 1920,
height: 1080
})
const insets = await load()
await nextTick()
expect(insets.value).toEqual({ left: 0, right: 0, top: 0, bottom: 0 })
})
it('clamps negative differences to zero', async () => {
mockRect(canvasEl, {
left: 100,
right: 1820,
top: 50,
bottom: 1030,
width: 1720,
height: 980,
x: 100,
y: 50
})
mockRect(panelEl, {
left: 0,
right: 1920,
top: 0,
bottom: 1080,
width: 1920,
height: 1080
})
const insets = await load()
await nextTick()
expect(insets.value).toEqual({ left: 0, right: 0, top: 0, bottom: 0 })
})
})

View File

@@ -0,0 +1,40 @@
import { useElementBounding } from '@vueuse/core'
import type { ComputedRef } from 'vue'
import { computed } from 'vue'
import type { ViewportInsets } from '@/lib/litegraph/src/DragAndScale'
let shared: ComputedRef<ViewportInsets> | null = null
/**
* Reactive insets representing the area of `#graph-canvas` obscured by the
* `.graph-canvas-panel` overlay (sidebar, right panel, etc.) on each side.
*
* Backed by VueUse's `useElementBounding`, which uses passive observers and
* caches reads, so call sites pay no per-call layout cost. Singleton — the
* underlying observers attach once for the app's lifetime.
*/
export function useCanvasViewportInsets(): ComputedRef<ViewportInsets> {
if (shared) return shared
const canvas = useElementBounding(() =>
document.getElementById('graph-canvas')
)
const panel = useElementBounding(() =>
document.querySelector<HTMLElement>('.graph-canvas-panel')
)
shared = computed<ViewportInsets>(() => {
const panelMissing = panel.width.value === 0 && panel.height.value === 0
if (panelMissing) return { left: 0, right: 0, top: 0, bottom: 0 }
return {
left: Math.max(0, panel.left.value - canvas.left.value),
right: Math.max(0, canvas.right.value - panel.right.value),
top: Math.max(0, panel.top.value - canvas.top.value),
bottom: Math.max(0, canvas.bottom.value - panel.bottom.value)
}
})
return shared
}

View File

@@ -1,5 +1,6 @@
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useCanvasViewportInsets } from '@/composables/canvas/useCanvasViewportInsets'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { useSubgraphOperations } from '@/composables/graph/useSubgraphOperations'
import { useExternalLink } from '@/composables/useExternalLink'
@@ -401,7 +402,9 @@ export function useCoreCommands(): ComfyCommand[] {
})
return
}
app.canvas.fitViewToSelectionAnimated()
app.canvas.fitViewToSelectionAnimated({
insets: useCanvasViewportInsets().value
})
}
},
{

View File

@@ -0,0 +1,134 @@
import { describe, expect, it, vi } from 'vitest'
import { DragAndScale } from './DragAndScale'
function createDragAndScale(width: number, height: number): DragAndScale {
const dpr = window.devicePixelRatio
const element = {
width: width * dpr,
height: height * dpr
} as HTMLCanvasElement
return new DragAndScale(element)
}
describe('DragAndScale.fitToBounds', () => {
it('centers bounds in viewport without insets', () => {
const ds = createDragAndScale(1920, 1080)
const bounds: [number, number, number, number] = [0, 0, 400, 300]
ds.fitToBounds(bounds)
// Content should be centered in full viewport
const scaledW = 1920 / ds.scale
const scaledH = 1080 / ds.scale
expect(ds.offset[0]).toBeCloseTo(-bounds[2] * 0.5 + scaledW * 0.5)
expect(ds.offset[1]).toBeCloseTo(-bounds[3] * 0.5 + scaledH * 0.5)
})
it('centers bounds within the unobscured region for a left inset', () => {
const ds = createDragAndScale(1920, 1080)
const bounds: [number, number, number, number] = [0, 0, 400, 300]
const left = 300
ds.fitToBounds(bounds, { insets: { left } })
// The bounds center should map to the center of the visible region,
// i.e. left + (fullCw - left) / 2 in canvas pixels.
const boundsCenterX = bounds[0] + bounds[2] * 0.5
const visibleCenterX = left + (1920 - left) / 2
expect((boundsCenterX + ds.offset[0]) * ds.scale).toBeCloseTo(
visibleCenterX
)
})
it('centers bounds within the unobscured region for a right inset', () => {
const ds = createDragAndScale(1920, 1080)
const bounds: [number, number, number, number] = [0, 0, 400, 300]
const right = 300
ds.fitToBounds(bounds, { insets: { right } })
const boundsCenterX = bounds[0] + bounds[2] * 0.5
const visibleCenterX = (1920 - right) / 2
expect((boundsCenterX + ds.offset[0]) * ds.scale).toBeCloseTo(
visibleCenterX
)
})
it('centers bounds within the unobscured region for a top inset', () => {
const ds = createDragAndScale(1920, 1080)
const bounds: [number, number, number, number] = [0, 0, 400, 300]
const top = 200
ds.fitToBounds(bounds, { insets: { top } })
const boundsCenterY = bounds[1] + bounds[3] * 0.5
const visibleCenterY = top + (1080 - top) / 2
expect((boundsCenterY + ds.offset[1]) * ds.scale).toBeCloseTo(
visibleCenterY
)
})
it('uses reduced viewport for scale calculation with insets', () => {
const ds = createDragAndScale(1920, 1080)
const bounds: [number, number, number, number] = [0, 0, 800, 600]
const dsNoInset = createDragAndScale(1920, 1080)
dsNoInset.fitToBounds(bounds)
ds.fitToBounds(bounds, { insets: { left: 300, right: 300 } })
// Insets reduce available width, so scale should be smaller
expect(ds.scale).toBeLessThan(dsNoInset.scale)
})
it('does nothing different when insets are all zero', () => {
const ds = createDragAndScale(1920, 1080)
const bounds: [number, number, number, number] = [0, 0, 400, 300]
const dsNoInset = createDragAndScale(1920, 1080)
dsNoInset.fitToBounds(bounds)
ds.fitToBounds(bounds, { insets: { left: 0, right: 0, top: 0, bottom: 0 } })
expect(ds.scale).toBeCloseTo(dsNoInset.scale)
expect(ds.offset[0]).toBeCloseTo(dsNoInset.offset[0])
expect(ds.offset[1]).toBeCloseTo(dsNoInset.offset[1])
})
})
describe('DragAndScale.animateToBounds', () => {
it('accepts insets in animation options', () => {
const ds = createDragAndScale(1920, 1080)
const bounds: [number, number, number, number] = [0, 0, 400, 300]
// Should not throw when insets are provided
expect(() =>
ds.animateToBounds(bounds, () => {}, {
duration: 1,
insets: { left: 300, right: 200 }
})
).not.toThrow()
})
it('ends at the same state as fitToBounds with the same insets', () => {
vi.useFakeTimers()
try {
const bounds: [number, number, number, number] = [50, 50, 500, 400]
const insets = { left: 150, right: 250, top: 100 }
const dsFit = createDragAndScale(1200, 900)
dsFit.fitToBounds(bounds, { insets })
const dsAnimate = createDragAndScale(1200, 900)
dsAnimate.animateToBounds(bounds, () => {}, { duration: 350, insets })
vi.advanceTimersByTime(400)
expect(dsAnimate.scale).toBeCloseTo(dsFit.scale, 4)
expect(dsAnimate.offset[0]).toBeCloseTo(dsFit.offset[0], 4)
expect(dsAnimate.offset[1]).toBeCloseTo(dsFit.offset[1], 4)
} finally {
vi.useRealTimers()
}
})
})

View File

@@ -11,6 +11,18 @@ export interface DragAndScaleState {
scale: number
}
/**
* Insets that reduce the effective viewport area used by fit-to-bounds
* calculations. Each value is in CSS pixels and represents the width/height
* of UI panels overlaying the canvas on each side.
*/
export interface ViewportInsets {
left?: number
right?: number
top?: number
bottom?: number
}
export type AnimationOptions = {
/** Duration of the animation in milliseconds. */
duration?: number
@@ -18,6 +30,8 @@ export type AnimationOptions = {
zoom?: number
/** The animation easing function (curve) */
easing?: EaseFunction
/** Insets that reduce the effective viewport for panel-aware fitting. */
insets?: ViewportInsets
}
export class DragAndScale {
@@ -190,7 +204,7 @@ export class DragAndScale {
*/
fitToBounds(
bounds: ReadOnlyRect,
{ zoom = 0.75 }: { zoom?: number } = {}
{ zoom = 0.75, insets }: { zoom?: number; insets?: ViewportInsets } = {}
): void {
//If element hasn't initialized (browser tab is in background)
//it has a size of 300x150 and a more reasonable default is used instead.
@@ -198,8 +212,16 @@ export class DragAndScale {
this.element.width === 300 && this.element.height === 150
? [1920, 1080]
: [this.element.width, this.element.height]
const cw = width / window.devicePixelRatio
const ch = height / window.devicePixelRatio
const fullCw = width / window.devicePixelRatio
const fullCh = height / window.devicePixelRatio
const insetLeft = insets?.left ?? 0
const insetRight = insets?.right ?? 0
const insetTop = insets?.top ?? 0
const insetBottom = insets?.bottom ?? 0
const cw = fullCw - insetLeft - insetRight
const ch = fullCh - insetTop - insetBottom
let targetScale = this.scale
if (zoom > 0) {
@@ -214,9 +236,12 @@ export class DragAndScale {
const scaledWidth = cw / targetScale
const scaledHeight = ch / targetScale
// Calculate the target position to center the bounds in the viewport
const targetX = -bounds[0] - bounds[2] * 0.5 + scaledWidth * 0.5
const targetY = -bounds[1] - bounds[3] * 0.5 + scaledHeight * 0.5
// Calculate the target position to center the bounds in the visible area
// Shift by insetLeft/insetTop so content is centered within the unobscured region
const targetX =
-bounds[0] - bounds[2] * 0.5 + scaledWidth * 0.5 + insetLeft / targetScale
const targetY =
-bounds[1] - bounds[3] * 0.5 + scaledHeight * 0.5 + insetTop / targetScale
// Apply the changes immediately
this.offset[0] = targetX
@@ -234,7 +259,8 @@ export class DragAndScale {
{
duration = 350,
zoom = 0.75,
easing = EaseFunction.EASE_IN_OUT_QUAD
easing = EaseFunction.EASE_IN_OUT_QUAD,
insets
}: AnimationOptions = {}
) {
if (!(duration > 0)) throw new RangeError('Duration must be greater than 0')
@@ -247,13 +273,20 @@ export class DragAndScale {
}
const easeFunction = easeFunctions[easing] ?? easeFunctions.linear
const insetLeft = insets?.left ?? 0
const insetRight = insets?.right ?? 0
const insetTop = insets?.top ?? 0
const insetBottom = insets?.bottom ?? 0
const startTimestamp = performance.now()
const cw = this.element.width / window.devicePixelRatio
const ch = this.element.height / window.devicePixelRatio
const fullCw = this.element.width / window.devicePixelRatio
const fullCh = this.element.height / window.devicePixelRatio
const cw = fullCw - insetLeft - insetRight
const ch = fullCh - insetTop - insetBottom
const startX = this.offset[0]
const startY = this.offset[1]
const startX2 = startX - cw / this.scale
const startY2 = startY - ch / this.scale
const startX2 = startX - fullCw / this.scale
const startY2 = startY - fullCh / this.scale
const startScale = this.scale
let targetScale = startScale
@@ -268,10 +301,12 @@ export class DragAndScale {
const scaledWidth = cw / targetScale
const scaledHeight = ch / targetScale
const targetX = -bounds[0] - bounds[2] * 0.5 + scaledWidth * 0.5
const targetY = -bounds[1] - bounds[3] * 0.5 + scaledHeight * 0.5
const targetX2 = targetX - scaledWidth
const targetY2 = targetY - scaledHeight
const targetX =
-bounds[0] - bounds[2] * 0.5 + scaledWidth * 0.5 + insetLeft / targetScale
const targetY =
-bounds[1] - bounds[3] * 0.5 + scaledHeight * 0.5 + insetTop / targetScale
const targetX2 = targetX - fullCw / targetScale
const targetY2 = targetY - fullCh / targetScale
const animate = (timestamp: number) => {
const elapsed = timestamp - startTimestamp
@@ -289,7 +324,7 @@ export class DragAndScale {
const currentWidth = Math.abs(currentX2 - currentX)
const currentHeight = Math.abs(currentY2 - currentY)
this.scale = Math.min(cw / currentWidth, ch / currentHeight)
this.scale = Math.min(fullCw / currentWidth, fullCh / currentHeight)
}
setDirty()

View File

@@ -1,6 +1,7 @@
import _ from 'es-toolkit/compat'
import { downloadFile, openFileInNewTab } from '@/base/common/downloadUtil'
import { useCanvasViewportInsets } from '@/composables/canvas/useCanvasViewportInsets'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { useSubgraphOperations } from '@/composables/graph/useSubgraphOperations'
import { useNodeAnimatedImage } from '@/composables/node/useNodeAnimatedImage'
@@ -990,7 +991,7 @@ export const useLitegraphService = () => {
const bounds = createBounds(nodes)
if (!bounds) return
canvas.ds.fitToBounds(bounds)
canvas.ds.fitToBounds(bounds, { insets: useCanvasViewportInsets().value })
canvas.setDirty(true, true)
}