Compare commits

...

4 Commits

Author SHA1 Message Date
Glary-Bot
7e10cf97ee fix(website): memoize GitHub stars fetch to one call per build
BaseLayout.astro frontmatter runs per page in SSG, so the unauthenticated
GitHub REST call ran ~46 times per build, easily exceeding the 60 req/h
anonymous rate limit. On rate-limited builds fetchGitHubStars returned
null, githubStars became '', and the star badge silently disappeared.

Memoize the in-flight promise in module scope keyed by owner/repo so all
pages share a single request. Pass an injectable fetchImpl for tests, and
narrow the JSON response type instead of treating it as any.

Also change the truthy check in BaseLayout from 'rawStars ? ...' to
'rawStars !== null ? ...' so a hypothetical zero-star repo would still
render (the old behavior also hid 0).
2026-05-27 21:35:01 +00:00
AustinMroz
b89940134f Better preview grid tiling (#12463)
The previous image preview tiling code was less than ideal. It had fixed
breakpoints based on the number of images. Outputs with many images
would become comically long.

This PR instead tiles images to fill the available space.
| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/e793ce65-8efc-44ca-b049-98f066a65b7d"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/ca891ce2-335f-42ce-aeec-a99579f669c8"
/>|
2026-05-27 20:26:44 +00:00
Christian Byrne
7ac1cbbd53 test: add E2E coverage for NE, SW, NW corner node resizing (#11408)
*PR Created by the Glary-Bot Agent*

---

## Summary

- Adds parameterized Playwright E2E tests covering all non-SE resize
corners (NE, SW, NW), closing the coverage gap in the `useNodeResize.ts`
switch statement
- Adds `resizeFromCorner()` and `getResizeHandle()` to `VueNodeFixture`
for reuse across tests
- Test cases are derived from the production `RESIZE_HANDLES` config so
they stay in sync with the actual handle definitions

## Test Groups (8 new tests)

| Group | Tests | Coverage |
|-------|-------|----------|
| Corner resize directions | NE, SW, NW — size increases and correct
edges shift | Lines 110-124, 184 |
| Opposite edge anchoring | NE, SW, NW — opposite corner stays fixed |
Position compensation end-to-end |
| Minimum size enforcement | SW width clamp (≥ MIN_NODE_WIDTH), NE
height clamp | Lines 162-176 |

## Design Decisions

**Locator-based handle discovery**: `resizeFromCorner()` finds handles
via `getByRole('button', { name: ariaLabel })` instead of coordinate
offsets. The resize handles have `opacity-0 pointer-events-auto`,
meaning they're always interactive even when visually transparent —
Playwright considers elements with `opacity: 0` as visible (it only
gates on `visibility: hidden` / `display: none` / zero-size bounding
box). If this approach turns out to be flaky in CI due to handle
discoverability, we can fall back to coordinate-based targeting
(computing offsets from the node's bounding box corners), which is what
the original SE-corner test uses.

**Parameterization from production config**: Tests import
`RESIZE_HANDLES` from `resizeHandleConfig.ts` and derive test case data
(drag direction, which axes move) from the corner name. An upfront guard
throws if any expected corner is missing from the config, preventing
silent coverage loss.

**Aria-label coupling**: `RESIZE_HANDLE_LABELS` in `VueNodeFixture`
hardcodes the English aria-label strings. This is intentional — tests
run in English locale, and aria-labels are the accessibility interface
contract. If a more stable hook is needed (e.g., `data-testid` per
handle), that can be added to `LGraphNode.vue` in a follow-up.

**Frame settlement**: `resizeFromCorner()` calls `nextFrame()` after the
mouse-up to ensure layout settles before assertions run, per
`FLAKE_PREVENTION_RULES.md`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11408-test-add-E2E-coverage-for-NE-SW-NW-corner-node-resizing-3476d73d3650818d8a5ce5d6d535b38c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
Co-authored-by: Connor Byrne <c.byrne@comfy.org>
2026-05-27 17:26:59 +00:00
jaeone94
7caba4408d fix: open model library for desktop model downloads (#12478)
## Summary

Open the Model Library sidebar when a missing-model download starts on
desktop so users can immediately see Electron download progress. This is
a temporary UX patch until the dedicated desktop missing-model download
progress bar lands.

## Changes

- **What**: Activates the `model-library` sidebar tab before starting
desktop missing-model downloads.
- **Dependencies**: None.

## Review Focus

Confirm the minimal behavior is scoped to the desktop download path and
covers both individual downloads and Download all through the shared
`downloadModel()` helper. This should remain small and easy to remove
once the progress bar flow is available.

## Screenshots (if applicable)



https://github.com/user-attachments/assets/d5b01db7-46b5-4a52-bb11-45e75a422474



## Test Plan

- `pnpm format`
- `pnpm lint`
- `pnpm typecheck`
- `pnpm test:unit`
- `pnpm knip`
2026-05-27 15:25:26 +00:00
14 changed files with 554 additions and 89 deletions

View File

@@ -32,7 +32,7 @@ const ogImageURL = new URL(ogImage, siteBase)
const rawLocale = Astro.currentLocale ?? 'en'
const locale: Locale = rawLocale === 'zh-CN' ? 'zh-CN' : 'en'
const rawStars = await fetchGitHubStars('Comfy-Org', 'ComfyUI')
const githubStars = rawStars ? formatStarCount(rawStars) : ''
const githubStars = rawStars !== null ? formatStarCount(rawStars) : ''
const gtmId = 'GTM-NP9JM6K7'
const gtmEnabled = import.meta.env.PROD

View File

@@ -1,11 +1,16 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { fetchGitHubStars, formatStarCount } from './github'
import {
fetchGitHubStars,
formatStarCount,
resetGitHubStarsFetcherForTests
} from './github'
describe('fetchGitHubStars', () => {
const savedOverride = process.env.WEBSITE_GITHUB_STARS_OVERRIDE
afterEach(() => {
resetGitHubStarsFetcherForTests()
vi.restoreAllMocks()
if (savedOverride === undefined)
delete process.env.WEBSITE_GITHUB_STARS_OVERRIDE
@@ -27,6 +32,67 @@ describe('fetchGitHubStars', () => {
'WEBSITE_GITHUB_STARS_OVERRIDE must be a non-negative integer'
)
})
it('memoizes concurrent fetches for the same repo to one network call', async () => {
const fetchImpl = vi.fn(
async () =>
new Response(JSON.stringify({ stargazers_count: 110000 }), {
status: 200,
headers: { 'content-type': 'application/json' }
})
)
const [a, b, c] = await Promise.all([
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch),
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch),
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch)
])
expect(a).toBe(110000)
expect(b).toBe(110000)
expect(c).toBe(110000)
expect(fetchImpl).toHaveBeenCalledTimes(1)
})
it('keys the in-flight cache by owner/repo', async () => {
const fetchImpl = vi.fn(async (url: string | URL | Request) => {
const href = typeof url === 'string' ? url : url.toString()
const count = href.includes('other-repo') ? 42 : 110000
return new Response(JSON.stringify({ stargazers_count: count }), {
status: 200,
headers: { 'content-type': 'application/json' }
})
})
const [comfy, other] = await Promise.all([
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch),
fetchGitHubStars('Comfy-Org', 'other-repo', fetchImpl as typeof fetch)
])
expect(comfy).toBe(110000)
expect(other).toBe(42)
expect(fetchImpl).toHaveBeenCalledTimes(2)
})
it('returns null when GitHub responds non-2xx', async () => {
const fetchImpl = vi.fn(
async () => new Response('rate limited', { status: 403 })
)
await expect(
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch)
).resolves.toBeNull()
})
it('returns null when fetch throws', async () => {
const fetchImpl = vi.fn(async () => {
throw new Error('network down')
})
await expect(
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch)
).resolves.toBeNull()
})
})
describe('formatStarCount', () => {

View File

@@ -1,22 +1,50 @@
const inflight = new Map<string, Promise<number | null>>()
export function resetGitHubStarsFetcherForTests(): void {
inflight.clear()
}
export async function fetchGitHubStars(
owner: string,
repo: string
repo: string,
fetchImpl: typeof fetch = fetch
): Promise<number | null> {
const override = readGitHubStarsOverride()
if (override !== undefined) return override
const key = `${owner}/${repo}`
const cached = inflight.get(key)
if (cached) return cached
const request = doFetch(owner, repo, fetchImpl)
inflight.set(key, request)
return request
}
async function doFetch(
owner: string,
repo: string,
fetchImpl: typeof fetch
): Promise<number | null> {
try {
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
headers: { Accept: 'application/vnd.github.v3+json' }
})
const res = await fetchImpl(
`https://api.github.com/repos/${owner}/${repo}`,
{ headers: { Accept: 'application/vnd.github.v3+json' } }
)
if (!res.ok) return null
const data = await res.json()
return data.stargazers_count ?? null
const data: unknown = await res.json()
return readStargazerCount(data)
} catch {
return null
}
}
function readStargazerCount(data: unknown): number | null {
if (data === null || typeof data !== 'object') return null
const count = (data as { stargazers_count?: unknown }).stargazers_count
return typeof count === 'number' ? count : null
}
export function formatStarCount(count: number): string {
if (count >= 1_000_000) {
const m = count / 1_000_000

View File

@@ -128,7 +128,8 @@ export const TestIds = {
pinIndicator: 'node-pin-indicator',
innerWrapper: 'node-inner-wrapper',
mainImage: 'main-image',
slotConnectionDot: 'slot-connection-dot'
slotConnectionDot: 'slot-connection-dot',
imageGrid: 'image-grid'
},
selectionToolbox: {
root: 'selection-toolbox',

View File

@@ -1,8 +1,15 @@
import { expect } from '@playwright/test'
import type { Locator } from '@playwright/test'
import type { CompassCorners } from '@/lib/litegraph/src/interfaces'
import { TitleEditor } from '@e2e/fixtures/components/TitleEditor'
import { TestIds } from '@e2e/fixtures/selectors'
interface BoxOrigin {
readonly x: number
readonly y: number
}
/** DOM-centric helper for a single Vue-rendered node on the canvas. */
export class VueNodeFixture {
public readonly header: Locator
@@ -15,7 +22,9 @@ export class VueNodeFixture {
public readonly root: Locator
public readonly widgets: Locator
public readonly imagePreview: Locator
public readonly imageGrid: Locator
public readonly content: Locator
public readonly resize: { bottomRight: Locator }
constructor(private readonly locator: Locator) {
this.header = locator.locator('[data-testid^="node-header-"]')
@@ -28,7 +37,10 @@ export class VueNodeFixture {
this.root = locator
this.widgets = this.locator.locator('.lg-node-widget')
this.imagePreview = locator.locator('.image-preview')
this.imageGrid = locator.getByTestId(TestIds.node.imageGrid)
this.content = locator.locator('.lg-node-content')
const bottomRight = locator.getByRole('button', { name: 'bottom-right' })
this.resize = { bottomRight }
}
async getTitle(): Promise<string> {
@@ -77,4 +89,100 @@ export class VueNodeFixture {
: slotLocators.filter({ has: nameOrLocator })
return filteredLocator.getByTestId('slot-dot').locator('..')
}
/**
* Click the node header to select it, then return its bounding box.
* Throws if the node is not laid out because geometry-sensitive tests
* cannot proceed without coordinates.
*/
async selectAndGetBox(): Promise<{
x: number
y: number
width: number
height: number
}> {
await this.header.click()
const box = await this.boundingBox()
if (!box) {
throw new Error('Node bounding box not found after select')
}
return box
}
/**
* Assert this node's top-left origin stays within `precision` decimal
* places of `expected`. Wraps the polled bounding-box pattern that drift
* tests repeat for both axes.
*/
async expectAnchoredAt(
expected: BoxOrigin,
{ precision = 1 }: { precision?: number } = {}
): Promise<void> {
await expect.poll(this.pollLeftEdge).toBeCloseTo(expected.x, precision)
await expect.poll(this.pollTopEdge).toBeCloseTo(expected.y, precision)
}
/** Poll the node's left/x edge for use with `expect.poll`. */
pollLeftEdge = async (): Promise<number | null> =>
(await this.boundingBox())?.x ?? null
/** Poll the node's top/y edge for use with `expect.poll`. */
pollTopEdge = async (): Promise<number | null> =>
(await this.boundingBox())?.y ?? null
/** Poll the node's right edge (x + width) for use with `expect.poll`. */
pollRightEdge = async (): Promise<number | null> => {
const b = await this.boundingBox()
return b ? b.x + b.width : null
}
/** Poll the node's bottom edge (y + height) for use with `expect.poll`. */
pollBottomEdge = async (): Promise<number | null> => {
const b = await this.boundingBox()
return b ? b.y + b.height : null
}
/** Poll the node's width for use with `expect.poll`. */
pollWidth = async (): Promise<number | null> =>
(await this.boundingBox())?.width ?? null
/** Poll the node's height for use with `expect.poll`. */
pollHeight = async (): Promise<number | null> =>
(await this.boundingBox())?.height ?? null
/** Locator for the resize handle at the given corner, scoped to this node. */
getResizeHandle(corner: CompassCorners): Locator {
return this.root.locator(`[data-corner="${corner}"]`)
}
/**
* Drag the resize handle at `corner` by (deltaX, deltaY) viewport pixels.
* Uses `hover()` to land the pointer on the handle with Playwright's
* actionability checks before starting the mouse sequence, which protects
* against occluding overlays and subpixel hit-test misses.
*/
async resizeFromCorner(
corner: CompassCorners,
deltaX: number,
deltaY: number
): Promise<void> {
const handle = this.getResizeHandle(corner)
await handle.hover()
const box = await handle.boundingBox()
if (!box) {
throw new Error(
`Resize handle for corner "${corner}" has no bounding box`
)
}
const page = this.locator.page()
const startX = box.x + box.width / 2
const startY = box.y + box.height / 2
await page.mouse.move(startX, startY)
await page.mouse.down()
await page.mouse.move(startX + deltaX, startY + deltaY, {
steps: 5
})
await page.mouse.up()
}
}

View File

@@ -1,11 +1,15 @@
import { expect } from '@playwright/test'
import { expect, mergeTests } from '@playwright/test'
import type { Locator } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import {
getPromotedWidgetNames,
getPromotedWidgetCountByName
} from '@e2e/fixtures/utils/promotedWidgets'
import { webSocketFixture } from '@e2e/fixtures/ws'
const wstest = mergeTests(test, webSocketFixture)
test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
async function loadImageOnNode(comfyPage: ComfyPage) {
@@ -136,3 +140,44 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
}
)
})
async function countColumns(locator: Locator) {
return await locator.locator('img').evaluateAll((images) => {
const yOffsets = images.map((image) => image.getBoundingClientRect().y)
return yOffsets.filter((yOffset) => yOffset === yOffsets[0]).length
})
}
test.describe('Vue Nodes Batch Image Preview', { tag: '@vue-nodes' }, () => {
wstest(
'Image previews tile to fit node',
async ({ comfyMouse, comfyPage, getWebSocket }) => {
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
await test.step('Add node', async () => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.searchBoxV2.addNode('Preview Image')
const previewImage = comfyPage.vueNodes.getNodeByTitle('Preview Image')
await expect(previewImage).toBeVisible()
})
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview Image')
await test.step('Inject multiple previews', async () => {
const file = { filename: 'example.png', type: 'input' }
const images = new Array(100).fill(file)
execution.executed('', '1', { images })
await expect(node.imageGrid.locator('img')).toHaveCount(100)
})
const { bottomRight } = node.resize
await expect.poll(() => countColumns(node.imageGrid)).toBe(10)
await comfyMouse.resizeByDragging(bottomRight, { x: 200 })
await expect.poll(() => countColumns(node.imageGrid)).toBeGreaterThan(10)
await comfyMouse.resizeByDragging(bottomRight, { x: -200, y: 200 })
await expect.poll(() => countColumns(node.imageGrid)).toBeLessThan(10)
}
)
})

View File

@@ -1,56 +1,165 @@
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { MIN_NODE_WIDTH } from '@/renderer/core/layout/transform/graphRenderTransform'
import {
RESIZE_HANDLES,
hasNorthEdge,
hasWestEdge
} from '@/renderer/extensions/vueNodes/interactions/resize/resizeHandleConfig'
test.describe('Vue Node Resizing', { tag: '@vue-nodes' }, () => {
test('should resize node without position drift after selecting', async ({
comfyPage
}) => {
// Get a Vue node fixture
const node = await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
const initialBox = await node.boundingBox()
if (!initialBox) throw new Error('Node bounding box not found')
async function setupResizableNode(comfyPage: ComfyPage, title: string) {
await expect(comfyPage.vueNodes.getNodeByTitle(title)).toHaveCount(1)
const node = await comfyPage.vueNodes.getFixtureByTitle(title)
const box = await node.selectAndGetBox()
return { node, box }
}
// Select the node first (this was causing the bug)
await node.header.click()
test.describe(
'Vue Node Resizing',
{ tag: ['@vue-nodes', '@canvas', '@node'] },
() => {
let originalMinimapVisible: boolean | undefined
// Get position after selection
const selectedBox = await node.boundingBox()
if (!selectedBox)
throw new Error('Node bounding box not found after select')
// Minimap overlays the canvas and intercepts pointer events that land in
// its hit area during resize drags, so disable it for this suite. Capture
// and restore the prior value to avoid leaking the override to other specs
// that run on the same user-data-dir.
test.beforeEach(async ({ comfyPage }) => {
originalMinimapVisible = await comfyPage.settings.getSetting<boolean>(
'Comfy.Minimap.Visible'
)
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', false)
await comfyPage.canvasOps.resetView()
})
// Verify position unchanged after selection
await expect
.poll(async () => (await node.boundingBox())?.x)
.toBeCloseTo(initialBox.x, 1)
await expect
.poll(async () => (await node.boundingBox())?.y)
.toBeCloseTo(initialBox.y, 1)
test.afterEach(async ({ comfyPage }) => {
if (originalMinimapVisible !== undefined) {
await comfyPage.settings.setSetting(
'Comfy.Minimap.Visible',
originalMinimapVisible
)
}
})
// Now resize from bottom-right corner
const resizeStartX = selectedBox.x + selectedBox.width - 5
const resizeStartY = selectedBox.y + selectedBox.height - 5
test('should resize node without position drift after selecting', async ({
comfyPage
}) => {
const { node, box: initialBox } = await setupResizableNode(
comfyPage,
'Load Checkpoint'
)
await comfyPage.page.mouse.move(resizeStartX, resizeStartY)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(resizeStartX + 50, resizeStartY + 30)
await comfyPage.page.mouse.up()
await node.expectAnchoredAt(initialBox)
// Position should NOT have changed (the bug was position drift)
await expect
.poll(async () => (await node.boundingBox())?.x)
.toBeCloseTo(initialBox.x, 1)
await expect
.poll(async () => (await node.boundingBox())?.y)
.toBeCloseTo(initialBox.y, 1)
await node.resizeFromCorner('SE', 50, 30)
// Size should have increased
await expect
.poll(async () => (await node.boundingBox())?.width)
.toBeGreaterThan(initialBox.width)
await expect
.poll(async () => (await node.boundingBox())?.height)
.toBeGreaterThan(initialBox.height)
})
})
await node.expectAnchoredAt(initialBox)
await expect.poll(node.pollWidth).toBeGreaterThan(initialBox.width)
await expect.poll(node.pollHeight).toBeGreaterThan(initialBox.height)
})
const cornerCases = RESIZE_HANDLES.map((h) => ({
corner: h.corner,
dragX: hasWestEdge(h.corner) ? -50 : 50,
dragY: hasNorthEdge(h.corner) ? -40 : 40
}))
test.describe('corner resize directions', () => {
cornerCases.forEach(({ corner, dragX, dragY }) => {
test(`${corner}: size increases and correct edges shift`, async ({
comfyPage
}) => {
const { node, box } = await setupResizableNode(comfyPage, 'KSampler')
await node.resizeFromCorner(corner, dragX, dragY)
await expect.poll(node.pollWidth).toBeGreaterThan(box.width)
await expect.poll(node.pollHeight).toBeGreaterThan(box.height)
if (hasWestEdge(corner)) {
await expect.poll(node.pollLeftEdge).toBeLessThan(box.x)
} else {
await expect.poll(node.pollLeftEdge).toBeCloseTo(box.x, 0)
}
if (hasNorthEdge(corner)) {
await expect.poll(node.pollTopEdge).toBeLessThan(box.y)
} else {
await expect.poll(node.pollTopEdge).toBeCloseTo(box.y, 0)
}
})
})
})
test.describe('opposite edge anchoring', () => {
cornerCases.forEach(({ corner, dragX, dragY }) => {
test(`${corner} resize keeps opposite corner fixed`, async ({
comfyPage
}) => {
const { node, box } = await setupResizableNode(comfyPage, 'KSampler')
const pollAnchorX = hasWestEdge(corner)
? node.pollRightEdge
: node.pollLeftEdge
const pollAnchorY = hasNorthEdge(corner)
? node.pollBottomEdge
: node.pollTopEdge
const anchorX = hasWestEdge(corner) ? box.x + box.width : box.x
const anchorY = hasNorthEdge(corner) ? box.y + box.height : box.y
await node.resizeFromCorner(corner, dragX, dragY)
await expect.poll(pollAnchorX).toBeCloseTo(anchorX, 0)
await expect.poll(pollAnchorY).toBeCloseTo(anchorY, 0)
})
})
})
test.describe('minimum size enforcement', () => {
test('SW resize clamps width, keeping right edge fixed', async ({
comfyPage
}) => {
const { node, box } = await setupResizableNode(comfyPage, 'KSampler')
const rightEdge = box.x + box.width
await node.resizeFromCorner('SW', box.width + 100, 0)
await expect.poll(node.pollRightEdge).toBeCloseTo(rightEdge, 0)
await expect.poll(node.pollWidth).toBeGreaterThanOrEqual(MIN_NODE_WIDTH)
})
test('NE resize clamps height at its lower bound', async ({
comfyPage
}) => {
const { node } = await setupResizableNode(comfyPage, 'KSampler')
// Default nodes render at content-minimum height; grow from SE so NE
// has room to shrink back down to the clamp.
await node.resizeFromCorner('SE', 0, 200)
const expandedBox = await node.boundingBox()
if (!expandedBox)
throw new Error('Node bounding box not found after SE grow')
const bottomEdge = expandedBox.y + expandedBox.height
// Overdrag once to hit the clamp, then again to prove further dragging
// does not shrink past the minimum (idempotent clamp).
await node.resizeFromCorner('NE', 0, expandedBox.height + 100)
const clampedHeight = (await node.boundingBox())?.height
if (clampedHeight === undefined)
throw new Error('Node bounding box not found after NE clamp')
expect(clampedHeight).toBeLessThan(expandedBox.height)
await node.resizeFromCorner('NE', 0, 200)
await expect.poll(node.pollHeight).toBeCloseTo(clampedHeight, 0)
await expect.poll(node.pollBottomEdge).toBeCloseTo(bottomEdge, 0)
})
})
}
)

View File

@@ -1,22 +1,46 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
downloadModel,
fetchModelMetadata,
isModelDownloadable,
toBrowsableUrl
} from './missingModelDownload'
const fetchMock = vi.fn()
const { fetchMock, mockIsDesktop, mockSidebarTabStore, mockStartDownload } =
vi.hoisted(() => ({
fetchMock: vi.fn(),
mockIsDesktop: { value: false },
mockSidebarTabStore: { activeSidebarTabId: null as string | null },
mockStartDownload: vi.fn()
}))
vi.stubGlobal('fetch', fetchMock)
vi.mock('@/platform/distribution/types', () => ({ isDesktop: false }))
vi.mock('@/stores/electronDownloadStore', () => ({}))
vi.mock('@/platform/distribution/types', () => ({
get isDesktop() {
return mockIsDesktop.value
}
}))
vi.mock('@/stores/electronDownloadStore', () => ({
useElectronDownloadStore: () => ({
start: mockStartDownload
})
}))
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
useSidebarTabStore: () => mockSidebarTabStore
}))
let testId = 0
describe('fetchModelMetadata', () => {
beforeEach(() => {
fetchMock.mockReset()
mockIsDesktop.value = false
mockSidebarTabStore.activeSidebarTabId = null
mockStartDownload.mockReset()
testId++
})
@@ -213,3 +237,31 @@ describe('isModelDownloadable', () => {
).toBe(false)
})
})
describe('downloadModel', () => {
beforeEach(() => {
mockIsDesktop.value = false
mockSidebarTabStore.activeSidebarTabId = null
mockStartDownload.mockReset()
})
it('opens the model library sidebar before starting a desktop download', () => {
mockIsDesktop.value = true
downloadModel(
{
name: 'model.safetensors',
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
directory: 'checkpoints'
},
{ checkpoints: ['/models/checkpoints'] }
)
expect(mockSidebarTabStore.activeSidebarTabId).toBe('model-library')
expect(mockStartDownload).toHaveBeenCalledWith({
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
savePath: '/models/checkpoints',
filename: 'model.safetensors'
})
})
})

