mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-28 00:45:03 +00:00
Compare commits
4 Commits
jaewon/hid
...
glary/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e10cf97ee | ||
|
|
b89940134f | ||
|
|
7ac1cbbd53 | ||
|
|
7caba4408d |
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -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 -
|
||||
|
||||
Reference in New Issue
Block a user