mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 05:19:03 +00:00
Compare commits
3 Commits
drjkl/slop
...
cross-disp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ebd371c92 | ||
|
|
435622709f | ||
|
|
c21fbedf15 |
138
src/composables/canvas/useCanvasViewportInsets.test.ts
Normal file
138
src/composables/canvas/useCanvasViewportInsets.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
40
src/composables/canvas/useCanvasViewportInsets.ts
Normal file
40
src/composables/canvas/useCanvasViewportInsets.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
97
src/lib/litegraph/src/DragAndScale.test.ts
Normal file
97
src/lib/litegraph/src/DragAndScale.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user