Compare commits

...

15 Commits

Author SHA1 Message Date
Connor Byrne
1b6cc598f6 test: pick up imageCrop empty-state baseline from #11360
Cherry-picks browser_tests/tests/vueNodes/widgets/imageCrop.spec.ts and
its image-crop-empty-state-chromium-linux.png snapshot from upstream
commit 54f312765 (PR #11360, "test: regenerate screenshot expectations").

The shard (8, 8) failure on this PR was 'Empty state matches screenshot
baseline' — the node renders 400x580 vs the stale 400x530 baseline (50px
taller, 0.09 ratio, exceeds the 0.05 maxDiffPixelRatio). Drift is at 1x
DPR and unrelated to this PR's viewport/DPR migration; it pre-existed on
4ad66359 (CI run 25245534619, May 2). The base branch
glary/fix-app-mode-template-viewport (#11420) has not yet picked up
#11360 from main, so we surgically apply just the two files instead of
rebasing onto main and unstacking.

Touches only this single test + its baseline; no other regenerated
baselines from #11360 are pulled in.
2026-05-04 16:02:41 -07:00
Connor Byrne
bd89d04fb2 test: cover dpr threading in queryLinkSegmentAtPoint and queryLinkAtPoint
Adds unit coverage for the new optional dpr parameter on layoutStore
hit-test queries:

- explicit dpr scales the stroke point (point.x/y * dpr)
- omitted dpr falls back to window.devicePixelRatio
- queryLinkAtPoint forwards dpr through to queryLinkSegmentAtPoint

Mirrors the production call from LGraphCanvas.processMouseDown which now
passes this.dpr to keep the layout-store hit-test in lockstep with the
isPointInStroke fallback at low DPR.

Refs https://github.com/Comfy-Org/ComfyUI_frontend/pull/11498
2026-05-04 16:00:13 -07:00
Connor Byrne
b78f0bde37 test: update chromium-0.5x rerouteNode baseline for raw DPR pipeline
The DPR migration (ADR 0009) intentionally preserves sub-1 DPR through
the rendering pipeline instead of clamping to 1. At chromium-0.5x this
produces a tiny (0.01 ratio) anti-aliasing shift along link curves,
ports, and reroute dots — the rendering is now consistent with the raw
0.5 device pixel ratio rather than the previously-clamped 1.0.

Update the chromium-0.5x baseline only. Other DPR baselines (1x, 2x)
are unaffected because their DPR was never clamped.

Refs https://github.com/Comfy-Org/ComfyUI_frontend/pull/11498#pullrequestreview-4151044800
2026-05-04 14:12:09 -07:00
Connor Byrne
0b3aa3e463 fix: restore draw flush in scheduled template-load viewport path
Re-introduce the `this.canvas?.draw(true, true)` call that was lost when
the scheduled template-load path was inlined off of `resizeCanvas()`.
Without this flush, when a template is loaded while the canvas is hidden
(app mode -> linearMode=true), the scheduler queue runs measure/apply on
a still-hidden element and `#graph-canvas` sticks at width=0/height=0,
breaking `appModeTemplateViewport.spec.ts`.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11498#pullrequestreview-4172180600
2026-05-04 13:36:07 -07:00
bymyself
9a0cec3b17 fix: align link hit-test DPR source with canvas in queryLinkSegmentAtPoint
processMouseDown's isPointInStroke fallback uses this.dpr, but
layoutStore.queryLinkSegmentAtPoint was reading raw window.devicePixelRatio.
With the DPR migration in place these two hit-test paths can now disagree
on low-DPR displays, making alt/shift-click link detection depend on which
path wins.

Thread an optional dpr argument through queryLinkSegmentAtPoint /
queryLinkAtPoint and the LayoutStore interface, and pass this.dpr from
processMouseDown so both hit-test paths share the canvas-owned DPR.
Window fallback is preserved for legacy callers without a canvas reference.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11498#pullrequestreview-4151044800
2026-05-04 13:30:56 -07:00
bymyself
a201d732df fix: preserve raw DPR to restore low-DPR (<1) viewport behavior
Clamping rawDpr to >=1 in measureViewport and the LGraphCanvas
constructor changed behavior on supported low-DPR matrices (e.g.
chromium-0.5x). Previously, several render/hit paths read raw
window.devicePixelRatio (unclamped); after the migration to
LGraphCanvas.dpr they all received the clamped value, breaking
rerouteNode.spec.ts and imageCrop.spec.ts on the 0.5x project.

Treat the clamp as a sanity fallback only (non-positive / NaN -> 1)
and otherwise pass the device-reported value through.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11498#pullrequestreview-4151044800
2026-05-04 13:30:56 -07:00
bymyself
bd426070e5 refactor: complete DPR migration to viewport system (ADR 0009 Accepted)
Migrate all window.devicePixelRatio reads to use LGraphCanvas.dpr,
the single source of truth set by applyViewport.

LGraphCanvas:
- Add dpr property, initialized in constructor, updated on resize()
- Migrate 6 internal DPR reads: drawFrontCanvas compositing,
  drawBackCanvas transform reset, centerOnNode, renderInfo,
  processMouseDown hit-testing, LOD threshold calculation
- Mark resize() as @deprecated

External consumers:
- litegraphService.getCanvasCenter(): use canvas.dpr
- useMinimapViewport: use canvas.dpr
- layoutStore: documented TODO (no canvas reference at this layer)
- DragAndScale: documented exception (no canvas reference)

app.ts:
- Mark resizeCanvas() as @deprecated
- Inline viewport calls in scheduler path
- Set canvas.dpr after applyViewport

canvasViewport.ts:
- Export CanvasViewport type
- applyViewport returns the viewport for chaining

ADR 0009: Proposed -> Accepted
2026-05-04 13:30:56 -07:00
bymyself
e0ba479d6d test: fix appModeTemplateViewport second toggle (no-op after new tab)
Loading a template via loadGraphData(..., null, { openSource: 'template' })
calls afterLoadNewGraph(null, ...), which creates a new temporary workflow
tab in graph mode and switches the active workflow to it. The original test
then toggled app mode a second time, but on the new (graph-mode) workflow
that toggle ENTERED app mode again instead of exiting — leaving the canvas
hidden (0x0) and timing out toBeVisible().

The canvas re-shows automatically when the new graph-mode workflow becomes
active, so the manual second toggle is unnecessary. The bug under test is
still covered: queued canvasScheduler ops would run against a 0x0 canvas
without the fix, corrupting ds.scale/ds.offset, which the assertions catch.
2026-05-01 23:22:28 -07:00
bymyself
52f64e5823 test: add Playwright regression test for app mode template viewport
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11420#discussion_r3107586364
2026-04-21 18:59:06 -07:00
bymyself
96451f3713 fix: name linearMode transition condition for clarity
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11420#discussion_r3107646467
2026-04-21 18:59:02 -07:00
bymyself
53b119d280 fix: address code review items for viewport PR
- Uncomment canvasNotMeasurable guards in DragAndScale fitToBounds/animateToBounds
- Make LGraphCanvas.resize() delegate to measureViewport/applyViewport
- Move devAssert to src/base/common/ (DDD layer compliance)
- Enhance devAssert with pluggable reporter hook; wire Sentry + toast in main.ts
- Set ADR 0009 status to Proposed (pending full migration in follow-up)
2026-04-20 19:42:05 -07:00
GitHub Action
a8f22f1a1b [automated] Apply ESLint and Oxfmt fixes 2026-04-21 02:18:03 +00:00
bymyself
a7a60c919c fix: unify canvas resize paths with CanvasViewport system (ADR 0009)
Two independent resize paths existed: resizeCanvas() in app.ts (DPR-aware)
and LGraphCanvas.resize() (DPR-unaware). Neither documented its dependency
on the other, creating implicit temporal coupling. The bg/fg canvas size
mismatch occurred because drawFrontCanvas() composites the bg canvas by
dividing its dimensions by DPR — assuming both canvases were DPR-scaled —
but LGraphCanvas.resize() set them to CSS pixels.

Introduce CanvasViewport: a plain frozen data object (ECS component style)
that serves as single source of truth for canvas sizing. measureViewport()
is a pure function producing viewport state from DOM measurements.
applyViewport() atomically sizes both fg and bg canvases and scales their
contexts, eliminating the possibility of a partial resize.

- Add devAssert() utility: throws in DEV, console.errors in prod
- Add fg/bg size invariant assertion in LGraphCanvas.draw()
- Make LGraphCanvas.resize() DPR-aware (scales both canvases + contexts)
- Replace resizeCanvas() internals with viewport system
- Wire viewport resize into canvasScheduler for app-mode transitions
- 13 new unit tests for viewport and assert modules
2026-04-20 19:14:09 -07:00
bymyself
ecf3d594c3 temp: test if guard is the thing breaking mobile tests 2026-04-20 17:18:08 -07:00
Glary-Bot
c3074a0a11 fix: add canvas scheduler to fix app mode template viewport corruption
- Add useCanvasScheduler: module-level singleton that queues canvas ops
  and flushes them in RAF when the canvas is visible. Auto-flushes on
  linearMode transitions.
- isCanvasReady checks both offsetParent and offsetWidth/offsetHeight
  to verify canvas has non-zero rendered dimensions.
- Add fitToBounds/animateToBounds zero-dimension bailout in DragAndScale
  to prevent scale=0 / offset=NaN when canvas is hidden.
- Replace scattered canvasVisible checks and inline RAF hacks in
  loadGraphData() with canvasScheduler.schedule().
- Replace double-nested RAF in subgraphNavigationStore with scheduler.
- All fitView RAF paths now go through canvasScheduler so clear()
  cancels stale ops across load boundaries.
- Each scheduled op is isolated with try/catch so one failure doesn't
  drop subsequent ops.
2026-04-19 22:33:55 +00:00
28 changed files with 1064 additions and 74 deletions

View File

@@ -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>

View 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

View File

@@ -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

View 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.

View File

@@ -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

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

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

View File

@@ -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

View File

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

View File

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

View File

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

View File

@@ -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()

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

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

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

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

View File

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

View File

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

View File

@@ -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

View File

@@ -42,6 +42,7 @@ describe('useMinimapViewport', () => {
width: 1600,
height: 1200
} as HTMLCanvasElement,
dpr: 2,
ds: {
scale: 1,
offset: [0, 0]

View File

@@ -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,

View File

@@ -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]

View File

@@ -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

View File

@@ -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]

View File

@@ -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()

View File

@@ -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[] = []