mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 13:32:11 +00:00
Compare commits
15 Commits
fix-masked
...
glary/view
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b6cc598f6 | ||
|
|
bd89d04fb2 | ||
|
|
b78f0bde37 | ||
|
|
0b3aa3e463 | ||
|
|
9a0cec3b17 | ||
|
|
a201d732df | ||
|
|
bd426070e5 | ||
|
|
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)
|
||||
})
|
||||
})
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 70 KiB |
@@ -167,7 +167,7 @@ test.describe('Image Crop', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
)
|
||||
|
||||
test(
|
||||
'Empty state matches screenshot baseline',
|
||||
'Empty state matches the screenshot baseline',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
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
|
||||
|
||||
Accepted
|
||||
|
||||
## 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.
|
||||
|
||||
`LGraphCanvas` stores a `dpr` property that is set whenever a viewport is applied. All internal DPR consumers (`drawFrontCanvas`, `drawBackCanvas`, `centerOnNode`, `renderInfo`, `processMouseDown` hit testing, LOD threshold calculation) read `this.dpr` instead of `window.devicePixelRatio`. External consumers with access to the canvas instance (e.g. `litegraphService`, minimap composables) also read `canvas.dpr`. The only code that reads `window.devicePixelRatio` directly is (a) the viewport measurement functions themselves, (b) `DragAndScale` which doesn't have access to the canvas instance, and (c) `layoutStore` which operates at a layer without a direct canvas reference.
|
||||
|
||||
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
|
||||
|
||||
- Adds a new abstraction layer that all canvas-sizing code must flow through.
|
||||
- `DragAndScale` and `layoutStore` still read `window.devicePixelRatio` directly because they lack a reference to the canvas instance. A future refactor could thread the `dpr` value through, but the current exception is documented and stable.
|
||||
|
||||
## 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.
|
||||
@@ -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 }
|
||||
@@ -194,10 +194,14 @@ export class DragAndScale {
|
||||
): 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.
|
||||
// DPR is stable between viewport application and fit-to-bounds calls.
|
||||
// DragAndScale intentionally reads window.devicePixelRatio directly
|
||||
// because it doesn't have access to the viewport system.
|
||||
const [width, height] =
|
||||
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 +254,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
|
||||
|
||||
@@ -35,27 +35,15 @@ describe('LGraphCanvas.renderInfo', () => {
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses canvas.height divided by devicePixelRatio as y fallback', () => {
|
||||
it('uses canvas.height divided by dpr as y fallback', () => {
|
||||
lgCanvas.canvas.width = 1920
|
||||
lgCanvas.canvas.height = 2160
|
||||
lgCanvas.dpr = 2
|
||||
|
||||
const originalDPR = window.devicePixelRatio
|
||||
Object.defineProperty(window, 'devicePixelRatio', {
|
||||
value: 2,
|
||||
configurable: true
|
||||
})
|
||||
lgCanvas.renderInfo(ctx, 10, 0)
|
||||
|
||||
try {
|
||||
lgCanvas.renderInfo(ctx, 10, 0)
|
||||
|
||||
// lineCount = 5 (graph present, no info_text), lineHeight = 13
|
||||
// y = canvas.height / DPR - (lineCount + 1) * lineHeight
|
||||
expect(ctx.translate).toHaveBeenCalledWith(10, 2160 / 2 - 6 * 13)
|
||||
} finally {
|
||||
Object.defineProperty(window, 'devicePixelRatio', {
|
||||
value: originalDPR,
|
||||
configurable: true
|
||||
})
|
||||
}
|
||||
// lineCount = 5 (graph present, no info_text), lineHeight = 13
|
||||
// y = canvas.height / DPR - (lineCount + 1) * lineHeight
|
||||
expect(ctx.translate).toHaveBeenCalledWith(10, 2160 / 2 - 6 * 13)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
@@ -505,7 +510,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
const baseFontSize = LiteGraph.NODE_TEXT_SIZE // 14px
|
||||
const dprAdjustment = Math.sqrt(window.devicePixelRatio || 1) //Using sqrt here because higher DPR monitors do not linearily scale the readability of the font, instead they increase the font by some heurisitc, and to approximate we use sqrt to say basically a DPR of 2 increases the readability by 40%, 3 by 70%
|
||||
const dprAdjustment = Math.sqrt(this.dpr) //Using sqrt here because higher DPR monitors do not linearily scale the readability of the font, instead they increase the font by some heurisitc, and to approximate we use sqrt to say basically a DPR of 2 increases the readability by 40%, 3 by 70%
|
||||
|
||||
// Calculate the zoom level where text becomes unreadable
|
||||
this._lowQualityZoomThreshold =
|
||||
@@ -752,6 +757,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
/** Link rendering adapter for litegraph-to-canvas integration */
|
||||
linkRenderer: LitegraphLinkAdapter | null = null
|
||||
|
||||
/** Device pixel ratio from the last applied viewport. Single source of truth for DPR. */
|
||||
dpr: number = 1
|
||||
|
||||
/** If true, enable drag zoom. Ctrl+Shift+Drag Up/Down: zoom canvas. */
|
||||
dragZoomEnabled: boolean = false
|
||||
/** The start position of the drag zoom and original read-only state. */
|
||||
@@ -1948,6 +1956,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
this.bgcanvas = document.createElement('canvas')
|
||||
this.bgcanvas.width = this.canvas.width
|
||||
this.bgcanvas.height = this.canvas.height
|
||||
this.dpr = window.devicePixelRatio ?? 1
|
||||
|
||||
const ctx = element.getContext?.('2d')
|
||||
if (ctx == null) {
|
||||
@@ -2529,10 +2538,17 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
// Set the width of the line for isPointInStroke checks
|
||||
const { lineWidth } = this.ctx
|
||||
this.ctx.lineWidth = this.connections_width + 7
|
||||
const dpi = Math.max(window?.devicePixelRatio ?? 1, 1)
|
||||
const dpi = this.dpr
|
||||
|
||||
// Try layout store for segment hit testing first (more precise)
|
||||
const hitSegment = layoutStore.queryLinkSegmentAtPoint({ x, y }, this.ctx)
|
||||
// Try layout store for segment hit testing first (more precise).
|
||||
// Pass this.dpr so the layout-store hit-test uses the same DPR as the
|
||||
// isPointInStroke fallback below; otherwise the two paths can disagree
|
||||
// on low-DPR displays (e.g. chromium-0.5x).
|
||||
const hitSegment = layoutStore.queryLinkSegmentAtPoint(
|
||||
{ x, y },
|
||||
this.ctx,
|
||||
this.dpr
|
||||
)
|
||||
|
||||
for (const linkSegment of this.renderedPaths) {
|
||||
const centre = linkSegment._pos
|
||||
@@ -4816,7 +4832,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
* centers the camera on a given node
|
||||
*/
|
||||
centerOnNode(node: LGraphNode): void {
|
||||
const dpi = window?.devicePixelRatio || 1
|
||||
const dpi = this.dpr
|
||||
this.ds.offset[0] =
|
||||
-node.pos[0] -
|
||||
node.size[0] * 0.5 +
|
||||
@@ -4961,6 +4977,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
|
||||
@@ -5043,7 +5065,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
if (this.bgcanvas == this.canvas) {
|
||||
this.drawBackCanvas()
|
||||
} else {
|
||||
const scale = window.devicePixelRatio
|
||||
const scale = this.dpr
|
||||
ctx.drawImage(
|
||||
this.bgcanvas,
|
||||
0,
|
||||
@@ -5371,12 +5393,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
const lineHeight = 13
|
||||
const lineCount = (this.graph ? 5 : 1) + (this.info_text ? 1 : 0)
|
||||
x = x || 10
|
||||
y =
|
||||
y ||
|
||||
this.canvas.height /
|
||||
((this.canvas.ownerDocument.defaultView ?? window).devicePixelRatio ||
|
||||
1) -
|
||||
(lineCount + 1) * lineHeight
|
||||
y = y || this.canvas.height / this.dpr - (lineCount + 1) * lineHeight
|
||||
|
||||
ctx.save()
|
||||
ctx.translate(x, y)
|
||||
@@ -5445,7 +5462,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
// reset in case of error
|
||||
if (!this.viewport) {
|
||||
const scale = window.devicePixelRatio
|
||||
const scale = this.dpr
|
||||
ctx.restore()
|
||||
ctx.setTransform(scale, 0, 0, scale, 0, 0)
|
||||
}
|
||||
@@ -6512,8 +6529,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
|
||||
* @deprecated Use {@link measureViewport} + {@link applyViewport} from `canvasViewport.ts` instead.
|
||||
* This method remains for legacy callers that rely on parent-element fallback sizing.
|
||||
*/
|
||||
resize(width?: number, height?: number): void {
|
||||
if (!width && !height) {
|
||||
@@ -6526,12 +6543,20 @@ 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.dpr = viewport.dpr
|
||||
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('preserves sub-1 DPR (e.g. chromium-0.5x test matrix)', () => {
|
||||
const vp = measureViewport(800, 600, 0.5, 0)
|
||||
expect(vp.dpr).toBe(0.5)
|
||||
expect(vp.physicalWidth).toBe(400)
|
||||
expect(vp.physicalHeight).toBe(300)
|
||||
})
|
||||
|
||||
it('falls back to 1 for invalid (non-positive) DPR', () => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
66
src/renderer/core/canvas/canvasViewport.ts
Normal file
66
src/renderer/core/canvas/canvasViewport.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
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 {
|
||||
// Preserve raw DPR so sub-1 displays (e.g. chromium-0.5x) keep their
|
||||
// native scale. Only fall back to 1 for invalid (<= 0 / NaN) values.
|
||||
const dpr = rawDpr > 0 ? 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
|
||||
): CanvasViewport {
|
||||
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
|
||||
return viewport
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
)
|
||||
@@ -646,3 +646,78 @@ describe('layoutStore CRDT operations', () => {
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('layoutStore queryLinkSegmentAtPoint DPR threading', () => {
|
||||
beforeEach(() => {
|
||||
layoutStore.initializeFromLiteGraph([])
|
||||
})
|
||||
|
||||
// Minimal Path2D stub — happy-dom does not implement Path2D, but the store
|
||||
// only stores it and passes it back to ctx.isPointInStroke (which we mock).
|
||||
const stubPath = {} as unknown as Path2D
|
||||
|
||||
const seedSegment = (linkId = 1, rerouteId: number | null = null) => {
|
||||
layoutStore.updateLinkSegmentLayout(linkId, rerouteId, {
|
||||
path: stubPath,
|
||||
bounds: { x: 0, y: 0, width: 100, height: 100 },
|
||||
centerPos: { x: 50, y: 50 }
|
||||
})
|
||||
return { linkId, rerouteId }
|
||||
}
|
||||
|
||||
const makeCtx = (hit = true) => {
|
||||
const isPointInStroke = vi.fn().mockReturnValue(hit)
|
||||
return {
|
||||
ctx: {
|
||||
lineWidth: 17,
|
||||
isPointInStroke
|
||||
} as unknown as CanvasRenderingContext2D,
|
||||
isPointInStroke
|
||||
}
|
||||
}
|
||||
|
||||
it('uses caller-supplied dpr to scale the stroke hit-test point', () => {
|
||||
const { linkId } = seedSegment()
|
||||
const { ctx, isPointInStroke } = makeCtx()
|
||||
|
||||
const result = layoutStore.queryLinkSegmentAtPoint(
|
||||
{ x: 50, y: 50 },
|
||||
ctx,
|
||||
0.5
|
||||
)
|
||||
|
||||
expect(result).toEqual({ linkId, rerouteId: null })
|
||||
expect(isPointInStroke).toHaveBeenCalledWith(stubPath, 25, 25)
|
||||
})
|
||||
|
||||
it('falls back to window.devicePixelRatio when dpr is omitted', () => {
|
||||
seedSegment()
|
||||
const { ctx, isPointInStroke } = makeCtx()
|
||||
|
||||
const originalDpr = window.devicePixelRatio
|
||||
Object.defineProperty(window, 'devicePixelRatio', {
|
||||
configurable: true,
|
||||
value: 2
|
||||
})
|
||||
try {
|
||||
layoutStore.queryLinkSegmentAtPoint({ x: 50, y: 50 }, ctx)
|
||||
} finally {
|
||||
Object.defineProperty(window, 'devicePixelRatio', {
|
||||
configurable: true,
|
||||
value: originalDpr
|
||||
})
|
||||
}
|
||||
|
||||
expect(isPointInStroke).toHaveBeenCalledWith(stubPath, 100, 100)
|
||||
})
|
||||
|
||||
it('threads dpr through queryLinkAtPoint to the segment hit-test', () => {
|
||||
const { linkId } = seedSegment(7)
|
||||
const { ctx, isPointInStroke } = makeCtx()
|
||||
|
||||
const hit = layoutStore.queryLinkAtPoint({ x: 50, y: 50 }, ctx, 3)
|
||||
|
||||
expect(hit).toBe(linkId)
|
||||
expect(isPointInStroke).toHaveBeenCalledWith(stubPath, 150, 150)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -661,10 +661,17 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
|
||||
/**
|
||||
* Query link segment at point (returns structured data)
|
||||
*
|
||||
* @param dpr Device pixel ratio used to map the CSS-space point into the
|
||||
* canvas's device-pixel-scaled stroke space. Pass the active
|
||||
* `LGraphCanvas.dpr` so this hit-test agrees with `processMouseDown`'s
|
||||
* `isPointInStroke` fallback. Falls back to `window.devicePixelRatio`
|
||||
* for legacy callers without a canvas reference.
|
||||
*/
|
||||
queryLinkSegmentAtPoint(
|
||||
point: Point,
|
||||
ctx?: CanvasRenderingContext2D
|
||||
ctx?: CanvasRenderingContext2D,
|
||||
dpr?: number
|
||||
): { linkId: LinkId; rerouteId: RerouteId | null } | null {
|
||||
// Determine tolerance from current canvas state (if available)
|
||||
// - Use the caller-provided ctx.lineWidth (LGraphCanvas sets this to connections_width + padding)
|
||||
@@ -695,9 +702,12 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
if (!segmentLayout) continue
|
||||
|
||||
if (ctx && segmentLayout.path) {
|
||||
// Match LiteGraph behavior: hit test uses device pixel ratio for coordinates
|
||||
// Prefer the caller-supplied DPR (the active LGraphCanvas.dpr) so
|
||||
// this hit-test stays in lockstep with processMouseDown's fallback
|
||||
// path; fall back to window.devicePixelRatio for legacy callers.
|
||||
const dpi =
|
||||
(typeof window !== 'undefined' && window?.devicePixelRatio) || 1
|
||||
dpr ??
|
||||
((typeof window !== 'undefined' && window?.devicePixelRatio) || 1)
|
||||
const hit = ctx.isPointInStroke(
|
||||
segmentLayout.path,
|
||||
point.x * dpi,
|
||||
@@ -732,10 +742,11 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
*/
|
||||
queryLinkAtPoint(
|
||||
point: Point,
|
||||
ctx?: CanvasRenderingContext2D
|
||||
ctx?: CanvasRenderingContext2D,
|
||||
dpr?: number
|
||||
): LinkId | null {
|
||||
// Invoke segment query and return just the linkId
|
||||
const segment = this.queryLinkSegmentAtPoint(point, ctx)
|
||||
const segment = this.queryLinkSegmentAtPoint(point, ctx, dpr)
|
||||
return segment ? segment.linkId : null
|
||||
}
|
||||
|
||||
|
||||
@@ -274,10 +274,15 @@ export interface LayoutStore {
|
||||
queryNodesInBounds(bounds: Bounds): NodeId[]
|
||||
|
||||
// Hit testing queries for links, slots, and reroutes
|
||||
queryLinkAtPoint(point: Point, ctx?: CanvasRenderingContext2D): LinkId | null
|
||||
queryLinkAtPoint(
|
||||
point: Point,
|
||||
ctx?: CanvasRenderingContext2D,
|
||||
dpr?: number
|
||||
): LinkId | null
|
||||
queryLinkSegmentAtPoint(
|
||||
point: Point,
|
||||
ctx?: CanvasRenderingContext2D
|
||||
ctx?: CanvasRenderingContext2D,
|
||||
dpr?: number
|
||||
): { linkId: LinkId; rerouteId: RerouteId | null } | null
|
||||
querySlotAtPoint(point: Point): SlotLayout | null
|
||||
queryRerouteAtPoint(point: Point): RerouteLayout | null
|
||||
|
||||
@@ -42,6 +42,7 @@ describe('useMinimapViewport', () => {
|
||||
width: 1600,
|
||||
height: 1200
|
||||
} as HTMLCanvasElement,
|
||||
dpr: 2,
|
||||
ds: {
|
||||
scale: 1,
|
||||
offset: [0, 0]
|
||||
|
||||
@@ -44,7 +44,7 @@ export function useMinimapViewport(
|
||||
if (!c) return
|
||||
|
||||
const canvasEl = c.canvas
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const dpr = c.dpr
|
||||
|
||||
canvasDimensions.value = {
|
||||
width: canvasEl.clientWidth || canvasEl.width / dpr,
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSche
|
||||
*/
|
||||
export interface MinimapCanvas {
|
||||
canvas: HTMLCanvasElement
|
||||
dpr: number
|
||||
ds: {
|
||||
scale: number
|
||||
offset: [number, number]
|
||||
|
||||
@@ -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'
|
||||
@@ -966,16 +971,11 @@ export class ComfyApp {
|
||||
)
|
||||
}
|
||||
|
||||
/** @deprecated Use {@link measureViewportFromElement} + {@link applyViewport} directly. */
|
||||
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.dpr = viewport.dpr
|
||||
this.canvas?.draw(true, true)
|
||||
}
|
||||
|
||||
@@ -1137,6 +1137,9 @@ export class ComfyApp {
|
||||
silentAssetErrors?: boolean
|
||||
} = {}
|
||||
) {
|
||||
const canvasScheduler = useCanvasScheduler()
|
||||
canvasScheduler.clear()
|
||||
|
||||
const {
|
||||
checkForRerouteMigration = false,
|
||||
openSource,
|
||||
@@ -1284,7 +1287,6 @@ export class ComfyApp {
|
||||
}
|
||||
}
|
||||
|
||||
const canvasVisible = !!(this.canvasEl.width && this.canvasEl.height)
|
||||
const fitView = () => {
|
||||
if (
|
||||
restore_view &&
|
||||
@@ -1307,7 +1309,7 @@ export class ComfyApp {
|
||||
this.canvas.visible_area
|
||||
)
|
||||
) {
|
||||
requestAnimationFrame(() => useLitegraphService().fitView())
|
||||
canvasScheduler.schedule(() => useLitegraphService().fitView())
|
||||
}
|
||||
} else {
|
||||
useLitegraphService().fitView()
|
||||
@@ -1341,7 +1343,18 @@ export class ComfyApp {
|
||||
)
|
||||
}
|
||||
|
||||
if (canvasVisible) fitView()
|
||||
canvasScheduler.schedule(() => {
|
||||
const vp = measureViewportFromElement(this.canvasEl)
|
||||
applyViewport(vp, this.canvasEl, this.canvas.bgcanvas)
|
||||
this.canvas.dpr = vp.dpr
|
||||
// Match the deprecated resizeCanvas() flush so the canvas paints
|
||||
// immediately after the scheduler restores its size; without this
|
||||
// the template-load path can leave #graph-canvas at width=0/height=0
|
||||
// when transitioning from app mode (regression of
|
||||
// appModeTemplateViewport.spec.ts).
|
||||
this.canvas?.draw(true, true)
|
||||
fitView()
|
||||
})
|
||||
} catch (error) {
|
||||
useDialogService().showErrorDialog(error, {
|
||||
title: t('errorDialog.loadWorkflowTitle'),
|
||||
@@ -1431,13 +1444,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
|
||||
|
||||
@@ -938,7 +938,7 @@ export const useLitegraphService = () => {
|
||||
}
|
||||
|
||||
function getCanvasCenter(): Point {
|
||||
const dpi = Math.max(window.devicePixelRatio ?? 1, 1)
|
||||
const dpi = app.canvas?.dpr ?? 1
|
||||
const visibleArea = app.canvas?.ds?.visible_area
|
||||
if (!visibleArea) {
|
||||
return [0, 0]
|
||||
|
||||
@@ -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