mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 05:19:03 +00:00
Compare commits
8 Commits
ext-api/i-
...
glary/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0ba479d6d | ||
|
|
52f64e5823 | ||
|
|
96451f3713 | ||
|
|
53b119d280 | ||
|
|
a8f22f1a1b | ||
|
|
a7a60c919c | ||
|
|
ecf3d594c3 | ||
|
|
c3074a0a11 |
@@ -4,7 +4,7 @@
|
||||
<button
|
||||
:class="
|
||||
cn(
|
||||
'hardware-option w-[170px] h-[190px] p-5 flex flex-col items-center rounded-3xl transition-all duration-200 bg-neutral-900/70 border-4',
|
||||
'hardware-option flex h-[190px] w-[170px] flex-col items-center rounded-3xl border-4 bg-neutral-900/70 p-5 transition-all duration-200',
|
||||
selected ? 'border-solid border-brand-yellow' : 'border-transparent'
|
||||
)
|
||||
"
|
||||
@@ -12,13 +12,13 @@
|
||||
>
|
||||
<!-- Icon/Logo Area - Rounded square container -->
|
||||
<div
|
||||
class="icon-container w-[110px] h-[110px] shrink-0 rounded-2xl bg-neutral-800 flex items-center justify-center overflow-hidden"
|
||||
class="icon-container flex h-[110px] w-[110px] shrink-0 items-center justify-center overflow-hidden rounded-2xl bg-neutral-800"
|
||||
>
|
||||
<img
|
||||
v-if="imagePath"
|
||||
:src="imagePath"
|
||||
:alt="placeholderText"
|
||||
class="w-full h-full object-cover"
|
||||
class="size-full object-cover"
|
||||
style="object-position: 57% center"
|
||||
draggable="false"
|
||||
/>
|
||||
@@ -28,7 +28,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Text Content -->
|
||||
<div v-if="subtitle" class="text-center mt-4">
|
||||
<div v-if="subtitle" class="mt-4 text-center">
|
||||
<div class="text-sm text-neutral-500">{{ subtitle }}</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
114
browser_tests/tests/appModeTemplateViewport.spec.ts
Normal file
114
browser_tests/tests/appModeTemplateViewport.spec.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
import { comfyPageFixture as test, comfyExpect } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
/**
|
||||
* Regression test for viewport corruption when loading a template in app mode.
|
||||
*
|
||||
* Root cause: fitView() ran against a 0×0 canvas element hidden by
|
||||
* display:none (linearMode=true), producing scale=0 and offset=NaN.
|
||||
* The canvas scheduler now defers viewport ops until the canvas is visible.
|
||||
*/
|
||||
test.describe('App Mode Template Viewport', { tag: ['@canvas', '@ui'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enableLinearMode()
|
||||
await comfyPage.appMode.suppressVueNodeSwitchPopup()
|
||||
})
|
||||
|
||||
test('loading a template in app mode does not corrupt viewport', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Enter app mode (canvas becomes hidden via v-show / display:none)
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
await comfyExpect(comfyPage.canvas).toBeHidden()
|
||||
|
||||
// Load a template while canvas is hidden — this is the scenario
|
||||
// that previously caused scale=0 / offset=NaN corruption.
|
||||
// Note: loadGraphData(..., null, { openSource: 'template' }) creates a new
|
||||
// temporary workflow tab in graph mode (see workflowService.afterLoadNewGraph),
|
||||
// which switches the active workflow and re-shows the canvas automatically.
|
||||
await comfyPage.page.evaluate(async () => {
|
||||
const app = window.app!
|
||||
const workflow = app.graph.serialize()
|
||||
|
||||
await app.loadGraphData(workflow as ComfyWorkflowJSON, true, true, null, {
|
||||
openSource: 'template'
|
||||
})
|
||||
})
|
||||
|
||||
// Loading the template switched to a new graph-mode workflow, so the
|
||||
// canvas should become visible and queued scheduler ops should flush.
|
||||
await comfyExpect(comfyPage.canvas).toBeVisible()
|
||||
|
||||
// Wait a frame for the scheduler to flush
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify the viewport was NOT corrupted
|
||||
const viewport = await comfyPage.page.evaluate(() => {
|
||||
const ds = window.app!.canvas.ds
|
||||
return {
|
||||
scale: ds.scale,
|
||||
offsetX: ds.offset[0],
|
||||
offsetY: ds.offset[1]
|
||||
}
|
||||
})
|
||||
|
||||
expect(viewport.scale, 'Scale must not be 0').toBeGreaterThan(0)
|
||||
expect(Number.isFinite(viewport.offsetX), 'Offset X must not be NaN').toBe(
|
||||
true
|
||||
)
|
||||
expect(Number.isFinite(viewport.offsetY), 'Offset Y must not be NaN').toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('nodes are visible after loading template in app mode and returning to graph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Enter app mode
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
await comfyExpect(comfyPage.canvas).toBeHidden()
|
||||
|
||||
// Load template while canvas is hidden — see note in the previous test
|
||||
// about the new graph-mode workflow tab that this opens.
|
||||
await comfyPage.page.evaluate(async () => {
|
||||
const app = window.app!
|
||||
const workflow = app.graph.serialize()
|
||||
|
||||
await app.loadGraphData(workflow as ComfyWorkflowJSON, true, true, null, {
|
||||
openSource: 'template'
|
||||
})
|
||||
})
|
||||
|
||||
// The template load switches to a new graph-mode workflow, so the canvas
|
||||
// should become visible without requiring a manual app-mode toggle.
|
||||
await comfyExpect(comfyPage.canvas).toBeVisible()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify nodes exist and are within the visible viewport
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const app = window.app!
|
||||
const canvas = app.canvas
|
||||
const nodes = app.graph._nodes
|
||||
if (nodes.length === 0) return false
|
||||
|
||||
canvas.ds.computeVisibleArea(canvas.viewport)
|
||||
const [vx, vy, vw, vh] = canvas.ds.visible_area
|
||||
return nodes.some(
|
||||
(n: { pos: number[]; size: number[] }) =>
|
||||
n.pos[0] + n.size[0] > vx &&
|
||||
n.pos[0] < vx + vw &&
|
||||
n.pos[1] + n.size[1] > vy &&
|
||||
n.pos[1] < vy + vh
|
||||
)
|
||||
}),
|
||||
{ message: 'At least one node should be within the visible viewport' }
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
})
|
||||
88
docs/adr/0009-canvas-viewport-system.md
Normal file
88
docs/adr/0009-canvas-viewport-system.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# 9. Canvas Viewport System
|
||||
|
||||
Date: 2026-04-20
|
||||
|
||||
## Status
|
||||
|
||||
Proposed
|
||||
|
||||
## Context
|
||||
|
||||
LGraphCanvas uses a dual-canvas architecture: a foreground canvas (the DOM element) renders nodes, and a background canvas (offscreen) renders the grid, links, and groups. `drawFrontCanvas()` composites the background onto the foreground by dividing the background canvas dimensions by `devicePixelRatio` — assuming both canvases were DPR-scaled. `drawBackCanvas()` reinforces this assumption by applying `ctx.setTransform(scale, 0, 0, scale, 0, 0)` using DPR. Both canvases must have identical physical (DPR-scaled) dimensions for compositing to produce correct results.
|
||||
|
||||
Two independent resize paths exist today:
|
||||
|
||||
- **`resizeCanvas()` in app.ts** is DPR-aware: it multiplies CSS pixels by `devicePixelRatio` to set physical canvas dimensions and calls `ctx.scale()` on both contexts.
|
||||
- **`LGraphCanvas.resize()`** is DPR-unaware: it sets both canvases to CSS pixel dimensions directly, producing canvases at 1× regardless of display density.
|
||||
|
||||
Neither path documents that it depends on the other, creating implicit temporal coupling. Code that calls one without the other produces a background/foreground size mismatch.
|
||||
|
||||
The original bug: when switching from app mode (canvas hidden via `v-show`) to graph mode, `resize()` was called to force dimensions onto the newly-visible canvas. Because `resize()` is DPR-unaware, the background canvas received CSS pixel dimensions while `drawFrontCanvas()` divided those dimensions by DPR (expecting physical pixels), producing a scaled-down composite. The canvas scheduler (`useCanvasScheduler`) solved the "hidden canvas" lifecycle problem (deferring draws until the canvas is visible) but left the DPR mismatch because it calls the DPR-unaware `LGraphCanvas.resize()`.
|
||||
|
||||
`window.devicePixelRatio` is read at 6+ call sites across LGraphCanvas (`drawFrontCanvas`, `drawBackCanvas`, `centerOnNode`, `renderInfo`, `processMouseDown` hit testing, font scaling) and 3+ call sites in app.ts/renderer code. Each reads independently with no shared source of truth, so any change to DPR handling requires auditing every call site.
|
||||
|
||||
## Decision
|
||||
|
||||
Introduce a `CanvasViewport` — a plain, frozen data object that serves as the single source of truth for canvas sizing:
|
||||
|
||||
```ts
|
||||
interface CanvasViewport {
|
||||
readonly cssWidth: number
|
||||
readonly cssHeight: number
|
||||
readonly dpr: number
|
||||
readonly physicalWidth: number // cssWidth * dpr
|
||||
readonly physicalHeight: number // cssHeight * dpr
|
||||
readonly generation: number // monotonically increasing
|
||||
}
|
||||
```
|
||||
|
||||
Two functions operate on this type:
|
||||
|
||||
- **`measureViewport(container, dpr?)`** — a pure function that produces a new `CanvasViewport` from DOM measurements. Accepts an optional DPR override for testing and for scenarios where DPR changes mid-session (display switching).
|
||||
- **`applyViewport(viewport, fgCanvas, bgCanvas)`** — a side-effecting function that atomically sizes both foreground and background canvases to the viewport's physical dimensions and scales their 2D contexts. Both canvases are updated in a single call, eliminating the possibility of a partial resize.
|
||||
|
||||
A `devAssert(condition, message)` utility throws in DEV mode and `console.error`s in production. It is used at draw boundaries to enforce invariants:
|
||||
|
||||
- Foreground and background canvas dimensions are equal.
|
||||
- The viewport generation is fresh (not stale from a previous resize cycle).
|
||||
|
||||
The existing `LGraphCanvas.resize()` method and `resizeCanvas()` in app.ts are both replaced by calls through the viewport system. Both paths collapse into one: measure → apply → draw.
|
||||
|
||||
The viewport system composes with the existing `CanvasScheduler` — the scheduler handles **when** (deferring until the canvas is visible), the viewport handles **what** (correct DPR-scaled dimensions applied atomically to both canvases). Neither modifies the other.
|
||||
|
||||
### Design Principles
|
||||
|
||||
Following the ECS principles established in [ADR 0008](0008-entity-component-system.md):
|
||||
|
||||
- `CanvasViewport` is a **plain data component** — no methods, no back-references, frozen after creation.
|
||||
- `measureViewport` is a **pure system function** — testable without DOM (accepts dimension inputs).
|
||||
- `applyViewport` is a **side-effecting system** — testable with mock canvas objects.
|
||||
- No methods are added to `LGraphCanvas` or any other entity class.
|
||||
|
||||
### Alternatives Considered
|
||||
|
||||
1. **Reactive derivation (Vue `computed`)** — rejected because it would require Vue reactivity inside litegraph internals, crossing a hard architectural boundary between the Vue application layer and the litegraph rendering layer.
|
||||
2. **Transaction/batch-commit pattern** — rejected as overkill for a single async boundary (the `requestAnimationFrame` call). The measure/apply split achieves the same atomicity guarantee with less machinery.
|
||||
3. **Just fixing `resizeCanvas()` to also update bgcanvas** — rejected because it doesn't address the scattered DPR reads or prevent future divergence. A point fix solves today's bug but leaves the same class of bug latent at every other DPR read site.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Single source of truth for canvas dimensions and DPR eliminates an entire class of sizing bugs where foreground and background canvases diverge.
|
||||
- The generation counter enables stale-state detection — any consumer can verify it is reading from a consistent resize cycle.
|
||||
- Phase separation (measure vs apply) makes the resize lifecycle explicit and assertable.
|
||||
- Pure functions (`measureViewport`) are trivially testable without DOM fixtures.
|
||||
- Composes cleanly with the existing `CanvasScheduler` without modifying it.
|
||||
|
||||
### Negative
|
||||
|
||||
- All existing `window.devicePixelRatio` reads in LGraphCanvas need migration to use the viewport's `dpr` field. This migration is incremental and not blocking.
|
||||
- Adds a new abstraction layer that all canvas-sizing code must flow through.
|
||||
- A lint rule banning direct `window.devicePixelRatio` reads in canvas code requires team awareness during the migration period.
|
||||
|
||||
## Notes
|
||||
|
||||
- References [ADR 0008](0008-entity-component-system.md) for the design principles (plain data components, pure system functions, no methods on entities).
|
||||
- The `devAssert` utility is general-purpose and can be used beyond canvas sizing for any invariant that should be loud in development but non-fatal in production.
|
||||
- Migration of existing DPR reads in LGraphCanvas (`centerOnNode`, `renderInfo`, `processMouseDown` hit testing, `drawBackCanvas` `setTransform`, font scaling) can be done incrementally in follow-up PRs. This ADR covers the foundation and the critical resize path.
|
||||
@@ -18,6 +18,7 @@ An Architecture Decision Record captures an important architectural decision mad
|
||||
| [0006](0006-primitive-node-copy-paste-lifecycle.md) | PrimitiveNode Copy/Paste Lifecycle | Proposed | 2026-02-22 |
|
||||
| [0007](0007-node-execution-output-passthrough-schema.md) | NodeExecutionOutput Passthrough Schema | Accepted | 2026-03-11 |
|
||||
| [0008](0008-entity-component-system.md) | Entity Component System | Proposed | 2026-03-23 |
|
||||
| [0009](0009-canvas-viewport-system.md) | Canvas Viewport System | Accepted | 2026-04-20 |
|
||||
|
||||
## Creating a New ADR
|
||||
|
||||
|
||||
61
src/base/common/devAssert.test.ts
Normal file
61
src/base/common/devAssert.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { devAssert, setDevAssertReporter } from '@/base/common/devAssert'
|
||||
|
||||
describe('devAssert', () => {
|
||||
beforeEach(() => {
|
||||
setDevAssertReporter(undefined as never)
|
||||
})
|
||||
|
||||
it('does nothing when condition is true', () => {
|
||||
expect(() => devAssert(true, 'should not fire')).not.toThrow()
|
||||
})
|
||||
|
||||
it('throws in DEV mode when condition is false', () => {
|
||||
expect(() => devAssert(false, 'test failure')).toThrow(
|
||||
'[Invariant] test failure'
|
||||
)
|
||||
})
|
||||
|
||||
it('always console.errors when condition is false', () => {
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
try {
|
||||
devAssert(false, 'error msg')
|
||||
} catch {
|
||||
// expected in DEV
|
||||
}
|
||||
expect(spy).toHaveBeenCalledWith('[Invariant] error msg')
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('calls reporter when set', () => {
|
||||
const reporter = vi.fn()
|
||||
setDevAssertReporter(reporter)
|
||||
try {
|
||||
devAssert(false, 'reported msg')
|
||||
} catch {
|
||||
// expected in DEV
|
||||
}
|
||||
expect(reporter).toHaveBeenCalledWith('[Invariant] reported msg')
|
||||
})
|
||||
|
||||
it('does not call reporter when condition is true', () => {
|
||||
const reporter = vi.fn()
|
||||
setDevAssertReporter(reporter)
|
||||
devAssert(true, 'should not fire')
|
||||
expect(reporter).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('console.errors in production when condition is false', () => {
|
||||
const originalDev = import.meta.env.DEV
|
||||
try {
|
||||
import.meta.env.DEV = false
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
devAssert(false, 'prod failure')
|
||||
expect(spy).toHaveBeenCalledWith('[Invariant] prod failure')
|
||||
spy.mockRestore()
|
||||
} finally {
|
||||
import.meta.env.DEV = originalDev
|
||||
}
|
||||
})
|
||||
})
|
||||
21
src/base/common/devAssert.ts
Normal file
21
src/base/common/devAssert.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
type AssertReporter = (formatted: string) => void
|
||||
|
||||
let reporter: AssertReporter | undefined
|
||||
|
||||
function setDevAssertReporter(fn: AssertReporter) {
|
||||
reporter = fn
|
||||
}
|
||||
|
||||
function devAssert(condition: boolean, message: string): asserts condition {
|
||||
if (!condition) {
|
||||
const formatted = `[Invariant] ${message}`
|
||||
console.error(formatted)
|
||||
reporter?.(formatted)
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
throw new Error(formatted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { devAssert, setDevAssertReporter }
|
||||
@@ -198,6 +198,7 @@ export class DragAndScale {
|
||||
this.element.width === 300 && this.element.height === 150
|
||||
? [1920, 1080]
|
||||
: [this.element.width, this.element.height]
|
||||
if (width <= 0 || height <= 0) return
|
||||
const cw = width / window.devicePixelRatio
|
||||
const ch = height / window.devicePixelRatio
|
||||
let targetScale = this.scale
|
||||
@@ -250,6 +251,7 @@ export class DragAndScale {
|
||||
const startTimestamp = performance.now()
|
||||
const cw = this.element.width / window.devicePixelRatio
|
||||
const ch = this.element.height / window.devicePixelRatio
|
||||
if (cw <= 0 || ch <= 0) return
|
||||
const startX = this.offset[0]
|
||||
const startY = this.offset[1]
|
||||
const startX2 = startX - cw / this.scale
|
||||
|
||||
@@ -10,6 +10,11 @@ import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculatio
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import {
|
||||
applyViewport,
|
||||
measureViewport
|
||||
} from '@/renderer/core/canvas/canvasViewport'
|
||||
import { devAssert } from '@/base/common/devAssert'
|
||||
import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||
|
||||
import { CanvasPointer } from './CanvasPointer'
|
||||
@@ -4961,6 +4966,12 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
if (!this.canvas || this.canvas.width == 0 || this.canvas.height == 0)
|
||||
return
|
||||
|
||||
devAssert(
|
||||
this.canvas.width === this.bgcanvas.width &&
|
||||
this.canvas.height === this.bgcanvas.height,
|
||||
`Canvas size mismatch: fg=${this.canvas.width}×${this.canvas.height} bg=${this.bgcanvas.width}×${this.bgcanvas.height}`
|
||||
)
|
||||
|
||||
// fps counting
|
||||
const now = LiteGraph.getTime()
|
||||
this.render_time = (now - this.last_draw_time) * 0.001
|
||||
@@ -6512,8 +6523,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
/**
|
||||
* resizes the canvas to a given size, if no size is passed, then it tries to fill the parentNode
|
||||
* @todo Remove or rewrite
|
||||
* Resizes the canvas to a given size, if no size is passed, then it tries to fill the parentNode.
|
||||
* Uses the CanvasViewport system internally for DPR-aware sizing.
|
||||
*/
|
||||
resize(width?: number, height?: number): void {
|
||||
if (!width && !height) {
|
||||
@@ -6526,12 +6537,19 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
height = parent.offsetHeight
|
||||
}
|
||||
|
||||
if (this.canvas.width == width && this.canvas.height == height) return
|
||||
const viewport = measureViewport(
|
||||
width ?? 0,
|
||||
height ?? 0,
|
||||
window.devicePixelRatio ?? 1
|
||||
)
|
||||
|
||||
this.canvas.width = width ?? 0
|
||||
this.canvas.height = height ?? 0
|
||||
this.bgcanvas.width = this.canvas.width
|
||||
this.bgcanvas.height = this.canvas.height
|
||||
if (
|
||||
this.canvas.width === viewport.physicalWidth &&
|
||||
this.canvas.height === viewport.physicalHeight
|
||||
)
|
||||
return
|
||||
|
||||
applyViewport(viewport, this.canvas, this.bgcanvas)
|
||||
this.setDirty(true, true)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { DragAndScale } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
function createCanvas(width: number, height: number): HTMLCanvasElement {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
return canvas
|
||||
}
|
||||
|
||||
describe('DragAndScale.fitToBounds', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'devicePixelRatio', {
|
||||
configurable: true,
|
||||
value: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('returns early when element width is 0', () => {
|
||||
const dragAndScale = new DragAndScale(createCanvas(0, 400))
|
||||
|
||||
dragAndScale.offset = [13, 29]
|
||||
dragAndScale.scale = 2
|
||||
|
||||
dragAndScale.fitToBounds([0, 0, 500, 500])
|
||||
|
||||
expect(dragAndScale.offset).toEqual([13, 29])
|
||||
expect(dragAndScale.scale).toBe(2)
|
||||
})
|
||||
|
||||
it('returns early when element height is 0', () => {
|
||||
const dragAndScale = new DragAndScale(createCanvas(400, 0))
|
||||
|
||||
dragAndScale.offset = [7, 11]
|
||||
dragAndScale.scale = 0.6
|
||||
|
||||
dragAndScale.fitToBounds([0, 0, 500, 500])
|
||||
|
||||
expect(dragAndScale.offset).toEqual([7, 11])
|
||||
expect(dragAndScale.scale).toBe(0.6)
|
||||
})
|
||||
|
||||
it('uses fallback 1920x1080 when canvas is 300x150', () => {
|
||||
const dragAndScale = new DragAndScale(createCanvas(300, 150))
|
||||
|
||||
dragAndScale.fitToBounds([0, 0, 600, 600])
|
||||
|
||||
expect(dragAndScale.scale).toBeCloseTo(1.35)
|
||||
})
|
||||
|
||||
it('calculates the correct scale for normal dimensions', () => {
|
||||
const dragAndScale = new DragAndScale(createCanvas(1000, 500))
|
||||
|
||||
dragAndScale.fitToBounds([0, 0, 500, 250])
|
||||
|
||||
expect(dragAndScale.scale).toBeCloseTo(1.25)
|
||||
expect(dragAndScale.offset[0]).toBeCloseTo(150)
|
||||
expect(dragAndScale.offset[1]).toBeCloseTo(75)
|
||||
})
|
||||
})
|
||||
12
src/main.ts
12
src/main.ts
@@ -20,6 +20,9 @@ import '@/lib/litegraph/public/css/litegraph.css'
|
||||
import router from '@/router'
|
||||
import { useBootstrapStore } from '@/stores/bootstrapStore'
|
||||
|
||||
import { setDevAssertReporter } from '@/base/common/devAssert'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
import App from './App.vue'
|
||||
// Intentionally relative import to ensure the CSS is loaded in the right order (after litegraph.css)
|
||||
import './assets/css/style.css'
|
||||
@@ -108,6 +111,15 @@ app
|
||||
modules: [VueFireAuth()]
|
||||
})
|
||||
|
||||
setDevAssertReporter((message) => {
|
||||
if (__IS_NIGHTLY__) {
|
||||
useToastStore().addAlert(message)
|
||||
}
|
||||
if (isCloud || __DISTRIBUTION__ === 'desktop') {
|
||||
Sentry.captureMessage(message, 'warning')
|
||||
}
|
||||
})
|
||||
|
||||
const bootstrapStore = useBootstrapStore(pinia)
|
||||
void bootstrapStore.startStoreBootstrap()
|
||||
|
||||
|
||||
112
src/renderer/core/canvas/__tests__/canvasViewport.test.ts
Normal file
112
src/renderer/core/canvas/__tests__/canvasViewport.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
applyViewport,
|
||||
measureViewport
|
||||
} from '@/renderer/core/canvas/canvasViewport'
|
||||
|
||||
function mockCanvas(
|
||||
width = 0,
|
||||
height = 0
|
||||
): HTMLCanvasElement & { scaleArgs: number[][] } {
|
||||
const scaleArgs: number[][] = []
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
getContext: () => ({
|
||||
scale: (x: number, y: number) => scaleArgs.push([x, y])
|
||||
}),
|
||||
scaleArgs
|
||||
} as unknown as HTMLCanvasElement & { scaleArgs: number[][] }
|
||||
}
|
||||
|
||||
describe('measureViewport', () => {
|
||||
it('computes physical dimensions from CSS dimensions and DPR', () => {
|
||||
const vp = measureViewport(800, 600, 2, 0)
|
||||
expect(vp.cssWidth).toBe(800)
|
||||
expect(vp.cssHeight).toBe(600)
|
||||
expect(vp.dpr).toBe(2)
|
||||
expect(vp.physicalWidth).toBe(1600)
|
||||
expect(vp.physicalHeight).toBe(1200)
|
||||
})
|
||||
|
||||
it('clamps DPR to minimum of 1', () => {
|
||||
const vp = measureViewport(800, 600, 0.5, 0)
|
||||
expect(vp.dpr).toBe(1)
|
||||
expect(vp.physicalWidth).toBe(800)
|
||||
expect(vp.physicalHeight).toBe(600)
|
||||
})
|
||||
|
||||
it('clamps negative DPR to 1', () => {
|
||||
const vp = measureViewport(100, 100, -1, 0)
|
||||
expect(vp.dpr).toBe(1)
|
||||
})
|
||||
|
||||
it('increments generation from previous value', () => {
|
||||
const vp1 = measureViewport(800, 600, 1, 0)
|
||||
expect(vp1.generation).toBe(1)
|
||||
|
||||
const vp2 = measureViewport(800, 600, 1, vp1.generation)
|
||||
expect(vp2.generation).toBe(2)
|
||||
})
|
||||
|
||||
it('rounds physical dimensions', () => {
|
||||
const vp = measureViewport(801, 601, 1.5, 0)
|
||||
expect(vp.physicalWidth).toBe(Math.round(801 * 1.5))
|
||||
expect(vp.physicalHeight).toBe(Math.round(601 * 1.5))
|
||||
})
|
||||
|
||||
it('returns a frozen object', () => {
|
||||
const vp = measureViewport(800, 600, 2, 0)
|
||||
expect(Object.isFrozen(vp)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyViewport', () => {
|
||||
it('sets both canvases to physical dimensions', () => {
|
||||
const vp = measureViewport(800, 600, 2, 0)
|
||||
const fg = mockCanvas()
|
||||
const bg = mockCanvas()
|
||||
|
||||
applyViewport(vp, fg, bg)
|
||||
|
||||
expect(fg.width).toBe(1600)
|
||||
expect(fg.height).toBe(1200)
|
||||
expect(bg.width).toBe(1600)
|
||||
expect(bg.height).toBe(1200)
|
||||
})
|
||||
|
||||
it('scales both canvas contexts by DPR', () => {
|
||||
const vp = measureViewport(800, 600, 2, 0)
|
||||
const fg = mockCanvas()
|
||||
const bg = mockCanvas()
|
||||
|
||||
applyViewport(vp, fg, bg)
|
||||
|
||||
expect(fg.scaleArgs).toEqual([[2, 2]])
|
||||
expect(bg.scaleArgs).toEqual([[2, 2]])
|
||||
})
|
||||
|
||||
it('produces identical dimensions on both canvases', () => {
|
||||
const vp = measureViewport(1920, 1080, 2.5, 0)
|
||||
const fg = mockCanvas(100, 100)
|
||||
const bg = mockCanvas(200, 300)
|
||||
|
||||
applyViewport(vp, fg, bg)
|
||||
|
||||
expect(fg.width).toBe(bg.width)
|
||||
expect(fg.height).toBe(bg.height)
|
||||
})
|
||||
|
||||
it('handles DPR of 1 without scaling artifacts', () => {
|
||||
const vp = measureViewport(800, 600, 1, 0)
|
||||
const fg = mockCanvas()
|
||||
const bg = mockCanvas()
|
||||
|
||||
applyViewport(vp, fg, bg)
|
||||
|
||||
expect(fg.width).toBe(800)
|
||||
expect(fg.height).toBe(600)
|
||||
expect(fg.scaleArgs).toEqual([[1, 1]])
|
||||
})
|
||||
})
|
||||
211
src/renderer/core/canvas/__tests__/useCanvasScheduler.test.ts
Normal file
211
src/renderer/core/canvas/__tests__/useCanvasScheduler.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, reactive } from 'vue'
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
canvasElement: {
|
||||
offsetParent: {} as Element | null,
|
||||
offsetWidth: 1920,
|
||||
offsetHeight: 1080
|
||||
},
|
||||
pendingFrames: new Map<number, FrameRequestCallback>(),
|
||||
nextFrameId: 1,
|
||||
cancelAnimationFrame: vi.fn()
|
||||
}))
|
||||
|
||||
const mockStore = reactive({
|
||||
linearMode: false,
|
||||
canvas: {
|
||||
get canvas() {
|
||||
return testState.canvasElement
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => mockStore
|
||||
}))
|
||||
|
||||
function runNextAnimationFrame(): void {
|
||||
const nextEntry = testState.pendingFrames.entries().next().value
|
||||
if (!nextEntry) return
|
||||
const [id, callback] = nextEntry
|
||||
testState.pendingFrames.delete(id)
|
||||
callback(performance.now())
|
||||
}
|
||||
|
||||
describe('useCanvasScheduler', () => {
|
||||
beforeEach(async () => {
|
||||
mockStore.linearMode = false
|
||||
testState.canvasElement.offsetParent = document.body
|
||||
testState.canvasElement.offsetWidth = 1920
|
||||
testState.canvasElement.offsetHeight = 1080
|
||||
testState.pendingFrames.clear()
|
||||
testState.nextFrameId = 1
|
||||
testState.cancelAnimationFrame.mockReset()
|
||||
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
const id = testState.nextFrameId++
|
||||
testState.pendingFrames.set(id, cb)
|
||||
return id
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => {
|
||||
testState.cancelAnimationFrame(id)
|
||||
testState.pendingFrames.delete(id)
|
||||
})
|
||||
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
async function createScheduler() {
|
||||
const mod = await import('@/renderer/core/canvas/useCanvasScheduler')
|
||||
return mod.useCanvasScheduler()
|
||||
}
|
||||
|
||||
it('schedule executes operation in next RAF when canvas is ready', async () => {
|
||||
const scheduler = await createScheduler()
|
||||
const op = vi.fn()
|
||||
|
||||
scheduler.schedule(op)
|
||||
expect(op).not.toHaveBeenCalled()
|
||||
|
||||
runNextAnimationFrame()
|
||||
expect(op).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('schedule queues operation when canvas is not ready', async () => {
|
||||
const scheduler = await createScheduler()
|
||||
const op = vi.fn()
|
||||
|
||||
testState.canvasElement.offsetParent = null
|
||||
scheduler.schedule(op)
|
||||
|
||||
expect(scheduler.pending()).toBe(1)
|
||||
expect(op).not.toHaveBeenCalled()
|
||||
expect(testState.pendingFrames.size).toBe(0)
|
||||
})
|
||||
|
||||
it('schedule queues when canvas has zero dimensions', async () => {
|
||||
const scheduler = await createScheduler()
|
||||
const op = vi.fn()
|
||||
|
||||
testState.canvasElement.offsetWidth = 0
|
||||
testState.canvasElement.offsetHeight = 0
|
||||
scheduler.schedule(op)
|
||||
|
||||
expect(scheduler.pending()).toBe(1)
|
||||
expect(op).not.toHaveBeenCalled()
|
||||
expect(testState.pendingFrames.size).toBe(0)
|
||||
})
|
||||
|
||||
it('flush executes queued operations when canvas becomes ready', async () => {
|
||||
const scheduler = await createScheduler()
|
||||
const first = vi.fn()
|
||||
const second = vi.fn()
|
||||
|
||||
testState.canvasElement.offsetParent = null
|
||||
scheduler.schedule(first)
|
||||
scheduler.schedule(second)
|
||||
|
||||
testState.canvasElement.offsetParent = document.body
|
||||
scheduler.flush()
|
||||
|
||||
expect(first).toHaveBeenCalledOnce()
|
||||
expect(second).toHaveBeenCalledOnce()
|
||||
expect(scheduler.pending()).toBe(0)
|
||||
})
|
||||
|
||||
it('flush is a no-op when canvas is not ready', async () => {
|
||||
const scheduler = await createScheduler()
|
||||
const op = vi.fn()
|
||||
|
||||
testState.canvasElement.offsetParent = null
|
||||
scheduler.schedule(op)
|
||||
scheduler.flush()
|
||||
|
||||
expect(op).not.toHaveBeenCalled()
|
||||
expect(scheduler.pending()).toBe(1)
|
||||
})
|
||||
|
||||
it('clear discards all pending operations and cancels RAF', async () => {
|
||||
const scheduler = await createScheduler()
|
||||
const op = vi.fn()
|
||||
|
||||
scheduler.schedule(op)
|
||||
expect(testState.pendingFrames.size).toBe(1)
|
||||
|
||||
scheduler.clear()
|
||||
|
||||
expect(scheduler.pending()).toBe(0)
|
||||
expect(testState.cancelAnimationFrame).toHaveBeenCalledOnce()
|
||||
runNextAnimationFrame()
|
||||
expect(op).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deduplicates RAF scheduling to one pending frame', async () => {
|
||||
const scheduler = await createScheduler()
|
||||
|
||||
scheduler.schedule(vi.fn())
|
||||
scheduler.schedule(vi.fn())
|
||||
scheduler.schedule(vi.fn())
|
||||
|
||||
expect(testState.pendingFrames.size).toBe(1)
|
||||
})
|
||||
|
||||
it('executes operations in FIFO order', async () => {
|
||||
const scheduler = await createScheduler()
|
||||
const calls: string[] = []
|
||||
|
||||
scheduler.schedule(() => calls.push('first'))
|
||||
scheduler.schedule(() => calls.push('second'))
|
||||
scheduler.schedule(() => calls.push('third'))
|
||||
|
||||
runNextAnimationFrame()
|
||||
|
||||
expect(calls).toEqual(['first', 'second', 'third'])
|
||||
})
|
||||
|
||||
it('continues executing remaining ops when one throws', async () => {
|
||||
const scheduler = await createScheduler()
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const first = vi.fn()
|
||||
const failing = vi.fn(() => {
|
||||
throw new Error('op failed')
|
||||
})
|
||||
const third = vi.fn()
|
||||
|
||||
scheduler.schedule(first)
|
||||
scheduler.schedule(failing)
|
||||
scheduler.schedule(third)
|
||||
|
||||
runNextAnimationFrame()
|
||||
|
||||
expect(first).toHaveBeenCalledOnce()
|
||||
expect(failing).toHaveBeenCalledOnce()
|
||||
expect(third).toHaveBeenCalledOnce()
|
||||
expect(consoleSpy).toHaveBeenCalledOnce()
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('auto-flushes queued ops when linearMode transitions to false', async () => {
|
||||
const scheduler = await createScheduler()
|
||||
const op = vi.fn()
|
||||
|
||||
testState.canvasElement.offsetParent = null
|
||||
mockStore.linearMode = true
|
||||
await nextTick()
|
||||
|
||||
scheduler.schedule(op)
|
||||
expect(scheduler.pending()).toBe(1)
|
||||
|
||||
const framesBefore = testState.pendingFrames.size
|
||||
|
||||
testState.canvasElement.offsetParent = document.body
|
||||
mockStore.linearMode = false
|
||||
await nextTick()
|
||||
|
||||
expect(testState.pendingFrames.size).toBeGreaterThan(framesBefore)
|
||||
|
||||
while (testState.pendingFrames.size > 0) runNextAnimationFrame()
|
||||
expect(op).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
63
src/renderer/core/canvas/canvasViewport.ts
Normal file
63
src/renderer/core/canvas/canvasViewport.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
interface CanvasViewport {
|
||||
readonly cssWidth: number
|
||||
readonly cssHeight: number
|
||||
readonly dpr: number
|
||||
readonly physicalWidth: number
|
||||
readonly physicalHeight: number
|
||||
readonly generation: number
|
||||
}
|
||||
|
||||
let currentGeneration = 0
|
||||
|
||||
function measureViewport(
|
||||
cssWidth: number,
|
||||
cssHeight: number,
|
||||
rawDpr: number,
|
||||
prevGeneration?: number
|
||||
): CanvasViewport {
|
||||
const dpr = Math.max(rawDpr, 1)
|
||||
return Object.freeze({
|
||||
cssWidth,
|
||||
cssHeight,
|
||||
dpr,
|
||||
physicalWidth: Math.round(cssWidth * dpr),
|
||||
physicalHeight: Math.round(cssHeight * dpr),
|
||||
generation: (prevGeneration ?? currentGeneration) + 1
|
||||
})
|
||||
}
|
||||
|
||||
function measureViewportFromElement(
|
||||
element: HTMLCanvasElement,
|
||||
rawDpr?: number,
|
||||
prevGeneration?: number
|
||||
): CanvasViewport {
|
||||
const saved = { w: element.width, h: element.height }
|
||||
element.width = element.height = NaN
|
||||
const { width, height } = element.getBoundingClientRect()
|
||||
element.width = saved.w
|
||||
element.height = saved.h
|
||||
return measureViewport(
|
||||
width,
|
||||
height,
|
||||
rawDpr ?? window.devicePixelRatio,
|
||||
prevGeneration
|
||||
)
|
||||
}
|
||||
|
||||
function applyViewport(
|
||||
viewport: CanvasViewport,
|
||||
fg: HTMLCanvasElement,
|
||||
bg: HTMLCanvasElement
|
||||
): void {
|
||||
fg.width = viewport.physicalWidth
|
||||
fg.height = viewport.physicalHeight
|
||||
bg.width = viewport.physicalWidth
|
||||
bg.height = viewport.physicalHeight
|
||||
|
||||
fg.getContext('2d')?.scale(viewport.dpr, viewport.dpr)
|
||||
bg.getContext('2d')?.scale(viewport.dpr, viewport.dpr)
|
||||
|
||||
currentGeneration = viewport.generation
|
||||
}
|
||||
|
||||
export { measureViewport, measureViewportFromElement, applyViewport }
|
||||
94
src/renderer/core/canvas/useCanvasScheduler.ts
Normal file
94
src/renderer/core/canvas/useCanvasScheduler.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
type CanvasOp = () => void
|
||||
|
||||
interface CanvasScheduler {
|
||||
/** Queue an op that runs in the next RAF when canvas is visible. */
|
||||
schedule(op: CanvasOp): void
|
||||
/** Execute all queued ops synchronously (if canvas is ready). */
|
||||
flush(): void
|
||||
/** Discard all pending ops and cancel any scheduled RAF. */
|
||||
clear(): void
|
||||
/** Number of queued ops. */
|
||||
pending(): number
|
||||
/** Whether the canvas element is visible and properly sized. */
|
||||
isCanvasReady(): boolean
|
||||
}
|
||||
|
||||
export const useCanvasScheduler = createSharedComposable(
|
||||
(): CanvasScheduler => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const queue: CanvasOp[] = []
|
||||
let rafId: number | null = null
|
||||
|
||||
function isCanvasReady(): boolean {
|
||||
try {
|
||||
const el = canvasStore.canvas?.canvas
|
||||
if (el == null || el.offsetParent === null) return false
|
||||
return el.offsetWidth > 0 && el.offsetHeight > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function requestFlush(): void {
|
||||
if (rafId != null || queue.length === 0) return
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null
|
||||
flush()
|
||||
})
|
||||
}
|
||||
|
||||
function schedule(op: CanvasOp): void {
|
||||
queue.push(op)
|
||||
if (isCanvasReady()) requestFlush()
|
||||
}
|
||||
|
||||
function flush(): void {
|
||||
if (!isCanvasReady()) return
|
||||
const ops = queue.splice(0)
|
||||
for (const [index, op] of ops.entries()) {
|
||||
try {
|
||||
op()
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'[CanvasScheduler] Scheduled canvas operation failed during flush',
|
||||
{
|
||||
error: err,
|
||||
remainingInBatch: ops.length - index - 1,
|
||||
pendingQueue: queue.length,
|
||||
canvasReady: isCanvasReady()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clear(): void {
|
||||
queue.length = 0
|
||||
if (rafId != null) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
}
|
||||
}
|
||||
|
||||
function pending(): number {
|
||||
return queue.length
|
||||
}
|
||||
|
||||
watch(
|
||||
() => canvasStore.linearMode,
|
||||
(isLinear, wasLinear) => {
|
||||
const canvasBecameVisible = wasLinear && !isLinear
|
||||
if (canvasBecameVisible && queue.length > 0) {
|
||||
requestFlush()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return { schedule, flush, clear, pending, isCanvasReady }
|
||||
}
|
||||
)
|
||||
@@ -7,6 +7,11 @@ import { shallowRef } from 'vue'
|
||||
import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { syncLayoutStoreNodeBoundsFromGraph } from '@/renderer/core/layout/sync/syncLayoutStoreFromGraph'
|
||||
import { useCanvasScheduler } from '@/renderer/core/canvas/useCanvasScheduler'
|
||||
import {
|
||||
applyViewport,
|
||||
measureViewportFromElement
|
||||
} from '@/renderer/core/canvas/canvasViewport'
|
||||
import { flushScheduledSlotLayoutSync } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
|
||||
|
||||
import { st, t } from '@/i18n'
|
||||
@@ -967,15 +972,8 @@ export class ComfyApp {
|
||||
}
|
||||
|
||||
private resizeCanvas(canvas: HTMLCanvasElement) {
|
||||
// Limit minimal scale to 1, see https://github.com/comfyanonymous/ComfyUI/pull/845
|
||||
const scale = Math.max(window.devicePixelRatio, 1)
|
||||
|
||||
// Clear fixed width and height while calculating rect so it uses 100% instead
|
||||
canvas.height = canvas.width = NaN
|
||||
const { width, height } = canvas.getBoundingClientRect()
|
||||
canvas.width = Math.round(width * scale)
|
||||
canvas.height = Math.round(height * scale)
|
||||
canvas.getContext('2d')?.scale(scale, scale)
|
||||
const viewport = measureViewportFromElement(canvas)
|
||||
applyViewport(viewport, canvas, this.canvas.bgcanvas)
|
||||
this.canvas?.draw(true, true)
|
||||
}
|
||||
|
||||
@@ -1137,6 +1135,9 @@ export class ComfyApp {
|
||||
silentAssetErrors?: boolean
|
||||
} = {}
|
||||
) {
|
||||
const canvasScheduler = useCanvasScheduler()
|
||||
canvasScheduler.clear()
|
||||
|
||||
const {
|
||||
checkForRerouteMigration = false,
|
||||
openSource,
|
||||
@@ -1284,7 +1285,6 @@ export class ComfyApp {
|
||||
}
|
||||
}
|
||||
|
||||
const canvasVisible = !!(this.canvasEl.width && this.canvasEl.height)
|
||||
const fitView = () => {
|
||||
if (
|
||||
restore_view &&
|
||||
@@ -1307,7 +1307,7 @@ export class ComfyApp {
|
||||
this.canvas.visible_area
|
||||
)
|
||||
) {
|
||||
requestAnimationFrame(() => useLitegraphService().fitView())
|
||||
canvasScheduler.schedule(() => useLitegraphService().fitView())
|
||||
}
|
||||
} else {
|
||||
useLitegraphService().fitView()
|
||||
@@ -1341,7 +1341,10 @@ export class ComfyApp {
|
||||
)
|
||||
}
|
||||
|
||||
if (canvasVisible) fitView()
|
||||
canvasScheduler.schedule(() => {
|
||||
this.resizeCanvas(this.canvasEl)
|
||||
fitView()
|
||||
})
|
||||
} catch (error) {
|
||||
useDialogService().showErrorDialog(error, {
|
||||
title: t('errorDialog.loadWorkflowTitle'),
|
||||
@@ -1431,13 +1434,6 @@ export class ComfyApp {
|
||||
this.rootGraph.serialize() as unknown as ComfyWorkflowJSON
|
||||
)
|
||||
|
||||
// If the canvas was not visible and we're a fresh load, resize the canvas and fit the view
|
||||
// This fixes switching from app mode to a new graph mode workflow (e.g. load template)
|
||||
if (!canvasVisible && (!workflow || typeof workflow === 'string')) {
|
||||
this.canvas.resize()
|
||||
requestAnimationFrame(() => fitView())
|
||||
}
|
||||
|
||||
// Drop missing-node entries whose enclosing subgraph is
|
||||
// muted/bypassed. The initial JSON scan only checks each node's
|
||||
// own mode; the cascade from an inactive container is applied here
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCanvasScheduler } from '@/renderer/core/canvas/useCanvasScheduler'
|
||||
import { requestSlotLayoutSyncForAllNodes } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
@@ -27,6 +28,7 @@ export const useSubgraphNavigationStore = defineStore(
|
||||
() => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const canvasScheduler = useCanvasScheduler()
|
||||
const router = useRouter()
|
||||
const routeHash = useRouteHash()
|
||||
|
||||
@@ -140,12 +142,12 @@ export const useSubgraphNavigationStore = defineStore(
|
||||
}
|
||||
|
||||
// First visit — fit to content so subgraph nodes are visible
|
||||
requestAnimationFrame(() => {
|
||||
canvasScheduler.schedule(() => {
|
||||
if (getActiveGraphId() !== graphId) return
|
||||
if (!canvas.graph?.nodes?.length) return
|
||||
useLitegraphService().fitView()
|
||||
// fitView changes scale/offset, so re-sync slot positions for
|
||||
// collapsed nodes whose DOM-relative measurement is now stale.
|
||||
// Defer slot sync to the next frame so the browser paints the
|
||||
// new scale/offset from fitView before slot geometry is measured.
|
||||
requestAnimationFrame(() => {
|
||||
if (getActiveGraphId() !== graphId) return
|
||||
requestSlotLayoutSyncForAllNodes()
|
||||
|
||||
@@ -21,7 +21,13 @@ const { mockSetDirty, mockFitView, mockRequestSlotSyncAll } = vi.hoisted(
|
||||
)
|
||||
|
||||
vi.mock('@/scripts/app', () => {
|
||||
const mockCanvasElement = {
|
||||
offsetParent: document.body,
|
||||
offsetWidth: 1920,
|
||||
offsetHeight: 1080
|
||||
}
|
||||
const mockCanvas = {
|
||||
canvas: mockCanvasElement,
|
||||
subgraph: undefined as unknown,
|
||||
graph: undefined as unknown,
|
||||
ds: {
|
||||
@@ -58,8 +64,20 @@ vi.mock('@/scripts/app', () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@vueuse/core', async () => {
|
||||
const actual = await vi.importActual('@vueuse/core')
|
||||
return {
|
||||
...actual,
|
||||
createSharedComposable: <Fn extends (...args: unknown[]) => unknown>(
|
||||
fn: Fn
|
||||
) => fn
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
linearMode: false,
|
||||
canvas: app.canvas,
|
||||
getCanvas: () => app.canvas
|
||||
})
|
||||
}))
|
||||
@@ -76,6 +94,18 @@ vi.mock(
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/renderer/core/canvas/useCanvasScheduler', () => ({
|
||||
useCanvasScheduler: () => ({
|
||||
schedule: (op: () => void) => {
|
||||
requestAnimationFrame(() => op())
|
||||
},
|
||||
flush: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
pending: () => 0,
|
||||
isCanvasReady: () => true
|
||||
})
|
||||
}))
|
||||
|
||||
const mockCanvas = app.canvas
|
||||
|
||||
let rafCallbacks: FrameRequestCallback[] = []
|
||||
|
||||
Reference in New Issue
Block a user