View File

@@ -1,6 +1,7 @@
import { downloadUrlToHfRepoUrl, isCivitaiModelUrl } from '@/utils/formatUtil'
import { isDesktop } from '@/platform/distribution/types'
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
const ALLOWED_SOURCES = [
'https://civitai.com/',
@@ -26,6 +27,8 @@ const WHITE_LISTED_URLS: ReadonlySet<string> = new Set([
'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth'
])
const MODEL_LIBRARY_TAB_ID = 'model-library'
export interface ModelWithUrl {
name: string
url: string
@@ -72,6 +75,7 @@ export function downloadModel(
const modelPaths = paths[model.directory]
if (modelPaths?.[0]) {
useSidebarTabStore().activeSidebarTabId = MODEL_LIBRARY_TAB_ID
void useElectronDownloadStore().start({
url: model.url,
savePath: modelPaths[0],

View File

@@ -7,30 +7,32 @@
<!-- Grid View -->
<div
v-if="viewMode === 'grid'"
ref="gridEl"
data-testid="image-grid"
class="group/panel relative grid w-full gap-1 overflow-hidden rounded-sm p-1"
class="relative grid w-full flex-1 gap-1 rounded-sm p-1 contain-size"
:style="{ gridTemplateColumns: `repeat(${gridCols}, 1fr)` }"
>
<button
<Button
v-for="(url, index) in imageUrls"
:key="index"
class="focus-visible:ring-ring relative cursor-pointer overflow-hidden rounded-sm border-0 bg-transparent p-0 focus-visible:ring-2 focus-visible:outline-none"
size="unset"
class="ring-ring overflow-hidden rounded-none p-0 hover:ring-1 focus-visible:ring-2"
:aria-label="
$t('g.viewImageOfTotal', {
index: index + 1,
total: imageUrls.length
})
"
@pointerdown="trackPointerStart"
@click="handleGridThumbnailClick($event, index)"
@click="openImageInGallery(index)"
>
<img
:src="url"
:alt="`${$t('g.galleryThumbnail')} ${index + 1}`"
draggable="false"
class="pointer-events-none size-full object-contain"
@load="updateAspectRatio($event, index)"
/>
</button>
</Button>
</div>
<!-- Gallery View (Image Wrapper) -->
@@ -167,11 +169,12 @@
</template>
<script setup lang="ts">
import { useTimeoutFn } from '@vueuse/core'
import { computed, nextTick, ref, watch } from 'vue'
import { useElementSize, useTimeoutFn } from '@vueuse/core'
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
import Button from '@/components/ui/button/Button.vue'
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { useToastStore } from '@/platform/updates/common/toastStore'
@@ -202,12 +205,17 @@ function defaultViewMode(urls: readonly string[]): ViewMode {
return urls.length > 1 ? 'grid' : 'gallery'
}
const { width: gridWidth, height: gridHeight } = useElementSize(
useTemplateRef('gridEl')
)
const currentIndex = ref(0)
const viewMode = ref<ViewMode>(defaultViewMode(imageUrls))
const galleryPanelEl = ref<HTMLDivElement>()
const actualDimensions = ref<string | null>(null)
const imageError = ref(false)
const showLoader = ref(false)
const imageAspectRatio = ref(1)
const { start: startDelayedLoader, stop: stopDelayedLoader } = useTimeoutFn(
() => {
@@ -227,10 +235,8 @@ const imageAltText = computed(() =>
})
)
const gridCols = computed(() => {
const count = imageUrls.length
if (count <= 4) return 2
if (count <= 9) return 3
return 4
const bias = gridWidth.value / gridHeight.value / imageAspectRatio.value
return Math.max(Math.round(Math.sqrt(imageUrls.length * bias)), 1)
})
watch(
@@ -274,6 +280,14 @@ function handleImageLoad(event: Event) {
}
}
function updateAspectRatio(event: Event, index: number) {
if (!(event.target instanceof HTMLImageElement) || index !== 0) return
const { naturalWidth, naturalHeight } = event.target
if (naturalWidth && naturalHeight) {
imageAspectRatio.value = naturalWidth / naturalHeight
}
}
function handleImageError() {
stopDelayedLoader()
showLoader.value = false
@@ -310,20 +324,6 @@ function setCurrentIndex(index: number) {
}
}
const CLICK_THRESHOLD = 3
let pointerStartPos = { x: 0, y: 0 }
function trackPointerStart(event: PointerEvent) {
pointerStartPos = { x: event.clientX, y: event.clientY }
}
function handleGridThumbnailClick(event: MouseEvent, index: number) {
const dx = event.clientX - pointerStartPos.x
const dy = event.clientY - pointerStartPos.y
if (Math.abs(dx) > CLICK_THRESHOLD || Math.abs(dy) > CLICK_THRESHOLD) return
openImageInGallery(index)
}
async function openImageInGallery(index: number) {
setCurrentIndex(index)
viewMode.value = 'gallery'

View File

@@ -212,6 +212,7 @@
v-for="handle in RESIZE_HANDLES"
:key="handle.corner"
role="button"
:data-corner="handle.corner"
:aria-label="t(handle.i18nKey)"
:class="
cn(

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import type { CompassCorners } from '@/lib/litegraph/src/interfaces'
import { RESIZE_HANDLES, hasNorthEdge, hasWestEdge } from './resizeHandleConfig'
describe('hasWestEdge', () => {
it.for<[CompassCorners, boolean]>([
['NW', true],
['SW', true],
['NE', false],
['SE', false]
])('corner %s -> %s', ([corner, expected]) => {
expect(hasWestEdge(corner)).toBe(expected)
})
})
describe('hasNorthEdge', () => {
it.for<[CompassCorners, boolean]>([
['NW', true],
['NE', true],
['SW', false],
['SE', false]
])('corner %s -> %s', ([corner, expected]) => {
expect(hasNorthEdge(corner)).toBe(expected)
})
})
describe('RESIZE_HANDLES', () => {
it('defines exactly one entry per CompassCorners member', () => {
const expected = new Set<CompassCorners>(['NE', 'NW', 'SE', 'SW'])
const actual = new Set(RESIZE_HANDLES.map((handle) => handle.corner))
expect(actual).toEqual(expected)
expect(RESIZE_HANDLES).toHaveLength(expected.size)
})
})

View File

@@ -43,3 +43,11 @@ export const RESIZE_HANDLES: ResizeHandle[] = [
svgTransform: 'scale(-1, -1)'
}
] as const
/** True for corners on the left edge of a node (SW, NW) — these move the x-origin when dragged. */
export const hasWestEdge = (corner: CompassCorners): boolean =>
corner === 'SW' || corner === 'NW'
/** True for corners on the top edge of a node (NE, NW) — these move the y-origin when dragged. */
export const hasNorthEdge = (corner: CompassCorners): boolean =>
corner === 'NE' || corner === 'NW'

View File

@@ -8,6 +8,10 @@ import { MIN_NODE_WIDTH } from '@/renderer/core/layout/transform/graphRenderTran
import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap'
import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync'
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
import {
hasNorthEdge,
hasWestEdge
} from '@/renderer/extensions/vueNodes/interactions/resize/resizeHandleConfig'
export interface ResizeCallbackPayload {
size: Size
@@ -135,20 +139,23 @@ export function useNodeResize(
break
}
const isWestCorner = hasWestEdge(activeCorner)
const isNorthCorner = hasNorthEdge(activeCorner)
// Apply snap-to-grid
if (shouldSnap(moveEvent)) {
// Snap position first for N/W corners, then compensate size
if (activeCorner.includes('N') || activeCorner.includes('W')) {
if (isNorthCorner || isWestCorner) {
const originalX = newX
const originalY = newY
const snapped = applySnapToPosition({ x: newX, y: newY })
newX = snapped.x
newY = snapped.y
if (activeCorner.includes('N')) {
if (isNorthCorner) {
newHeight += originalY - newY
}
if (activeCorner.includes('W')) {
if (isWestCorner) {
newWidth += originalX - newX
}
}
@@ -166,7 +173,7 @@ export function useNodeResize(
parseFloat(nodeElement.style.getPropertyValue('min-width') || '0') ||
MIN_NODE_WIDTH
if (newWidth < minWidth) {
if (activeCorner.includes('W')) {
if (isWestCorner) {
newX =
resizeStartPosition.value.x + resizeStartSize.value.width - minWidth
}
@@ -179,7 +186,7 @@ export function useNodeResize(
// a responsive breakpoint.
const minContentHeight = measureMinContentHeight(newWidth)
if (newHeight < minContentHeight) {
if (activeCorner.includes('N')) {
if (isNorthCorner) {
newY =
resizeStartPosition.value.y +
resizeStartSize.value.height -