Compare commits

...

3 Commits

Author SHA1 Message Date
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 331 additions and 17 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,97 @@
import { describe, expect, it } 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('shifts center rightward when left inset is applied', () => {
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: 300 } })
// With a left inset, the offset should be shifted left (more negative)
// to push content away from the left panel
expect(ds.offset[0]).toBeLessThan(dsNoInset.offset[0])
})
it('shifts center leftward when right inset is applied', () => {
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: { right: 300 } })
// With right inset, the available width shrinks and the content is
// centered in the remaining left portion — offset decreases
expect(ds.offset[0]).toBeLessThan(dsNoInset.offset[0])
})
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()
})
})

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

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)
}